from h5py._hl import files """ Fluency CAD - Main Application A parametric CAD application built on OpenCASCADE Technology (OCCT) with a modern pygfx-based 3D renderer. """ import sys import math import uuid import logging from typing import Optional, List, Dict, Any, Tuple from dataclasses import dataclass, field logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QToolBar, QStatusBar, QFileDialog, QMessageBox, QDockWidget, QTreeWidget, QTreeWidgetItem, QLabel, QDoubleSpinBox, QSpinBox, QComboBox, QPushButton, QGroupBox, QListWidget, QListWidgetItem, QTabWidget, QTextEdit, QDialog, QCheckBox, QButtonGroup, QFrame, QMenu, QMenuBar, QSplitter, QSizePolicy, QInputDialog, ) from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect from PySide6.QtGui import ( QAction, QIcon, QKeySequence, QPainter, QPainterPath, QPen, QColor, QBrush, QFont, QFontMetrics, QCursor, QPolygonF, ) from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity from fluency.geometry.base import Point2D, Point3D from fluency.rendering.occ_renderer import OCCRenderer from fluency.models.data_model import Project, Component, Sketch, Body 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 → endpoints, curves → 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 ExtrudeDialog(QDialog): """Dialog for extrude options. Carries an optional ``preview_callback`` that is invoked whenever the user changes any option; the host uses it to render a live transparent preview of the operation result in the 3D view. Passing *False* (or *None*) to the callback tells the host to clear the preview. """ def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Extrude Options") self.setMinimumWidth(320) self._preview_callback = None layout = QVBoxLayout(self) length_layout = QHBoxLayout() length_layout.addWidget(QLabel("Extrude Length (mm):")) self.length_input = QDoubleSpinBox() self.length_input.setDecimals(2) self.length_input.setRange(-10000, 10000) self.length_input.setValue(10) length_layout.addWidget(self.length_input) layout.addLayout(length_layout) self.symmetric_checkbox = QCheckBox("Symmetric Extrude") layout.addWidget(self.symmetric_checkbox) self.invert_checkbox = QCheckBox("Invert Extrusion") layout.addWidget(self.invert_checkbox) self.cut_checkbox = QCheckBox("Perform Cut") layout.addWidget(self.cut_checkbox) self.union_checkbox = QCheckBox("Combine (Union)") layout.addWidget(self.union_checkbox) self.through_all_checkbox = QCheckBox("Through All (cut/union target)") self.through_all_checkbox.setToolTip( "Ignore the typed length and extrude far enough to fully pass " "through the cut/union target body. Applies when Perform Cut or " "Combine (Union) is checked." ) layout.addWidget(self.through_all_checkbox) self.rounded_checkbox = QCheckBox("Round Edges") layout.addWidget(self.rounded_checkbox) line = QFrame() line.setFrameShape(QFrame.HLine) line.setFrameShadow(QFrame.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("OK") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) # Live preview: recompute on every option change. Use a light- # weight guard so we don't emit before the host has wired up the # callback. for w in ( self.length_input, self.symmetric_checkbox, self.invert_checkbox, self.cut_checkbox, self.union_checkbox, self.through_all_checkbox, self.rounded_checkbox, ): # The spinbox has valueChanged; the checkboxes have stateChanged. # Each must be wired in its own try/except so that a missing # signal on one widget type doesn't skip the OTHER signal's # connection (the prior single-try version accidentally # left checkboxes un-connected when valueChanged raised first). try: w.valueChanged.connect(self._emit_preview) except AttributeError: pass try: w.stateChanged.connect(self._emit_preview) except AttributeError: pass def set_preview_callback(self, callback) -> None: """Install the live-preview callback (or *None* to disable).""" self._preview_callback = callback # Emit once so the initial state shows a preview right away. self._emit_preview() def _emit_preview(self, *args) -> None: if self._preview_callback is None: return try: self._preview_callback(self.get_values()) except Exception as exc: # preview must never break the dialog logger.debug("extrude preview callback raised: %s", exc) def hideEvent(self, event): # Tell the host to clear the preview when the dialog goes away # (accept, reject, or close). The host is responsible for the # actual viewer cleanup. if self._preview_callback is not None: try: self._preview_callback(None) except Exception: pass super().hideEvent(event) def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]: return ( self.length_input.value(), self.symmetric_checkbox.isChecked(), self.invert_checkbox.isChecked(), self.cut_checkbox.isChecked(), self.union_checkbox.isChecked(), self.through_all_checkbox.isChecked(), self.rounded_checkbox.isChecked(), ) class RevolveDialog(QDialog): """Dialog for revolve options.""" def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Revolve Options") self.setMinimumWidth(300) layout = QVBoxLayout(self) angle_layout = QHBoxLayout() angle_layout.addWidget(QLabel("Revolve Angle (°):")) self.angle_input = QDoubleSpinBox() self.angle_input.setDecimals(1) self.angle_input.setRange(1, 360) self.angle_input.setValue(360) self.angle_input.setSuffix("°") angle_layout.addWidget(self.angle_input) layout.addLayout(angle_layout) line = QFrame() line.setFrameShape(QFrame.HLine) line.setFrameShadow(QFrame.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("OK") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) 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._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 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._hovered_point = None self._hovered_point_entity = None self._hovered_line = None self._hovered_line_entity = None self._selected_entities = [] 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._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)) @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 get_sketch(self) -> Optional[OCCSketch]: return self._sketch def create_sketch(self) -> OCCSketch: self._sketch = OCCSketch() self._points = [] self._lines = [] self._circles = [] self.update() return self._sketch def reset_buffers(self): self._draw_buffer = [] self._dynamic_line_end = None self._mode = None self._clear_move_state() 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 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 # 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 _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 point_snap = self._find_nearest_point(pos) if point_snap: 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 if self._snap_mode.get("vert", False): vert = self._apply_vertical_snap(start, result) if abs(result.x() - start.x()) < 10: result = vert 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 = 50 # world units per grid cell (matches paintEvent 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 # Point snap (excluding moved points) if self._snap_mode.get("point", False): nearest = 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 if nearest is not None: 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. """ 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 # Point-to-line-segment distance check dx = x2 - x1 dy = y2 - y1 if dx == 0 and dy == 0: continue t = ((world_pos.x() - x1) * dx + (world_pos.y() - y1) * dy) / (dx*dx + dy*dy) t = max(0, min(1, t)) proj_x = x1 + t * dx proj_y = y1 + t * dy dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2) if 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. ``OCCSketch._lines`` maps line id -> (start_point_id, end_point_id); 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). """ if not self._sketch: return [anchor] adjacency: Dict[int, List[int]] = {} for _line_id, (sid, eid2) in self._sketch._lines.items(): adjacency.setdefault(sid, []).append(eid2) adjacency.setdefault(eid2, []).append(sid) 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.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)): 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. face = self._sketch.find_face_at(world_pos.x(), world_pos.y()) 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])): 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._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 == "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"]: snapped = self._apply_all_snaps( event.pos(), self._world_to_screen(self._draw_buffer[0]) ) self._dynamic_line_end = self._screen_to_world(snapped) # Constraint-tag hover takes priority — if the cursor is over a tag, # we highlight it for delete and skip point/line 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.setCursor(Qt.PointingHandCursor) self.update() return if self._hovered_constraint_idx != -1: self._hovered_constraint_idx = -1 self.update() # Point hover — show an open-hand cursor when a move is possible # (explicit Move tool OR no tool selected at all) hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor 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.setCursor(hover_cursor) else: self._hovered_point = None self._hovered_point_entity = None # Check line hover 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 # In move mode, hovering a circle curve should also show grab cursor 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) # Face region hover (fallback when no point/line/circle/tag is under the cursor). if (self._hovered_constraint_idx < 0 and self._hovered_point is None and self._hovered_line is None and self._sketch is not None): face = self._sketch.find_face_at(world_pos.x(), world_pos.y()) if face is not None: self._hovered_face = face self.setCursor(Qt.PointingHandCursor if self._mode in ("select", None) else Qt.ArrowCursor) else: self._hovered_face = None else: self._hovered_face = None 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() # 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 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 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 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() # ─── Drawing handlers ───────────────────────────────────────────────── def _handle_line_click(self, pos: QPoint): if not self._sketch: self._sketch = OCCSketch() 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) 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])) self._solve_and_sync() self._draw_buffer = [pos] self.sketch_updated.emit() self.update() def _handle_rectangle_click(self, pos: QPoint): if not self._sketch: self._sketch = OCCSketch() if not self._draw_buffer: self._draw_buffer.append(pos) 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) 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])) 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): if not self._sketch: self._sketch = OCCSketch() 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) 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_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 ── pen = QPen(QColor("#45475a"), 1) painter.setPen(pen) grid_size = 50 * self._zoom ox = int(self._offset.x() % max(1, int(grid_size))) oy = int(self._offset.y() % max(1, int(grid_size))) for x in range(ox, self.width(), max(1, int(grid_size))): painter.drawLine(x, 0, x, self.height()) for y in range(oy, self.height(), max(1, int(grid_size))): painter.drawLine(0, y, self.width(), y) # ── Origin axes ── origin = self._world_to_screen(QPoint(0, 0)) pen = QPen(QColor("#f38ba8"), 2) painter.setPen(pen) painter.drawLine(origin.x() - 20, origin.y(), origin.x() + 20, origin.y()) painter.drawLine(origin.x(), origin.y() - 20, origin.x(), origin.y() + 20) # ── 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 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 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 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)) # ── 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)) # ── 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 class Viewer3DWidget(QWidget): """3D viewer widget using OCC's native AIS display.""" # Emitted when the user picks a planar face to sketch on. # Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z). facePicked = Signal(tuple, tuple, tuple, object) # Emitted when face-pick mode is cancelled (Esc) so the host can uncheck. pickFaceCancelled = Signal() def __init__(self, parent=None): super().__init__(parent) # For OCC's direct OpenGL rendering we need Qt to not paint over it. self.setAttribute(Qt.WA_PaintOnScreen) self.setAttribute(Qt.WA_OpaquePaintEvent) self.setAutoFillBackground(False) # Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work. self.setFocusPolicy(Qt.StrongFocus) # Try OCC renderer first; fall back to pygfx if unavailable. self._renderer: Any = None self._initialized = False self._meshes: Dict[str, Any] = {} self._selected_normal: Optional[Tuple[float, float, float]] = None self._centroid: Optional[Tuple[float, float, float]] = None self._pending_meshes: List[Tuple] = [] # When True, a left-click picks a planar face (for sketch-on-surface) # instead of orbiting the camera. Set via set_pick_face_mode(). self._pick_face_mode: bool = False # Most recently recorded owning obj_id for the face returned by # ``pick_planar_face``. Stashed on each pick pass so the host can # pair the picked face with the body it belongs to (used to auto- # target a cut/union extrude against the body the sketch was # projected onto). self._last_pick_owner_obj_id: Optional[str] = None def _init_renderer(self) -> None: """Create the best available renderer.""" if self._renderer is not None: return import sys as _sys _sys.stdout.flush() logger.info("Renderer: starting import...") from fluency.rendering.occ_renderer import OCCRenderer from fluency.rendering.pygfx_renderer import PygfxRenderer logger.info("Renderer: imports done, creating OCCRenderer...") occ = OCCRenderer() logger.info("Renderer: calling occ.initialize...") try: ok = occ.initialize(self) except Exception as exc: logger.warning(f"OCCRenderer init raised: {exc}") ok = False logger.info(f"Renderer: OCC result={ok}") if ok: self._renderer = occ logger.info("Using OCCRenderer (native BRep display)") else: logger.info("Falling back to PygfxRenderer") self._renderer = PygfxRenderer() logger.info("Renderer: calling pygfx initialize...") self._renderer.initialize(self) logger.info("Renderer: pygfx init done") self._initialized = True logger.info("Renderer: initialization complete") def showEvent(self, event): logger.info("Viewer3DWidget showEvent - initializing renderer") if not self._initialized: self._init_renderer() logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}") for args in self._pending_meshes: self.add_mesh(*args) self._pending_meshes.clear() self._renderer.render() def _ensure_initialized(self): if not self._initialized: logger.debug("Ensuring renderer is initialized") self._init_renderer() def get_renderer(self): self._ensure_initialized() return self._renderer def show_shape(self, shape: Any, color=None, name=None) -> str: """Display an OCC TopoDS_Shape. Uses OCCRenderer.add_shape for native AIS display, or falls back to triangulation + add_mesh for the PygfxRenderer. """ self._ensure_initialized() from fluency.rendering.occ_renderer import OCCRenderer if isinstance(self._renderer, OCCRenderer): oid = self._renderer.add_shape(shape, color, name) self._renderer.render() return oid # Fallback: tessellate and use the mesh pipeline. from fluency.geometry_occ.kernel import OCGeometryKernel k = OCGeometryKernel() from fluency.geometry_occ.sketch import OCCSketch # Build a temporary OCCGeometryObject to use the kernel's mesh helpers. from fluency.geometry_occ.kernel import OCCGeometryObject obj = OCCGeometryObject(shape) verts, faces = k.get_mesh(obj) oid = self._renderer.add_mesh(verts, faces, color, name) # Edges try: e_verts, e_edges = k.get_edges(obj) if len(e_verts) > 0: self._renderer.add_wireframe(e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges") except Exception: pass self._renderer.render() return oid def add_mesh(self, vertices, faces, color=None, name=None) -> str: logger.debug( f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}" ) if not self._initialized: self._pending_meshes.append((vertices, faces, color, name)) logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}") return f"pending_{len(self._pending_meshes)}" self._ensure_initialized() mesh_id = self._renderer.add_mesh(vertices, faces, color, name) self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name} self._renderer.render() logger.info(f"Added mesh: {mesh_id}, name={name}") return mesh_id def update_mesh(self, mesh_id: str, vertices, faces): self._ensure_initialized() self._renderer.update_mesh(mesh_id, vertices, faces) self._meshes[mesh_id] = {"vertices": vertices, "faces": faces} self._renderer.render() def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str: self._ensure_initialized() wid = self._renderer.add_wireframe(vertices, edges, color or (0.9, 0.9, 0.9), line_width, name) self._renderer.render() return wid def remove_mesh(self, mesh_id: str): self._ensure_initialized() self._renderer.remove_mesh(mesh_id) if mesh_id in self._meshes: del self._meshes[mesh_id] self._renderer.render() def set_visibility(self, mesh_id: str, visible: bool) -> bool: """Show or hide a previously-added mesh without removing it. Used by the per-body visibility toggle on the body list so the user can quickly hide a body (e.g. to verify a cut worked on another body). Returns True on success, False if the mesh is unknown to the renderer or the renderer doesn't support it (e.g. the Pygfx fallback ABI). """ self._ensure_initialized() fn = getattr(self._renderer, "set_visibility", None) if fn is None: return False ok = fn(mesh_id, visible) if ok: self._renderer.render() return ok def set_transparency(self, mesh_id: str, transparency: float) -> bool: """Set a previously-added mesh's transparency (0..1). Used by the live extrude preview to dim the target body so the previewed result reads on top of it. """ self._ensure_initialized() fn = getattr(self._renderer, "set_object_transparency", None) if fn is None: return False return fn(mesh_id, transparency) def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None: """Display a temporary transparent preview of *shape* in the 3D view. Used by the ExtrudeDialog live preview: as the user drags the length spinner or toggles Cut/Through-All, the host recomputes the operation result and shows it here. Call clear_preview() when the dialog closes. """ self._ensure_initialized() fn = getattr(self._renderer, "preview_shape", None) if fn is None: return fn(shape, color, transparency) def clear_preview(self) -> None: """Remove the live extrude preview shape, if any.""" if not self._initialized or self._renderer is None: return fn = getattr(self._renderer, "clear_preview", None) if fn is None: return fn() def clear_scene(self): self._ensure_initialized() self._renderer.clear_scene() self._meshes.clear() self._renderer.render() def fit_camera(self): self._ensure_initialized() self._renderer.fit_camera() self._renderer.render() def mousePressEvent(self, event): self._ensure_initialized() # Face-pick mode: a left-click selects a planar face to sketch on. if self._pick_face_mode and event.button() == Qt.LeftButton: self._handle_face_pick(event) return self._renderer.handle_mouse_press(event) super().mousePressEvent(event) def mouseMoveEvent(self, event): self._ensure_initialized() # In face-pick mode, keep dynamic highlighting but don't orbit. if self._pick_face_mode: if hasattr(self._renderer, "handle_mouse_move"): # Still forward for hover-detect (no button held → detect only). self._renderer.handle_mouse_move(event) super().mouseMoveEvent(event) return self._renderer.handle_mouse_move(event) super().mouseMoveEvent(event) def paintEngine(self): """Return None to prevent Qt from painting over OCC's direct OpenGL.""" return None def paintEvent(self, event): """Empty paintEvent — OCC draws directly via OpenGL.""" pass def mouseReleaseEvent(self, event): self._ensure_initialized() self._renderer.handle_mouse_release(event) super().mouseReleaseEvent(event) def wheelEvent(self, event): self._ensure_initialized() self._renderer.handle_wheel(event) super().wheelEvent(event) def resizeEvent(self, event): super().resizeEvent(event) self._ensure_initialized() self._renderer.handle_resize(event.size().width(), event.size().height()) def set_camera_position(self, position, target): self._ensure_initialized() self._renderer.set_camera_position(position, target) self._renderer.render() # ─── Face-pick mode (sketch-on-surface) ──────────────────────────────── def set_pick_face_mode(self, enabled: bool) -> None: """Toggle face-pick mode. When enabled, the cursor selects planar faces for sketch placement instead of orbiting the camera. Middle button still pans; wheel zooms. """ self._pick_face_mode = bool(enabled) if enabled: self.setCursor(Qt.CrossCursor) else: self.unsetCursor() def is_pick_face_mode(self) -> bool: return self._pick_face_mode def highlight_face(self, face: Any) -> None: """Tint the picked face light-blue/transparent in the 3D viewer.""" self._ensure_initialized() fn = getattr(self._renderer, "highlight_face", None) if fn is not None: fn(face) self._renderer.render() def clear_face_highlight(self) -> None: """Remove the persistent face-selection tint.""" self._ensure_initialized() fn = getattr(self._renderer, "clear_face_highlight", None) if fn is not None: fn() self._renderer.render() def _handle_face_pick(self, event) -> None: """Detect a planar face under the click and emit facePicked.""" self._ensure_initialized() picker = getattr(self._renderer, "pick_planar_face", None) if picker is None: logger.warning("Renderer has no pick_planar_face support") return # Qt6: prefer position().toPoint() over deprecated pos(). pos = event.position().toPoint() if hasattr(event, "position") else event.pos() info = picker(pos.x(), pos.y()) if info is None: logger.info("Face pick: no planar face under cursor") return # Stash the owning obj_id so MainWindow._on_face_picked can pair the # picked face with the body it belongs to (for auto-targeted cut). self._last_pick_owner_obj_id = info.get("owner_obj_id") self.facePicked.emit( tuple(info["origin"]), tuple(info["normal"]), tuple(info["x_dir"]), info["face"], ) def set_view(self, view: str): # Prefer the renderer's native orientation snap (preserves target, # refits the scene). Falls back to absolute eye positions for # renderers that don't implement set_view_orientation. self._ensure_initialized() if hasattr(self._renderer, "set_view_orientation"): self._renderer.set_view_orientation(view) self._renderer.render() return positions = { "iso": ((100, 100, 100), (0, 0, 0)), "top": ((0, 0, 200), (0, 0, 0)), "front": ((0, -200, 0), (0, 0, 0)), "right": ((200, 0, 0), (0, 0, 0)), "back": ((0, 200, 0), (0, 0, 0)), "left": ((-200, 0, 0), (0, 0, 0)), "bottom": ((0, 0, -200), (0, 0, 0)), } if view in positions: pos, target = positions[view] self.set_camera_position(pos, target) def mouseDoubleClickEvent(self, event): # Double-click → fit all (common CAD convention). self._ensure_initialized() if event.button() == Qt.LeftButton: self.fit_camera() super().mouseDoubleClickEvent(event) def keyPressEvent(self, event): # Esc cancels face-pick mode. if self._pick_face_mode and event.key() == Qt.Key_Escape: self.set_pick_face_mode(False) self.pickFaceCancelled.emit() return # Navigation shortcuts (lowercase = view presets, F = fit, # P/O = perspective/orthographic, R = reset). self._ensure_initialized() key = event.text().lower() mapping = { "f": "fit", "r": "reset", "1": "front", "2": "back", "3": "top", "4": "bottom", "5": "left", "6": "right", "7": "iso", } action = mapping.get(key) if action == "fit": self.fit_camera() return if action == "reset": if hasattr(self._renderer, "reset_camera"): self._renderer.reset_camera() self._renderer.render() else: self.set_view("iso") return if action in ("front", "back", "top", "bottom", "left", "right", "iso"): self.set_view(action) return if key == "p" and hasattr(self._renderer, "set_camera_perspective"): self._renderer.set_camera_perspective() self._renderer.render() return if key == "o" and hasattr(self._renderer, "set_camera_orthographic"): self._renderer.set_camera_orthographic() self._renderer.render() return super().keyPressEvent(event) class MainWindow(QMainWindow): """Main application window.""" def __init__(self): super().__init__() logger.info("Initializing MainWindow") self._project = Project() self._kernel = OCGeometryKernel() logger.info("Created Project and OCGeometryKernel") self._current_component: Optional[Component] = None self._current_sketch: Optional[Sketch] = None self._selected_body: Optional[Body] = None self._component_buttons: List[QPushButton] = [] self._component_group: Optional[QButtonGroup] = None self._setup_ui() self._setup_connections() self._create_initial_component() logger.info("MainWindow initialization complete") def _setup_ui(self): self.setWindowTitle("Fluency CAD 2.0") self.setMinimumSize(1400, 900) self._create_menus() self._create_central_widget() self._create_dock_widgets() logger.info("Ready") def _create_menus(self): menubar = self.menuBar() file_menu = menubar.addMenu("&File") new_action = QAction("&New Project", self) new_action.setShortcut(QKeySequence.New) new_action.triggered.connect(self._new_project) file_menu.addAction(new_action) open_action = QAction("&Open STEP/IGES...", self) open_action.setShortcut(QKeySequence.Open) open_action.triggered.connect(self._import_file) file_menu.addAction(open_action) file_menu.addSeparator() export_step = QAction("Export &STEP...", self) export_step.triggered.connect(self._export_step) file_menu.addAction(export_step) export_iges = QAction("Export &IGES...", self) export_iges.triggered.connect(self._export_iges) file_menu.addAction(export_iges) export_stl = QAction("Export S&TL...", self) export_stl.triggered.connect(self._export_stl) file_menu.addAction(export_stl) file_menu.addSeparator() exit_action = QAction("E&xit", self) exit_action.setShortcut(QKeySequence.Quit) exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) view_menu = menubar.addMenu("&View") view_menu.addAction("Fit All", self._fit_view) view_menu.addAction("Reset View", self._reset_view) view_menu.addSeparator() for view_name in ["Isometric", "Top", "Front", "Right", "Back", "Left", "Bottom"]: action = QAction(view_name, self) action.triggered.connect( lambda checked, v=view_name.lower(): self._viewer_3d.set_view(v) ) view_menu.addAction(action) help_menu = menubar.addMenu("&Help") help_menu.addAction("About", self._show_about) def _create_central_widget(self): """ Recreates the classic grid-based layout from the original fluencyCAD. Grid layout: Col 0 (≤200px) Col 1 (expand) Col 2 (expand) Col 3 (≤200px) r0: Workplanes InputTab Model Viewer Modify r1: Drawing r2: Constrain r3: Snaps (tab) r6: Export r4: (Snaps cont) r7-8: Bodies r5: (Snaps cont) r6-8: Sketch List r9: Comp Tools Comp Buttons (span 2) Assembly Tools """ central = QWidget() self.setCentralWidget(central) grid = QGridLayout(central) grid.setContentsMargins(5, 5, 5, 5) grid.setSpacing(4) grid.setColumnStretch(0, 0) # left column fixed # Sketch (col 1) and 3D viewer (col 2) share the remaining width. # The split is hover-driven: entering the sketch enlarges it, entering # the 3D viewer grows it to 2/3 (sketch shrinks to 1/3). Resting = 1:1. grid.setColumnStretch(1, 1) grid.setColumnStretch(2, 1) grid.setColumnStretch(3, 0) # right column fixed self._grid = grid # ---- Row 0, Col 0: Workplanes ---- wp_group = QGroupBox("Workplanes") wp_group.setMaximumWidth(200) wp_layout = QGridLayout(wp_group) self._btn_wp_origin = QPushButton("WP Origin") self._btn_wp_origin.setToolTip("Working Plane at 0, 0, 0") self._btn_wp_origin.setShortcut("W") wp_layout.addWidget(self._btn_wp_origin, 0, 0) self._btn_wp_face = QPushButton("WP Face") self._btn_wp_face.setToolTip("Working Plane from selected face") self._btn_wp_face.setShortcut("P") self._btn_wp_face.setCheckable(True) wp_layout.addWidget(self._btn_wp_face, 0, 1) self._btn_wp_flip = QPushButton("WP Flip") self._btn_wp_flip.setToolTip("Flip normal direction") self._btn_wp_flip.setShortcut("N") wp_layout.addWidget(self._btn_wp_flip, 1, 0) self._btn_wp_move = QPushButton("WP Mve") self._btn_wp_move.setToolTip("Move workplane") self._btn_wp_move.setShortcut("M") wp_layout.addWidget(self._btn_wp_move, 1, 1) # Underlay: show / hide the construction lines projected from the # source face. Off by default is meaningless (no face picked yet) # so it auto-unchecks when the user clears the source face. self._btn_underlay = QPushButton("Underlay") self._btn_underlay.setToolTip( "Show / hide the construction lines projected from the source face" ) self._btn_underlay.setCheckable(True) self._btn_underlay.setChecked(True) self._btn_underlay.setEnabled(False) # becomes enabled after WP Face wp_layout.addWidget(self._btn_underlay, 2, 0) # ClrFace: forget the picked source face. Removes the underlay # construction-line entities and leaves the workplane + sketch # intact, so the user can keep drawing on a free-standing sketch. self._btn_clr_face = QPushButton("ClrFace") self._btn_clr_face.setToolTip("Forget the picked source face (keep the workplane)") self._btn_clr_face.setEnabled(False) # becomes enabled after WP Face wp_layout.addWidget(self._btn_clr_face, 2, 1) grid.addWidget(wp_group, 0, 0) # ---- Row 1, Col 0: Drawing ---- draw_group = QGroupBox("Drawing") draw_group.setMaximumWidth(200) draw_layout = QGridLayout(draw_group) self._btn_line = QPushButton("Line") self._btn_line.setCheckable(True) self._btn_line.setShortcut("S") draw_layout.addWidget(self._btn_line, 0, 0) self._btn_rect = QPushButton("Rctgl") self._btn_rect.setCheckable(True) draw_layout.addWidget(self._btn_rect, 0, 1) self._btn_circle = QPushButton("Circle") self._btn_circle.setCheckable(True) draw_layout.addWidget(self._btn_circle, 1, 0) self._btn_slot = QPushButton("Slot") self._btn_slot.setCheckable(True) draw_layout.addWidget(self._btn_slot, 1, 1) self._btn_move_sketch = QPushButton("Move") self._btn_move_sketch.setCheckable(True) self._btn_move_sketch.setToolTip("Move a drawn element (drag a point/line/rectangle/circle).") self._btn_move_sketch.setShortcut("Q") draw_layout.addWidget(self._btn_move_sketch, 1, 2, 1, 1) # separator sep = QFrame() sep.setFrameShape(QFrame.HLine) sep.setFrameShadow(QFrame.Sunken) draw_layout.addWidget(sep, 2, 0, 1, 3) self._btn_construct = QPushButton("Cstrct") self._btn_construct.setCheckable(True) draw_layout.addWidget(self._btn_construct, 3, 0) self._btn_snap = QPushButton("Snap") self._btn_snap.setCheckable(True) self._btn_snap.setChecked(True) draw_layout.addWidget(self._btn_snap, 3, 1) grid.addWidget(draw_group, 1, 0) # ---- Row 2, Col 0: Constrain ---- con_group = QGroupBox("Constrain") con_group.setMaximumWidth(200) con_layout = QGridLayout(con_group) self._btn_con_ptpt = QPushButton("Pt_Pt") self._btn_con_ptpt.setCheckable(True) self._btn_con_ptpt.setToolTip("Point to Point Coincident") con_layout.addWidget(self._btn_con_ptpt, 0, 0) self._btn_con_ptline = QPushButton("Pt_Lne") self._btn_con_ptline.setCheckable(True) self._btn_con_ptline.setToolTip("Point to Line Distance") con_layout.addWidget(self._btn_con_ptline, 0, 1) self._btn_con_mid = QPushButton("Pt_Mid_L") self._btn_con_mid.setCheckable(True) self._btn_con_mid.setToolTip("Point to Midpoint") con_layout.addWidget(self._btn_con_mid, 1, 0) self._btn_con_perp = QPushButton("Perp_Lne") self._btn_con_perp.setCheckable(True) self._btn_con_perp.setToolTip("Perpendicular Constraint") con_layout.addWidget(self._btn_con_perp, 1, 1) self._btn_con_horiz = QPushButton("Horiz") self._btn_con_horiz.setCheckable(True) self._btn_con_horiz.setToolTip("Horizontal Constraint") con_layout.addWidget(self._btn_con_horiz, 2, 0) self._btn_con_vert = QPushButton("Vert") self._btn_con_vert.setCheckable(True) self._btn_con_vert.setToolTip("Vertical Constraint") con_layout.addWidget(self._btn_con_vert, 2, 1) self._btn_con_dist = QPushButton("Distnce") self._btn_con_dist.setCheckable(True) self._btn_con_dist.setToolTip("Distance Constraint") con_layout.addWidget(self._btn_con_dist, 3, 0) self._btn_con_sym = QPushButton("Symetrc") self._btn_con_sym.setCheckable(True) con_layout.addWidget(self._btn_con_sym, 3, 1) grid.addWidget(con_group, 2, 0) # ---- Row 3-5, Col 0: Snaps tab ---- self._snaps_tab = QTabWidget() self._snaps_tab.setMaximumWidth(200) self._snaps_tab.setTabPosition(QTabWidget.TabPosition.South) snaps_tab1 = QWidget() snaps_layout = QVBoxLayout(snaps_tab1) snap_group = QGroupBox("Snapping Points") snap_grid = QGridLayout(snap_group) snap_grid.setContentsMargins(2, 2, 2, 2) self._btn_snap_point = QPushButton("Pnt") self._btn_snap_point.setCheckable(True) self._btn_snap_point.setChecked(True) snap_grid.addWidget(self._btn_snap_point, 0, 0) self._btn_snap_mid = QPushButton("MidP") self._btn_snap_mid.setCheckable(True) snap_grid.addWidget(self._btn_snap_mid, 0, 1) self._btn_snap_horiz = QPushButton("Horiz") self._btn_snap_horiz.setCheckable(True) snap_grid.addWidget(self._btn_snap_horiz, 1, 0) self._btn_snap_vert = QPushButton("Vert") self._btn_snap_vert.setCheckable(True) snap_grid.addWidget(self._btn_snap_vert, 1, 1) self._btn_snap_angle = QPushButton("Angles") self._btn_snap_angle.setCheckable(True) snap_grid.addWidget(self._btn_snap_angle, 2, 0) self._btn_snap_grid = QPushButton("Grid") self._btn_snap_grid.setCheckable(True) snap_grid.addWidget(self._btn_snap_grid, 2, 1) # separator sep2 = QFrame() sep2.setFrameShape(QFrame.HLine) sep2.setFrameShadow(QFrame.Sunken) snap_grid.addWidget(sep2, 3, 0, 1, 2) snap_grid.addWidget(QLabel("Snp Dst"), 4, 0) snap_grid.addWidget(QLabel("Angl Stps"), 4, 1) self._spin_snap_dist = QSpinBox() self._spin_snap_dist.setRange(1, 30) self._spin_snap_dist.setValue(10) self._spin_snap_dist.setSuffix("px") snap_grid.addWidget(self._spin_snap_dist, 5, 0) self._spin_angle = QSpinBox() self._spin_angle.setRange(1, 180) self._spin_angle.setValue(15) self._spin_angle.setSuffix("°") snap_grid.addWidget(self._spin_angle, 5, 1) snaps_layout.addWidget(snap_group) self._snaps_tab.addTab(snaps_tab1, "Setg 1") self._snaps_tab.addTab(QWidget(), "Setg 2") grid.addWidget(self._snaps_tab, 3, 0) # snaps tab spans rows 3-5 grid.setRowStretch(3, 0) grid.setRowStretch(4, 1) grid.setRowStretch(5, 1) # Build row 6-8 spacer for left column # Reserve space so the sketch list reaches the bottom grid.setRowMinimumHeight(6, 0) grid.setRowMinimumHeight(7, 0) grid.setRowMinimumHeight(8, 0) # ---- Row 6-8, Col 0: Sketch List ---- sk_list_group = QGroupBox("Sketch") sk_list_group.setMaximumWidth(200) sk_list_layout = QVBoxLayout(sk_list_group) self._sketch_list = QListWidget() self._sketch_list.setSelectionRectVisible(True) sk_list_layout.addWidget(self._sketch_list) sk_tools = QGroupBox("Tools") sk_tools_grid = QGridLayout(sk_tools) sk_tools_grid.setContentsMargins(2, 2, 2, 2) self._btn_add_sketch = QPushButton("Add") sk_tools_grid.addWidget(self._btn_add_sketch, 0, 0) self._btn_edit_sketch = QPushButton("Edt") sk_tools_grid.addWidget(self._btn_edit_sketch, 0, 1) self._btn_del_sketch = QPushButton("Del") sk_tools_grid.addWidget(self._btn_del_sketch, 0, 2) sk_list_layout.addWidget(sk_tools) grid.addWidget(sk_list_group, 6, 0, 3, 1) # ---- Row 0-8, Col 1: Input Tabs ---- self._input_tabs = QTabWidget() sketch_tab = QWidget() sketch_tab_layout = QVBoxLayout(sketch_tab) sketch_tab_layout.setContentsMargins(0, 0, 0, 0) self._sketch_widget = Sketch2DWidget() sketch_tab_layout.addWidget(self._sketch_widget) self._input_tabs.addTab(sketch_tab, "Sketch") code_tab = QWidget() code_tab_layout = QVBoxLayout(code_tab) self._code_edit = QTextEdit() self._code_edit.setFont(QFont("Monaco", 10)) self._code_edit.setPlaceholderText("# Enter Python code here...") code_tab_layout.addWidget(self._code_edit) code_tools = QHBoxLayout() self._btn_apply_code = QPushButton("Apply Code") self._btn_load_code = QPushButton("Load Code") self._btn_save_code = QPushButton("Save Code") self._btn_del_code = QPushButton("Delete Code") code_tools.addWidget(self._btn_apply_code) code_tools.addWidget(self._btn_load_code) code_tools.addWidget(self._btn_save_code) code_tools.addWidget(self._btn_del_code) code_tab_layout.addLayout(code_tools) self._input_tabs.addTab(code_tab, "Code") grid.addWidget(self._input_tabs, 0, 1, 9, 1) # ---- Row 0-8, Col 2: Model Viewer ---- viewer_group = QGroupBox("Model Viewer") viewer_layout = QVBoxLayout(viewer_group) viewer_layout.setContentsMargins(5, 5, 5, 5) self._viewer_3d = Viewer3DWidget() viewer_layout.addWidget(self._viewer_3d) grid.addWidget(viewer_group, 0, 2, 9, 1) # Focus split is toggled by Spacebar / the Layout button # (see _toggle_panel_focus). self._panel_focus: str = "equal" # "equal" | "sketch" | "viewer" # ---- Row 0, Col 3: Modify ---- modify_group = QGroupBox("Modify") modify_group.setMaximumWidth(200) modify_layout = QGridLayout(modify_group) self._btn_extrude = QPushButton("Extrd") self._btn_extrude.setToolTip("Extrude sketch") modify_layout.addWidget(self._btn_extrude, 0, 0) self._btn_cut = QPushButton("Cut") self._btn_cut.setToolTip("Boolean cut") modify_layout.addWidget(self._btn_cut, 0, 1) self._btn_combine = QPushButton("Comb") self._btn_combine.setToolTip("Boolean union") modify_layout.addWidget(self._btn_combine, 1, 0) self._btn_move = QPushButton("Mve") self._btn_move.setToolTip("Move body") modify_layout.addWidget(self._btn_move, 1, 1) self._btn_revolve = QPushButton("Rev") self._btn_revolve.setToolTip("Revolve sketch") modify_layout.addWidget(self._btn_revolve, 2, 0) self._btn_array = QPushButton("Arry") self._btn_array.setToolTip("Pattern array") modify_layout.addWidget(self._btn_array, 2, 1) grid.addWidget(modify_group, 0, 3, 1, 1, Qt.AlignTop) # ---- Row 6, Col 3: Export ---- export_group = QGroupBox("Export") export_group.setMaximumWidth(200) export_layout = QVBoxLayout(export_group) self._btn_export_stl = QPushButton("STL") export_layout.addWidget(self._btn_export_stl) self._btn_export_step = QPushButton("STEP") export_layout.addWidget(self._btn_export_step) self._btn_export_iges = QPushButton("IGES") export_layout.addWidget(self._btn_export_iges) grid.addWidget(export_group, 6, 3) # ---- Row 7-8, Col 3: Bodies / Operations ---- body_group = QGroupBox("Bodys / Operations") body_group.setMaximumWidth(200) body_layout = QVBoxLayout(body_group) self._body_list = QListWidget() self._body_list.setSelectionRectVisible(True) body_layout.addWidget(self._body_list) body_tools_grid = QGridLayout() body_tools_grid.setContentsMargins(2, 2, 2, 2) self._btn_update_body = QPushButton("Upd") body_tools_grid.addWidget(self._btn_update_body, 0, 0) self._btn_edit_sketch_3 = QPushButton("Nothing") body_tools_grid.addWidget(self._btn_edit_sketch_3, 0, 1) self._btn_del_body = QPushButton("Del") body_tools_grid.addWidget(self._btn_del_body, 0, 2) body_layout.addLayout(body_tools_grid) grid.addWidget(body_group, 7, 3, 2, 1) # ---- Row 9, Col 0: Component Tools ---- comp_tool_group = QGroupBox("Component Tools") comp_tool_group.setMinimumHeight(50) comp_tool_layout = QHBoxLayout(comp_tool_group) self._btn_new_compo = QPushButton("New") self._btn_new_compo.setFixedSize(QSize(50, 50)) comp_tool_layout.addWidget(self._btn_new_compo) self._btn_del_compo = QPushButton("Del") self._btn_del_compo.setFixedSize(QSize(50, 50)) comp_tool_layout.addWidget(self._btn_del_compo) grid.addWidget(comp_tool_group, 9, 0) # ---- Row 9, Col 1-2: Components (button box) ---- compo_group = QGroupBox("Components") compo_group.setMinimumHeight(50) compo_layout = QHBoxLayout(compo_group) self._component_box = QWidget() self._component_box_layout = QHBoxLayout(self._component_box) self._component_box_layout.setAlignment(Qt.AlignLeft) self._component_group = QButtonGroup(self) self._component_group.setExclusive(True) compo_layout.addWidget(self._component_box) compo_layout.addStretch() grid.addWidget(compo_group, 9, 1, 1, 2) # ---- Row 9, Col 3: Assembly Tools ---- assy_group = QGroupBox("Assembly Tools") assy_group.setMinimumHeight(50) assy_layout = QHBoxLayout(assy_group) self._btn_add_connector = QPushButton("+ Cnct") self._btn_add_connector.setFixedSize(QSize(50, 50)) assy_layout.addWidget(self._btn_add_connector) self._btn_del_connector = QPushButton("- Cnct") self._btn_del_connector.setFixedSize(QSize(50, 50)) assy_layout.addWidget(self._btn_del_connector) grid.addWidget(assy_group, 9, 3) def _toggle_panel_focus(self): """Cycle the sketch/viewer split: equal → sketch → viewer → equal. Driven by Spacebar and the Layout button (§_setup_connections). """ order = ["equal", "sketch", "viewer"] try: nxt = order[(order.index(self._panel_focus) + 1) % len(order)] except (AttributeError, ValueError): nxt = "equal" self._set_panel_focus(nxt) def _set_panel_focus(self, panel: str): """Set the sketch/viewer column stretches based on the focus mode.""" if not hasattr(self, "_grid"): self._panel_focus = panel return self._panel_focus = panel if panel == "viewer": # Viewer 2/3, sketch 1/3 — more room for 3D work, sketch stays visible. self._grid.setColumnStretch(1, 1) self._grid.setColumnStretch(2, 2) elif panel == "sketch": # Sketch 2/3, viewer 1/3 — comfortable sketching, 3D stays visible. self._grid.setColumnStretch(1, 2) self._grid.setColumnStretch(2, 1) else: # equal self._grid.setColumnStretch(1, 1) self._grid.setColumnStretch(2, 1) logger.info(f"Panel focus -> {self._panel_focus}") def keyPressEvent(self, event): # Spacebar cycles the sketch/viewer split so you can grow the side you're # working in without leaving the keyboard. if event.key() == Qt.Key_Space: self._toggle_panel_focus() event.accept() return super().keyPressEvent(event) def _create_dock_widgets(self): pass def _setup_connections(self): self._btn_line.clicked.connect(lambda: self._set_sketch_mode("line")) self._btn_rect.clicked.connect(lambda: self._set_sketch_mode("rectangle")) self._btn_circle.clicked.connect(lambda: self._set_sketch_mode("circle")) self._btn_slot.clicked.connect(lambda: self._set_sketch_mode("slot")) self._btn_move_sketch.clicked.connect(lambda: self._set_sketch_mode("select")) self._btn_construct.clicked.connect(self._on_construct_change) self._btn_con_ptpt.clicked.connect(lambda: self._set_sketch_mode("constrain_coincident")) self._btn_con_ptline.clicked.connect(lambda: self._set_sketch_mode("constrain_ptline")) self._btn_con_horiz.clicked.connect(lambda: self._set_sketch_mode("constrain_horizontal")) self._btn_con_vert.clicked.connect(lambda: self._set_sketch_mode("constrain_vertical")) self._btn_con_mid.clicked.connect(lambda: self._set_sketch_mode("constrain_midpoint")) self._btn_con_perp.clicked.connect(lambda: self._set_sketch_mode("constrain_perpendicular")) self._btn_con_dist.clicked.connect(lambda: self._set_sketch_mode("constrain_distance")) self._btn_con_sym.clicked.connect(lambda: self._set_sketch_mode("constrain_symmetric")) self._btn_snap_point.clicked.connect( lambda c: self._sketch_widget.set_snap_mode("point", c) ) self._btn_snap_mid.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("mpoint", c)) self._btn_snap_horiz.clicked.connect( lambda c: self._sketch_widget.set_snap_mode("horiz", c) ) self._btn_snap_vert.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("vert", c)) self._btn_snap_angle.clicked.connect( lambda c: self._sketch_widget.set_snap_mode("angle", c) ) self._btn_snap_grid.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("grid", c)) self._spin_snap_dist.valueChanged.connect(self._sketch_widget.set_snap_distance) self._spin_angle.valueChanged.connect(self._sketch_widget.set_angle_steps) self._btn_extrude.clicked.connect(self._extrude_sketch) self._btn_cut.clicked.connect(self._boolean_cut) self._btn_combine.clicked.connect(self._boolean_union) self._btn_revolve.clicked.connect(self._revolve_sketch) self._btn_add_sketch.clicked.connect(self._add_sketch_to_component) self._btn_edit_sketch.clicked.connect(self._edit_sketch) self._btn_del_sketch.clicked.connect(self._delete_sketch) self._btn_wp_face.toggled.connect(self._on_face_sketch_toggled) self._viewer_3d.facePicked.connect(self._on_face_picked) self._viewer_3d.pickFaceCancelled.connect( lambda: self._btn_wp_face.setChecked(False) ) self._btn_new_compo.clicked.connect(self._new_component) self._btn_del_compo.clicked.connect(self._delete_component) self._btn_update_body.clicked.connect(self._redraw_bodies) self._btn_del_body.clicked.connect(self._delete_body) self._btn_export_stl.clicked.connect(self._export_stl) self._btn_export_step.clicked.connect(self._export_step) self._btn_export_iges.clicked.connect(self._export_iges) self._sketch_widget.constrain_done.connect(self._on_constrain_done) self._sketch_widget.sketch_updated.connect(self._on_sketch_updated) self._sketch_list.currentItemChanged.connect(self._on_sketch_selected) self._body_list.currentItemChanged.connect(self._on_body_list_changed) # Per-body visibility toggle: the user clicks the checkbox next # to a body name in the right-hand list. We update the body's # ``visible`` flag and ask the viewer to show/hide the mesh. # (itemChanged also fires for selection changes; the handler # filters on the check-state role.) self._body_list.itemChanged.connect(self._on_body_visibility_changed) self._btn_wp_origin.clicked.connect(self._new_sketch_origin) self._btn_wp_flip.clicked.connect(self._flip_workplane) self._btn_wp_move.clicked.connect(self._move_workplane) # Underlay show/hide and ClrFace — both stay in sync with the # source face state managed by set_source_face / clear_source_face. self._btn_underlay.toggled.connect(self._on_underlay_toggled) self._btn_clr_face.clicked.connect(self._on_clear_source_face) # Generic buttons self._btn_move.clicked.connect(self._translate_body) self._btn_array.clicked.connect(self._pattern_array) self._btn_edit_sketch_3.clicked.connect(self._edit_sketch) # Snap toggle self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c)) def _create_initial_component(self): self._new_component() def _set_sketch_mode(self, mode: str): self._sketch_widget.set_mode(mode) for btn in [ self._btn_line, self._btn_rect, self._btn_circle, self._btn_slot, self._btn_move_sketch, self._btn_con_ptpt, self._btn_con_ptline, self._btn_con_horiz, self._btn_con_vert, self._btn_con_mid, self._btn_con_perp, self._btn_con_dist, self._btn_con_sym, ]: btn.setChecked(False) if mode in ["line", "rectangle", "circle", "slot"]: if mode == "line": self._btn_line.setChecked(True) elif mode == "rectangle": self._btn_rect.setChecked(True) elif mode == "circle": self._btn_circle.setChecked(True) elif mode == "slot": self._btn_slot.setChecked(True) elif mode == "select": self._btn_move_sketch.setChecked(True) elif mode.startswith("constrain_"): if mode == "constrain_coincident": self._btn_con_ptpt.setChecked(True) elif mode == "constrain_horizontal": self._btn_con_horiz.setChecked(True) elif mode == "constrain_vertical": self._btn_con_vert.setChecked(True) def _on_construct_change(self, checked): self._sketch_widget.set_construct_mode(checked) def _on_constrain_done(self): for btn in [ self._btn_line, self._btn_rect, self._btn_circle, self._btn_slot, self._btn_move_sketch, self._btn_con_ptpt, self._btn_con_ptline, self._btn_con_horiz, self._btn_con_vert, self._btn_con_mid, self._btn_con_perp, self._btn_con_dist, self._btn_con_sym, ]: btn.setChecked(False) self._sketch_widget.set_mode(None) def _on_sketch_updated(self): pass def _get_active_component_index(self) -> int: for i, btn in enumerate(self._component_buttons): if btn.isChecked(): return i return 0 def _new_component(self): logger.info("=== NEW COMPONENT ===") comp = self._project.add_component() self._current_component = comp logger.info(f"Created component: {comp.name}") btn = QPushButton(str(len(self._project.components))) btn.setCheckable(True) btn.setFixedSize(QSize(40, 40)) btn.clicked.connect(self._on_component_button_clicked) btn.setChecked(True) for b in self._component_buttons: b.setChecked(False) self._component_buttons.append(btn) self._component_group.addButton(btn) self._component_box_layout.addWidget(btn) self._refresh_lists() logger.info(f"Created component: {comp.name}") def _delete_component(self): idx = self._get_active_component_index() comp_ids = list(self._project.components.keys()) if idx < len(comp_ids): comp_id = comp_ids[idx] del self._project.components[comp_id] if self._component_buttons: btn = self._component_buttons.pop(idx) self._component_group.removeButton(btn) btn.deleteLater() if self._component_buttons: self._component_buttons[0].setChecked(True) self._refresh_lists() logger.info(f"Deleted component") def _on_component_button_clicked(self): idx = self._get_active_component_index() comp_ids = list(self._project.components.keys()) if idx < len(comp_ids): self._current_component = self._project.components[comp_ids[idx]] self._refresh_lists() self._redraw_bodies() def _refresh_lists(self): self._sketch_list.clear() self._body_list.clear() if self._current_component: for sketch_id, sketch in self._current_component.sketches.items(): self._sketch_list.addItem(sketch.name) for body_id, body in self._current_component.bodies.items(): # QListWidgetItem with a checkbox so the user can toggle # each body's visibility in the 3D viewer. The item's # data role stores the body id so the toggle handler can # look up the right body without relying on display text. item = QListWidgetItem(body.name) item.setData(Qt.UserRole, body_id) # Qt.Checked = visible, Qt.Unchecked = hidden. Default # is whatever the body model says. item.setCheckState( Qt.Checked if body.visible else Qt.Unchecked ) # Greying out a hidden body's name is a nice UX touch. if not body.visible: item.setForeground(QColor("#6c7086")) self._body_list.addItem(item) def _redraw_bodies(self): self._viewer_3d.clear_scene() if self._current_component: for body_id, body in self._current_component.bodies.items(): if body.geometry: logger.debug(f"Redrawing body: {body.name}") shape = self._kernel._get_shape(body.geometry) body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name) logger.info(f"Redraw render object: {body.render_object}") self._viewer_3d.fit_camera() def _new_sketch_origin(self): self._sketch_widget.create_sketch() self._sketch_widget.set_mode("line") self._btn_line.setChecked(True) logger.info("New sketch at origin") def _flip_workplane(self): logger.info("Flip workplane (not implemented)") def _move_workplane(self): logger.info("Move workplane: use middle-click pan in 3D view") def _translate_body(self): if not self._selected_body or not self._selected_body.geometry: QMessageBox.warning(self, "No Body", "Select a body first") return dx, ok1 = QInputDialog.getDouble(self, "Translate", "DX (mm):", 0, -10000, 10000, 2) if not ok1: return dy, ok2 = QInputDialog.getDouble(self, "Translate", "DY (mm):", 0, -10000, 10000, 2) if not ok2: return dz, ok3 = QInputDialog.getDouble(self, "Translate", "DZ (mm):", 0, -10000, 10000, 2) if not ok3: return try: new_geom = self._kernel.translate(self._selected_body.geometry, (dx, dy, dz)) self._selected_body.geometry = new_geom self._redraw_bodies() logger.info(f"Translated body by ({dx}, {dy}, {dz})") except Exception as e: QMessageBox.critical(self, "Error", f"Translation failed: {e}") def _pattern_array(self): logger.info("Pattern array not yet implemented") # ─── Sketch-on-surface (face pick) ──────────────────────────────────── def _on_face_sketch_toggled(self, checked: bool) -> None: """Toggle the 3D viewer's face-pick mode (WP Face button).""" self._viewer_3d.set_pick_face_mode(checked) if checked: # Clear any previous face-selection tint before picking a new one. self._viewer_3d.clear_face_highlight() # Make sure the 3D viewer has focus so it receives the click. self._viewer_3d.setFocus() self._viewer_3d.activateWindow() self.statusBar().showMessage( "Pick a planar face in the 3D viewer to sketch on (Esc to cancel)", 8000, ) def _on_face_picked(self, origin, normal, x_dir, face_shape) -> None: """Create a new sketch on the picked planar face and switch to 2D. Also records *which body* the picked face belonged to on the sketch (``sketch._source_body_id``) so a later "Perform Cut" / "Combine" extrude operation auto-targets that body instead of the first body in the dict. Auto-selects the new sketch in the left-hand list so the user can immediately Extrude/Cut without hunting for the row. """ # ``facePicked`` carries the face shape PLUS the owning obj_id from # ``pick_planar_face`` (the renderer matches DetectedInteractive # against tracked AIS objects). We extract that owner so the cut # can target the right body. source_body = None logger.info( f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}" ) # Pull the owning obj_id the renderer stashed on this pick pass. owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None) if owner_obj_id and self._current_component is not None: for bid, body in self._current_component.bodies.items(): if body.render_object == owner_obj_id: source_body = body logger.info(f"Sketch source body: {body.name}") break # Tint the picked face light-blue so the selection is visible in 3D. self._viewer_3d.highlight_face(face_shape) # Leave pick mode (the button stays toggled until we uncheck it). self._btn_wp_face.setChecked(False) self._viewer_3d.set_pick_face_mode(False) if not self._current_component: self._current_component = self._project.add_component() sketch = self._current_component.add_sketch() sketch.name = f"Sketch on face {len(self._current_component.sketches)}" # Place the sketch on the picked plane (sets fields + syncs occ_sketch). sketch.set_workplane(origin, normal, x_dir) # Keep the face reference for the projection underlay (Phase 3). sketch._source_face = face_shape # Remember which body the sketch lives on so a later cut / combine # extrude auto-targets it. ``source_body`` may be None if the # pick landed on an untracked shape (e.g. an imported STEP that # wasn't registered as a component body — robust fallback then). sketch._source_body_id = source_body.id if source_body else None # Hand the sketch to the 2D widget and focus the sketch panel. # Always build a clean OCC sketch carrying the face workplane so the # widget draws on the picked plane. if sketch.occ_sketch is None or sketch.occ_sketch.get_entity_count() > 0: sketch.occ_sketch = self._sketch_widget.create_sketch() sketch.apply_workplane() self._sketch_widget.set_sketch(sketch.occ_sketch) self._sketch_widget.set_source_face(face_shape, origin, normal, x_dir) self._current_sketch = sketch self._sketch_widget.set_mode("line") self._btn_line.setChecked(True) self._refresh_lists() # Auto-select the freshly created sketch in the left-hand list so a # 3D op (Extrude/Cut) operates on it without the user hunting for # the row. _on_sketch_selected loads it into the widget for editing. for row in range(self._sketch_list.count()): item = self._sketch_list.item(row) if item is not None and item.text() == sketch.name: self._sketch_list.setCurrentRow(row) break # Switch focus to the sketch panel so the user can draw immediately. self._set_panel_focus("sketch") self.statusBar().showMessage( f"Sketch placed on face — drawing in 2D on that plane", 6000 ) # The face is now the source for the underlay construction lines: # enable the show/hide toggle and the ClrFace button. self._btn_underlay.setEnabled(True) self._btn_underlay.setChecked(True) self._btn_clr_face.setEnabled(True) def _on_underlay_toggled(self, checked: bool) -> None: """Show or hide the underlay construction lines in the 2D view. Toggling this button does NOT remove the external entities from the sketch solver — they stay there so existing constraints that reference them keep working. The entities are just hidden from paint + hover + hit-test while the toggle is off. """ self._sketch_widget.set_underlay_visible(checked) self.statusBar().showMessage( f"Underlay {'visible' if checked else 'hidden'}", 2000 ) def _on_clear_source_face(self) -> None: """Forget the source face: remove underlay entities, keep the workplane. After this, the sketch remains on the same plane but the face reference and its projected construction lines are gone. The user keeps whatever user-drawn geometry they already added (and any constraints they already applied, since they were pinned to entity ids that are now removed along with the underlay). """ self._sketch_widget.clear_source_face() self._btn_underlay.setEnabled(False) self._btn_underlay.setChecked(False) self._btn_clr_face.setEnabled(False) if self._current_sketch is not None: # Drop the saved reference on the model so re-editing the # sketch later doesn't re-create the underlay. self._current_sketch._source_face = None self.statusBar().showMessage( "Source face cleared — underlay construction lines removed", 3000 ) def _pattern_array_placeholder(self): pass def _add_sketch_to_component(self): logger.info("=== ADD SKETCH TO COMPONENT ===") if not self._current_component: logger.info("No current component, creating new one") self._current_component = self._project.add_component() sketch = self._current_component.add_sketch() logger.debug(f"Created sketch: {sketch.name}") sketch_widget_sketch = self._sketch_widget.get_sketch() logger.debug(f"Sketch from widget: {sketch_widget_sketch}") sketch.occ_sketch = sketch_widget_sketch if not sketch.occ_sketch: logger.info("Creating new sketch in widget") sketch.occ_sketch = self._sketch_widget.create_sketch() # Adopt the widget sketch's existing 3D workplane (e.g. set by a # face-pick) instead of clobbering it with this Sketch's default XY # fields — otherwise a sketch drawn on a picked face would jump back # to the world origin plane on the next extrude. if sketch.occ_sketch is not None and hasattr(sketch.occ_sketch, "get_workplane"): wp = sketch.occ_sketch.get_workplane() import numpy as _np sketch.workplane_origin = _np.asarray(wp[0], dtype=float) sketch.workplane_normal = _np.asarray(wp[1], dtype=float) sketch.workplane_x_dir = _np.asarray(wp[2], dtype=float) # Sync the sketch's workplane (origin/normal/x_dir) into the OCC sketch # so geometry is built on the right plane. sketch.apply_workplane() self._current_sketch = sketch self._refresh_lists() self._sketch_widget.set_mode(None) logger.info(f"Added sketch: {sketch.name}") logger.info(f"=== SKETCH ADDED: {sketch.name} ===") def _edit_sketch(self): selected = self._sketch_list.currentItem() if not selected: return name = selected.text() for sketch_id, sketch in self._current_component.sketches.items(): if sketch.name == name: self._current_sketch = sketch if sketch.occ_sketch: sketch.apply_workplane() self._sketch_widget.set_sketch(sketch.occ_sketch) # If the sketch carries a saved source face (sketch-on- # surface), re-bind it so the underlay construction lines # come back. set_source_face rebuilds the external # entities and re-orients the 2D view. if getattr(sketch, "_source_face", None) is not None and sketch.occ_sketch is not None: wp = sketch.occ_sketch.get_workplane() origin, normal, x_dir = wp[0], wp[1], wp[2] self._sketch_widget.set_source_face( sketch._source_face, origin, normal, x_dir ) self._btn_underlay.setEnabled(True) self._btn_underlay.setChecked(True) self._btn_clr_face.setEnabled(True) else: # No saved face: make sure the underlay buttons # reflect that the widget has no source face bound. self._btn_underlay.setEnabled(False) self._btn_underlay.setChecked(True) self._btn_clr_face.setEnabled(False) self._sketch_widget.set_mode("line") self._btn_line.setChecked(True) logger.info(f"Editing sketch: {name}") break def _on_sketch_selected(self, current, previous): """When sketch is selected in list, load it for editing.""" if current and self._current_component: name = current.text() for sketch_id, sketch in self._current_component.sketches.items(): if sketch.name == name: self._current_sketch = sketch if sketch.occ_sketch and hasattr(sketch.occ_sketch, 'get_entity_count') and sketch.occ_sketch.get_entity_count() > 0: self._sketch_widget.set_sketch(sketch.occ_sketch) break def _delete_sketch(self): selected = self._sketch_list.currentItem() if not selected or not self._current_component: return name = selected.text() to_delete = None for sketch_id, sketch in self._current_component.sketches.items(): if sketch.name == name: to_delete = sketch_id break if to_delete: del self._current_component.sketches[to_delete] self._refresh_lists() logger.info(f"Deleted sketch: {name}") def _on_sketch_list_changed(self, current, previous): if current and self._current_component: name = current.text() for sketch_id, sketch in self._current_component.sketches.items(): if sketch.name == name: self._current_sketch = sketch break def _on_body_list_changed(self, current, previous): if current and self._current_component: name = current.text() for body_id, body in self._current_component.bodies.items(): if body.name == name: self._selected_body = body logger.info(f"Selected: {name}") break def _on_body_visibility_changed(self, item: QListWidgetItem) -> None: """Toggle a body's 3D visibility when the user flips its checkbox. itemChanged also fires for selection (not just check-state) changes, so we filter on the check state being the changed role. The body is looked up via the UserRole data we set in _refresh_lists. """ if self._current_component is None: return body_id = item.data(Qt.UserRole) if body_id is None: return body = self._current_component.bodies.get(body_id) if body is None: return new_visible = item.checkState() == Qt.Checked if body.visible == new_visible: return # no change body.visible = new_visible # Greying out hidden bodies gives a quick visual hint in the list. item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086")) # Apply to the 3D viewer: if the body has a rendered object, hide # or show it. Bodies without a render_object (e.g. just-created, # not yet displayed) don't need viewer updates; they'll pick up # the visibility at the next redraw. if body.render_object is not None: ok = self._viewer_3d.set_visibility(body.render_object, new_visible) if not ok: logger.debug( "set_visibility failed for body %s (render_object=%r)", body.name, body.render_object, ) logger.info( f"{'Visible' if new_visible else 'Hidden'}: {body.name}" ) # ─── Extrude / cut helpers (shared by live preview + apply) ──────── def _resolve_extrude_target( self, sketch: Sketch, exclude_body: Optional[Body] = None ) -> Optional[Body]: """Choose the body a cut / union should target. Preference order: 1. the body the sketch was projected onto (``sketch._source_body_id``) 2. the first body in the component that isn't the *exclude_body* (the freshly-extruded tool itself, which we don't want to cut *itself*). Returns *None* if there is no candidate (e.g. the sketch wasn't on a face and the component has no other bodies). """ if self._current_component is None: return None bodies = self._current_component.bodies src_id = getattr(sketch, "_source_body_id", None) if src_id is not None and src_id in bodies: cand = bodies[src_id] if cand is not exclude_body: return cand for body in bodies.values(): if body is exclude_body: continue return body return None def _through_all_length(self, target: Body, sketch: Sketch) -> float: """Height (mm) for ``kernel.extrude(..., symmetric=True)`` to pass *through* the target body. Computes the target body's bounding-box extent along the sketch's workplane normal direction ("extent" = how far the body reaches on either side of the face). With ``symmetric=True`` the kernel extrudes ``± height/2``, so to clear the full ``extent`` on each side we need ``height = 2 × (extent + buffer)``. The 5 mm buffer on each side guarantees the tool pokes out past the body so the boolean reliably removes the through volume. """ import numpy as _np try: p_min, p_max = self._kernel.get_bounding_box(target.geometry) except Exception: logger.debug("through-all bbox failed", exc_info=True) return 2000.0 # generous fallback if bbox fails for any reason origin = _np.asarray(sketch.workplane_origin, dtype=float) normal = _np.asarray(sketch.workplane_normal, dtype=float) normal = normal / max(_np.linalg.norm(normal), 1e-12) corners = [] for xs in (p_min.x, p_max.x): for ys in (p_min.y, p_max.y): for zs in (p_min.z, p_max.z): corners.append(_np.array([xs, ys, zs])) ds = [_np.dot(c - origin, normal) for c in corners] extent = max(abs(min(ds)), abs(max(ds))) # Symmetric through: cover ±(extent + 5 mm) on each side of the # face plane, which means a total height of 2×(extent + 5). return 2.0 * float(extent) + 10.0 def _compute_extrude_result( self, sketch: Sketch, face_geom: Any, length: float, symmetric: bool, invert: bool, cut: bool, union: bool, through_all: bool, ) -> Optional[Dict[str, Any]]: """Compute the *previewable* result of an extrude/cut/union. Returns a dict with: - "result_shape": final TopoDS_Shape (the thing to show / commit) - "target_body": the Body being modified (None for plain extrude) - "tool_geom": the extruded profile geometry (the boolean tool) - "tool_shape": same, as a TopoDS_Shape (for show/remove) Or *None* if the geometry can't be built (e.g. empty sketch). Mutates nothing on the project — safe to call repeatedly for the live preview. The apply path (:meth:`_extrude_sketch`) commits the returned shape onto ``target_body`` (or creates a new body for plain extrudes). """ if face_geom is None: return None # Resolve target (only meaningful for cut / union). target = self._resolve_extrude_target(sketch) if (cut or union) else None # Determine the extrude length and direction. if through_all and target is not None: # Pass-through: symmetric extrude large enough to clear the body # on both sides of the face plane (direction-agnostic). extrude_length = self._through_all_length(target, sketch) symmetric = True invert = False else: # Cut targeting a body must go *into* the body — the picked face's # outward normal points AWAY from the body, so a non-inverted # extrude would build a boss ABOVE the face and the boolean cut # would remove nothing. Force the tool into the body so # "Perform Cut" always carves a real pocket. if cut and target is not None: invert = True extrude_length = -length if invert else length try: tool_geom = self._kernel.extrude( face_geom, extrude_length, symmetric=symmetric ) except Exception as exc: logger.debug("preview extrude failed: %s", exc) return None if tool_geom is None: return None tool_shape = self._kernel._get_shape(tool_geom) if target is not None: try: if cut: result_geom = self._kernel.boolean_difference( target.geometry, tool_geom ) else: # union result_geom = self._kernel.boolean_union( target.geometry, tool_geom ) except Exception as exc: logger.debug("preview boolean failed: %s", exc) return None result_shape = self._kernel._get_shape(result_geom) return { "result_shape": result_shape, "result_geom": result_geom, "target_body": target, "tool_geom": tool_geom, "tool_shape": tool_shape, } # Plain extrude: the tool IS the result. return { "result_shape": tool_shape, "result_geom": tool_geom, "target_body": None, "tool_geom": tool_geom, "tool_shape": tool_shape, } def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None: """Install a live-preview callback on *dialog* for *sketch*. The host dims the body the cut/union will target (if any) so the previewed result reads clearly on top of it. The dimming is reverted on dialog close (see hideEvent → callback(None)). """ # Track which bodies we dimmed so we can restore their transparency # exactly (they might have had a non-zero transparency to start, in # which case we leave them alone). state = {"dimmed": []} def _apply_dim(target: Optional[Body]): # Undo any prior dim, then dim the new target. for bid, tval in state["dimmed"]: body = self._current_component.bodies.get(bid) if self._current_component else None if body is not None and body.render_object is not None: self._viewer_3d.set_transparency(body.render_object, 0.0) state["dimmed"].clear() if target is not None and target.render_object is not None: ok = self._viewer_3d.set_transparency(target.render_object, 0.6) if ok: state["dimmed"].append((target.id, 0.6)) def _clear(): self._viewer_3d.clear_preview() for bid, _tval in state["dimmed"]: body = self._current_component.bodies.get(bid) if self._current_component else None if body is not None and body.render_object is not None: self._viewer_3d.set_transparency(body.render_object, 0.0) state["dimmed"].clear() def _callback(values): if values is None: _clear() return length, symmetric, invert, cut, union, through_all, _rounded = values result = self._compute_extrude_result( sketch, face_geom, length, symmetric, invert, bool(cut), bool(union), bool(through_all), ) if result is None or result["result_shape"] is None: self._viewer_3d.clear_preview() _apply_dim(None) return self._viewer_3d.show_preview(result["result_shape"]) _apply_dim(result["target_body"]) dialog.set_preview_callback(_callback) def _extrude_sketch(self): logger.info("=== EXTRUDE SKETCH ===") if not self._current_component: logger.warning("No current component") return sketch = self._current_sketch logger.debug(f"Current sketch: {sketch}") if not sketch or not sketch.occ_sketch: sketch_entity = self._sketch_widget.get_sketch() logger.debug(f"Sketch from widget: {sketch_entity}") if not sketch_entity: logger.warning("No sketch entity found") QMessageBox.warning(self, "No Sketch", "Please create a sketch first") return sketch.occ_sketch = sketch_entity # Resolve the profile geometry *before* opening the dialog so the # live preview can use it. Prefer the selected face (which can # include holes) over the full sketch. face_geom = self._sketch_widget.get_selected_face_geometry() if face_geom is not None: logger.info("Using selected face geometry (with holes)") else: face_geom = sketch.occ_sketch.get_geometry() logger.debug(f"Geometry: {face_geom}") if not face_geom: logger.error("No geometry from sketch") QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry") return dialog = ExtrudeDialog(self) # Wire up the live preview: every spinbox/checkbox change rebuilds # the result via the shared helper and shows it transparent. self._start_extrude_preview(dialog, sketch, face_geom) accepted = dialog.exec() # The dialog's hideEvent already fired the callback with *None* to # clear the preview and un-dim any body — but be defensive in case # a subclass swallows the event. self._viewer_3d.clear_preview() if not accepted: logger.info("Extrude dialog cancelled") return length, symmetric, invert, cut, union, through_all, rounded = dialog.get_values() logger.info( f"Extrude params: length={length}, symmetric={symmetric}, " f"invert={invert}, cut={cut}, union={union}, through_all={through_all}" ) try: result = self._compute_extrude_result( sketch, face_geom, length, symmetric, invert, bool(cut), bool(union), bool(through_all), ) if result is None or result["result_geom"] is None: logger.warning("Extrude produced no geometry") QMessageBox.warning(self, "No Geometry", "Extrude produced no geometry") return target = result["target_body"] if target is not None: # Cut / union: commit the result onto the *target* body in # place (don't create a separate tool body — the previous # implementation did, and that was the user-perceived # "added without cut" bug once the spurious body was # deleted). target.geometry = result["result_geom"] if target.render_object is not None: self._viewer_3d.remove_mesh(target.render_object) shape = self._kernel._get_shape(target.geometry) target.render_object = self._viewer_3d.show_shape( shape, target.color, target.name ) op = "cut" if cut else "union" logger.info( f"{op.capitalize()} applied: {target.name} now holds the result" ) body_name = target.name else: # Plain extrude: create a new body for the extrusion. body = self._current_component.add_body( Body( name=f"Extrusion_{len(self._current_component.bodies) + 1}", geometry=result["result_geom"], source_sketch=sketch, source_operation="extrude", ) ) logger.info(f"Created body: {body.name}") logger.debug("Adding shape to OCC viewer") shape = self._kernel._get_shape(body.geometry) body.render_object = self._viewer_3d.show_shape( shape, body.color, body.name ) logger.info(f"Render object: {body.render_object}") body_name = body.name self._refresh_lists() self._viewer_3d.fit_camera() logger.info(f"Extruded: {body_name}") logger.info("=== EXTRUDE COMPLETE ===") except Exception as e: logger.exception(f"Extrude failed: {e}") QMessageBox.critical(self, "Error", f"Extrude failed: {e}") def _revolve_sketch(self): logger.info("=== REVOLVE SKETCH ===") if not self._current_component: logger.warning("No current component") return sketch = self._current_sketch if not sketch or not sketch.occ_sketch: sketch_entity = self._sketch_widget.get_sketch() if not sketch_entity: QMessageBox.warning(self, "No Sketch", "Please create a sketch first") return sketch.occ_sketch = sketch_entity dialog = RevolveDialog(self) if not dialog.exec(): logger.info("Revolve dialog cancelled") return angle = dialog.angle_input.value() try: face_geom = self._sketch_widget.get_selected_face_geometry() if face_geom is not None: geometry = face_geom else: geometry = sketch.occ_sketch.get_geometry() if not geometry: QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry") return body_geometry = self._kernel.revolve(geometry, angle) body = self._current_component.add_body( Body( name=f"Revolution_{len(self._current_component.bodies) + 1}", geometry=body_geometry, source_sketch=sketch, source_operation="revolve", ) ) logger.debug("Adding shape to OCC viewer") shape = self._kernel._get_shape(body_geometry) body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name) logger.info(f"Render object: {body.render_object}") self._refresh_lists() self._viewer_3d.fit_camera() logger.info(f"Revolved: {body.name}") except Exception as e: logger.exception(f"Revolve failed: {e}") QMessageBox.critical(self, "Error", f"Revolve failed: {e}") def _boolean_cut(self): logger.info("=== BOOLEAN CUT ===") if not self._current_component or len(self._current_component.bodies) < 2: QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform cut.\nCreate multiple bodies first.") return # Use the first body in the list as base, last as tool body_ids = list(self._current_component.bodies.keys()) if len(body_ids) < 2: return # Let user pick which body to use as tool body_names = [self._current_component.bodies[bid].name for bid in body_ids] tool_name, ok = QInputDialog.getItem( self, "Select Tool Body", "Body to subtract (tool):", body_names, len(body_names) - 1, False ) if not ok: return tool_id = None base_id = None for bid in body_ids: if self._current_component.bodies[bid].name == tool_name: tool_id = bid else: base_id = bid if tool_id is None or base_id is None: return base_body = self._current_component.bodies[base_id] tool_body = self._current_component.bodies[tool_id] if not base_body.geometry or not tool_body.geometry: QMessageBox.warning(self, "No Geometry", "One of the bodies has no geometry") return try: result_geom = self._kernel.boolean_difference(base_body.geometry, tool_body.geometry) new_body = self._current_component.add_body( Body( name=f"Cut_{len(self._current_component.bodies) + 1}", geometry=result_geom, source_operation="boolean_cut", ) ) logger.debug("Adding shape to OCC viewer") shape = self._kernel._get_shape(result_geom) new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name) logger.info(f"Render object: {new_body.render_object}") self._refresh_lists() self._viewer_3d.fit_camera() logger.info(f"Cut complete: {new_body.name}") except Exception as e: logger.exception(f"Boolean cut failed: {e}") QMessageBox.critical(self, "Error", f"Boolean cut failed: {e}") def _boolean_union(self): logger.info("=== BOOLEAN UNION ===") if not self._current_component or len(self._current_component.bodies) < 2: QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform union.") return bodies = list(self._current_component.bodies.values()) geometries = [b.geometry for b in bodies if b.geometry] if len(geometries) < 2: QMessageBox.warning(self, "Need Bodies", "Not enough bodies with valid geometry.") return try: result_geom = self._kernel.boolean_union(*geometries) new_body = self._current_component.add_body( Body( name=f"Union_{len(self._current_component.bodies) + 1}", geometry=result_geom, source_operation="boolean_union", ) ) logger.debug("Adding shape to OCC viewer") shape = self._kernel._get_shape(result_geom) new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name) logger.info(f"Render object: {new_body.render_object}") self._refresh_lists() self._viewer_3d.fit_camera() logger.info(f"Union complete: {new_body.name}") except Exception as e: logger.exception(f"Boolean union failed: {e}") QMessageBox.critical(self, "Error", f"Boolean union failed: {e}") def _delete_body(self): selected = self._body_list.currentItem() if not selected or not self._current_component: return name = selected.text() to_delete = None for body_id, body in self._current_component.bodies.items(): if body.name == name: to_delete = body_id if body.render_object: self._viewer_3d.remove_mesh(body.render_object) break if to_delete: del self._current_component.bodies[to_delete] self._refresh_lists() logger.info(f"Deleted body: {name}") def _new_project(self): self._project = Project() self._current_component = None self._current_sketch = None self._selected_body = None for btn in self._component_buttons: btn.deleteLater() self._component_buttons.clear() # set_sketch(None) clears the underlay entities via the new # set_sketch guard, but we also need to drop the saved source face # and reset the workplane buttons to their disabled state. self._sketch_widget.clear_source_face() self._sketch_widget.set_sketch(None) self._viewer_3d.clear_scene() self._refresh_lists() self._btn_underlay.setEnabled(False) self._btn_underlay.setChecked(True) self._btn_clr_face.setEnabled(False) self._create_initial_component() logger.info("New project created") def _import_file(self): filepath, _ = QFileDialog.getOpenFileName( self, "Import File", "", "STEP Files (*.step *.stp);;IGES Files (*.iges *.igs)" ) if filepath: try: if filepath.lower().endswith((".step", ".stp")): geometry = self._kernel.import_step(filepath) else: geometry = self._kernel.import_iges(filepath) if not self._current_component: self._current_component = self._project.add_component() body = self._current_component.add_body( Body(name="Imported", geometry=geometry, source_operation="import") ) vertices, faces = body.get_mesh(self._kernel) body.render_object = self._viewer_3d.add_mesh( vertices, faces, body.color, body.name ) self._refresh_lists() self._viewer_3d.fit_camera() logger.info(f"Imported: {filepath}") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to import: {e}") def _export_step(self): if not self._selected_body: QMessageBox.warning(self, "No Selection", "Please select a body") return filepath, _ = QFileDialog.getSaveFileName( self, "Export STEP", "", "STEP Files (*.step *.stp)" ) if filepath: if self._kernel.export_step(self._selected_body.geometry, filepath): logger.info(f"Exported: {filepath}") else: QMessageBox.warning(self, "Export Failed", "Failed to export STEP") def _export_iges(self): if not self._selected_body: QMessageBox.warning(self, "No Selection", "Please select a body") return filepath, _ = QFileDialog.getSaveFileName( self, "Export IGES", "", "IGES Files (*.iges *.igs)" ) if filepath: if self._kernel.export_iges(self._selected_body.geometry, filepath): logger.info(f"Exported: {filepath}") else: QMessageBox.warning(self, "Export Failed", "Failed to export IGES") def _export_stl(self): if not self._selected_body: QMessageBox.warning(self, "No Selection", "Please select a body") return filepath, _ = QFileDialog.getSaveFileName(self, "Export STL", "", "STL Files (*.stl)") if filepath: if self._kernel.export_stl(self._selected_body.geometry, filepath): logger.info(f"Exported: {filepath}") else: QMessageBox.warning(self, "Export Failed", "Failed to export STL") def _fit_view(self): self._viewer_3d.fit_camera() def _reset_view(self): self._viewer_3d.set_camera_position((100, 100, 100), (0, 0, 0)) def _show_about(self): QMessageBox.about( self, "About Fluency CAD", "Fluency CAD 2.0\n\n" "A parametric CAD application built on:\n" "- OpenCASCADE Technology (OCCT)\n" "- CadQuery Python bindings\n" "- pygfx WebGPU renderer\n\n" "Features:\n" "- STEP/IGES import/export\n" "- Parametric sketching\n" "- Boolean operations\n" "- Fillets and chamfers\n" "- Component timeline", ) def main() -> int: app = QApplication(sys.argv) app.setStyle("Fusion") window = MainWindow() window.show() return app.exec() if __name__ == "__main__": sys.exit(main())