- added contrain context menu

- improved line pickability.
This commit is contained in:
bklronin
2026-07-19 11:18:10 +02:00
parent d6e829c23d
commit 2037106c0d
+493 -118
View File
@@ -6,11 +6,10 @@ import logging
import math
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
from PySide6.QtCore import Qt, Signal, QPoint, QPointF, QRect
from PySide6.QtGui import (
QBrush,
QColor,
QCursor,
QFont,
QFontMetrics,
QPainter,
@@ -20,6 +19,8 @@ from PySide6.QtGui import (
)
from PySide6.QtWidgets import (
QInputDialog,
QMenu,
QMessageBox,
QWidget,
)
@@ -31,7 +32,9 @@ logger = logging.getLogger(__name__)
def _project_face_to_uv(
face: Any,
workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
workplane: Tuple[
Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]
],
) -> List[List[Tuple[float, float]]]:
"""Project a planar ``TopoDS_Face``'s boundary edges into the UV frame.
@@ -48,9 +51,9 @@ def _project_face_to_uv(
from OCP.GeomAbs import GeomAbs_Line
from OCP.gp import gp_Pnt
origin = np.asarray(workplane[0], dtype=float) # (x,y,z)
normal = np.asarray(workplane[1], dtype=float) # plane normal
x_dir = np.asarray(workplane[2], dtype=float) # in-plane x axis
origin = np.asarray(workplane[0], dtype=float) # (x,y,z)
normal = np.asarray(workplane[1], dtype=float) # plane normal
x_dir = np.asarray(workplane[2], dtype=float) # in-plane x axis
x_dir = x_dir / np.linalg.norm(x_dir)
normal = normal / np.linalg.norm(normal)
y_dir = np.cross(normal, x_dir)
@@ -107,17 +110,25 @@ class Sketch2DWidget(QWidget):
self._points: List[OCCSketchEntity] = []
self._lines: List[Tuple[OCCSketchEntity, OCCSketchEntity]] = []
self._circles: List[Tuple[OCCSketchEntity, float]] = []
self._arcs: List[Tuple[OCCSketchEntity, float, OCCSketchEntity, OCCSketchEntity, float]] = [] # (center, radius, start_point, end_point, sweep)
self._arcs: List[
Tuple[OCCSketchEntity, float, OCCSketchEntity, OCCSketchEntity, float]
] = [] # (center, radius, start_point, end_point, sweep)
# Accumulated sweep tracking during arc draw (smoothly follows cursor)
self._arc_accum_sweep: float = 0.0
self._arc_prev_angle: Optional[float] = None
self._draw_buffer: List[QPoint] = []
self._hovered_point: Optional[QPoint] = None
self._hovered_point_entity: Optional[OCCSketchEntity] = None # point entity under cursor (for Delete)
self._hovered_point_entity: Optional[OCCSketchEntity] = (
None # point entity under cursor (for Delete)
)
self._hovered_line: Optional[Tuple[QPoint, QPoint]] = None
self._hovered_line_entity: Optional[OCCSketchEntity] = None # line entity under cursor (for Delete)
self._hovered_constraint_idx: int = -1 # constraint-log index hovered over its tag (for Delete)
self._hovered_line_entity: Optional[OCCSketchEntity] = (
None # line entity under cursor (for Delete)
)
self._hovered_constraint_idx: int = (
-1
) # constraint-log index hovered over its tag (for Delete)
self._constraint_tags: List[Dict[str, Any]] = [] # cached tag rects for paint + hit-test
self._hovered_face: Any = None # detected face dict (see OCCSketch.detect_faces) or None
self._selected_face: Any = None
@@ -254,11 +265,7 @@ class Sketch2DWidget(QWidget):
# Snapshot the entity list before modifying (add_point adds to
# _entities, which would change dict size mid-iteration).
for eid, entity in list(self._sketch._entities.items()):
if (
entity.is_external
and entity.entity_type == "point"
and entity.geometry is not None
):
if entity.is_external and entity.entity_type == "point" and entity.geometry is not None:
x, y = entity.geometry
new_pt = self._sketch.add_point(x, y)
new_pt.is_construction = False
@@ -379,9 +386,7 @@ class Sketch2DWidget(QWidget):
imported += len(lines)
except Exception as exc:
logger.debug("underlay polyline import failed: %s", exc)
logger.info(
"Imported %d construction-line segments from source face", imported
)
logger.info("Imported %d construction-line segments from source face", imported)
# Pull the new external entities into the UI lists so they're
# snap/hover/paint targets.
self._rebuild_from_sketch()
@@ -654,6 +659,15 @@ class Sketch2DWidget(QWidget):
int(self.height() / 2 - pos.y() * self._zoom + self._offset.y()),
)
def _pick_tolerance_world(self, min_pixels: float = 10.0) -> float:
"""Screen-pixel pick tolerance converted to world coordinates.
Keeps the clickable zone roughly constant in screen space regardless
of zoom level. A small floor (0.5 world units) prevents the
tolerance from vanishing when fully zoomed out.
"""
return max(0.5, min_pixels / max(self._zoom, 0.01))
# ─── Snapping ─────────────────────────────────────────────────────────
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
@@ -674,7 +688,9 @@ class Sketch2DWidget(QWidget):
nearest = point
return nearest
def _find_nearest_point_entity(self, pos: QPoint, max_distance: int = 15) -> Optional[OCCSketchEntity]:
def _find_nearest_point_entity(
self, pos: QPoint, max_distance: int = 15
) -> Optional[OCCSketchEntity]:
"""Find the nearest point entity to a screen position.
External (underlay) points are pickable when the underlay is visible
@@ -877,11 +893,13 @@ class Sketch2DWidget(QWidget):
if entity.geometry:
x, y = entity.geometry
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
if dist < 5: # tolerance in world coords
if dist < self._pick_tolerance_world(10):
return entity
return None
def _get_line_entity_at(self, world_pos: QPoint) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
def _get_line_entity_at(
self, world_pos: QPoint
) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
"""Find a line near the given world position.
External (underlay) lines are pickable when the underlay is visible
@@ -910,15 +928,15 @@ class Sketch2DWidget(QWidget):
# Compute perpendicular distance to the infinite line.
line_len_sq = dx * dx + dy * dy
# Perpendicular distance: |(P - P1) × (P2 - P1)| / |P2 - P1|
perp_dist = abs(dx * (y1 - world_pos.y()) - dy * (x1 - world_pos.x())) / math.sqrt(line_len_sq)
perp_dist = abs(dx * (y1 - world_pos.y()) - dy * (x1 - world_pos.x())) / math.sqrt(
line_len_sq
)
is_cl = line_ent is not None and self._is_centerline(line_ent)
if is_cl:
# Centerlines are infinite reference axes: use the
# perpendicular distance directly (no segment clamping).
# Zoom-adjusted tolerance: at least 20 pixels in screen space
# so the axes are easily pickable regardless of zoom level.
tol = max(10.0, 20.0 / max(self._zoom, 0.01))
tol = self._pick_tolerance_world(12)
if perp_dist < tol:
return (p1_ent, p2_ent)
else:
@@ -928,8 +946,15 @@ class Sketch2DWidget(QWidget):
t = max(0.0, min(1.0, t))
proj_x = x1 + t * dx
proj_y = y1 + t * dy
seg_dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2)
if seg_dist < 10: # tolerance
seg_dist = math.sqrt(
(world_pos.x() - proj_x) ** 2 + (world_pos.y() - proj_y) ** 2
)
# External (underlay) lines get a tighter tolerance so
# they don't steal picks from user-drawn sketch lines.
tol = (
self._pick_tolerance_world(8) if is_ext else self._pick_tolerance_world(14)
)
if seg_dist < tol:
return (p1_ent, p2_ent)
return None
@@ -965,7 +990,9 @@ class Sketch2DWidget(QWidget):
return self._sketch._entities.get(sid), self._sketch._entities.get(eid2)
return None, None
def _find_line_entity_for_line_xy(self, p1_xy: Tuple[float, float], p2_xy: Tuple[float, float]) -> Optional[OCCSketchEntity]:
def _find_line_entity_for_line_xy(
self, p1_xy: Tuple[float, float], p2_xy: Tuple[float, float]
) -> Optional[OCCSketchEntity]:
"""Find the OCCSketchEntity for a line defined by its endpoint tuples."""
for eid, start_end in self._sketch._lines.items():
sid, eid2 = start_end
@@ -974,15 +1001,25 @@ class Sketch2DWidget(QWidget):
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
sx, sy = s_ent.geometry
ex, ey = e_ent.geometry
if (abs(sx - p1_xy[0]) < 0.1 and abs(sy - p1_xy[1]) < 0.1 and
abs(ex - p2_xy[0]) < 0.1 and abs(ey - p2_xy[1]) < 0.1):
if (
abs(sx - p1_xy[0]) < 0.1
and abs(sy - p1_xy[1]) < 0.1
and abs(ex - p2_xy[0]) < 0.1
and abs(ey - p2_xy[1]) < 0.1
):
return s_ent # Return the line entity reference
if (abs(sx - p2_xy[0]) < 0.1 and abs(sy - p2_xy[1]) < 0.1 and
abs(ex - p1_xy[0]) < 0.1 and abs(ey - p1_xy[1]) < 0.1):
if (
abs(sx - p2_xy[0]) < 0.1
and abs(sy - p2_xy[1]) < 0.1
and abs(ex - p1_xy[0]) < 0.1
and abs(ey - p1_xy[1]) < 0.1
):
return s_ent
return None
def _get_constraints_for_line(self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity) -> List[str]:
def _get_constraints_for_line(
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
) -> List[str]:
"""Get constraint labels from both endpoint entities."""
return list(set(p1_ent.constraints + p2_ent.constraints))
@@ -1040,7 +1077,9 @@ class Sketch2DWidget(QWidget):
# tag is dropped rather than the paint event crashing.
logger.debug(
"_point_world: could not round geometry for entity %s (%r, %r)",
pid, x, y,
pid,
x,
y,
)
return None
@@ -1091,46 +1130,57 @@ class Sketch2DWidget(QWidget):
label = ""
if ctype == "horizontal":
anchor = self._line_world_mid(ids[0]); label = "hrz"
anchor = self._line_world_mid(ids[0])
label = "hrz"
elif ctype == "vertical":
anchor = self._line_world_mid(ids[0]); label = "vrt"
anchor = self._line_world_mid(ids[0])
label = "vrt"
elif ctype == "midpoint":
anchor = self._line_world_mid(ids[1]); label = "mid"
anchor = self._line_world_mid(ids[1])
label = "mid"
elif ctype == "distance":
# Distance may be point-to-point OR point-to-line (e.g.
# point-on-line coincident surfaces as a coincident entry;
# a future point-to-line distance would do the same).
# Use _entity_anchor so a line id routes to the line
# midpoint instead of crashing on round(<tuple>).
a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
a = self._entity_anchor(ids[0])
b = self._entity_anchor(ids[1])
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
if a is not None and b is not None:
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
label = f"dst {params[0]:.1f}" if params else "dst"
elif ctype == "parallel":
anchor = self._line_world_mid(ids[0]); label = "par"
anchor = self._line_world_mid(ids[0])
label = "par"
elif ctype == "perpendicular":
anchor = self._line_world_mid(ids[0]); label = "perp"
anchor = self._line_world_mid(ids[0])
label = "perp"
elif ctype == "angle":
anchor = self._line_world_mid(ids[0])
label = f"ang {params[0]:.0f}" if params else "ang"
elif ctype == "equal":
anchor = self._line_world_mid(ids[0]); label = "eql"
anchor = self._line_world_mid(ids[0])
label = "eql"
elif ctype == "coincident":
# Coincident can be point-to-point OR point-on-line (when
# a line is one of the targets). Use _entity_anchor so
# the line's midpoint is used as a fallback anchor when
# one of the ids is a line.
a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
a = self._entity_anchor(ids[0])
b = self._entity_anchor(ids[1])
if a is not None and b is not None:
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
label = "coin"
elif ctype == "symmetric":
anchor = self._line_world_mid(ids[2]); label = "sym"
anchor = self._line_world_mid(ids[2])
label = "sym"
elif ctype == "fixed":
anchor = self._point_world(ids[0]); label = "fix"
anchor = self._point_world(ids[0])
label = "fix"
elif ctype == "equal_radius":
anchor = self._point_world(ids[0]); label = "eqr"
anchor = self._point_world(ids[0])
label = "eqr"
else:
continue
@@ -1157,7 +1207,10 @@ class Sketch2DWidget(QWidget):
# other tags as normal.
logger.debug(
"Skipped constraint tag #%s (%s) due to %s: %s",
idx, entry.get("type"), type(exc).__name__, exc,
idx,
entry.get("type"),
type(exc).__name__,
exc,
)
continue
return tags
@@ -1242,7 +1295,7 @@ class Sketch2DWidget(QWidget):
if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry
d = math.sqrt((world.x() - cx) ** 2 + (world.y() - cy) ** 2)
if abs(d - r) < 8:
if abs(d - r) < self._pick_tolerance_world(16):
return c_ent, QPoint(int(round(cx)), int(round(cy)))
return None
@@ -1271,6 +1324,162 @@ class Sketch2DWidget(QWidget):
self.update()
return ok
# ─── Context menu: loop detection & constraint presets ─────────────────
def _find_loop_from_point(self, point_entity: OCCSketchEntity) -> Optional[List[int]]:
"""Trace a 4-sided closed loop starting from *point_entity*.
Returns ``[p1_id, l1_id, p2_id, l2_id, p3_id, l3_id, p4_id, l4_id]``
(alternating point/line ids, 8 elements for a quadrilateral) if found,
otherwise ``None``.
"""
if self._sketch is None:
return None
start_id = point_entity.id
lines = self._sketch._lines # line_id -> (p1_id, p2_id)
# Collect all lines touching the start point and the 'other' endpoint.
connected: List[Tuple[int, int]] = []
for lid, (p1, p2) in lines.items():
if p1 == start_id:
connected.append((lid, p2))
elif p2 == start_id:
connected.append((lid, p1))
if len(connected) < 2:
return None
# DFS from each first edge looking for a 4-edge cycle back to start.
for first_lid, second_pid in connected:
path: List[int] = [start_id, first_lid, second_pid]
if self._trace_loop_recursive(path, lines, start_id, depth=1):
# Strip the duplicate start point at the end so the path is
# exactly [p1,l1,p2,l2,p3,l3,p4,l4] (8 elements).
return path[:-1]
return None
@staticmethod
def _trace_loop_recursive(
path: List[int],
lines: Dict[int, Tuple[int, int]],
start_id: int,
depth: int,
) -> bool:
"""Recursively extend *path* looking for a 4-edge cycle.
``path`` alternates point-id, line-id, point-id, …
Returns ``True`` when a valid 4-edge cycle back to *start_id* is found.
"""
if depth >= 4:
return path[-1] == start_id
current_pid = path[-1]
prev_lid = path[-2]
for lid, (p1, p2) in lines.items():
if lid == prev_lid:
continue
if p1 == current_pid:
next_pid = p2
elif p2 == current_pid:
next_pid = p1
else:
continue
path.append(lid)
path.append(next_pid)
if Sketch2DWidget._trace_loop_recursive(path, lines, start_id, depth + 1):
return True
path.pop()
path.pop()
return False
def _classify_loop_lines(
self, loop: List[int]
) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
"""Classify the four lines of *loop* as horizontal-ish / vertical-ish.
Uses an angle heuristic: lines within ±45° of the u-axis are
"horizontal", the other two are "vertical".
Returns ``(horiz_entities, vert_entities)``.
"""
horiz: List[OCCSketchEntity] = []
vert: List[OCCSketchEntity] = []
if self._sketch is None:
return horiz, vert
for i in range(1, len(loop), 2): # line ids at indices 1, 3, 5, 7
lid = loop[i]
ent = self._sketch._entities.get(lid)
if ent is None:
continue
p1_id, p2_id = self._sketch._lines[lid]
p1_ent = self._sketch._entities.get(p1_id)
p2_ent = self._sketch._entities.get(p2_id)
if not p1_ent or not p2_ent or not p1_ent.geometry or not p2_ent.geometry:
continue
x1, y1 = p1_ent.geometry
x2, y2 = p2_ent.geometry
angle = abs(math.atan2(y2 - y1, x2 - x1))
if angle < math.pi / 4 or angle > 3 * math.pi / 4:
horiz.append(ent)
else:
vert.append(ent)
return horiz, vert
def _constrain_loop(self, loop: List[int], variant: str) -> None:
"""Apply constraint preset *variant* to the 4-sided *loop*.
Variants:
- ``"square"`` — H + V + equal-length
- ``"rectangle"`` — H + V
- ``"rectangle_construction"`` — H + V + diagonal construction lines
- ``"trapezoid"`` — parallel pair on the horizontal lines only
"""
if self._sketch is None:
return
sketch = self._sketch
horiz, vert = self._classify_loop_lines(loop)
if len(horiz) != 2 or len(vert) != 2:
logger.warning("Loop classification failed cannot constrain")
return
if self._undo_manager:
self._undo_manager.save_state()
# Point entities in loop order (indices 0, 2, 4, 6)
point_ents = [sketch._entities.get(loop[i]) for i in range(0, len(loop), 2)]
if variant == "trapezoid":
# Only parallel constraint on the horizontal pair.
sketch.constrain_parallel(horiz[0], horiz[1])
else:
# Square / Rectangle / Rectangle+Construction all start with H + V.
sketch.constrain_horizontal(horiz[0])
sketch.constrain_horizontal(horiz[1])
sketch.constrain_vertical(vert[0])
sketch.constrain_vertical(vert[1])
if variant == "square":
# Force one adjacent pair equal → all four equal via H + V.
sketch.constrain_equal_length(horiz[0], vert[0])
elif variant == "rectangle_construction":
# Add the two diagonals as construction lines.
p1, p2, p3, p4 = point_ents
if p1 and p2 and p3 and p4:
d1 = sketch.add_line(p1, p3)
d2 = sketch.add_line(p2, p4)
d1.is_construction = True
d2.is_construction = True
self._solve_and_sync()
sketch_updated = getattr(self, "sketch_updated", None)
if sketch_updated is not None:
sketch_updated.emit()
self.update()
# ─── Mouse events ─────────────────────────────────────────────────────
def mousePressEvent(self, event):
@@ -1283,14 +1492,16 @@ class Sketch2DWidget(QWidget):
return
if event.button() == Qt.RightButton:
self._mode = None
self._draw_buffer = []
self._dynamic_line_end = None
self._selected_entities = []
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
self.constrain_done.emit()
self.update()
# Only clear drawing-state, preserve any point selection for the
# context menu (contextMenuEvent is invoked by Qt after this).
if self._mode in ("line", "rectangle", "circle", "arc", "slot"):
self._mode = None
self._draw_buffer = []
self._dynamic_line_end = None
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
self.constrain_done.emit()
# Do NOT return — let Qt deliver the contextMenuEvent.
return
if event.button() == Qt.LeftButton:
@@ -1298,9 +1509,12 @@ class Sketch2DWidget(QWidget):
if self._mode in ("select", None) and self._sketch:
# ① Tight point check (4 px) — a deliberate grab on a point.
tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
if (tight_ent is not None and tight_ent.geometry
and not self._is_external(tight_ent)
and not self._is_centerline(tight_ent)):
if (
tight_ent is not None
and tight_ent.geometry
and not self._is_external(tight_ent)
and not self._is_centerline(tight_ent)
):
x, y = tight_ent.geometry
moving = self._collect_connected_points(tight_ent)
orig: Dict[int, Tuple[float, float]] = {}
@@ -1324,9 +1538,11 @@ class Sketch2DWidget(QWidget):
# selection zone. External (underlay) entities are fixed
# references and can't be dragged.
target = self._find_move_target(event.pos())
if (target is not None
and not self._is_external(target[0])
and not self._is_centerline(target[0])):
if (
target is not None
and not self._is_external(target[0])
and not self._is_centerline(target[0])
):
anchor_ent, anchor_world = target
moving = self._collect_connected_points(anchor_ent)
orig: Dict[int, Tuple[float, float]] = {}
@@ -1362,7 +1578,8 @@ class Sketch2DWidget(QWidget):
return
snapped_pos = self._apply_all_snaps(
event.pos(), self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None
event.pos(),
self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None,
)
world_snapped = (
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
@@ -1410,12 +1627,14 @@ class Sketch2DWidget(QWidget):
return
# Handle element move (grab all points of the element, snap the anchor)
if self._move_active and self._move_anchor is not None and self._move_anchor_orig is not None:
if (
self._move_active
and self._move_anchor is not None
and self._move_anchor_orig is not None
):
anchor_orig_screen = self._world_to_screen(self._move_anchor_orig)
exclude_ids = set(e.id for e in self._moving_points)
snapped_screen = self._apply_move_snaps(
event.pos(), anchor_orig_screen, exclude_ids
)
snapped_screen = self._apply_move_snaps(event.pos(), anchor_orig_screen, exclude_ids)
target_world = self._screen_to_world(snapped_screen)
dx = target_world.x() - self._move_anchor_orig.x()
dy = target_world.y() - self._move_anchor_orig.y()
@@ -1442,9 +1661,11 @@ class Sketch2DWidget(QWidget):
ew = self._dynamic_line_end
cx_f, cy_f = cw.x(), cw.y()
sx_f, sy_f = sw.x(), sw.y()
r = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2) if (
(sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2 > 0
) else 1.0
r = (
math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2)
if ((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2 > 0)
else 1.0
)
dx = ew.x() - cx_f
dy = ew.y() - cy_f
d = math.sqrt(dx * dx + dy * dy)
@@ -1541,7 +1762,7 @@ class Sketch2DWidget(QWidget):
if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
if abs(d - r) < 8:
if abs(d - r) < self._pick_tolerance_world(16):
over_circle = True
break
self._hovered_face = None
@@ -1595,7 +1816,9 @@ class Sketch2DWidget(QWidget):
else:
# Auto-constrain: point snap during move → coincident
if self._snap_point_target is not None and self._move_anchor is not None:
self._sketch.constrain_coincident(self._move_anchor, self._snap_point_target)
self._sketch.constrain_coincident(
self._move_anchor, self._snap_point_target
)
self._solve_and_sync()
# Snap modes are honoured during the move (see _apply_move_snaps
# in mouseMoveEvent), so the committed positions are already snapped.
@@ -1614,7 +1837,7 @@ class Sketch2DWidget(QWidget):
delta = event.angleDelta().y()
factor = 1.1 if delta > 0 else 0.9
self._zoom *= factor
self._zoom = max(0.1, min(10.0, self._zoom))
self._zoom = max(0.05, min(200.0, self._zoom))
self.update()
def keyPressEvent(self, event):
@@ -1632,8 +1855,9 @@ class Sketch2DWidget(QWidget):
return
# Redo: Ctrl+Shift+Z or Ctrl+Y
if (event.key() == Qt.Key_Z and event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)) or \
(event.key() == Qt.Key_Y and event.modifiers() & Qt.ControlModifier):
if (
event.key() == Qt.Key_Z and event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)
) or (event.key() == Qt.Key_Y and event.modifiers() & Qt.ControlModifier):
if self._undo_manager and self._undo_manager.can_redo:
self._undo_manager.redo()
self._rebuild_from_sketch()
@@ -1648,7 +1872,11 @@ class Sketch2DWidget(QWidget):
# line > point. Works in Move mode or when no tool is selected; ignored
# while actively drawing so an in-progress line isn't clobbered.
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
if (
self._mode in ("select", None)
and not self._draw_buffer
and self._sketch is not None
):
if self._hovered_constraint_idx >= 0:
self._delete_hovered_constraint()
event.accept()
@@ -1664,7 +1892,11 @@ class Sketch2DWidget(QWidget):
# C key toggles the hovered line between normal and construction mode.
if event.key() == Qt.Key_C:
if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
if (
self._mode in ("select", None)
and not self._draw_buffer
and self._sketch is not None
):
if self._hovered_line_entity is not None:
self._toggle_hovered_line_construction()
event.accept()
@@ -1672,6 +1904,83 @@ class Sketch2DWidget(QWidget):
super().keyPressEvent(event)
def contextMenuEvent(self, event):
"""Right-click context menu — constraint presets for quadrilateral loops."""
if self._sketch is None:
return
world_pos = self._screen_to_world(event.pos())
point_entity = self._get_point_entity_at(world_pos)
# Only offer the constraint menu when clicking directly on a sketch
# point that belongs to a closed 4-sided loop.
if point_entity is None or self._is_external(point_entity):
return
loop = self._find_loop_from_point(point_entity)
menu = QMenu(self)
constraint_actions = []
if loop is not None and len(loop) == 8:
horiz, vert = self._classify_loop_lines(loop)
valid = len(horiz) == 2 and len(vert) == 2
a_square = menu.addAction("Constrain as Square")
a_square.setEnabled(valid)
constraint_actions.append((a_square, "square"))
a_rect = menu.addAction("Constrain as Rectangle")
a_rect.setEnabled(valid)
constraint_actions.append((a_rect, "rectangle"))
a_rect_c = menu.addAction("Constrain as Rectangle + Construction")
a_rect_c.setEnabled(valid)
constraint_actions.append((a_rect_c, "rectangle_construction"))
a_trap = menu.addAction("Constrain as Trapezoid")
a_trap.setEnabled(valid)
constraint_actions.append((a_trap, "trapezoid"))
menu.addSeparator()
elif loop is not None:
# Loop detected but not a quadrilateral → inform the user.
QMessageBox.information(
self,
"Not a Quadrilateral",
"The selected point belongs to a shape with "
f"{len(loop) // 2} sides.\n"
"Only four-sided loops can be constrained with these presets.",
)
return
# else: no loop at all — fall through to the basic point actions.
fix_action = menu.addAction("Fix Point")
delete_action = menu.addAction("Delete Point")
action = menu.exec_(event.globalPos())
if action is None:
return
for act, variant in constraint_actions:
if action is act:
assert loop is not None # guaranteed by constraint_actions population
self._constrain_loop(loop, variant)
return
if action == fix_action:
if self._undo_manager:
self._undo_manager.save_state()
self._sketch.constrain_fixed(point_entity)
self._solve_and_sync()
self.update()
elif action == delete_action:
if self._undo_manager:
self._undo_manager.save_state()
self._sketch.delete_point(point_entity)
self._rebuild_from_sketch()
self._solve_and_sync()
self.update()
def _delete_hovered_constraint(self):
"""Delete the hovered constraint (by log index) and recompute the rest."""
idx = self._hovered_constraint_idx
@@ -1779,9 +2088,7 @@ class Sketch2DWidget(QWidget):
# Flip the construction flag on the line entity itself.
new_val = not line_ent.is_construction
line_ent.is_construction = new_val
logger.info(
f"Toggled line {line_ent.id} construction -> {new_val}"
)
logger.info(f"Toggled line {line_ent.id} construction -> {new_val}")
self._hovered_line = None
self._hovered_line_entity = None
self._solve_and_sync()
@@ -2080,6 +2387,7 @@ class Sketch2DWidget(QWidget):
# Degenerate: centers on top of each other → draw a circle
# using the midpoint-to-cursor distance as radius.
import math as _m
r_fallback = _m.hypot(pos.x() - c1_pos.x(), pos.y() - c1_pos.y())
if r_fallback > 0:
self._sketch.add_circle(c1_ent, r_fallback)
@@ -2093,8 +2401,8 @@ class Sketch2DWidget(QWidget):
# Perpendicular distance from cursor to the centerline C1↔C2.
import math as _m
r = abs((pos.x() - c1_pos.x()) * dy_c
- (pos.y() - c1_pos.y()) * dx_c) / _m.sqrt(L_sq)
r = abs((pos.x() - c1_pos.x()) * dy_c - (pos.y() - c1_pos.y()) * dx_c) / _m.sqrt(L_sq)
if r < 0.5:
r = 0.5 # minimum radius so the slot doesn't collapse
@@ -2113,9 +2421,9 @@ class Sketch2DWidget(QWidget):
# Top / bottom are relative to the perpendicular direction.
# Perp points "up" from the centreline; -perp points "down".
t1 = corner(c1_pos.x(), c1_pos.y(), 1) # C1 + perp
t1 = corner(c1_pos.x(), c1_pos.y(), 1) # C1 + perp
b1 = corner(c1_pos.x(), c1_pos.y(), -1) # C1 - perp
t2 = corner(c2_pos.x(), c2_pos.y(), 1) # C2 + perp
t2 = corner(c2_pos.x(), c2_pos.y(), 1) # C2 + perp
b2 = corner(c2_pos.x(), c2_pos.y(), -1) # C2 - perp
# Create point entities
@@ -2137,9 +2445,7 @@ class Sketch2DWidget(QWidget):
for corner_pt in (pt1, pb1, pt2, pb2):
if corner_pt.geometry is not None:
cgx, cgy = corner_pt.geometry
snap_target = self._get_point_entity_at(
QPoint(int(round(cgx)), int(round(cgy)))
)
snap_target = self._get_point_entity_at(QPoint(int(round(cgx)), int(round(cgy))))
if snap_target is not None and snap_target.id != corner_pt.id:
self._sketch.constrain_coincident(corner_pt, snap_target)
@@ -2322,8 +2628,15 @@ class Sketch2DWidget(QWidget):
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
if line_ent is not None:
# Line clicked: constrain its length (endpoint distance).
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
self._constraint_distance_value, 0, 10000, 2)
dist, ok = QInputDialog.getDouble(
self,
"Distance",
"Distance (mm):",
self._constraint_distance_value,
0,
10000,
2,
)
if ok and self._sketch:
# Save state before adding constraint
if self._undo_manager:
@@ -2339,8 +2652,9 @@ class Sketch2DWidget(QWidget):
if len(self._selected_entities) >= 2:
e1, e2 = self._selected_entities[:2]
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
self._constraint_distance_value, 0, 10000, 2)
dist, ok = QInputDialog.getDouble(
self, "Distance", "Distance (mm):", self._constraint_distance_value, 0, 10000, 2
)
if ok and self._sketch:
# Save state before adding constraint
if self._undo_manager:
@@ -2495,12 +2809,11 @@ class Sketch2DWidget(QWidget):
if c_ent.geometry and r > 0:
cx, cy = c_ent.geometry
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
if abs(d - r) < 8: # Clicked on circle circumference
if abs(d - r) < self._pick_tolerance_world(16): # Clicked on circle circumference
# Prompt for diameter value
current_diameter = r * 2.0
diameter, ok = QInputDialog.getDouble(
self, "Diameter", "Diameter (mm):",
current_diameter, 0, 10000, 2
self, "Diameter", "Diameter (mm):", current_diameter, 0, 10000, 2
)
if ok and self._sketch:
# Save state before adding constraint
@@ -2515,9 +2828,7 @@ class Sketch2DWidget(QWidget):
self._circles[i] = (ent, new_radius)
break
# Record constraint for undo/redo
self._sketch._record_constraint(
"diameter", (c_ent.id,), (diameter,)
)
self._sketch._record_constraint("diameter", (c_ent.id,), (diameter,))
self._solve_and_sync()
logger.info(f"Diameter constraint: {diameter:.2f}mm")
self._selected_entities = []
@@ -2585,8 +2896,8 @@ class Sketch2DWidget(QWidget):
# regardless of zoom. When zoomed out too far to show 10mm lines
# clearly, only the 100mm major grid is drawn.
MIN_PX_SPACING = 6.0 # skip a grid level if screen spacing is below this
grid_10 = 10 # 10mm minor grid
grid_100 = 100 # 100mm major grid
grid_10 = 10 # 10mm minor grid
grid_100 = 100 # 100mm major grid
# Compute screen-space spacing
px_10 = grid_10 * self._zoom
@@ -2635,6 +2946,27 @@ class Sketch2DWidget(QWidget):
painter.drawLine(0, sy, self.width(), sy)
wy += grid_10
# ── Draw 1mm fine grid (only at high zoom for precision work) ──
grid_1 = 1 # 1mm fine grid
px_1 = grid_1 * self._zoom
if px_1 >= MIN_PX_SPACING:
pen_fine = QPen(QColor("#353548"), 0.3)
start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_1) * grid_1
end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_1) * grid_1
start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_1) * grid_1
end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_1) * grid_1
painter.setPen(pen_fine)
wx = start_x
while wx <= end_x:
sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
painter.drawLine(sx, 0, sx, self.height())
wx += grid_1
wy = start_y
while wy <= end_y:
sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
painter.drawLine(0, sy, self.width(), sy)
wy += grid_1
# ── Centerlines (X and Y reference axes through origin) ──
# X centerline = horizontal (red dashed), Y centerline = vertical (green dashed).
# Both span the full viewport so they are always visible as reference guides.
@@ -2660,10 +2992,12 @@ class Sketch2DWidget(QWidget):
# underlay on top of the entity-based lines.
if self._source_underlay_uv and self._underlay_visible:
if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
fill_poly = QPolygonF([
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
for (u, v) in self._source_underlay_uv[0]
])
fill_poly = QPolygonF(
[
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
for (u, v) in self._source_underlay_uv[0]
]
)
painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
painter.setPen(Qt.NoPen)
painter.drawPolygon(fill_poly)
@@ -2713,9 +3047,7 @@ class Sketch2DWidget(QWidget):
continue
if entity.geometry:
x, y = entity.geometry
screen_pos = self._world_to_screen(
QPoint(int(round(x)), int(round(y)))
)
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
painter.setPen(QPen(QColor("#fab387"), 1))
painter.setBrush(QBrush(QColor("#fab387")))
painter.drawEllipse(screen_pos, 4, 4)
@@ -2736,7 +3068,11 @@ class Sketch2DWidget(QWidget):
sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
is_construction = p1_ent.is_construction or p2_ent.is_construction or (line_ent is not None and line_ent.is_construction)
is_construction = (
p1_ent.is_construction
or p2_ent.is_construction
or (line_ent is not None and line_ent.is_construction)
)
if is_construction:
painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine))
else:
@@ -2811,8 +3147,10 @@ class Sketch2DWidget(QWidget):
QPoint(int(round(preview[i][0])), int(round(preview[i][1])))
)
p2 = self._world_to_screen(
QPoint(int(round(preview[(i + 1) % len(preview)][0])),
int(round(preview[(i + 1) % len(preview)][1])))
QPoint(
int(round(preview[(i + 1) % len(preview)][0])),
int(round(preview[(i + 1) % len(preview)][1])),
)
)
painter.drawLine(p1, p2)
@@ -2827,8 +3165,42 @@ class Sketch2DWidget(QWidget):
p1 = self._world_to_screen(self._draw_buffer[0])
p2 = self._world_to_screen(self._dynamic_line_end)
painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
painter.drawRect(min(p1.x(), p2.x()), min(p1.y(), p2.y()),
abs(p2.x() - p1.x()), abs(p2.y() - p1.y()))
painter.drawRect(
min(p1.x(), p2.x()), min(p1.y(), p2.y()), abs(p2.x() - p1.x()), abs(p2.y() - p1.y())
)
# ── Dimension labels for rectangle preview ──
w1 = self._draw_buffer[0]
w2 = self._dynamic_line_end
width_mm = abs(w2.x() - w1.x())
height_mm = abs(w2.y() - w1.y())
rx = min(p1.x(), p2.x())
ry = min(p1.y(), p2.y())
rw = abs(p2.x() - p1.x())
rh = abs(p2.y() - p1.y())
painter.save()
painter.setPen(QPen(QColor("#f9e2af"), 1))
font = painter.font()
font.setPointSize(9)
painter.setFont(font)
# Horizontal label (top edge, centered)
if rw > 20:
h_label = f"{width_mm:.1f} mm"
h_rect = painter.fontMetrics().boundingRect(h_label)
h_x = rx + rw / 2 - h_rect.width() / 2
h_y = ry - 8 if ry > 20 else ry + rh + 16
painter.drawText(int(h_x), int(h_y), h_label)
# Vertical label (right edge, centered)
if rh > 20:
v_label = f"{height_mm:.1f} mm"
v_rect = painter.fontMetrics().boundingRect(v_label)
v_x = (
rx + rw + 8
if rx + rw + v_rect.width() + 16 < self.width()
else rx - v_rect.width() - 8
)
v_y = ry + rh / 2 + v_rect.height() / 2
painter.drawText(int(v_x), int(v_y), v_label)
painter.restore()
if self._draw_buffer and self._dynamic_line_end and self._mode == "circle":
center = self._world_to_screen(self._draw_buffer[0])
@@ -2849,8 +3221,8 @@ class Sketch2DWidget(QWidget):
painter.drawEllipse(center, int(r), int(r))
elif len(self._draw_buffer) == 2:
# Click 2 done: show arc from start to cursor
cw = self._draw_buffer[0] # center world
sw = self._draw_buffer[1] # start world
cw = self._draw_buffer[0] # center world
sw = self._draw_buffer[1] # start world
ew = self._dynamic_line_end # end world (mouse)
cx_f, cy_f = cw.x(), cw.y()
@@ -2897,8 +3269,7 @@ class Sketch2DWidget(QWidget):
dy_c = c2.y() - c1.y()
L = math.sqrt(dx_c * dx_c + dy_c * dy_c)
if L > 0:
r = abs((cursor.x() - c1.x()) * dy_c
- (cursor.y() - c1.y()) * dx_c) / L
r = abs((cursor.x() - c1.x()) * dy_c - (cursor.y() - c1.y()) * dx_c) / L
if r < 0.5:
r = 0.5
perp_x = -dy_c / L
@@ -2983,9 +3354,11 @@ class Sketch2DWidget(QWidget):
outer = face["outer"]
if outer["type"] == "polygon":
pts = outer["points"]
sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1]))))
sp0 = self._world_to_screen(
QPoint(int(round(pts[0][0])), int(round(pts[0][1])))
)
path.moveTo(sp0.x(), sp0.y())
for (px, py) in pts[1:]:
for px, py in pts[1:]:
sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
path.lineTo(sp.x(), sp.y())
path.closeSubpath()
@@ -2998,9 +3371,11 @@ class Sketch2DWidget(QWidget):
for hole in face["holes"]:
if hole["type"] == "polygon":
pts = hole["points"]
sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1]))))
sp0 = self._world_to_screen(
QPoint(int(round(pts[0][0])), int(round(pts[0][1])))
)
path.moveTo(sp0.x(), sp0.y())
for (px, py) in pts[1:]:
for px, py in pts[1:]:
sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
path.lineTo(sp.x(), sp.y())
path.closeSubpath()