diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py index 8f71c2c..dde4408 100644 --- a/src/fluency/ui/sketch_widget.py +++ b/src/fluency/ui/sketch_widget.py @@ -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(). - 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()