"""2D sketch widget — paints the sketcher, handles drawing / snapping / constraints.""" from __future__ import annotations 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.QtGui import ( QBrush, QColor, QCursor, QFont, QFontMetrics, QPainter, QPainterPath, QPen, QPolygonF, ) from PySide6.QtWidgets import ( QInputDialog, QWidget, ) from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity 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]], ) -> List[List[Tuple[float, float]]]: """Project a planar ``TopoDS_Face``'s boundary edges into the UV frame. *workplane* is (origin, normal, x_dir). Returns a list of polylines, each a list of (u, v) points, one per boundary edge (lines \u2192 endpoints, curves \u2192 sampled). Used by the 2D sketch widget to draw the face as an underlay when sketching on a surface. """ import numpy as np from OCP.TopExp import TopExp_Explorer from OCP.TopAbs import TopAbs_EDGE, TopAbs_WIRE from OCP.TopoDS import TopoDS from OCP.BRepAdaptor import BRepAdaptor_Curve 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 x_dir = x_dir / np.linalg.norm(x_dir) normal = normal / np.linalg.norm(normal) y_dir = np.cross(normal, x_dir) y_dir = y_dir / np.linalg.norm(y_dir) def world_to_uv(p: gp_Pnt) -> Tuple[float, float]: v = np.array([p.X() - origin[0], p.Y() - origin[1], p.Z() - origin[2]]) return (float(np.dot(v, x_dir)), float(np.dot(v, y_dir))) polylines: List[List[Tuple[float, float]]] = [] # Iterate wires of the face (outer + inner = holes), then edges. wire_expl = TopExp_Explorer(face, TopAbs_WIRE) while wire_expl.More(): wire = wire_expl.Current() edge_expl = TopExp_Explorer(wire, TopAbs_EDGE) while edge_expl.More(): edge = TopoDS.Edge_s(edge_expl.Current()) try: crv = BRepAdaptor_Curve(edge) f = crv.FirstParameter() l = crv.LastParameter() is_line = crv.GetType() == GeomAbs_Line if is_line: pts = [crv.Value(f), crv.Value(l)] else: # Sample 32 segments across the parameter range. pts = [crv.Value(f + (l - f) * i / 32.0) for i in range(33)] poly = [world_to_uv(p) for p in pts] polylines.append(poly) except Exception: pass edge_expl.Next() wire_expl.Next() return polylines class Sketch2DWidget(QWidget): """2D sketching widget with SolveSpace constraint solving and drawing tools.""" constrain_done = Signal() sketch_updated = Signal() def __init__(self, parent=None): super().__init__(parent) self.setMinimumSize(400, 300) self.setMouseTracking(True) self._sketch: Optional[OCCSketch] = None self._mode: Optional[str] = None self._is_construct: bool = False 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) # 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_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._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 self._selected_entities: List[OCCSketchEntity] = [] # Source face for sketch-on-surface: the planar face the user picked # in the 3D viewer, plus its workplane (origin/normal/x_dir). Phase 3 # projects this face's edges into UV and draws them as an underlay. self._source_face: Any = None self._source_workplane: Optional[Tuple[Tuple[float, float, float], ...]] = None self._source_underlay_uv: List[Any] = [] # cached UV polylines for paintEvent # Underlay visibility: the dashed construction lines projected from # the source face can be hidden/shown without losing the source # face reference (useful when the underlay is too busy). Toggled # from the "Underlay" button in the MainWindow UI. self._underlay_visible: bool = True self._snap_mode: Dict[str, bool] = { "point": True, "mpoint": False, "horiz": False, "vert": False, "grid": False, "angle": False, } self._snap_distance: int = 10 self._angle_steps: int = 15 self._zoom: float = 1.0 self._offset: QPoint = QPoint(0, 0) self._panning: bool = False self._pan_start: Optional[QPoint] = None self._dynamic_line_end: Optional[QPoint] = None self._temp_entities: List[Any] = [] self._constraint_distance_value: float = 10.0 # Pending distance constraint input self._pending_distance_val: Optional[float] = None # Element move state (move tool / select mode) self._moving_points: List[OCCSketchEntity] = [] self._move_anchor: Optional[OCCSketchEntity] = None self._move_anchor_orig: Optional[QPoint] = None self._move_orig_positions: Dict[int, Tuple[float, float]] = {} self._move_active: bool = False # Auto-constraint tracking on snap self._snap_point_target: Optional[OCCSketchEntity] = None self._snap_line_target: Optional[OCCSketchEntity] = None self._snap_horizontal: bool = False self._snap_vertical: bool = False # Rectangle first-click snap target (stored so the second click # doesn't overwrite it and the correct corner gets constrained). self._rect_first_snap_target: Optional[OCCSketchEntity] = None # Offset preview state (live preview while the OffsetDialog is open). self._offset_preview_points: List[Tuple[float, float]] = [] self._offset_preview_active: bool = False self.setFocusPolicy(Qt.StrongFocus) self._setup_ui() def _setup_ui(self): self.setStyleSheet("background-color: #1e1e2e;") def set_sketch(self, sketch: Optional[OCCSketch]): self._sketch = sketch self._rebuild_from_sketch() self._draw_buffer = [] self._clear_face_state() # If the new sketch carries a workplane, refresh the source underlay. self._refresh_source_underlay() # A brand new sketch has no external entities — strip the old # underlay from the previous sketch so the construction lines # don't bleed into the new sketch. (set_source_face will reimport # them if the new sketch is on a face too.) if self._sketch is not None and self._sketch is not sketch: self._sketch.remove_external_entities() self.update() def clear_source_face(self) -> None: """Forget the picked source face and remove the underlay entities. Use this when the user wants to drop the face reference (e.g. they want to draw a free-standing sketch without the body's outline showing through). Removes the underlay entities from the solver, clears the cached polyline data, and resets the view to whatever zoom the user had before the face was set. """ if self._sketch is not None: self._sketch.remove_external_entities() self._source_face = None self._source_workplane = None self._source_underlay_uv = [] self._rebuild_from_sketch() self.clear_offset_preview() self._hovered_point = None self._hovered_point_entity = None self._hovered_line = None self._hovered_line_entity = None self._selected_entities = [] self.update() def _convert_underlay_to_sketch(self) -> None: """Convert underlay (external/construction) entities into regular geometry. Creates new non-construction point and line entities at the same positions as every external entity in the sketch, coincident- constrained to their external counterparts so they stay aligned. The underlay reference entities are left intact (the user can still toggle them on/off). After calling this, the newly created regular geometry participates in face detection and can be selected for offset, extrude, or cut operations — without the user having to manually trace the projected outlines. """ if self._sketch is None: return # Map external point IDs → newly created regular point entities ext_to_new: Dict[int, OCCSketchEntity] = {} # 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 ): x, y = entity.geometry new_pt = self._sketch.add_point(x, y) new_pt.is_construction = False self._points.append(new_pt) self._sketch.constrain_coincident(new_pt, entity) ext_to_new[eid] = new_pt if not ext_to_new: return # Create matching lines for each external line segment for eid, (sid, eid2) in list(self._sketch._lines.items()): s_ent = self._sketch._entities.get(sid) e_ent = self._sketch._entities.get(eid2) if s_ent and e_ent and s_ent.is_external and e_ent.is_external: new_s = ext_to_new.get(sid) new_e = ext_to_new.get(eid2) if new_s is not None and new_e is not None: new_line = self._sketch.add_line(new_s, new_e) self._lines.append((new_s, new_e)) self._solve_and_sync() self._rebuild_from_sketch() # Hide the underlay so the user can see the new solid geometry self._underlay_visible = False self.update() def set_source_face( self, face: Any, origin: Tuple[float, float, float], normal: Tuple[float, float, float], x_dir: Tuple[float, float, float], ) -> None: """Store the picked 3D face and reorient the 2D view to its plane. Called by MainWindow after a face pick. Projects the face's boundary edges into the sketch's UV frame, caches them for the underlay fill, *and* imports them as construction-line entities in the underlying OCCSketch. Those entities are fixed in the solver, so the user can snap to them and add distance / horizontal / vertical / parallel / perpendicular / midpoint / coincident constraints against them — e.g. place a hole "50 mm from the body's top edge" by clicking the underay corner, the hole centre, and entering 50. Also re-centres/scales the 2D view to look down the plane. """ self._source_face = face self._source_workplane = (tuple(origin), tuple(normal), tuple(x_dir)) # Ensure the OCCSketch shares the same workplane so UV↔world agrees. if self._sketch is not None: self._sketch.set_workplane(origin, normal, x_dir) self._refresh_source_underlay() self._import_underlay_as_construction_lines() self._orient_view_to_plane() self.update() def _refresh_source_underlay(self) -> None: """Project the source face's boundary edges into UV for the underlay.""" self._source_underlay_uv = [] if self._source_face is None or self._source_workplane is None: return if self._sketch is None: return try: self._source_underlay_uv = _project_face_to_uv( self._source_face, self._source_workplane ) except Exception: logger.debug("source underlay projection failed", exc_info=True) def set_underlay_visible(self, visible: bool) -> None: """Show or hide the underlay (face-projected construction lines). When hidden, the external entities stay in the OCCSketch solver (constraints referencing them keep working) but they're not rendered, snapped to, or hit-tested in the 2D view. """ self._underlay_visible = bool(visible) # Re-render and drop any in-flight hover that pointed at an underlay # entity (otherwise the cursor would freeze on a no-longer-drawn # underlay element after the user hides it). if not self._underlay_visible: self._hovered_point = None self._hovered_point_entity = None self._hovered_line = None self._hovered_line_entity = None self.update() def _import_underlay_as_construction_lines(self) -> None: """Convert the projected face edges into real construction-line entities. Each polyline in ``_source_underlay_uv`` becomes a chain of external point entities and external line segment entities in the underlying OCCSketch. External points/lines are tagged ``is_external`` and ``is_construction`` so the paintEvent renders them as dashed construction lines, and so the sketch profile path (detect_faces / get_geometry) skips them. The solver marks every external point fixed via ``dragged``, so a user drag of a related entity never moves the underlay. If a previous underlay was already imported it is cleared first so we don't accumulate duplicates on a re-pick of the same face. """ if self._sketch is None or not self._source_underlay_uv: return # Clear any prior external entities before importing fresh ones so a # repeated face pick doesn't pile up duplicate construction lines. self._sketch.remove_external_entities() imported = 0 for poly in self._source_underlay_uv: if len(poly) < 2: continue try: _, lines = self._sketch.add_external_polyline( [(float(u), float(v)) for (u, v) in poly] ) 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 ) # Pull the new external entities into the UI lists so they're # snap/hover/paint targets. self._rebuild_from_sketch() def _orient_view_to_plane(self) -> None: """Centre & scale the 2D view to fit the source face's UV bounds.""" if not self._source_underlay_uv: return # Collect all UV points across all cached polylines. all_pts = [pt for poly in self._source_underlay_uv for pt in poly] if not all_pts: return us = [p[0] for p in all_pts] vs = [p[1] for p in all_pts] umin, umax = min(us), max(us) vmin, vmax = min(vs), max(vs) cu, cv = (umin + umax) / 2.0, (vmin + vmax) / 2.0 du, dv = max(umax - umin, 1e-6), max(vmax - vmin, 1e-6) # Zoom so the face fits with ~20% margin; offset so the face centre # maps to the widget centre (world (cu,cv) → screen centre). w, h = max(self.width(), 100), max(self.height(), 100) self._zoom = min(w / (du * 1.2), h / (dv * 1.2)) # _world_to_screen: screen = world*zoom + centre + offset. # We want world (cu,cv) → screen (w/2, h/2). Solve for offset. # screen_x = cu*zoom + w/2 + offset_x → offset_x = -cu*zoom # screen_y = h/2 - cv*zoom + offset_y → offset_y = cv*zoom self._offset = QPoint(int(-cu * self._zoom), int(cv * self._zoom)) self.update() def _clear_face_state(self): self._hovered_face = None self._selected_face = None def get_selected_face_geometry(self) -> Any: """Return the OCCGeometryObject for the currently selected face, or None.""" if self._selected_face is not None and self._sketch is not None: return self._sketch.build_face_geometry(self._selected_face) return None def clear_selected_face(self): self._selected_face = None self._hovered_face = None @staticmethod def _faces_match(a: Any, b: Any) -> bool: """Content-based face comparison (dicts may be from different calls to detect_faces).""" if a is None or b is None: return False return Sketch2DWidget._loop_match(a.get("outer"), b.get("outer")) @staticmethod def _loop_match(a: Any, b: Any) -> bool: if a is None or b is None or a["type"] != b["type"]: return False if a["type"] == "polygon": return a["points"][0] == b["points"][0] and len(a["points"]) == len(b["points"]) else: # circle return a["center"] == b["center"] and a["radius"] == b["radius"] def _rebuild_from_sketch(self): """Rebuild UI point/line lists from the OCCSketch entity data. External (underlay) entities are included in the UI lists so they are valid pick targets for constraints and snap. The hit-test / move / delete paths all check ``is_external`` and either skip them (delete) or refuse to start a drag on them (move). """ self._points = [] self._lines = [] self._circles = [] self._arcs = [] self._selected_entities = [] if self._sketch: # Collect points in creation order (user + external). for eid, entity in self._sketch._entities.items(): if entity.entity_type == "point": self._points.append(entity) for eid, entity in self._sketch._entities.items(): if entity.entity_type == "line" and eid in self._sketch._lines: sid, eid2 = self._sketch._lines[eid] s_ent = self._sketch._entities.get(sid) e_ent = self._sketch._entities.get(eid2) if s_ent and e_ent: self._lines.append((s_ent, e_ent)) for eid, (cid, r) in self._sketch._circles.items(): c_ent = self._sketch._entities.get(cid) if c_ent: self._circles.append((c_ent, r)) # Rebuild arcs: arc data is stored as {"center": cid, "start": sid, "end": eid2, "radius": r} for eid, arc_data in self._sketch._arcs.items(): center_id = arc_data.get("center") start_id = arc_data.get("start") end_id = arc_data.get("end") radius = arc_data.get("radius", 0) sweep = arc_data.get("sweep") # may be None (legacy arcs) c_ent = self._sketch._entities.get(center_id) if center_id else None s_ent = self._sketch._entities.get(start_id) if start_id else None e_ent = self._sketch._entities.get(end_id) if end_id else None if c_ent and s_ent and e_ent: self._arcs.append((c_ent, radius, s_ent, e_ent, sweep)) @staticmethod def _is_external(entity: Any) -> bool: """True if an entity is a face-projected underlay / reference entity. External entities live in the solver so constraints can reference them, but they're protected from user deletion, dragging, and profile extrusion. """ return bool(entity is not None and getattr(entity, "is_external", False)) def _is_centerline(self, entity: Any) -> bool: """True if an entity is a centerline reference axis (X or Y). Centerlines are the permanent X (horizontal) and Y (vertical) construction lines through the sketch origin. They are rendered with distinct colors (red for X, green for Y) as viewport-spanning dashed lines, and are protected from deletion. """ if entity is None or self._sketch is None: return False return entity.id in self._sketch._centerline_ids def get_sketch(self) -> Optional[OCCSketch]: return self._sketch def create_sketch(self) -> OCCSketch: self._sketch = OCCSketch() self._sketch.add_centerlines() # Sync widget tracking lists from sketch so centerlines (and any # future auto-created entities) are immediately pickable. self._rebuild_from_sketch() self.update() return self._sketch def _ensure_sketch_with_centerlines(self) -> None: """Create a sketch with centerlines if none exists yet. Drawing handlers (line, rect, circle, arc, slot) auto-create an OCCSketch when the user starts drawing without first pressing WP Origin — but the old code used ``OCCSketch()`` directly, bypassing ``create_sketch()`` and leaving the sketch without reference axes. This helper ensures centerlines are always present, whether the sketch was created via WP Origin, WP Face, or on-the-fly by a drawing tool. """ if self._sketch is None: self._sketch = OCCSketch() self._sketch.add_centerlines() self._rebuild_from_sketch() def reset_buffers(self): self._draw_buffer = [] self._dynamic_line_end = None self._mode = None self._clear_move_state() self._rect_first_snap_target = None self.clear_offset_preview() self.update() def set_offset_preview(self, points: Optional[List[Tuple[float, float]]]) -> None: """Show or clear the offset preview overlay in the 2D view. *points* is a list of (x, y) tuples forming a closed polygon, or *None* / empty to clear the preview. Used by the OffsetDialog live-preview callback to show the offset result in real time. """ if points: self._offset_preview_points = list(points) self._offset_preview_active = True else: self._offset_preview_points = [] self._offset_preview_active = False self.update() def clear_offset_preview(self) -> None: """Remove the offset preview overlay.""" self._offset_preview_points = [] self._offset_preview_active = False self.update() def _clear_move_state(self): """Reset all element-move drag state.""" self._moving_points = [] self._move_anchor = None self._move_anchor_orig = None self._move_orig_positions = {} self._move_active = False self._hovered_face = None self._snap_point_target = None self._snap_line_target = None self._snap_horizontal = False self._snap_vertical = False self._rect_first_snap_target = None self._arc_accum_sweep = 0.0 self._arc_prev_angle = None def set_mode(self, mode: Optional[str]): self._mode = mode self._draw_buffer = [] self._dynamic_line_end = None self._selected_entities = [] self._hovered_constraint_idx = -1 self._hovered_face = None self._arc_accum_sweep = 0.0 self._arc_prev_angle = None self.clear_offset_preview() # Cancel an ongoing move when switching modes if self._move_active: self._clear_move_state() self.setCursor(Qt.ArrowCursor) self.update() def set_construct_mode(self, enabled: bool): self._is_construct = enabled def set_snap_mode(self, snap_type: str, enabled: bool): self._snap_mode[snap_type] = enabled def set_snap_distance(self, distance: int): self._snap_distance = distance def set_angle_steps(self, steps: int): self._angle_steps = steps def set_constraint_distance(self, distance: float): self._constraint_distance_value = distance # ─── Coordinate transforms ──────────────────────────────────────────── def _screen_to_world(self, pos: QPoint) -> QPoint: return QPoint( int((pos.x() - self.width() / 2 - self._offset.x()) / self._zoom), int((self.height() / 2 - pos.y() + self._offset.y()) / self._zoom), ) def _screen_to_world_f(self, pos: QPoint) -> Tuple[float, float]: """Float-precision version of ``_screen_to_world``. Used by face detection so that small shapes (sub-integer size at the current zoom) are pickable. The integer version truncates fractional world coords, which can place the hit point outside a tiny polygon. """ wx = (pos.x() - self.width() / 2 - self._offset.x()) / self._zoom wy = (self.height() / 2 - pos.y() + self._offset.y()) / self._zoom return (wx, wy) def _world_to_screen(self, pos: QPoint) -> QPoint: return QPoint( int(pos.x() * self._zoom + self.width() / 2 + self._offset.x()), int(self.height() / 2 - pos.y() * self._zoom + self._offset.y()), ) # ─── Snapping ───────────────────────────────────────────────────────── def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]: if not self._snap_mode.get("point", False): return None nearest = None min_dist = max_distance for entity in self._points: if entity.geometry: x, y = entity.geometry point = QPoint(int(round(x)), int(round(y))) screen_point = self._world_to_screen(point) dist = math.sqrt( (pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2 ) if dist < min_dist: min_dist = dist nearest = point return nearest 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 so the user can use them as constraint anchors; they are skipped otherwise. The function still respects the ``point`` snap mode toggle so the user can disable snapping entirely. """ if not self._snap_mode.get("point", False): return None nearest = None min_dist = max_distance for entity in self._points: if self._is_external(entity) and not self._underlay_visible: continue if entity.geometry: x, y = entity.geometry point = QPoint(int(round(x)), int(round(y))) screen_point = self._world_to_screen(point) dist = math.sqrt( (pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2 ) if dist < min_dist: min_dist = dist nearest = entity return nearest def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]: if not self._snap_mode.get("mpoint", False): return None for p1, p2 in self._lines: if p1.geometry and p2.geometry: x1, y1 = p1.geometry x2, y2 = p2.geometry mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2))) screen_mid = self._world_to_screen(mid) dist = math.sqrt((pos.x() - screen_mid.x()) ** 2 + (pos.y() - screen_mid.y()) ** 2) if dist < max_distance: return mid return None def _apply_angle_snap(self, start: QPoint, end: QPoint) -> QPoint: if not self._snap_mode.get("angle", False): return end dx = end.x() - start.x() dy = end.y() - start.y() angle = math.degrees(math.atan2(dy, dx)) length = math.sqrt(dx**2 + dy**2) snapped_angle = round(angle / self._angle_steps) * self._angle_steps snapped_rad = math.radians(snapped_angle) return QPoint( int(start.x() + length * math.cos(snapped_rad)), int(start.y() + length * math.sin(snapped_rad)), ) def _apply_horizontal_snap(self, start: QPoint, end: QPoint) -> QPoint: if not self._snap_mode.get("horiz", False): return end return QPoint(end.x(), start.y()) def _apply_vertical_snap(self, start: QPoint, end: QPoint) -> QPoint: if not self._snap_mode.get("vert", False): return end return QPoint(start.x(), end.y()) def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None) -> QPoint: result = pos # Reset auto-constraint tracking self._snap_point_target = None self._snap_line_target = None self._snap_horizontal = False self._snap_vertical = False point_snap = self._find_nearest_point(pos) if point_snap: self._snap_point_target = self._find_nearest_point_entity(pos) return self._world_to_screen(point_snap) if self._snap_mode.get("mpoint", False): mid_snap = self._find_midpoint_snap(pos) if mid_snap: return self._world_to_screen(mid_snap) if start: if self._snap_mode.get("horiz", False): horiz = self._apply_horizontal_snap(start, result) if abs(result.y() - start.y()) < 10: result = horiz self._snap_horizontal = True if self._snap_mode.get("vert", False): vert = self._apply_vertical_snap(start, result) if abs(result.x() - start.x()) < 10: result = vert self._snap_vertical = True if self._snap_mode.get("angle", False): result = self._apply_angle_snap(start, result) # Grid snap as a final fallback (only when nothing else applied) if result == pos and self._snap_mode.get("grid", False): grid_snap = self._find_grid_snap(pos) if grid_snap is not None: result = self._world_to_screen(grid_snap) return result def _find_grid_snap(self, pos: QPoint) -> Optional[QPoint]: """Return the nearest world-space grid intersection for a screen position.""" if not self._snap_mode.get("grid", False): return None world = self._screen_to_world(pos) grid_step = 10 # 10mm world units per grid cell (matches 10mm minor grid) gx = round(world.x() / grid_step) * grid_step gy = round(world.y() / grid_step) * grid_step return QPoint(gx, gy) def _apply_move_snaps( self, mouse_screen: QPoint, anchor_orig_screen: QPoint, exclude_ids: set, ) -> QPoint: """Snapping used while moving an element. The moved element's own points/lines are excluded from being snap candidates so the element never snaps to itself. ``anchor_orig_screen`` is the screen position of the grabbed anchor BEFORE the move started; it is used as the reference origin for horizontal/vertical/angle snapping. """ pos = mouse_screen # Reset auto-constraint tracking self._snap_point_target = None self._snap_line_target = None self._snap_horizontal = False self._snap_vertical = False # Point snap (excluding moved points) if self._snap_mode.get("point", False): nearest = None nearest_entity = None min_dist = self._snap_distance for entity in self._points: if entity.id in exclude_ids or not entity.geometry: continue x, y = entity.geometry sp = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2) if d < min_dist: min_dist = d nearest = sp nearest_entity = entity if nearest is not None: self._snap_point_target = nearest_entity return nearest # Midpoint snap (excluding lines whose both endpoints are being moved) if self._snap_mode.get("mpoint", False): nearest = None min_dist = self._snap_distance for p1, p2 in self._lines: if p1.id in exclude_ids and p2.id in exclude_ids: continue if p1.geometry and p2.geometry: x1, y1 = p1.geometry x2, y2 = p2.geometry mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2))) sm = self._world_to_screen(mid) d = math.sqrt((pos.x() - sm.x()) ** 2 + (pos.y() - sm.y()) ** 2) if d < min_dist: min_dist = d nearest = sm if nearest is not None: return nearest # Horizontal / vertical / angle snaps are relative to the original anchor result = pos if self._snap_mode.get("horiz", False): if abs(result.y() - anchor_orig_screen.y()) < 10: result = QPoint(result.x(), anchor_orig_screen.y()) if self._snap_mode.get("vert", False): if abs(result.x() - anchor_orig_screen.x()) < 10: result = QPoint(anchor_orig_screen.x(), result.y()) if self._snap_mode.get("angle", False): result = self._apply_angle_snap(anchor_orig_screen, result) # Grid snap as final fallback (only when nothing else changed) if result == pos and self._snap_mode.get("grid", False): gs = self._find_grid_snap(pos) if gs is not None: result = self._world_to_screen(gs) return result # ─── Solver helpers ─────────────────────────────────────────────────── def _get_point_entity_at(self, world_pos: QPoint) -> Optional[OCCSketchEntity]: """Find nearest point entity to world position. External (underlay) points are pickable when the underlay is visible so the user can use them as constraint anchors (e.g. the corner of a projected face); they're skipped when the underlay is hidden. """ for entity in self._points: if self._is_external(entity) and not self._underlay_visible: continue 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 return entity return None 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 (so the user can add a distance / horizontal / vertical / parallel / perpendicular / midpoint constraint against them) and skipped when the underlay is hidden. Centerlines are treated as infinite reference axes — the hit test uses perpendicular distance to the infinite line (no segment clamping) with a zoom-adjusted tolerance so they are pickable at any zoom level. """ for p1_ent, p2_ent in self._lines: line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) is_ext = bool(line_ent is not None and self._is_external(line_ent)) if is_ext and not self._underlay_visible: continue if p1_ent.geometry and p2_ent.geometry: x1, y1 = p1_ent.geometry x2, y2 = p2_ent.geometry dx = x2 - x1 dy = y2 - y1 if dx == 0 and dy == 0: continue # 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) 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)) if perp_dist < tol: return (p1_ent, p2_ent) else: # Regular lines: clamp the projection to the segment # and check distance to that clamped point. t = ((world_pos.x() - x1) * dx + (world_pos.y() - y1) * dy) / line_len_sq 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 return (p1_ent, p2_ent) return None def _find_line_sketch_entity( self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity ) -> Optional[OCCSketchEntity]: """Return the LINE sketch entity whose endpoints match the given point entities. ``OCCSketch._lines`` maps ``line_id -> (start_point_id, end_point_id)``; the line *entity* itself lives in ``_entities[line_id]`` and carries the solver line handle. The old handlers mistakenly returned the start *point* entity, whose solver handle is a point — passing that to ``horizontal``/``vertical``/``parallel``/``perpendicular``/``midpoint``/ point-on-line/``symmetric`` raises ``TypeError: unsupported entities``. """ if not self._sketch: return None for line_id, (sid, eid2) in self._sketch._lines.items(): s_ent = self._sketch._entities.get(sid) e_ent = self._sketch._entities.get(eid2) if (s_ent == p1_ent and e_ent == p2_ent) or (s_ent == p2_ent and e_ent == p1_ent): return self._sketch._entities.get(line_id) # the line entity return None def _get_line_endpoints( self, line_ent: OCCSketchEntity ) -> Tuple[Optional[OCCSketchEntity], Optional[OCCSketchEntity]]: """Return ``(start_point_entity, end_point_entity)`` for a given line entity.""" if not self._sketch: return None, None for line_id, (sid, eid2) in self._sketch._lines.items(): if self._sketch._entities.get(line_id) is line_ent: 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]: """Find the OCCSketchEntity for a line defined by its endpoint tuples.""" for eid, start_end in self._sketch._lines.items(): sid, eid2 = start_end s_ent = self._sketch._entities.get(sid) e_ent = self._sketch._entities.get(eid2) 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): 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): return s_ent return None 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)) # ─── Constraint tags (display + delete) ─────────────────────────────── def _line_world_mid(self, line_id: int) -> Optional[QPoint]: """World-space midpoint of the line with the given entity id.""" if not self._sketch or line_id not in self._sketch._lines: return None sid, eid2 = self._sketch._lines[line_id] s_ent = self._sketch._entities.get(sid) e_ent = self._sketch._entities.get(eid2) if not s_ent or not e_ent or not s_ent.geometry or not e_ent.geometry: return None x1, y1 = s_ent.geometry x2, y2 = e_ent.geometry return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2))) def _point_world(self, pid: int) -> Optional[QPoint]: """World-space position of the point entity with the given id. Defensive: returns *None* if the entity is missing, has no geometry, or its geometry is not a 2-tuple of numbers. The last check matters because the solver log also contains line ids (e.g. for point-on-line coincident or distance to a line), and a line's geometry is ``((x1,y1), (x2,y2))`` — naively unpacking that as ``(x, y)`` and calling ``round()`` on the inner tuples raises ``TypeError: type tuple doesn't define __round__ method``. The final ``try/except`` is a last-resort safety net for exotic cases (numpy scalars, complex numbers, badly-typed solver output) that the explicit type checks above might miss. Better to drop a constraint tag than to take down the entire paint event. """ if not self._sketch: return None ent = self._sketch._entities.get(pid) if not ent or not ent.geometry: return None geom = ent.geometry # A point's geometry is a flat 2-tuple of numbers; a line's is # ((x1, y1), (x2, y2)). Reject anything that doesn't look like # a point so callers don't crash on line/circle/arc ids. if not isinstance(geom, tuple) or len(geom) != 2: return None x, y = geom if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): return None try: return QPoint(int(round(x)), int(round(y))) except (TypeError, ValueError): # Last-resort guard: exotic x/y types (numpy scalars, NaN, # etc.) can still slip through. Returning None means the # 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, ) return None def _entity_anchor(self, eid: int) -> Optional[QPoint]: """Return a sensible world-space anchor for *any* entity id. Points → their position. Lines → the midpoint. Anything else (missing / unrecognised) → None. Used by constraint tag rendering so a coincident or distance constraint that involves a line (e.g. point-on-line, distance to a line) can be labelled without crashing the paint event. """ if not self._sketch: return None ent = self._sketch._entities.get(eid) if ent is None: return None if ent.entity_type == "line": return self._line_world_mid(eid) return self._point_world(eid) def _compute_constraint_tags(self) -> List[Dict[str, Any]]: """Build the list of constraint-tag descriptors from the constraint log. Each tag maps a displayed label + on-screen rect to the index of its entry in ``OCCSketch._constraint_log`` so the user can hover a tag and press Delete to remove exactly that constraint. Anchors are chosen per constraint type: line-attached tags sit at the line midpoint, point tags at the relevant point, point-pair tags between the two points. """ tags: List[Dict[str, Any]] = [] if not self._sketch: return tags fm = QFontMetrics(QFont("Monospace", 9)) # Track how many tags already share an anchor so we stack them vertically. stack_count: Dict[Tuple[int, int], int] = {} for idx, entry in enumerate(self._sketch._constraint_log): # One bad log entry (e.g. a dangling id after a delete, an # exotic geometry type, a numpy scalar that didn't round) must # not take down the entire paint event. Catch any failure # here, log it at debug, skip the tag, and keep painting. try: ctype = entry["type"] ids = entry["ids"] params = entry["params"] anchor: Optional[QPoint] = None label = "" if ctype == "horizontal": anchor = self._line_world_mid(ids[0]); label = "hrz" elif ctype == "vertical": anchor = self._line_world_mid(ids[0]); label = "vrt" elif ctype == "midpoint": 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]) # 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" elif ctype == "perpendicular": 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" 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]) 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" elif ctype == "fixed": anchor = self._point_world(ids[0]); label = "fix" elif ctype == "equal_radius": anchor = self._point_world(ids[0]); label = "eqr" else: continue if anchor is None: continue sc = self._world_to_screen(anchor) key = (sc.x(), sc.y()) slot = stack_count.get(key, 0) stack_count[key] = slot + 1 text = f"> {label} <" w = fm.horizontalAdvance(text) + 10 h = 16 # Stack successive tags above the anchor so they don't overlap. cx = sc.x() cy = sc.y() - 14 - slot * (h + 2) rect = QRect(cx - w // 2, cy - h // 2, w, h) tags.append({"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)}) except Exception as exc: # Catch any failure while building this one tag (bad # geometry, missing entity, numpy round weirdness, etc.) # so a single bad entry can't take down the whole paint # event. Drop the tag and move on; the user sees the # other tags as normal. logger.debug( "Skipped constraint tag #%s (%s) due to %s: %s", idx, entry.get("type"), type(exc).__name__, exc, ) continue return tags # ─── Element move helpers ───────────────────────────────────────────── def _collect_connected_points(self, anchor: OCCSketchEntity) -> List[OCCSketchEntity]: """Return anchor plus all point entities connected through lines AND arcs. ``OCCSketch._lines`` maps line id -> (start_point_id, end_point_id) and ``OCCSketch._arcs`` maps arc id -> {"center": cid, "start": sid, "end": eid, ...}. We BFS over that graph to gather the whole element (e.g. all 4 corners of a rectangle, or both endpoints of a single line, or all 6 points of a slot). """ if not self._sketch: return [anchor] adjacency: Dict[int, List[int]] = {} # Line connections for _line_id, (sid, eid2) in self._sketch._lines.items(): adjacency.setdefault(sid, []).append(eid2) adjacency.setdefault(eid2, []).append(sid) # Arc connections — an arc ties its centre to its start and end, and # also ties start to end so the whole arc body moves as a single unit. for _arc_id, arc_data in self._sketch._arcs.items(): center_id = arc_data.get("center") start_id = arc_data.get("start") end_id = arc_data.get("end") if center_id is not None: if start_id is not None: adjacency.setdefault(center_id, []).append(start_id) adjacency.setdefault(start_id, []).append(center_id) if end_id is not None: adjacency.setdefault(center_id, []).append(end_id) adjacency.setdefault(end_id, []).append(center_id) # Connect start <-> end so the arc body moves as a unit even # without traversing through centre (e.g. grabbing an endpoint # pulls the other endpoint and the centre together). if start_id is not None and end_id is not None: adjacency.setdefault(start_id, []).append(end_id) adjacency.setdefault(end_id, []).append(start_id) seen = {anchor.id} result: List[OCCSketchEntity] = [anchor] queue = [anchor.id] while queue: cur = queue.pop() for nbr in adjacency.get(cur, []): if nbr in seen: continue seen.add(nbr) ent = self._sketch._entities.get(nbr) if ent is not None: result.append(ent) queue.append(nbr) return result def _find_move_target(self, pos: QPoint) -> Optional[Tuple[OCCSketchEntity, QPoint]]: """Find the element under ``pos`` and return ``(anchor_entity, anchor_world)``. Click priority: nearest point > nearest line (anchor = closer endpoint) > circle circumference (anchor = center). The anchor's current world position is returned so snapping can use it as the reference origin. """ if not self._sketch: return None ent = self._find_nearest_point_entity(pos, max_distance=self._snap_distance) if ent is not None and ent.geometry: x, y = ent.geometry return ent, QPoint(int(round(x)), int(round(y))) world = self._screen_to_world(pos) line_hit = self._get_line_entity_at(world) if line_hit: p1_ent, p2_ent = line_hit if p1_ent.geometry and p2_ent.geometry: x1, y1 = p1_ent.geometry x2, y2 = p2_ent.geometry d1 = (world.x() - x1) ** 2 + (world.y() - y1) ** 2 d2 = (world.x() - x2) ** 2 + (world.y() - y2) ** 2 anchor = p1_ent if d1 <= d2 else p2_ent ax, ay = anchor.geometry return anchor, QPoint(int(round(ax)), int(round(ay))) for c_ent, r in self._circles: 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: return c_ent, QPoint(int(round(cx)), int(round(cy))) return None def _sync_solved_positions(self): """Sync solver positions back to UI points and lines.""" if not self._sketch: return for entity in self._points: if entity.handle is not None: try: x, y = self._sketch.solver.params(entity.handle.params) entity.geometry = (float(x), float(y)) except Exception: pass # Update line geometries from their endpoint positions for p1_ent, p2_ent in self._lines: if p1_ent.geometry and p2_ent.geometry: pass # geometry already updated via point sync def _solve_and_sync(self) -> bool: """Solve constraints, sync positions, update UI. Returns True if solved OK.""" if not self._sketch: return True ok = self._sketch.solve() self._sync_solved_positions() self.update() return ok # ─── Mouse events ───────────────────────────────────────────────────── def mousePressEvent(self, event): world_pos = self._screen_to_world(event.pos()) if event.button() == Qt.MiddleButton: self._panning = True self._pan_start = event.pos() self.setCursor(Qt.ClosedHandCursor) 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() return if event.button() == Qt.LeftButton: # Priority order: ① tight point-grab → ② face selection → ③ element move 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)): x, y = tight_ent.geometry moving = self._collect_connected_points(tight_ent) orig: Dict[int, Tuple[float, float]] = {} for e in moving: if e.geometry: orig[e.id] = (e.geometry[0], e.geometry[1]) self._moving_points = moving self._move_anchor = tight_ent self._move_anchor_orig = QPoint(int(round(x)), int(round(y))) self._move_orig_positions = orig self._move_active = True self.setCursor(Qt.ClosedHandCursor) return # ② Face region — click inside a closed face to select it. # Use float-precision world coords so small shapes (sub-integer # at the current zoom) are still pickable. fwx, fwy = self._screen_to_world_f(event.pos()) face = self._sketch.find_face_at(fwx, fwy) if face is not None: if self._faces_match(face, self._selected_face): # Clicking the same face again toggles it off. self._selected_face = None else: self._selected_face = face self._hovered_face = None self.update() return # ③ Wider element-move check (lines and circles). External # (underlay) entities are fixed references and can't be # dragged — fall through to the constraint / draw handlers # so a click on an underlay edge is treated as a constraint # pick (the desired behavior) rather than a no-op. 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])): anchor_ent, anchor_world = target moving = self._collect_connected_points(anchor_ent) orig: Dict[int, Tuple[float, float]] = {} for e in moving: if e.geometry: orig[e.id] = (e.geometry[0], e.geometry[1]) self._moving_points = moving self._move_anchor = anchor_ent self._move_anchor_orig = anchor_world self._move_orig_positions = orig self._move_active = True self.setCursor(Qt.ClosedHandCursor) return snapped_pos = self._apply_all_snaps( 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 ) if self._mode == "line": self._handle_line_click(world_snapped) elif self._mode == "rectangle": self._handle_rectangle_click(world_snapped) elif self._mode == "circle": self._handle_circle_click(world_snapped) elif self._mode == "arc": self._handle_arc_click(world_snapped) elif self._mode == "slot": self._handle_slot_click(world_snapped) elif self._mode == "select": self._handle_select_click(world_snapped) elif self._mode == "constrain_coincident": self._handle_constraint_coincident(world_snapped) elif self._mode == "constrain_horizontal": self._handle_constraint_horizontal(world_snapped) elif self._mode == "constrain_vertical": self._handle_constraint_vertical(world_snapped) elif self._mode == "constrain_distance": self._handle_constraint_distance(world_snapped) elif self._mode == "constrain_midpoint": self._handle_constraint_midpoint(world_snapped) elif self._mode == "constrain_perpendicular": self._handle_constraint_perpendicular(world_snapped) elif self._mode == "constrain_parallel": self._handle_constraint_parallel(world_snapped) elif self._mode == "constrain_ptline": self._handle_constraint_ptline(world_snapped) elif self._mode == "constrain_symmetric": self._handle_constraint_symmetric(world_snapped) def mouseMoveEvent(self, event): if self._panning and self._pan_start: delta = event.pos() - self._pan_start self._offset += delta self._pan_start = event.pos() self.update() 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: 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 ) 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() for ent in self._moving_points: if ent.id in self._move_orig_positions and ent.geometry is not None: ox, oy = self._move_orig_positions[ent.id] ent.geometry = (ox + dx, oy + dy) self.update() return world_pos = self._screen_to_world(event.pos()) if self._draw_buffer and self._mode in ["line", "rectangle", "circle", "arc", "slot"]: snapped = self._apply_all_snaps( event.pos(), self._world_to_screen(self._draw_buffer[0]) ) self._dynamic_line_end = self._screen_to_world(snapped) # Project arc end onto the circle so the preview snaps to # the correct radius (matching the start point's distance). if self._mode == "arc" and len(self._draw_buffer) == 2: cw = self._draw_buffer[0] sw = self._draw_buffer[1] 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 dx = ew.x() - cx_f dy = ew.y() - cy_f d = math.sqrt(dx * dx + dy * dy) if d > 0: self._dynamic_line_end = QPoint( int(round(cx_f + dx / d * r)), int(round(cy_f + dy / d * r)), ) else: self._dynamic_line_end = sw # Accumulated sweep tracking for arc (after click 2, before click 3) if self._mode == "arc" and len(self._draw_buffer) == 2: cw = self._draw_buffer[0] ew = self._dynamic_line_end current_angle = math.atan2(ew.y() - cw.y(), ew.x() - cw.x()) if self._arc_prev_angle is not None: delta = current_angle - self._arc_prev_angle # Normalise delta to [-PI, PI] (handle atan2 wrap-around) while delta > math.pi: delta -= 2 * math.pi while delta < -math.pi: delta += 2 * math.pi self._arc_accum_sweep += delta self._arc_prev_angle = current_angle # Constraint-tag hover takes priority — if the cursor is over a tag, # we highlight it for delete and skip point/line/face hover this move. self._constraint_tags = self._compute_constraint_tags() tag_hit = None for tag in self._constraint_tags: if tag["rect"].contains(event.pos()): tag_hit = tag["idx"] break if tag_hit is not None: self._hovered_constraint_idx = tag_hit self._hovered_point = None self._hovered_point_entity = None self._hovered_line = None self._hovered_line_entity = None self._hovered_face = None self.setCursor(Qt.PointingHandCursor) self.update() return if self._hovered_constraint_idx != -1: self._hovered_constraint_idx = -1 # Priority for select/move mode: point > face > line > circle. # Face is checked before line so the user can see and select the # wall region between two concentric boundaries (e.g. after offset). # In drawing modes, lines take priority so the user can snap to them. hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor # ── ① Point hover (tightest, always first) ── point_snap = self._find_nearest_point(event.pos()) if point_snap: self._hovered_point = point_snap self._hovered_point_entity = self._find_nearest_point_entity(event.pos()) self._hovered_line = None self._hovered_line_entity = None self._hovered_face = None self.setCursor(hover_cursor) else: self._hovered_point = None self._hovered_point_entity = None # ── ② Face hover (checked before line in select mode) ── # In select/move mode the face is the primary interaction target # (select to extrude etc.). Lines remain selectable via click # (element move) but the visual highlight shows the face region. face_found = False if self._mode in ("select", None) and self._sketch is not None: fwx, fwy = self._screen_to_world_f(event.pos()) face = self._sketch.find_face_at(fwx, fwy) if face is not None: self._hovered_face = face self._hovered_line = None self._hovered_line_entity = None self.setCursor(Qt.PointingHandCursor) face_found = True if not face_found: self._hovered_face = None # ── ③ Line hover (when no face under cursor) ── line_hit = self._get_line_entity_at(world_pos) if line_hit: p1_ent, p2_ent = line_hit if p1_ent.geometry and p2_ent.geometry: self._hovered_line = ( QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))), QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))), ) self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent) self.setCursor(hover_cursor) else: self._hovered_line = None self._hovered_line_entity = None self.setCursor(Qt.ArrowCursor) else: self._hovered_line = None self._hovered_line_entity = None # ── ④ Circle hover ── if self._mode in ("select", None): over_circle = False for c_ent, r in self._circles: 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: over_circle = True break self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor) else: self.setCursor(Qt.ArrowCursor) self.update() def mouseReleaseEvent(self, event): # Element move release: commit positions into the solver, solve, and # revert on a constraint failure. Solving AFTER the release is what # decides whether a constrained element snaps (back) or stays moved. if self._move_active and event.button() == Qt.LeftButton: if self._sketch and self._moving_points: new_positions: Dict[int, Tuple[float, float]] = {} for ent in self._moving_points: if ent.geometry is not None: new_positions[ent.id] = ent.geometry original = dict(self._move_orig_positions) # Push the dragged positions into the solver's params so free # points keep their new location (otherwise solve reverts them). self._sketch.set_positions(new_positions) ok = self._solve_and_sync() if not ok: logger.warning("Constraint violation while moving element; reverting") self._sketch.set_positions(original) self._solve_and_sync() self._snap_point_target = None 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._solve_and_sync() # Snap modes are honoured during the move (see _apply_move_snaps # in mouseMoveEvent), so the committed positions are already snapped. self._clear_move_state() self.setCursor(Qt.ArrowCursor) self.sketch_updated.emit() self.update() return if event.button() == Qt.MiddleButton: self._panning = False self._pan_start = None self.setCursor(Qt.ArrowCursor) def wheelEvent(self, event): 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.update() def keyPressEvent(self, event): # Delete / Backspace removes the entity currently under the cursor # and recomputes the surviving constraints. Priority: constraint tag > # 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._hovered_constraint_idx >= 0: self._delete_hovered_constraint() event.accept() return if self._hovered_line_entity is not None: self._delete_hovered_line() event.accept() return if self._hovered_point_entity is not None: self._delete_hovered_point() event.accept() return # 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._hovered_line_entity is not None: self._toggle_hovered_line_construction() event.accept() return super().keyPressEvent(event) def _delete_hovered_constraint(self): """Delete the hovered constraint (by log index) and recompute the rest.""" idx = self._hovered_constraint_idx if idx < 0 or self._sketch is None: return ok = self._sketch.remove_constraint_at(idx) logger.info(f"Deleted constraint #{idx}; recompute solved={ok}") self._hovered_constraint_idx = -1 self._rebuild_from_sketch() self._solve_and_sync() self.sketch_updated.emit() self.update() def _delete_hovered_line(self): """Delete the hovered line, recompute constraints, and refresh the widget.""" line_ent = self._hovered_line_entity if line_ent is None or self._sketch is None: return # External (underlay) lines are reference geometry from the source # face and can't be deleted one at a time. See delete_point for the # same guard — the whole underlay is cleared in one shot via # remove_external_entities when the source face is unset. if getattr(line_ent, "is_external", False): logger.debug("Refusing to delete external (underlay) line") return # Centerlines are permanent reference axes — refuse deletion. if self._is_centerline(line_ent): logger.debug("Refusing to delete centerline") return ok = self._sketch.delete_line(line_ent) logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}") # Refresh widget tracking from the pruned sketch and sync solved positions. self._rebuild_from_sketch() self._hovered_line = None self._hovered_line_entity = None self._hovered_point = None self._hovered_point_entity = None self._selected_entities = [] self._solve_and_sync() self.sketch_updated.emit() self.update() def _delete_hovered_point(self): """Delete the hovered point (and lines using it), then recompute.""" point_ent = self._hovered_point_entity if point_ent is None or self._sketch is None: return # External (underlay) points are reference geometry from the source # face — they can't be deleted individually. The whole underlay # is cleared via remove_external_entities when the source face is # removed; silently refuse here so the user gets no surprise # cascading deletion of every other underlay element. if getattr(point_ent, "is_external", False): logger.debug("Refusing to delete external (underlay) point") return # Centerline points are permanent reference anchors — refuse deletion. if self._is_centerline(point_ent): logger.debug("Refusing to delete centerline point") return ok = self._sketch.delete_point(point_ent) logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}") self._rebuild_from_sketch() self._hovered_point = None self._hovered_point_entity = None self._hovered_line = None self._hovered_line_entity = None self._selected_entities = [] self._solve_and_sync() self.sketch_updated.emit() self.update() def _toggle_hovered_line_construction(self) -> None: """Toggle the hovered line between normal and construction mode. The line entity's ``is_construction`` flag is flipped. The line entity is then marked as the sole authority for its construction state — its endpoint points are NOT toggled, so that other lines sharing the same endpoint are unaffected. The paint code checks the line entity's flag in addition to the endpoint flags. """ line_ent = self._hovered_line_entity if line_ent is None or self._sketch is None: return # External (underlay) lines are reference geometry from the source # face — they can't be toggled individually. if getattr(line_ent, "is_external", False): logger.debug("Refusing to toggle external (underlay) line") return # Centerlines are permanent reference axes — refuse toggling. if self._is_centerline(line_ent): logger.debug("Refusing to toggle centerline") return # 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}" ) self._hovered_line = None self._hovered_line_entity = None self._solve_and_sync() self.sketch_updated.emit() self.update() # ─── Drawing handlers ───────────────────────────────────────────────── def _handle_line_click(self, pos: QPoint): self._ensure_sketch_with_centerlines() if not self._draw_buffer: point = self._sketch.add_point(pos.x(), pos.y()) point.is_construction = self._is_construct self._points.append(point) self._draw_buffer.append(pos) # Auto-constrain: point snap → coincident on start point if self._snap_point_target is not None: self._sketch.constrain_coincident(point, self._snap_point_target) self._solve_and_sync() else: point = self._sketch.add_point(pos.x(), pos.y()) point.is_construction = self._is_construct self._points.append(point) if len(self._points) >= 2: line = self._sketch.add_line(self._points[-2], self._points[-1]) self._lines.append((self._points[-2], self._points[-1])) # Auto-constrain: point snap → coincident on end point if self._snap_point_target is not None: self._sketch.constrain_coincident(self._points[-1], self._snap_point_target) # Auto-constrain: detect horizontal / vertical from geometry if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False): p1_geom = self._points[-2].geometry p2_geom = self._points[-1].geometry if p1_geom is not None and p2_geom is not None: x1, y1 = p1_geom x2, y2 = p2_geom if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6: self._sketch.constrain_horizontal(line) elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6: self._sketch.constrain_vertical(line) self._solve_and_sync() self._draw_buffer = [pos] self.sketch_updated.emit() self.update() def _handle_rectangle_click(self, pos: QPoint): self._ensure_sketch_with_centerlines() if not self._draw_buffer: self._draw_buffer.append(pos) self._rect_first_snap_target = self._snap_point_target else: p1 = self._draw_buffer[0] p2 = pos corners = [ QPoint(p1.x(), p1.y()), QPoint(p2.x(), p1.y()), QPoint(p2.x(), p2.y()), QPoint(p1.x(), p2.y()), ] pts = [] for corner in corners: pt = self._sketch.add_point(corner.x(), corner.y()) pt.is_construction = self._is_construct self._points.append(pt) pts.append(pt) line_entities = [] for i in range(4): line = self._sketch.add_line(pts[i], pts[(i + 1) % 4]) self._lines.append((pts[i], pts[(i + 1) % 4])) line_entities.append(line) # Auto-constrain: point snap → coincident on the correct corners. # pts[0] = first click snapped position # pts[2] = second click snapped position if self._rect_first_snap_target is not None: self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target) if self._snap_point_target is not None: self._sketch.constrain_coincident(pts[2], self._snap_point_target) # Auto-constrain: detect horizontal / vertical from geometry if self._snap_mode.get("horiz", False): for idx in (0, 2): p_start = pts[idx] p_end = pts[(idx + 1) % 4] if p_start.geometry and p_end.geometry: dx = p_end.geometry[0] - p_start.geometry[0] if abs(dx) > 1e-6: self._sketch.constrain_horizontal(line_entities[idx]) if self._snap_mode.get("vert", False): for idx in (1, 3): p_start = pts[idx] p_end = pts[(idx + 1) % 4] if p_start.geometry and p_end.geometry: dy = p_end.geometry[1] - p_start.geometry[1] if abs(dy) > 1e-6: self._sketch.constrain_vertical(line_entities[idx]) self._solve_and_sync() self._draw_buffer = [] self._mode = None self.constrain_done.emit() self.sketch_updated.emit() self.update() def _handle_circle_click(self, pos: QPoint): self._ensure_sketch_with_centerlines() if not self._draw_buffer: center = self._sketch.add_point(pos.x(), pos.y()) center.is_construction = self._is_construct self._points.append(center) self._draw_buffer.append(pos) # Auto-constrain: point snap → coincident on center if self._snap_point_target is not None: self._sketch.constrain_coincident(center, self._snap_point_target) self._solve_and_sync() else: center = self._points[-1] cx, cy = center.geometry if center.geometry else (0, 0) radius = math.sqrt((pos.x() - cx) ** 2 + (pos.y() - cy) ** 2) if radius > 0: self._sketch.add_circle(center, radius) self._circles.append((center, radius)) self._draw_buffer = [] self._mode = None self.constrain_done.emit() self.sketch_updated.emit() self.update() def _handle_arc_click(self, pos: QPoint): """Three-click arc: center → start point (radius) → end point (sweep).""" self._ensure_sketch_with_centerlines() if not self._draw_buffer: # Click 1: place center point center = self._sketch.add_point(pos.x(), pos.y()) center.is_construction = self._is_construct self._points.append(center) self._draw_buffer.append(pos) # Auto-constrain: point snap → coincident on center if self._snap_point_target is not None: self._sketch.constrain_coincident(center, self._snap_point_target) self._solve_and_sync() elif len(self._draw_buffer) == 1: # Click 2: place start point (defines radius + start angle) start = self._sketch.add_point(pos.x(), pos.y()) start.is_construction = self._is_construct self._points.append(start) self._draw_buffer.append(pos) # Initialise accumulated sweep tracking cx = self._draw_buffer[0].x() cy = self._draw_buffer[0].y() self._arc_prev_angle = math.atan2(pos.y() - cy, pos.x() - cx) self._arc_accum_sweep = 0.0 # Auto-constrain: point snap → coincident on start if self._snap_point_target is not None: self._sketch.constrain_coincident(start, self._snap_point_target) self._solve_and_sync() else: # Click 3: place end point, then create arc center = self._points[-2] start_point = self._points[-1] cx, cy = center.geometry if center.geometry else (0, 0) sx, sy = start_point.geometry if start_point.geometry else (0, 0) radius = math.sqrt((sx - cx) ** 2 + (sy - cy) ** 2) # Project the click onto the circle so the end point # stays exactly on the same radius as the start. dx = pos.x() - cx dy = pos.y() - cy dist = math.sqrt(dx * dx + dy * dy) if dist > 0: ex = cx + dx / dist * radius ey = cy + dy / dist * radius else: ex = cx + radius ey = cy end = self._sketch.add_point(ex, ey) end.is_construction = self._is_construct self._points.append(end) # Use accumulated sweep when significant; fall back to computed. sweep = self._arc_accum_sweep if abs(sweep) < 0.01: # Barely moved — compute shortest-path from geometry # using the projected end position. ea = math.atan2(ey - cy, ex - cx) sa = math.atan2(sy - cy, sx - cx) sweep = ea - sa while sweep > math.pi: sweep -= 2 * math.pi while sweep < -math.pi: sweep += 2 * math.pi if radius > 0: self._sketch.add_arc(center, radius, start_point, end, sweep=sweep) self._arcs.append((center, radius, start_point, end, sweep)) # Auto-constrain: point snap → coincident on end point if self._snap_point_target is not None: self._sketch.constrain_coincident(end, self._snap_point_target) self._solve_and_sync() self._draw_buffer = [] self._mode = None self.constrain_done.emit() self.sketch_updated.emit() self.update() def _handle_slot_click(self, pos: QPoint): """Three-click slot: center1 → center2 (centerline) → width radius. Builds two semicircular arcs and two connecting lines forming a closed slot profile suitable for extrusion. """ self._ensure_sketch_with_centerlines() if not self._draw_buffer: # Click 1: place first arc center C1. c1 = self._sketch.add_point(pos.x(), pos.y()) c1.is_construction = self._is_construct self._points.append(c1) self._draw_buffer.append(pos) if self._snap_point_target is not None: self._sketch.constrain_coincident(c1, self._snap_point_target) self._solve_and_sync() self.sketch_updated.emit() self.update() return if len(self._draw_buffer) == 1: # Click 2: place second arc center C2 (defines centerline). c2 = self._sketch.add_point(pos.x(), pos.y()) c2.is_construction = self._is_construct self._points.append(c2) self._draw_buffer.append(pos) if self._snap_point_target is not None: self._sketch.constrain_coincident(c2, self._snap_point_target) self._solve_and_sync() self.sketch_updated.emit() self.update() return # Click 3: compute radius from perpendicular distance to centerline # and build the full slot profile. c1_pos = self._draw_buffer[0] c2_pos = self._draw_buffer[1] c1_ent = self._points[-2] c2_ent = self._points[-1] dx_c = c2_pos.x() - c1_pos.x() dy_c = c2_pos.y() - c1_pos.y() L_sq = dx_c * dx_c + dy_c * dy_c if L_sq < 1.0: # 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) self._circles.append((c1_ent, r_fallback)) self._draw_buffer = [] self._mode = None self.constrain_done.emit() self.sketch_updated.emit() self.update() return # 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) if r < 0.5: r = 0.5 # minimum radius so the slot doesn't collapse L = _m.sqrt(L_sq) alpha = _m.atan2(dy_c, dx_c) # Unit perpendicular (rotate 90° CCW) perp_x = -dy_c / L perp_y = dx_c / L # Four corner points (world coords) def corner(cx, cy, sign): return QPoint( int(round(cx + sign * r * perp_x)), int(round(cy + sign * r * perp_y)), ) # 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 b1 = corner(c1_pos.x(), c1_pos.y(), -1) # C1 - 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 pt1 = self._sketch.add_point(t1.x(), t1.y()) pt1.is_construction = self._is_construct self._points.append(pt1) pb1 = self._sketch.add_point(b1.x(), b1.y()) pb1.is_construction = self._is_construct self._points.append(pb1) pt2 = self._sketch.add_point(t2.x(), t2.y()) pt2.is_construction = self._is_construct self._points.append(pt2) pb2 = self._sketch.add_point(b2.x(), b2.y()) pb2.is_construction = self._is_construct self._points.append(pb2) # Auto-constrain: coincident on each corner point if it snaps onto # an existing point entity (e.g. vertex of another shape). 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))) ) if snap_target is not None and snap_target.id != corner_pt.id: self._sketch.constrain_coincident(corner_pt, snap_target) # Arc 1 (at C1): exterior sweep from top to bottom (CCW) arc1_ent = None arc2_ent = None if r > 0: arc1_ent = self._sketch.add_arc(c1_ent, r, pt1, pb1, sweep=_m.pi) self._arcs.append((c1_ent, r, pt1, pb1, _m.pi)) # Arc 2 (at C2): exterior sweep from bottom to top (CCW) arc2_ent = self._sketch.add_arc(c2_ent, r, pb2, pt2, sweep=_m.pi) self._arcs.append((c2_ent, r, pb2, pt2, _m.pi)) # Lines connecting the arc endpoints line1 = self._sketch.add_line(pb1, pb2) # bottom line self._lines.append((pb1, pb2)) line2 = self._sketch.add_line(pt2, pt1) # top line self._lines.append((pt2, pt1)) # ── Parametric construction: perpendicular reference per arc ── # Each arc needs a construction line from its centre, perpendicular # to the connecting lines, to define the arc-endpoint direction and # maintain smooth tangency between arc and line. The corner points # sit on this line at distance r from the centre (the arc radius). # Without this the solver can rotate the endpoints around the # centre, breaking the visual arc-line tangency, and bare distance # constraints lock the slot because the centre is the sketch's # first (reference-fixed) point. # Arc 1 (C1): construction line from centre to top endpoint acl1 = self._sketch.add_line(c1_ent, pt1) acl1.is_construction = True self._lines.append((c1_ent, pt1)) # ⟂ to the connecting lines (line1 ∥ line2, so ⟂⁠to one = ⟂⁠to both) self._sketch.constrain_perpendicular(acl1, line1) # Bottom endpoint sits on this same line (diametrically opposite) self._sketch.constrain_coincident(pb1, acl1) # Arc radius: both endpoints at distance r from centre self._sketch.constrain_distance(pt1, c1_ent, r) self._sketch.constrain_distance(pb1, c1_ent, r) # Arc 2 (C2): construction line from centre to top endpoint acl2 = self._sketch.add_line(c2_ent, pt2) acl2.is_construction = True self._lines.append((c2_ent, pt2)) self._sketch.constrain_perpendicular(acl2, line1) self._sketch.constrain_coincident(pb2, acl2) self._sketch.constrain_distance(pt2, c2_ent, r) self._sketch.constrain_distance(pb2, c2_ent, r) # Auto-constrain: horizontal / vertical on the two slot lines if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False): for line_ent, pa, pb in [(line1, pb1, pb2), (line2, pt2, pt1)]: if pa.geometry and pb.geometry: x1, y1 = pa.geometry x2, y2 = pb.geometry if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6: self._sketch.constrain_horizontal(line_ent) elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6: self._sketch.constrain_vertical(line_ent) # Auto-constrain: the two slot lines are always parallel self._sketch.constrain_parallel(line1, line2) # Auto-constrain: the two arcs have equal radius if arc1_ent is not None and arc2_ent is not None: self._sketch.constrain_equal_radius(arc1_ent, arc2_ent) self._solve_and_sync() self._draw_buffer = [] self._mode = None self.constrain_done.emit() self.sketch_updated.emit() self.update() def _handle_select_click(self, pos: QPoint): """Handle select mode - find and highlight points/lines for selection.""" # Check if clicking on an existing point nearest_entity = self._get_point_entity_at(pos) if nearest_entity: if nearest_entity not in self._selected_entities: self._selected_entities.append(nearest_entity) else: self._selected_entities.remove(nearest_entity) else: # Check if clicking on a line line_hit = self._get_line_entity_at(pos) if line_hit: p1_ent, p2_ent = line_hit for entity in [p1_ent, p2_ent]: if entity and entity not in self._selected_entities: self._selected_entities.append(entity) # ─── Constraint handlers (with solver calls) ────────────────────────── def _handle_constraint_coincident(self, world_pos: QPoint): ent = self._get_point_entity_at(world_pos) if ent is None: return self._selected_entities.append(ent) if len(self._selected_entities) >= 2: e1, e2 = self._selected_entities[:2] if self._sketch: self._sketch.constrain_coincident(e1, e2) ok = self._solve_and_sync() if ok: logger.info("Coincident constraint added") self._selected_entities = [] self._mode = None self.constrain_done.emit() self.update() def _handle_constraint_horizontal(self, world_pos: QPoint): line_hit = self._get_line_entity_at(world_pos) if line_hit is None: return p1_ent, p2_ent = line_hit # SolveSpace's horizontal() needs the LINE entity's handle, not a # point's. Look up the line sketch entity by its endpoints. line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) if line_ent is None: return if self._sketch: self._sketch.constrain_horizontal(line_ent) # Tag endpoints so paintEvent renders the "> hrz <" label. for ent in (p1_ent, p2_ent): if "hrz" not in ent.constraints: ent.constraints.append("hrz") ok = self._solve_and_sync() if ok: logger.info("Horizontal constraint added") else: logger.warning("Horizontal constraint failed to solve") self._mode = None self.constrain_done.emit() self.update() def _handle_constraint_vertical(self, world_pos: QPoint): line_hit = self._get_line_entity_at(world_pos) if line_hit is None: return p1_ent, p2_ent = line_hit line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) if line_ent is None: return if self._sketch: self._sketch.constrain_vertical(line_ent) for ent in (p1_ent, p2_ent): if "vrt" not in ent.constraints: ent.constraints.append("vrt") ok = self._solve_and_sync() if ok: logger.info("Vertical constraint added") else: logger.warning("Vertical constraint failed to solve") self._mode = None self.constrain_done.emit() self.update() def _handle_constraint_distance(self, world_pos: QPoint): point_ent = self._get_point_entity_at(world_pos) if point_ent: # Point clicked: collect points; constraint applied after 2nd point. self._selected_entities.append(point_ent) else: line_hit = self._get_line_entity_at(world_pos) if line_hit: p1_ent, p2_ent = line_hit 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) if ok and self._sketch: self._sketch.constrain_distance(p1_ent, p2_ent, dist) self._solve_and_sync() logger.info(f"Line distance {dist:.2f}mm") self._selected_entities = [] self._mode = None self.constrain_done.emit() self.update() return 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) if ok and self._sketch: self._sketch.constrain_distance(e1, e2, dist) self._solve_and_sync() logger.info(f"Distance {dist:.2f}mm") self._selected_entities = [] self._mode = None self.constrain_done.emit() self.update() def _handle_constraint_midpoint(self, world_pos: QPoint): point_ent = self._get_point_entity_at(world_pos) if point_ent and not self._selected_entities: self._selected_entities.append(point_ent) elif self._selected_entities: line_hit = self._get_line_entity_at(world_pos) if line_hit: p1_ent, p2_ent = line_hit line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) if line_ent is not None and self._sketch and self._selected_entities: self._sketch.constrain_midpoint(self._selected_entities[0], line_ent) for ent in (p1_ent, p2_ent): if "mid" not in ent.constraints: ent.constraints.append("mid") self._solve_and_sync() logger.info("Midpoint constraint added") self._selected_entities = [] self._mode = None self.constrain_done.emit() self.update() def _handle_constraint_perpendicular(self, world_pos: QPoint): line_hit = self._get_line_entity_at(world_pos) if line_hit is None: return p1_ent, p2_ent = line_hit target_ent = self._find_line_sketch_entity(p1_ent, p2_ent) if target_ent is None: return if not self._selected_entities: # First click: store this line ENTITY (not a point): self._selected_entities.append(target_ent) else: # Second click: apply perpendicular constraint between two LINE entities prev_ent = self._selected_entities[0] if self._sketch: self._sketch.constrain_perpendicular(prev_ent, target_ent) self._solve_and_sync() logger.info("Perpendicular constraint added") self._selected_entities = [] self._mode = None self.constrain_done.emit() self.update() def _handle_constraint_parallel(self, world_pos: QPoint): line_hit = self._get_line_entity_at(world_pos) if line_hit is None: return p1_ent, p2_ent = line_hit target_ent = self._find_line_sketch_entity(p1_ent, p2_ent) if target_ent is None: return if not self._selected_entities: self._selected_entities.append(target_ent) else: prev_ent = self._selected_entities[0] if self._sketch: self._sketch.constrain_parallel(prev_ent, target_ent) self._solve_and_sync() logger.info("Parallel constraint added") self._selected_entities = [] self._mode = None self.constrain_done.emit() self.update() def _handle_constraint_ptline(self, world_pos: QPoint): """Point-on-line coincident (point on line).""" point_ent = self._get_point_entity_at(world_pos) if point_ent and not self._selected_entities: self._selected_entities.append(point_ent) elif self._selected_entities: line_hit = self._get_line_entity_at(world_pos) if line_hit: p1_ent, p2_ent = line_hit line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) if line_ent is not None and self._sketch and self._selected_entities: # coincident(point, line) = point-on-line; needs the line handle self._sketch.constrain_coincident(self._selected_entities[0], line_ent) self._solve_and_sync() logger.info("Point-on-line constraint added") self._selected_entities = [] self._mode = None self.constrain_done.emit() self.update() def _handle_constraint_symmetric(self, world_pos: QPoint): """Symmetric constraint: select entity1, entity2, then mirror line.""" # Click 3: mirror line if len(self._selected_entities) == 2: line_hit = self._get_line_entity_at(world_pos) if line_hit: p1_ent, p2_ent = line_hit mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent) if mirror_line is not None and self._sketch: self._sketch.constrain_symmetric( self._selected_entities[0], self._selected_entities[1], mirror_line ) ok = self._solve_and_sync() if ok: logger.info("Symmetric constraint added") else: logger.warning("Symmetric constraint failed to solve") self._selected_entities = [] self._mode = None self.constrain_done.emit() self.update() return # Clicks 1-2: select point entities point_ent = self._get_point_entity_at(world_pos) if point_ent: self._selected_entities.append(point_ent) n_selected = len(self._selected_entities) if n_selected < 2: logger.info(f"Select entity {n_selected + 1} for symmetry (or line for mirror)") elif n_selected == 2: logger.info("Click on the mirror line") self.update() # ─── Painting ───────────────────────────────────────────────────────── def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF: return QPointF((p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0) def _point_distance(self, p1: QPoint, p2: QPoint) -> float: return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2) def _draw_distance_measurement(self, painter: QPainter, p1: QPoint, p2: QPoint): """Draw dimension lines and distance value between two world-coord points.""" sp1 = self._world_to_screen(p1) sp2 = self._world_to_screen(p2) dx = sp2.x() - sp1.x() dy = sp2.y() - sp1.y() length = math.sqrt(dx * dx + dy * dy) if length == 0: return # Perpendicular direction for offset lines perp_dx = -dy / length perp_dy = dx / length offset = 25.0 p1a = QPointF(sp1.x() + perp_dx, sp1.y() + perp_dy) p1b = QPointF(sp1.x() + perp_dx * offset, sp1.y() + perp_dy * offset) p2a = QPointF(sp2.x() + perp_dx, sp2.y() + perp_dy) p2b = QPointF(sp2.x() + perp_dx * offset, sp2.y() + perp_dy * offset) mid = QPointF((p1b.x() + p2b.x()) / 2, (p1b.y() + p2b.y()) / 2) pen_dim = QPen(QColor("#a6e3a1"), 1.5, Qt.DotLine) painter.setPen(pen_dim) painter.drawLine(p1a.toPoint(), p1b.toPoint()) painter.drawLine(p2a.toPoint(), p2b.toPoint()) painter.drawLine(p1b.toPoint(), p2b.toPoint()) # Draw distance text dist = self._point_distance(p1, p2) painter.save() painter.translate(mid) painter.scale(1, -1) painter.setPen(QPen(QColor("#a6e3a1"), 1)) painter.drawText(0, 0, f"{dist:.2f}") painter.restore() def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) painter.fillRect(self.rect(), QColor("#1e1e2e")) # ── Grid (fixed 10mm world-units spacing) ── # Minor grid: 10 world-unit (10mm) spacing. # Major grid: 100 world-unit (100mm) spacing drawn bolder. # Grid lines are drawn in WORLD space so they keep their meaning # 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 # Compute screen-space spacing px_10 = grid_10 * self._zoom px_100 = grid_100 * self._zoom # Viewport bounds in world coordinates top_left = self._screen_to_world(QPoint(0, 0)) bottom_right = self._screen_to_world(QPoint(self.width(), self.height())) pen_major = QPen(QColor("#45475a"), 1) pen_minor = QPen(QColor("#3a3a4e"), 0.5) # ── Draw 100mm major grid (always if visible) ── if px_100 >= MIN_PX_SPACING: start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_100) * grid_100 end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_100) * grid_100 start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_100) * grid_100 end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_100) * grid_100 painter.setPen(pen_major) 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_100 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_100 # ── Draw 10mm minor grid (only when wide enough spacing) ── if px_10 >= MIN_PX_SPACING: start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_10) * grid_10 end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_10) * grid_10 start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_10) * grid_10 end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_10) * grid_10 painter.setPen(pen_minor) 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_10 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_10 # ── 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. origin_sc = self._world_to_screen(QPoint(0, 0)) # X centerline (red) — horizontal through origin painter.setPen(QPen(QColor("#f38ba8"), 1.5, Qt.DashLine)) painter.drawLine(0, origin_sc.y(), self.width(), origin_sc.y()) # Y centerline (green) — vertical through origin painter.setPen(QPen(QColor("#a6e3a1"), 1.5, Qt.DashLine)) painter.drawLine(origin_sc.x(), 0, origin_sc.x(), self.height()) # Origin intersection point (small ring) painter.setPen(QPen(QColor("#cdd6f4"), 1)) painter.setBrush(Qt.NoBrush) painter.drawEllipse(origin_sc, 3, 3) # ── Source-face underlay fill (sketch-on-surface) ── # The underlay is now drawn as real construction-line entities # (rendered below in the Points / Lines sections with an orange # dashed style) so the user can pick them for constraints. We # still draw a faint fill over the *outer* loop of the projected # face here for visual context, but the lines themselves are NOT # drawn from this cache any more — that would double-paint the # 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] ]) painter.setBrush(QBrush(QColor(250, 179, 135, 28))) painter.setPen(Qt.NoPen) painter.drawPolygon(fill_poly) # ── Points ── for entity in self._points: # External / underlay points are rendered in the dedicated # underlay block below (with an orange style) and skipped when # the underlay is hidden. Skip them here to avoid double draw. if self._is_external(entity): continue # Centerline points are rendered as part of the viewport-spanning # axes above; skip them here to avoid double-drawing tiny dots # at the endpoints far out of view. if self._is_centerline(entity): continue if entity.geometry: x, y = entity.geometry screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) if entity.is_construction: painter.setPen(QPen(QColor("#6c7086"), 1)) painter.setBrush(QBrush(QColor("#6c7086"))) else: painter.setPen(QPen(QColor("#89b4fa"), 2)) painter.setBrush(QBrush(QColor("#89b4fa"))) size = 4 if entity.is_construction else 6 painter.drawEllipse(screen_pos, size, size) # ── Underlay (face-projected) construction lines & points ── # Drawn with an orange dashed style so the user can visually tell # them apart from user-drawn construction lines (grey). Gated by # the underlay visibility toggle. if self._underlay_visible: for p1_ent, p2_ent in self._lines: line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) if not self._is_external(line_ent): continue if p1_ent.geometry and p2_ent.geometry: x1, y1 = p1_ent.geometry x2, y2 = p2_ent.geometry sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1)))) sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2)))) painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine)) painter.drawLine(sp1, sp2) for entity in self._points: if not self._is_external(entity): continue if entity.geometry: x, y = entity.geometry 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) # ── Lines ── for p1_ent, p2_ent in self._lines: # External lines are drawn above; skip here to avoid double draw. line_ent = self._find_line_sketch_entity(p1_ent, p2_ent) if self._is_external(line_ent): continue # Centerlines are rendered as viewport-spanning axes above; # skip the finite-endpoint version here. if line_ent is not None and self._is_centerline(line_ent): continue if p1_ent.geometry and p2_ent.geometry: x1, y1 = p1_ent.geometry x2, y2 = p2_ent.geometry 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) if is_construction: painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine)) else: painter.setPen(QPen(QColor("#cdd6f4"), 2)) painter.drawLine(sp1, sp2) # ── Constraint tags (log-driven; drawn upright in screen space) ── # Tags are recomputed here so paint stays in sync with the latest solve. self._constraint_tags = self._compute_constraint_tags() tag_font = QFont("Monospace", 9) painter.setFont(tag_font) for tag in self._constraint_tags: rect: QRect = tag["rect"] hovered = tag["idx"] == self._hovered_constraint_idx # Background pill so the label is readable over the sketch. painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2)) painter.setBrush(QBrush(QColor(40, 40, 60, 200))) painter.drawRoundedRect(rect, 6, 6) painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1)) painter.drawText(rect, Qt.AlignCenter, tag["label"]) # ── Circles ── for center_ent, radius in self._circles: if center_ent.geometry: cx, cy = center_ent.geometry sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy)))) sr = radius * self._zoom painter.setPen(QPen(QColor("#cdd6f4"), 2)) painter.setBrush(Qt.NoBrush) painter.drawEllipse(sc, int(sr), int(sr)) # ── Arcs ── for arc_item in self._arcs: center_ent, radius, start_ent, end_ent, sweep = arc_item[:5] if not (center_ent.geometry and start_ent.geometry and end_ent.geometry): continue cx, cy = center_ent.geometry sx, sy = start_ent.geometry sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy)))) sr = int(radius * self._zoom) # Use stored sweep if available; fall back to shortest-path. if sweep is None: ex, ey = end_ent.geometry sa = math.atan2(sy - cy, sx - cx) ea = math.atan2(ey - cy, ex - cx) sweep = ea - sa while sweep > math.pi: sweep -= 2 * math.pi while sweep < -math.pi: sweep += 2 * math.pi start_angle = math.atan2(sy - cy, sx - cx) # QPainter: 1/16 degree, positive = CCW; 0° = 3 o'clock start_deg_16 = int(math.degrees(start_angle) * 16) span_deg_16 = int(math.degrees(sweep) * 16) rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2) painter.setPen(QPen(QColor("#cdd6f4"), 2)) painter.setBrush(Qt.NoBrush) painter.drawArc(rect, start_deg_16, span_deg_16) # ── Offset preview (live preview from OffsetDialog) ── if self._offset_preview_active and self._offset_preview_points: preview = self._offset_preview_points painter.setPen(QPen(QColor("#f9e2af"), 2, Qt.DashLine)) painter.setBrush(Qt.NoBrush) for i in range(len(preview)): p1 = self._world_to_screen( 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]))) ) painter.drawLine(p1, p2) # ── Dynamic drawing previews ── if self._draw_buffer and self._dynamic_line_end and self._mode == "line": start = self._world_to_screen(self._draw_buffer[0]) end = self._world_to_screen(self._dynamic_line_end) painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine)) painter.drawLine(start, end) if self._draw_buffer and self._dynamic_line_end and self._mode == "rectangle": 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())) if self._draw_buffer and self._dynamic_line_end and self._mode == "circle": center = self._world_to_screen(self._draw_buffer[0]) end = self._world_to_screen(self._dynamic_line_end) r = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2) painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine)) painter.setBrush(Qt.NoBrush) painter.drawEllipse(center, int(r), int(r)) if self._draw_buffer and self._dynamic_line_end and self._mode == "arc": if len(self._draw_buffer) == 1: # Click 1 done: show circle preview (center to cursor) center = self._world_to_screen(self._draw_buffer[0]) end = self._world_to_screen(self._dynamic_line_end) r = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2) painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine)) painter.setBrush(Qt.NoBrush) 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 ew = self._dynamic_line_end # end world (mouse) cx_f, cy_f = cw.x(), cw.y() sx_f, sy_f = sw.x(), sw.y() arc_radius = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2) sr = int(arc_radius * self._zoom) sc = self._world_to_screen(QPoint(int(round(cx_f)), int(round(cy_f)))) # Use accumulated sweep so the arc follows the mouse # smoothly even past 180° (no shortest-path flip). start_angle = math.atan2(sy_f - cy_f, sx_f - cx_f) sweep = self._arc_accum_sweep start_deg_16 = int(math.degrees(start_angle) * 16) span_deg_16 = int(math.degrees(sweep) * 16) rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2) painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine)) painter.setBrush(Qt.NoBrush) painter.drawArc(rect, start_deg_16, span_deg_16) # Also draw a helper line from center to cursor so the # user can see the sweep angle being defined painter.setPen(QPen(QColor("#a6e3a1"), 1, Qt.DotLine)) painter.drawLine(sc, self._world_to_screen(ew)) # ── Slot preview ── if self._draw_buffer and self._dynamic_line_end and self._mode == "slot": if len(self._draw_buffer) == 1: # Click 1 done: preview centerline c1 = self._world_to_screen(self._draw_buffer[0]) c2 = self._world_to_screen(self._dynamic_line_end) painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine)) painter.drawLine(c1, c2) elif len(self._draw_buffer) == 2: # Click 2 done: preview full slot outline c1 = self._draw_buffer[0] c2 = self._draw_buffer[1] cursor = self._dynamic_line_end dx_c = c2.x() - c1.x() 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 if r < 0.5: r = 0.5 perp_x = -dy_c / L perp_y = dx_c / L def sc(pt): return self._world_to_screen(QPoint(int(round(pt[0])), int(round(pt[1])))) c1s = (c1.x(), c1.y()) c2s = (c2.x(), c2.y()) t1 = (c1.x() + r * perp_x, c1.y() + r * perp_y) b1 = (c1.x() - r * perp_x, c1.y() - r * perp_y) t2 = (c2.x() + r * perp_x, c2.y() + r * perp_y) b2 = (c2.x() - r * perp_x, c2.y() - r * perp_y) painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine)) painter.setBrush(Qt.NoBrush) # Bottom line painter.drawLine(sc(b1), sc(b2)) # Top line painter.drawLine(sc(t2), sc(t1)) # Arc 1 (center C1, from t1 to b1, CCW exterior) sa1 = math.atan2(t1[1] - c1s[1], t1[0] - c1s[0]) sweep1 = math.pi n_seg = 16 for i in range(n_seg): a1 = sa1 + (i / n_seg) * sweep1 a2 = sa1 + ((i + 1) / n_seg) * sweep1 p1 = (c1s[0] + r * math.cos(a1), c1s[1] + r * math.sin(a1)) p2 = (c1s[0] + r * math.cos(a2), c1s[1] + r * math.sin(a2)) painter.drawLine(sc(p1), sc(p2)) # Arc 2 (center C2, from b2 to t2) sa2 = math.atan2(b2[1] - c2s[1], b2[0] - c2s[0]) sweep2 = math.pi for i in range(n_seg): a1 = sa2 + (i / n_seg) * sweep2 a2 = sa2 + ((i + 1) / n_seg) * sweep2 p1 = (c2s[0] + r * math.cos(a1), c2s[1] + r * math.sin(a1)) p2 = (c2s[0] + r * math.cos(a2), c2s[1] + r * math.sin(a2)) painter.drawLine(sc(p1), sc(p2)) # ── Hovered point highlight ── if self._hovered_point: screen_pos = self._world_to_screen(self._hovered_point) painter.setPen(QPen(QColor("#f9e2af"), 2)) painter.setBrush(Qt.NoBrush) painter.drawEllipse(screen_pos, 10, 10) # ── Hovered line distance measurement ── if self._hovered_line and not self._hovered_point: p1, p2 = self._hovered_line sp1 = self._world_to_screen(p1) sp2 = self._world_to_screen(p2) painter.setPen(QPen(QColor("#a6e3a1"), 2)) painter.drawLine(sp1, sp2) self._draw_distance_measurement(painter, p1, p2) # ── Moved-element highlight ── if self._move_active and self._moving_points: painter.setPen(QPen(QColor("#f38ba8"), 2)) painter.setBrush(Qt.NoBrush) for ent in self._moving_points: if ent.geometry: x, y = ent.geometry sp = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) painter.drawEllipse(sp, 10, 10) if self._move_anchor is not None and self._move_anchor.geometry: x, y = self._move_anchor.geometry sp = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) painter.setPen(QPen(QColor("#f9e2af"), 2)) painter.drawEllipse(sp, 12, 12) # ── Selected face preview (detected regions) ── if self._sketch: faces = self._sketch.detect_faces() for face in faces: is_hovered = self._faces_match(face, self._hovered_face) is_selected = self._faces_match(face, self._selected_face) if not (is_hovered or is_selected): continue path = QPainterPath() 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])))) path.moveTo(sp0.x(), sp0.y()) 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() else: # circle cx, cy = outer["center"] spc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy)))) sr = outer["radius"] * self._zoom path.addEllipse(spc, sr, sr) # Add holes as sub-paths (odd-even fill punches them out). 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])))) path.moveTo(sp0.x(), sp0.y()) 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() else: hcx, hcy = hole["center"] hspc = self._world_to_screen(QPoint(int(round(hcx)), int(round(hcy)))) hsr = hole["radius"] * self._zoom path.addEllipse(hspc, hsr, hsr) path.setFillRule(Qt.OddEvenFill) # Determine colours if is_selected and not is_hovered: fill = QColor(137, 180, 250, 100) # blue-ish stroke = QPen(QColor("#89b4fa"), 2) elif is_hovered: fill = QColor(249, 226, 175, 120) # gold-ish stroke = QPen(QColor("#f9e2af"), 2) else: fill = QColor(166, 227, 161, 80) # green-ish stroke = QPen(QColor("#a6e3a1"), 1) painter.setPen(stroke) painter.setBrush(fill) painter.drawPath(path) # ── Selected entities ── for entity in self._selected_entities: if entity.geometry: x, y = entity.geometry screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y)))) painter.setPen(QPen(QColor("#f9e2af"), 2)) painter.setBrush(Qt.NoBrush) painter.drawEllipse(screen_pos, 12, 12) # ── DOF display ── if self._sketch: try: dof = self._sketch.get_solver_dof() painter.save() painter.setPen(QPen(QColor("#a6adc8"), 1)) font = QFont("Monospace", 9) painter.setFont(font) painter.drawText(10, 20, f"DOF: {dof}") painter.restore() except Exception: pass