diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 3ee8ff9..6c902b5 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -6,9 +6,6 @@
-
-
-
@@ -329,7 +326,15 @@
1783239410744
-
+
+
+ 1783272988957
+
+
+
+ 1783272988957
+
+
diff --git a/gui.ui b/gui.ui
index 6fb5370..58c51fb 100644
--- a/gui.ui
+++ b/gui.ui
@@ -1190,9 +1190,17 @@
File
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/gui_ui.py b/gui_ui.py
index 8e10ebd..4a774bd 100644
--- a/gui_ui.py
+++ b/gui_ui.py
@@ -29,10 +29,22 @@ class Ui_fluencyCAD(object):
fluencyCAD.resize(1941, 1155)
self.actionNew_Project = QAction(fluencyCAD)
self.actionNew_Project.setObjectName(u"actionNew_Project")
- self.actionLoad_Project = QAction(fluencyCAD)
- self.actionLoad_Project.setObjectName(u"actionLoad_Project")
- self.actionRecent = QAction(fluencyCAD)
- self.actionRecent.setObjectName(u"actionRecent")
+ self.actionOpen_Project = QAction(fluencyCAD)
+ self.actionOpen_Project.setObjectName(u"actionOpen_Project")
+ self.actionSave_Project = QAction(fluencyCAD)
+ self.actionSave_Project.setObjectName(u"actionSave_Project")
+ self.actionSave_Project_As = QAction(fluencyCAD)
+ self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
+ self.actionImport_File = QAction(fluencyCAD)
+ self.actionImport_File.setObjectName(u"actionImport_File")
+ self.actionExport_Step = QAction(fluencyCAD)
+ self.actionExport_Step.setObjectName(u"actionExport_Step")
+ self.actionExport_Iges = QAction(fluencyCAD)
+ self.actionExport_Iges.setObjectName(u"actionExport_Iges")
+ self.actionExport_Stl = QAction(fluencyCAD)
+ self.actionExport_Stl.setObjectName(u"actionExport_Stl")
+ self.actionExit = QAction(fluencyCAD)
+ self.actionExit.setObjectName(u"actionExit")
self.centralwidget = QWidget(fluencyCAD)
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
@@ -649,9 +661,17 @@ class Ui_fluencyCAD(object):
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuSettings.menuAction())
self.menuFile.addAction(self.actionNew_Project)
- self.menuFile.addAction(self.actionLoad_Project)
- self.menuFile.addAction(self.actionRecent)
+ self.menuFile.addAction(self.actionOpen_Project)
+ self.menuFile.addAction(self.actionSave_Project)
+ self.menuFile.addAction(self.actionSave_Project_As)
self.menuFile.addSeparator()
+ self.menuFile.addAction(self.actionImport_File)
+ self.menuFile.addSeparator()
+ self.menuFile.addAction(self.actionExport_Step)
+ self.menuFile.addAction(self.actionExport_Iges)
+ self.menuFile.addAction(self.actionExport_Stl)
+ self.menuFile.addSeparator()
+ self.menuFile.addAction(self.actionExit)
self.retranslateUi(fluencyCAD)
@@ -663,9 +683,30 @@ class Ui_fluencyCAD(object):
def retranslateUi(self, fluencyCAD):
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
- self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
- self.actionLoad_Project.setText(QCoreApplication.translate("fluencyCAD", u"Load", None))
- self.actionRecent.setText(QCoreApplication.translate("fluencyCAD", u"Recent", None))
+ self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
+#if QT_CONFIG(shortcut)
+ self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
+#if QT_CONFIG(shortcut)
+ self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
+#if QT_CONFIG(shortcut)
+ self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
+#if QT_CONFIG(shortcut)
+ self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
+ self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
+ self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
+ self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
+ self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
+#if QT_CONFIG(shortcut)
+ self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
+#endif // QT_CONFIG(shortcut)
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
diff --git a/memory.db b/memory.db
new file mode 100644
index 0000000..e69de29
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index 24b3627..265513e 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -1673,3 +1673,205 @@ class OCCSketch(SketchInterface):
return self._solver.dof() == 0
except Exception:
return False
+
+ # ─── Serialization (used by fluency.io.project_io) ─────────────────────
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Serialize the sketch to a plain-dict for JSON storage.
+
+ Captures: the workplane, every entity (with its current geometry and
+ flags), the constraint log, and the entity counter. Live solver
+ handles are intentionally NOT saved — the consumer must call
+ :meth:`from_dict` (or :meth:`rebuild_from_dict`) to rebuild the
+ SolveSpace system before solving again.
+ """
+ # Sort entities by id so replay order is deterministic and matches
+ # creation order (ids are assigned monotonically by ``_next_id``).
+ entities_payload: List[Dict[str, Any]] = []
+ for eid in sorted(self._entities.keys()):
+ ent = self._entities[eid]
+ entities_payload.append(
+ {
+ "id": eid,
+ "type": ent.entity_type,
+ # geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)),
+ # circle→((cx,cy),r), arc→dict. All JSON-friendly.
+ "geometry": ent.geometry,
+ "is_construction": bool(ent.is_construction),
+ "is_external": bool(ent.is_external),
+ "constraints": list(ent.constraints),
+ }
+ )
+
+ # Sets become sorted lists for JSON. ``labels`` inside constraint_log
+ # is a set on the wire; convert to sorted list for JSON round-trip.
+ constraint_log_payload: List[Dict[str, Any]] = []
+ for entry in self._constraint_log:
+ constraint_log_payload.append(
+ {
+ "type": entry["type"],
+ "ids": list(entry["ids"]),
+ "params": list(entry["params"]),
+ "labels": sorted(entry["labels"]),
+ }
+ )
+
+ return {
+ "wp_origin": list(self._wp_origin),
+ "wp_normal": list(self._wp_normal),
+ "wp_x_dir": list(self._wp_x_dir),
+ "wp_y_dir": list(self._wp_y_dir),
+ "entity_counter": self._entity_counter,
+ "first_point_id": self._first_point_id,
+ "external_entity_ids": sorted(self._external_entity_ids),
+ "centerline_ids": sorted(self._centerline_ids),
+ "constraint_count": self._constraint_count,
+ "entities": entities_payload,
+ "constraint_log": constraint_log_payload,
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "OCCSketch":
+ """Build a fresh OCCSketch that reproduces the saved state.
+
+ Replays the construction sequence (points → lines → circles → arcs,
+ respecting external/centerline flags) and re-applies every constraint
+ in the saved log. The SolveSpace solver is deterministic for a given
+ input, so the post-solve state matches the saved one.
+ """
+ sk = cls()
+ sk.rebuild_from_dict(data)
+ return sk
+
+ def rebuild_from_dict(self, data: Dict[str, Any]) -> None:
+ """In-place restore from a dict produced by :meth:`to_dict`.
+
+ Wipes the current solver/state and re-creates every entity in id
+ order so :attr:`_first_point_id` is anchored correctly. Existing
+ callers (notably the solver-rebuild path on entity delete) don't use
+ this; only the project load path does.
+ """
+ # Wipe solver + trackers (don't lose the workplane yet — we set it
+ # explicitly below).
+ self.clear()
+ self._external_entity_ids.clear()
+ self._centerline_ids.clear()
+
+ # 1. Workplane.
+ self.set_workplane(
+ tuple(data["wp_origin"]),
+ tuple(data["wp_normal"]),
+ tuple(data["wp_x_dir"]),
+ )
+
+ # 2. Force the entity counter so the replay assigns the same ids as
+ # the saved sketch — the constraint log references those ids.
+ self._entity_counter = int(data.get("entity_counter", 0))
+
+ # 3. Replay entities in id order. We need the OCCSketchEntity
+ # objects back (for arc center/start/end lookups), so we
+ # reconstruct by id and let ``_next_id`` advance the counter.
+ entities_by_id: Dict[int, OCCSketchEntity] = {}
+ for entry in data.get("entities", []):
+ eid = int(entry["id"])
+ # Ensure the next _next_id() call returns eid.
+ self._entity_counter = eid - 1
+ etype = entry["type"]
+ geom = entry.get("geometry")
+ is_external = bool(entry.get("is_external", False))
+
+ if etype == "point":
+ x, y = float(geom[0]), float(geom[1])
+ if is_external:
+ ent = self.add_external_point(x, y)
+ else:
+ ent = self.add_point(x, y)
+ elif etype == "line":
+ # line geometry is ((x1,y1),(x2,y2)); we already know the
+ # endpoints exist as point entities. Look them up by saved
+ # position via _points (which was just populated above).
+ (x1, y1), (x2, y2) = geom
+ s_id = self._find_point_at(x1, y1)
+ e_id = self._find_point_at(x2, y2)
+ if s_id is None or e_id is None:
+ logger.warning(
+ "Skipping line %s during load: endpoints not found", eid
+ )
+ continue
+ if is_external:
+ ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
+ else:
+ ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id])
+ elif etype == "circle":
+ (cx, cy), radius = geom
+ c_id = self._find_point_at(cx, cy)
+ if c_id is None:
+ logger.warning(
+ "Skipping circle %s during load: center not found", eid
+ )
+ continue
+ ent = self.add_circle(entities_by_id[c_id], float(radius))
+ elif etype == "arc":
+ center_pos = tuple(geom["center"])
+ start_pos = tuple(geom["start"])
+ end_pos = tuple(geom["end"])
+ radius = float(geom["radius"])
+ sweep = float(geom.get("sweep", 0.0))
+ c_id = self._find_point_at(*center_pos)
+ s_id = self._find_point_at(*start_pos)
+ e_id = self._find_point_at(*end_pos)
+ if c_id is None or s_id is None or e_id is None:
+ logger.warning(
+ "Skipping arc %s during load: endpoints not found", eid
+ )
+ continue
+ ent = self.add_arc(
+ entities_by_id[c_id],
+ radius,
+ entities_by_id[s_id],
+ entities_by_id[e_id],
+ sweep=sweep,
+ )
+ else:
+ logger.warning("Unknown sketch entity type %r; skipping", etype)
+ continue
+
+ # Restore the per-entity UI flags / labels that aren't carried
+ # by the add_* methods themselves.
+ ent.is_construction = bool(entry.get("is_construction", False))
+ ent.constraints = list(entry.get("constraints", []))
+ entities_by_id[eid] = ent
+
+ # 4. Replay constraint log. ``_apply_constraint_log`` re-issues the
+ # solver call and pushes back into the entity tracker via
+ # ``entity.constraints``. We don't double-record into the log
+ # itself (the log was already cleared by ``clear()`` and we
+ # re-populate it here, so the live ``_constraint_count`` will
+ # reflect the saved state at the end).
+ for entry in data.get("constraint_log", []):
+ self._record_constraint(
+ entry["type"],
+ tuple(entry["ids"]),
+ tuple(entry.get("params", ())),
+ tuple(entry.get("labels", ())),
+ )
+ # Re-apply the live solver calls AFTER the log is restored, so that
+ # the constraint tracker matches the solver state on a fresh solve.
+ for entry in self._constraint_log:
+ self._apply_constraint_log(entry)
+
+ # 5. Restore external / centerline id sets. ``add_external_*`` adds
+ # to the set internally; if the entity's id was a regular entity
+ # for some reason (legacy / hand-edited file), fold it in too so
+ # the saved flag is authoritative.
+ for eid in data.get("external_entity_ids", []):
+ self._external_entity_ids.add(int(eid))
+ for eid in data.get("centerline_ids", []):
+ self._centerline_ids.add(int(eid))
+
+ def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]:
+ """Return the entity id of a point sitting at UV ``(x, y)`` (within tol)."""
+ for pid, pos in self._points.items():
+ if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol:
+ return pid
+ return None
diff --git a/src/fluency/io/__init__.py b/src/fluency/io/__init__.py
new file mode 100644
index 0000000..e5db9b9
--- /dev/null
+++ b/src/fluency/io/__init__.py
@@ -0,0 +1,5 @@
+"""I/O module: project save/load."""
+
+from fluency.io.project_io import save_project, load_project, project_zip_path
+
+__all__ = ["save_project", "load_project", "project_zip_path"]
diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py
new file mode 100644
index 0000000..390d11f
--- /dev/null
+++ b/src/fluency/io/project_io.py
@@ -0,0 +1,716 @@
+"""Project save/load — ``.fluency`` ZIP files.
+
+The on-disk format is a single ZIP archive:
+
+ project.json # project tree: components, sketches, bodies,
+ # workplanes, assemblies, connectors, view state
+ bodies/.step # one STEP file per Body (BRep geometry)
+ sketches//meta.json # sketch entities + constraints (kept separately
+ # so a single huge sketch doesn't bloat the
+ # main project.json)
+ sketches//solved.step # the sketch's solved face geometry
+
+Sketch constraint solving and 3D body geometry are both preserved by using
+OpenCASCADE's native STEP exporter (which is lossless for BRep). Everything
+else is JSON.
+
+The :func:`save_project` function is the entry point used by the File menu.
+The :func:`load_project` function returns a fully populated
+:class:`fluency.models.Project` (with a fresh ``OCGeometryKernel``) and an
+optional view-state dict that the main window can hand back to the renderer
+to restore the camera.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import shutil
+import tempfile
+import zipfile
+from dataclasses import asdict, is_dataclass
+from datetime import datetime
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+import numpy as np
+
+from fluency.models.data_model import (
+ Assembly,
+ AssemblyComponent,
+ AssemblyConnection,
+ Body,
+ Component,
+ Connector,
+ Project,
+ Sketch,
+ Workplane,
+)
+from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
+from fluency.geometry_occ.sketch import OCCSketch
+
+logger = logging.getLogger(__name__)
+
+
+# ── JSON-friendly type coercion ─────────────────────────────────────────────
+
+
+def _json_default(obj: Any) -> Any:
+ """Default JSON encoder for numpy / dataclass / datetime values."""
+ if isinstance(obj, np.ndarray):
+ return obj.tolist()
+ if isinstance(obj, (datetime,)):
+ return obj.isoformat()
+ if isinstance(obj, (set, frozenset)):
+ return sorted(obj)
+ if isinstance(obj, tuple):
+ return list(obj)
+ if is_dataclass(obj):
+ return asdict(obj)
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
+
+
+def _to_json(data: Any) -> str:
+ return json.dumps(data, default=_json_default, indent=2, sort_keys=False)
+
+
+def _coerce_listlike(value: Any) -> List[Any]:
+ """Cast arrays / tuples / numpy arrays to plain lists for JSON friendliness."""
+ if value is None:
+ return []
+ if isinstance(value, np.ndarray):
+ return value.tolist()
+ if isinstance(value, (list, tuple)):
+ return [v for v in value]
+ return list(value)
+
+
+def _to_3tuple(value: Any) -> Tuple[float, float, float]:
+ """Coerce a saved 3-vector to a tuple of floats (for OCC)."""
+ if value is None:
+ return (0.0, 0.0, 0.0)
+ if isinstance(value, np.ndarray):
+ seq = value.tolist()
+ else:
+ seq = list(value)
+ if len(seq) < 3:
+ seq = list(seq) + [0.0] * (3 - len(seq))
+ return (float(seq[0]), float(seq[1]), float(seq[2]))
+
+
+def _to_3vec(value: Any) -> np.ndarray:
+ """Coerce a saved 3-vector to a 3-element numpy array."""
+ if isinstance(value, np.ndarray):
+ return value.astype(float).reshape(3)
+ if value is None:
+ return np.zeros(3, dtype=float)
+ seq = list(value)
+ if len(seq) < 3:
+ seq = list(seq) + [0.0] * (3 - len(seq))
+ return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
+
+
+def _to_mat3(value: Any) -> np.ndarray:
+ """Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
+ if isinstance(value, np.ndarray):
+ arr = value.astype(float)
+ return arr.reshape(3, 3)
+ if value is None:
+ return np.eye(3, dtype=float)
+ flat = list(np.asarray(value, dtype=float).flatten())
+ if len(flat) < 9:
+ flat = flat + [0.0] * (9 - len(flat))
+ return np.array(flat[:9], dtype=float).reshape(3, 3)
+
+
+def _parse_iso(value: Optional[str]) -> datetime:
+ """Parse an ISO-8601 timestamp, falling back to ``now`` on failure."""
+ if not value:
+ return datetime.now()
+ try:
+ return datetime.fromisoformat(value)
+ except (TypeError, ValueError):
+ return datetime.now()
+
+
+# ── Model serialization (to_dict) ──────────────────────────────────────────
+
+
+def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
+ return {
+ "id": wp.id,
+ "name": wp.name,
+ "origin": list(wp.origin),
+ "normal": list(wp.normal),
+ "x_dir": list(wp.x_dir),
+ "visible": bool(wp.visible),
+ "created_at": wp.created_at.isoformat() if wp.created_at else None,
+ "modified_at": wp.modified_at.isoformat() if wp.modified_at else None,
+ }
+
+
+def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
+ wp = Workplane(
+ id=data.get("id") or None, # Workplane generates uuid if None
+ name=data.get("name", "Untitled Workplane"),
+ origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
+ normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
+ x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
+ visible=bool(data.get("visible", True)),
+ )
+ wp.created_at = _parse_iso(data.get("created_at"))
+ wp.modified_at = _parse_iso(data.get("modified_at"))
+ return wp
+
+
+def _body_to_dict(body: Body) -> Dict[str, Any]:
+ """Body serialization. ``geometry_ref`` is set later by the ZIP writer
+ once the STEP file is written."""
+ return {
+ "id": body.id,
+ "name": body.name,
+ "source_sketch_id": body.source_sketch.id if body.source_sketch else None,
+ "source_operation": body.source_operation,
+ "position": _coerce_listlike(body.position),
+ "rotation": _coerce_listlike(body.rotation),
+ "color": list(body.color) if body.color else [0.2, 0.4, 0.8],
+ "opacity": float(body.opacity),
+ "visible": bool(body.visible),
+ "has_geometry": body.geometry is not None,
+ "geometry_ref": None, # filled in by save_project
+ "created_at": body.created_at.isoformat() if body.created_at else None,
+ "modified_at": body.modified_at.isoformat() if body.modified_at else None,
+ }
+
+
+def _body_from_dict(
+ data: Dict[str, Any],
+ geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
+ source_sketch: Optional[Sketch] = None,
+) -> Body:
+ geometry: Optional[OCCGeometryObject] = None
+ if geometry_loader is not None and data.get("geometry_ref"):
+ geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
+
+ body = Body(
+ id=data.get("id") or None,
+ name=data.get("name", "Untitled Body"),
+ geometry=geometry,
+ source_sketch=source_sketch,
+ source_operation=data.get("source_operation", "extrude"),
+ position=_to_3vec(data.get("position")),
+ rotation=_to_mat3(data.get("rotation")),
+ color=tuple(data.get("color", [0.2, 0.4, 0.8])),
+ opacity=float(data.get("opacity", 1.0)),
+ visible=bool(data.get("visible", True)),
+ )
+ body.created_at = _parse_iso(data.get("created_at"))
+ body.modified_at = _parse_iso(data.get("modified_at"))
+ return body
+
+
+def _sketch_to_dict(sketch: Sketch) -> Dict[str, Any]:
+ occ_dict: Optional[Dict[str, Any]] = None
+ if sketch.occ_sketch is not None and isinstance(sketch.occ_sketch, OCCSketch):
+ try:
+ occ_dict = sketch.occ_sketch.to_dict()
+ except Exception as exc:
+ logger.warning("Sketch %s occ_sketch.to_dict() failed: %s", sketch.id, exc)
+
+ return {
+ "id": sketch.id,
+ "name": sketch.name,
+ "workplane_origin": _coerce_listlike(sketch.workplane_origin),
+ "workplane_normal": _coerce_listlike(sketch.workplane_normal),
+ "workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
+ "is_solved": bool(sketch.is_solved),
+ "is_fully_constrained": bool(sketch.is_fully_constrained),
+ "occ_sketch": occ_dict,
+ "has_geometry": sketch.geometry is not None,
+ "geometry_ref": None, # filled in by save_project
+ "created_at": sketch.created_at.isoformat() if sketch.created_at else None,
+ "modified_at": sketch.modified_at.isoformat() if sketch.modified_at else None,
+ }
+
+
+def _sketch_from_dict(
+ data: Dict[str, Any],
+ geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
+) -> Sketch:
+ occ_dict = data.get("occ_sketch")
+ occ_sketch: Optional[OCCSketch] = None
+ if occ_dict is not None:
+ try:
+ occ_sketch = OCCSketch.from_dict(occ_dict)
+ except Exception as exc:
+ logger.warning("Sketch %s OCCSketch.from_dict() failed: %s", data.get("id"), exc)
+ occ_sketch = OCCSketch()
+ else:
+ occ_sketch = OCCSketch()
+
+ # Re-apply the workplane (from_dict already does this internally, but be
+ # defensive in case the saved dict didn't carry the workplane fields).
+ occ_sketch.set_workplane(
+ tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
+ tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
+ tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
+ )
+
+ geometry: Optional[OCCGeometryObject] = None
+ if geometry_loader is not None and data.get("geometry_ref"):
+ geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
+
+ sk = Sketch(
+ id=data.get("id") or None,
+ name=data.get("name", "Untitled Sketch"),
+ occ_sketch=occ_sketch,
+ geometry=geometry,
+ is_solved=bool(data.get("is_solved", False)),
+ is_fully_constrained=bool(data.get("is_fully_constrained", False)),
+ )
+ sk.workplane_origin = _to_3vec(data.get("workplane_origin"))
+ sk.workplane_normal = _to_3vec(data.get("workplane_normal"))
+ sk.workplane_x_dir = _to_3vec(data.get("workplane_x_dir"))
+ sk.apply_workplane()
+ sk.created_at = _parse_iso(data.get("created_at"))
+ sk.modified_at = _parse_iso(data.get("modified_at"))
+ return sk
+
+
+def _component_to_dict(comp: Component) -> Dict[str, Any]:
+ return {
+ "id": comp.id,
+ "name": comp.name,
+ "description": comp.description,
+ "active_sketch": comp.active_sketch,
+ "active_workplane": comp.active_workplane,
+ "sketches": {sid: _sketch_to_dict(s) for sid, s in comp.sketches.items()},
+ "bodies": {bid: _body_to_dict(b) for bid, b in comp.bodies.items()},
+ "workplanes": {wid: _workplane_to_dict(w) for wid, w in comp.workplanes.items()},
+ "created_at": comp.created_at.isoformat() if comp.created_at else None,
+ "modified_at": comp.modified_at.isoformat() if comp.modified_at else None,
+ }
+
+
+def _component_from_dict(
+ data: Dict[str, Any],
+ body_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
+ sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
+) -> Component:
+ comp = Component(
+ id=data.get("id") or None,
+ name=data.get("name", "Untitled Component"),
+ description=data.get("description", ""),
+ active_sketch=data.get("active_sketch"),
+ active_workplane=data.get("active_workplane"),
+ )
+ comp.created_at = _parse_iso(data.get("created_at"))
+ comp.modified_at = _parse_iso(data.get("modified_at"))
+
+ for wid, wp_data in (data.get("workplanes") or {}).items():
+ comp.workplanes[wid] = _workplane_from_dict(wp_data)
+
+ # Sketches first so bodies can reference them.
+ for sid, sk_data in (data.get("sketches") or {}).items():
+ comp.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
+
+ for bid, body_data in (data.get("bodies") or {}).items():
+ src_sketch = None
+ src_id = body_data.get("source_sketch_id")
+ if src_id and src_id in comp.sketches:
+ src_sketch = comp.sketches[src_id]
+ comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
+
+ return comp
+
+
+def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
+ return {
+ "id": conn.id,
+ "name": conn.name,
+ "position": list(conn.position),
+ "normal": list(conn.normal),
+ "x_dir": list(conn.x_dir),
+ "axis_rotation": float(conn.axis_rotation),
+ "offset": float(conn.offset),
+ "assembly_component_id": conn.assembly_component_id,
+ "source_obj_id": conn.source_obj_id,
+ "partner_ac_id": conn.partner_ac_id,
+ "partner_connector_id": conn.partner_connector_id,
+ "is_grounded": bool(conn.is_grounded),
+ "created_at": conn.created_at.isoformat() if conn.created_at else None,
+ "modified_at": conn.modified_at.isoformat() if conn.modified_at else None,
+ }
+
+
+def _connector_from_dict(data: Dict[str, Any]) -> Connector:
+ conn = Connector(
+ id=data.get("id") or None,
+ name=data.get("name", "Untitled Connector"),
+ position=_to_3tuple(data.get("position")),
+ normal=_to_3tuple(data.get("normal")),
+ x_dir=_to_3tuple(data.get("x_dir")),
+ axis_rotation=float(data.get("axis_rotation", 0.0)),
+ offset=float(data.get("offset", 0.0)),
+ assembly_component_id=data.get("assembly_component_id", ""),
+ source_obj_id=data.get("source_obj_id", ""),
+ )
+ conn.partner_ac_id = data.get("partner_ac_id")
+ conn.partner_connector_id = data.get("partner_connector_id")
+ conn.is_grounded = bool(data.get("is_grounded", False))
+ conn.created_at = _parse_iso(data.get("created_at"))
+ conn.modified_at = _parse_iso(data.get("modified_at"))
+ return conn
+
+
+def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
+ return {
+ "id": ac.id,
+ "component_id": ac.component_id,
+ "name": ac.name,
+ "position": _coerce_listlike(ac.position),
+ "rotation": _coerce_listlike(ac.rotation),
+ "connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
+ "created_at": ac.created_at.isoformat() if ac.created_at else None,
+ "modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
+ }
+
+
+def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
+ ac = AssemblyComponent(
+ id=data.get("id") or None,
+ component_id=data.get("component_id", ""),
+ name=data.get("name", "Untitled Instance"),
+ position=_to_3vec(data.get("position")),
+ rotation=_to_mat3(data.get("rotation")),
+ )
+ ac.created_at = _parse_iso(data.get("created_at"))
+ ac.modified_at = _parse_iso(data.get("modified_at"))
+ for cid, c_data in (data.get("connectors") or {}).items():
+ ac.connectors[cid] = _connector_from_dict(c_data)
+ return ac
+
+
+def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
+ return {
+ "id": c.id,
+ "first_ac_id": c.first_ac_id,
+ "second_ac_id": c.second_ac_id,
+ "first_connector_id": c.first_connector_id,
+ "second_connector_id": c.second_connector_id,
+ "created_at": c.created_at.isoformat() if c.created_at else None,
+ }
+
+
+def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
+ conn = AssemblyConnection(
+ id=data.get("id") or None,
+ first_ac_id=data.get("first_ac_id", ""),
+ second_ac_id=data.get("second_ac_id", ""),
+ first_connector_id=data.get("first_connector_id"),
+ second_connector_id=data.get("second_connector_id"),
+ )
+ conn.created_at = _parse_iso(data.get("created_at"))
+ return conn
+
+
+def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
+ return {
+ "id": asm.id,
+ "name": asm.name,
+ "active_assembly_component": asm.active_assembly_component,
+ "components": {cid: _assembly_component_to_dict(ac) for cid, ac in asm.components.items()},
+ "connections": [_assembly_connection_to_dict(c) for c in asm.connections],
+ "created_at": asm.created_at.isoformat() if asm.created_at else None,
+ "modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
+ }
+
+
+def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
+ asm = Assembly(
+ id=data.get("id") or None,
+ name=data.get("name", "Untitled Assembly"),
+ active_assembly_component=data.get("active_assembly_component"),
+ )
+ asm.created_at = _parse_iso(data.get("created_at"))
+ asm.modified_at = _parse_iso(data.get("modified_at"))
+ for cid, ac_data in (data.get("components") or {}).items():
+ asm.components[cid] = _assembly_component_from_dict(ac_data)
+ for c_data in (data.get("connections") or []):
+ asm.connections.append(_assembly_connection_from_dict(c_data))
+ return asm
+
+
+def _project_to_dict(
+ project: Project,
+ view_state: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ return {
+ "format_version": 1,
+ "name": project.name,
+ "description": project.description,
+ "active_component": project.active_component,
+ "active_assembly": project.active_assembly,
+ "components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
+ "assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
+ "created_at": project.created_at.isoformat() if project.created_at else None,
+ "modified_at": project.modified_at.isoformat() if project.modified_at else None,
+ "view_state": view_state or {},
+ }
+
+
+# ── Geometry (STEP) write/read helpers ─────────────────────────────────────
+
+
+def _write_step_for_body(
+ kernel: OCGeometryKernel,
+ geometry: OCCGeometryObject,
+) -> Optional[bytes]:
+ """Serialize a single body geometry to a STEP byte string.
+
+ Returns *None* if OCC reports the shape is empty (so the ZIP can omit
+ the file and the body is restored as geometry-less). The temporary
+ file is created and immediately deleted; we never touch the user's
+ filesystem outside of ``tempfile``.
+ """
+ fd, tmp_path = tempfile.mkstemp(suffix=".step")
+ os.close(fd)
+ try:
+ ok = kernel.export_step(geometry, tmp_path)
+ if not ok:
+ return None
+ with open(tmp_path, "rb") as f:
+ return f.read()
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+def _read_step_bytes(
+ kernel: OCGeometryKernel,
+ data: bytes,
+) -> Optional[OCCGeometryObject]:
+ """Parse a STEP byte string back into an OCCGeometryObject."""
+ fd, tmp_path = tempfile.mkstemp(suffix=".step")
+ os.close(fd)
+ try:
+ with open(tmp_path, "wb") as f:
+ f.write(data)
+ geom = kernel.import_step(tmp_path)
+ return geom
+ except Exception as exc:
+ logger.warning("Failed to read STEP: %s", exc)
+ return None
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+# ── Save / Load entry points ───────────────────────────────────────────────
+
+
+def project_zip_path(path: str) -> str:
+ """Return *path* with the ``.fluency`` extension added if missing."""
+ base, ext = os.path.splitext(path)
+ if ext.lower() == ".fluency":
+ return path
+ return base + ".fluency"
+
+
+def save_project(
+ project: Project,
+ filepath: str,
+ view_state: Optional[Dict[str, Any]] = None,
+ kernel: Optional[OCGeometryKernel] = None,
+) -> str:
+ """Save *project* to a ``.fluency`` ZIP at *filepath*.
+
+ *view_state* (optional) is a free-form dict that the main window uses to
+ record camera position, active tab, etc. It is stored verbatim inside
+ ``project.json`` under the ``view_state`` key.
+
+ *kernel* is the OCGeometryKernel to use for STEP export. A new one is
+ created if not provided (slightly slower startup, but never holds stale
+ state). Pass the app's kernel to keep one canonical instance.
+
+ Returns the actual file path that was written.
+ """
+ filepath = project_zip_path(filepath)
+ kernel = kernel or OCGeometryKernel()
+
+ # Build the manifest in two passes:
+ # pass 1: serialize all metadata + collect body/sketches that need
+ # STEP files written alongside. We track the in-zip path of
+ # each STEP file in the body/sketches' ``geometry_ref`` slot.
+ # pass 2: write the ZIP, streaming each body/sketches's STEP data
+ # into its own archive member.
+ manifest = _project_to_dict(project, view_state)
+
+ # Per-body STEP files. Skipped if the body has no geometry.
+ body_files: List[Tuple[str, bytes]] = []
+ for comp_id, comp in project.components.items():
+ for body_id, body in comp.bodies.items():
+ if body.geometry is None:
+ continue
+ step_bytes = _write_step_for_body(kernel, body.geometry)
+ if step_bytes is None:
+ continue
+ arcname = f"bodies/{body_id}.step"
+ body_files.append((arcname, step_bytes))
+ manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
+
+ # Per-sketch STEP files (solved face geometry).
+ sketch_files: List[Tuple[str, bytes]] = []
+ sketch_meta_files: List[Tuple[str, bytes]] = []
+ for comp_id, comp in project.components.items():
+ for sketch_id, sketch in comp.sketches.items():
+ # Save the OCCSketch state to its own JSON file so the
+ # main project.json stays compact.
+ occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
+ meta = {
+ "id": sketch.id,
+ "name": sketch.name,
+ "workplane_origin": _coerce_listlike(sketch.workplane_origin),
+ "workplane_normal": _coerce_listlike(sketch.workplane_normal),
+ "workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
+ "is_solved": bool(sketch.is_solved),
+ "is_fully_constrained": bool(sketch.is_fully_constrained),
+ "occ_sketch": occ,
+ }
+ meta_arc = f"sketches/{sketch_id}/meta.json"
+ sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
+ # Drop the heavy occ_sketch payload from the main manifest so
+ # the file is smaller and edits are localised.
+ manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch"] = None
+ manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch_ref"] = meta_arc
+
+ if sketch.geometry is None:
+ continue
+ step_bytes = _write_step_for_body(kernel, sketch.geometry)
+ if step_bytes is None:
+ continue
+ arcname = f"sketches/{sketch_id}/solved.step"
+ sketch_files.append((arcname, step_bytes))
+ manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
+
+ # Write the ZIP. Use a temp file + rename so a partial write can't
+ # clobber an existing good file.
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
+ os.close(tmp_fd)
+ try:
+ with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("project.json", _to_json(manifest))
+ for arcname, data in body_files + sketch_files + sketch_meta_files:
+ zf.writestr(arcname, data)
+ # Atomic-ish replace.
+ shutil.move(tmp_path, filepath)
+ except Exception:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+
+ return filepath
+
+
+def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
+ """Load a project from a ``.fluency`` ZIP.
+
+ Returns ``(project, view_state)``. The caller is responsible for
+ handing *view_state* to the renderer (camera, etc.) and for re-rendering
+ the scene with the freshly-loaded bodies.
+ """
+ if not os.path.exists(filepath):
+ raise FileNotFoundError(filepath)
+
+ kernel = OCGeometryKernel()
+ body_cache: Dict[str, Optional[OCCGeometryObject]] = {}
+
+ # Body geometry can be reused across bodies if the same STEP appears
+ # under multiple names (rare, but cheap to handle). We cache by zip
+ # member name.
+ def body_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
+ if member_name in body_cache:
+ return body_cache[member_name]
+ try:
+ data = zipf.read(member_name)
+ except KeyError:
+ logger.warning("Body STEP missing in archive: %s", member_name)
+ body_cache[member_name] = None
+ return None
+ geom = _read_step_bytes(kernel, data)
+ body_cache[member_name] = geom
+ return geom
+
+ # Sketch geometry loader shares the same byte path. Sketches that have
+ # solved faces point at sketches//solved.step.
+ def sketch_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
+ return body_geometry_loader(member_name)
+
+ with zipfile.ZipFile(filepath, "r") as zipf:
+ manifest_raw = zipf.read("project.json")
+ manifest = json.loads(manifest_raw.decode("utf-8"))
+ view_state: Dict[str, Any] = manifest.get("view_state") or {}
+
+ # If a sketch's occ_sketch is referenced as a separate file, read
+ # it in now and patch the manifest so _sketch_from_dict sees it.
+ for comp_id, comp_data in (manifest.get("components") or {}).items():
+ for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
+ ref = sk_data.get("occ_sketch_ref")
+ if not ref:
+ continue
+ try:
+ meta_bytes = zipf.read(ref)
+ except KeyError:
+ logger.warning("Sketch meta missing in archive: %s", ref)
+ continue
+ meta = json.loads(meta_bytes.decode("utf-8"))
+ sk_data["occ_sketch"] = meta.get("occ_sketch")
+ # Workplane fields on the sketch-level file override the
+ # embedded ones (source of truth lives in the sidecar).
+ for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
+ "is_solved", "is_fully_constrained"):
+ if k in meta:
+ sk_data[k] = meta[k]
+
+ project = Project(
+ name=manifest.get("name", "Untitled Project"),
+ description=manifest.get("description", ""),
+ active_component=manifest.get("active_component"),
+ active_assembly=manifest.get("active_assembly"),
+ kernel=kernel,
+ )
+ project.file_path = filepath
+ project.created_at = _parse_iso(manifest.get("created_at"))
+ project.modified_at = _parse_iso(manifest.get("modified_at"))
+
+ for cid, c_data in (manifest.get("components") or {}).items():
+ project.components[cid] = _component_from_dict(
+ c_data,
+ body_geometry_loader=body_geometry_loader,
+ sketch_geometry_loader=sketch_geometry_loader,
+ )
+
+ for aid, a_data in (manifest.get("assemblies") or {}).items():
+ project.assemblies[aid] = _assembly_from_dict(a_data)
+
+ # After all components are loaded, re-wire connector partner ids so
+ # they point to the freshly-loaded AssemblyComponents. (The dict
+ # round-trip preserves the raw strings; we just make sure the partner
+ # ids are still present in the project so the assembly-move handler
+ # can follow the rigid-group graph.)
+ for asm in project.assemblies.values():
+ for conn in asm.connections:
+ if conn.first_connector_id and conn.first_ac_id in asm.components:
+ first_ac = asm.components[conn.first_ac_id]
+ if conn.first_connector_id in first_ac.connectors:
+ first_ac.connectors[conn.first_connector_id].is_grounded = True
+ if conn.second_connector_id and conn.second_ac_id in asm.components:
+ pass # already set in the connector itself
+
+ return project, view_state
diff --git a/src/fluency/main.py b/src/fluency/main.py
index 22d2ba7..53b64b0 100644
--- a/src/fluency/main.py
+++ b/src/fluency/main.py
@@ -1,7364 +1,53 @@
-from h5py._hl import files
+"""Fluency CAD - Main entry point.
-"""
-Fluency CAD - Main Application
+This module is intentionally thin. The actual UI lives in the
+``fluency.ui`` package:
-A parametric CAD application built on OpenCASCADE Technology (OCCT)
-with a modern pygfx-based 3D renderer.
+ ui.dialogs – 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
+ ui.viewer_widget – Viewer3DWidget (3D canvas)
+ ui.sketch_widget – Sketch2DWidget (2D sketcher + constraint solver)
+ ui.main_window – MainWindow (application shell)
+
+The public classes are re-exported here so that existing call sites
+that do ``from fluency.main import MainWindow`` (notably
+``tests/test_geometry.py``) keep working.
"""
-import sys
-import math
-import uuid
+from __future__ import annotations
+
import logging
-from datetime import datetime
-from typing import Optional, List, Dict, Any, Tuple
-from dataclasses import dataclass, field
+import sys
-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,
- QRadioButton,
- QLineEdit,
+from PySide6.QtWidgets import QApplication
+
+from fluency.ui.dialogs import (
+ ExtrudeDialog,
+ OffsetDialog,
+ RevolveDialog,
+ WorkplaneOrientationDialog,
)
-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
-from gui_ui import Ui_fluencyCAD
-
-
-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
-
-
-def _project_body_to_workplane(
- body_shape: Any,
- workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
-) -> List[List[Tuple[float, float]]]:
- """Project ALL edges of a 3D body onto a workplane, returning UV polylines.
-
- *workplane* is (origin, normal, x_dir). Every edge (linear and curved)
- of every face of *body_shape* is projected onto the workplane by mapping
- each sample point from 3D \u2192 UV (orthographic projection along the
- workplane normal). The result is a list of polylines, each a list of
- (u, v) points, suitable as underlay construction lines in the 2D sketch.
-
- This lets the user see the body's silhouette from the workplane's
- perspective and draw sketches precisely aligned to the body's features.
- """
- import numpy as np
- from OCP.TopExp import TopExp_Explorer
- from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_WIRE
- from OCP.TopoDS import TopoDS
- from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
- from OCP.GeomAbs import GeomAbs_Line
- from OCP.gp import gp_Pnt
-
- origin = np.asarray(workplane[0], dtype=float)
- normal = np.asarray(workplane[1], dtype=float)
- x_dir = np.asarray(workplane[2], dtype=float)
- 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 every face of the body, then each wire/edge within.
- face_expl = TopExp_Explorer(body_shape, TopAbs_FACE)
- while face_expl.More():
- face = face_expl.Current()
- 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 24 segments \u2014 enough for smooth curves.
- pts = [crv.Value(f + (l - f) * i / 24.0) for i in range(25)]
- poly = [world_to_uv(p) for p in pts]
- polylines.append(poly)
- except Exception:
- pass
- edge_expl.Next()
- wire_expl.Next()
- face_expl.Next()
-
- return polylines
-
-
-def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[Tuple[float, float]]:
- """Offset a closed polygon by *distance* (positive = outward).
-
- Uses the edge-normal method: each edge is offset along its outward
- normal, then adjacent offset edges are intersected to find the new
- vertex positions. Handles convex polygons well; concave (reflex)
- corners may produce self-intersecting results for large offsets.
-
- Returns the offset polygon as a list of (x, y) tuples (same length
- as *points*, closed).
- """
- import math
- n = len(points)
- if n < 3:
- return list(points)
-
- # Determine polygon orientation (signed area).
- area = 0.0
- for i in range(n):
- j = (i + 1) % n
- area += points[i][0] * points[j][1] - points[j][0] * points[i][1]
- is_ccw = area > 0.0
-
- # Compute edge directions and left normals.
- edges: List[Tuple[float, float]] = []
- normals: List[Tuple[float, float]] = []
- for i in range(n):
- j = (i + 1) % n
- dx = points[j][0] - points[i][0]
- dy = points[j][1] - points[i][1]
- length = math.hypot(dx, dy)
- if length < 1e-9:
- edges.append((0.0, 0.0))
- normals.append((0.0, 0.0))
- else:
- ux = dx / length
- uy = dy / length
- edges.append((ux, uy))
- # Left normal: (-uy, ux)
- normals.append((-uy, ux))
-
- # For CCW polygons the left normal points *inward*; flip for outward.
- if is_ccw:
- normals = [(-nx, -ny) for (nx, ny) in normals]
-
- result: List[Tuple[float, float]] = []
- for i in range(n):
- prev_i = (i - 1 + n) % n
- n_prev = normals[prev_i] # outward normal of edge (prev, i)
- n_curr = normals[i] # outward normal of edge (i, next)
-
- # Intersect the two offset edge lines to find the new vertex.
- # Line 1: through points[prev_i] + d*n_prev, direction = edges[prev_i]
- # Line 2: through points[i] + d*n_curr, direction = edges[i]
- p1x = points[prev_i][0] + distance * n_prev[0]
- p1y = points[prev_i][1] + distance * n_prev[1]
- d1x, d1y = edges[prev_i]
-
- p2x = points[i][0] + distance * n_curr[0]
- p2y = points[i][1] + distance * n_curr[1]
- d2x, d2y = edges[i]
-
- det = d1x * d2y - d1y * d2x
- if abs(det) < 1e-9:
- # Parallel edges — fall back to normal offset.
- result.append((points[i][0] + distance * n_curr[0],
- points[i][1] + distance * n_curr[1]))
- else:
- diff_x = p2x - p1x
- diff_y = p2y - p1y
- t1 = (diff_x * d2y - diff_y * d2x) / det
- result.append((p1x + t1 * d1x, p1y + t1 * d1y))
-
- return result
-
-
-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 OffsetDialog(QDialog):
- """Dialog for 2D sketch offset options.
-
- Shows a number input for the offset distance with a live preview
- callback so the sketch widget can render the offset result in real
- time. On accept the caller retrieves ``get_values()`` → distance.
- """
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Offset Sketch")
- self.setMinimumWidth(300)
-
- self._preview_callback = None
-
- layout = QVBoxLayout(self)
-
- dist_layout = QHBoxLayout()
- dist_layout.addWidget(QLabel("Offset Distance (mm):"))
- self.distance_input = QDoubleSpinBox()
- self.distance_input.setDecimals(2)
- self.distance_input.setRange(-10000, 10000)
- self.distance_input.setValue(10.0)
- self.distance_input.setSingleStep(0.5)
- dist_layout.addWidget(self.distance_input)
- layout.addLayout(dist_layout)
-
- self.inward_checkbox = QCheckBox("Offset Inward (negative)")
- self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
- layout.addWidget(self.inward_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 on every value change.
- self.distance_input.valueChanged.connect(self._emit_preview)
- self.inward_checkbox.stateChanged.connect(self._emit_preview)
-
- def set_preview_callback(self, callback) -> None:
- """Install the live-preview callback (or *None* to disable)."""
- self._preview_callback = callback
- 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:
- logger.debug("offset preview callback raised: %s", exc)
-
- def hideEvent(self, event):
- 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]:
- return (self.distance_input.value(), self.inward_checkbox.isChecked())
-
-
-class WorkplaneOrientationDialog(QDialog):
- """Modal dialog to choose the orientation of a new workplane.
-
- Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
- can retrieve the chosen orientation via :meth:`get_orientation`, which
- returns (normal, x_dir) pair (both as 3-tuples).
- """
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("New Workplane Orientation")
- self.setMinimumWidth(320)
-
- self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
- self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
-
- # Optional callback for live 3D preview of the workplane.
- # The host installs it via ``set_preview_callback``. The callback
- # receives ``(normal, x_dir)`` or *None* to clear.
- self._preview_callback = None
-
- layout = QVBoxLayout(self)
-
- # ── Orientation presets ──
- lbl = QLabel("Choose orientation:")
- layout.addWidget(lbl)
-
- self._preset_group = QButtonGroup(self)
- preset_layout = QGridLayout()
- presets = [
- ("XY (Top)", (0, 0, 1), (1, 0, 0)),
- ("XZ (Front)", (0, 1, 0), (1, 0, 0)),
- ("YZ (Right)", (1, 0, 0), (0, 1, 0)),
- ("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
- ("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
- ("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
- ]
- for idx, (label, normal, x_dir) in enumerate(presets):
- btn = QRadioButton(label)
- btn.setChecked(idx == 0)
- self._preset_group.addButton(btn, idx)
- btn.normal = normal
- btn.x_dir = x_dir
- preset_layout.addWidget(btn, idx // 2, idx % 2)
- layout.addLayout(preset_layout)
-
- # ── Custom angle (offset from XY) ──
- line = QFrame()
- line.setFrameShape(QFrame.HLine)
- line.setFrameShadow(QFrame.Sunken)
- layout.addWidget(line)
-
- self._custom_radio = QRadioButton("Custom (angle from XY):")
- self._custom_radio.setChecked(False)
- layout.addWidget(self._custom_radio)
-
- angle_layout = QHBoxLayout()
- angle_layout.addWidget(QLabel("Angle X (°):"))
- self._angle_x = QDoubleSpinBox()
- self._angle_x.setDecimals(1)
- self._angle_x.setRange(-360, 360)
- self._angle_x.setValue(0.0)
- self._angle_x.setSuffix("°")
- angle_layout.addWidget(self._angle_x)
- angle_layout.addWidget(QLabel("Angle Y (°):"))
- self._angle_y = QDoubleSpinBox()
- self._angle_y.setDecimals(1)
- self._angle_y.setRange(-360, 360)
- self._angle_y.setValue(0.0)
- self._angle_y.setSuffix("°")
- angle_layout.addWidget(self._angle_y)
- layout.addLayout(angle_layout)
-
- self._name_label = QLabel("Workplane Name:")
- layout.addWidget(self._name_label)
-
- self._name_input = QLineEdit()
- self._name_input.setText("Workplane 1")
- layout.addWidget(self._name_input)
-
- # ── Buttons ──
- line2 = QFrame()
- line2.setFrameShape(QFrame.HLine)
- line2.setFrameShadow(QFrame.Sunken)
- layout.addWidget(line2)
-
- button_layout = QHBoxLayout()
- ok_button = QPushButton("Create")
- ok_button.clicked.connect(self._on_ok)
- 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: update the 3D view whenever the user changes
- # the preset, custom radio toggle, or angle values.
- self._preset_group.buttonClicked.connect(self._on_preset_changed)
- self._custom_radio.toggled.connect(self._emit_preview)
- self._angle_x.valueChanged.connect(self._emit_preview)
- self._angle_y.valueChanged.connect(self._emit_preview)
-
- def set_preview_callback(self, callback) -> None:
- """Install a callback for live 3D preview of the workplane orientation.
-
- *callback* is called with ``(normal, x_dir)`` whenever the user
- changes the selection, or with *None* when the dialog closes.
- """
- self._preview_callback = callback
- # Emit once so the initial state shows a preview right away.
- self._emit_preview()
-
- def _emit_preview(self, *args) -> None:
- """Call the preview callback with the current orientation, if installed."""
- if self._preview_callback is None:
- return
- try:
- normal, x_dir, _name = self.get_orientation()
- self._preview_callback((normal, x_dir))
- except Exception as exc:
- logger.debug("workplane preview callback raised: %s", exc)
-
- def hideEvent(self, event):
- """Clear the live preview when the dialog closes."""
- if self._preview_callback is not None:
- try:
- self._preview_callback(None)
- except Exception:
- pass
- super().hideEvent(event)
-
- def _on_preset_changed(self, btn):
- """When a preset is selected, deselect the custom radio and emit preview."""
- self._custom_radio.setChecked(False)
- self._emit_preview()
-
- def _on_ok(self):
- """Compute the final orientation and accept."""
- import numpy as np
- import math
-
- if self._custom_radio.isChecked():
- # Custom: start from XY normal and rotate by the two angles.
- ax = math.radians(self._angle_x.value())
- ay = math.radians(self._angle_y.value())
- # Start from +Z normal, rotate around X then Y
- n = np.array([0.0, 0.0, 1.0])
- # Rotate around X
- rx = np.array([
- [1, 0, 0],
- [0, math.cos(ax), -math.sin(ax)],
- [0, math.sin(ax), math.cos(ax)],
- ])
- n = rx @ n
- # Rotate around Y
- ry = np.array([
- [math.cos(ay), 0, math.sin(ay)],
- [0, 1, 0],
- [-math.sin(ay), 0, math.cos(ay)],
- ])
- n = ry @ n
- n = n / np.linalg.norm(n)
- # x_dir: cross product of normal with world Y, or world Z if normal ~ Y
- world_y = np.array([0.0, 1.0, 0.0])
- if abs(np.dot(n, world_y)) > 0.99:
- world_y = np.array([0.0, 0.0, 1.0])
- x = np.cross(world_y, n)
- x_norm = np.linalg.norm(x)
- if x_norm > 1e-9:
- x = x / x_norm
- else:
- x = np.array([1.0, 0.0, 0.0])
- self._normal = tuple(float(v) for v in n)
- self._x_dir = tuple(float(v) for v in x)
-
- else:
- btn = self._preset_group.checkedButton()
- if btn is not None:
- self._normal = btn.normal
- self._x_dir = btn.x_dir
- self.accept()
-
- def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
- """Return (normal, x_dir, name) for the chosen workplane.
-
- Computes the current selection from the UI state so it works
- whether called before or after ``_on_ok``.
- """
- import numpy as np
- import math
-
- if self._custom_radio.isChecked():
- ax = math.radians(self._angle_x.value())
- ay = math.radians(self._angle_y.value())
- n = np.array([0.0, 0.0, 1.0])
- rx = np.array([
- [1, 0, 0],
- [0, math.cos(ax), -math.sin(ax)],
- [0, math.sin(ax), math.cos(ax)],
- ])
- n = rx @ n
- ry = np.array([
- [math.cos(ay), 0, math.sin(ay)],
- [0, 1, 0],
- [-math.sin(ay), 0, math.cos(ay)],
- ])
- n = ry @ n
- n = n / np.linalg.norm(n)
- world_y = np.array([0.0, 1.0, 0.0])
- if abs(np.dot(n, world_y)) > 0.99:
- world_y = np.array([0.0, 0.0, 1.0])
- x = np.cross(world_y, n)
- x_norm = np.linalg.norm(x)
- if x_norm > 1e-9:
- x = x / x_norm
- else:
- x = np.array([1.0, 0.0, 0.0])
- return (
- tuple(float(v) for v in n),
- tuple(float(v) for v in x),
- self._name_input.text().strip() or "Workplane",
- )
- else:
- btn = self._preset_group.checkedButton()
- if btn is not None:
- return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
- # Fallback: XY default.
- return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
-
-
-class Sketch2DWidget(QWidget):
- """2D sketching widget with SolveSpace constraint solving and drawing tools."""
-
- constrain_done = Signal()
- sketch_updated = Signal()
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setMinimumSize(400, 300)
- self.setMouseTracking(True)
-
- self._sketch: Optional[OCCSketch] = None
- self._mode: Optional[str] = None
- self._is_construct: bool = False
-
- self._points: List[OCCSketchEntity] = []
- self._lines: List[Tuple[OCCSketchEntity, OCCSketchEntity]] = []
- self._circles: List[Tuple[OCCSketchEntity, float]] = []
- self._arcs: List[Tuple[OCCSketchEntity, float, OCCSketchEntity, OCCSketchEntity, float]] = [] # (center, radius, start_point, end_point, sweep)
- # Accumulated sweep tracking during arc draw (smoothly follows cursor)
- self._arc_accum_sweep: float = 0.0
- self._arc_prev_angle: Optional[float] = None
-
- self._draw_buffer: List[QPoint] = []
- self._hovered_point: Optional[QPoint] = None
- self._hovered_point_entity: Optional[OCCSketchEntity] = None # point entity under cursor (for Delete)
- self._hovered_line: Optional[Tuple[QPoint, QPoint]] = None
- self._hovered_line_entity: Optional[OCCSketchEntity] = None # line entity under cursor (for Delete)
- self._hovered_constraint_idx: int = -1 # constraint-log index hovered over its tag (for Delete)
- self._constraint_tags: List[Dict[str, Any]] = [] # cached tag rects for paint + hit-test
- self._hovered_face: Any = None # detected face dict (see OCCSketch.detect_faces) or None
- self._selected_face: Any = None
- self._selected_entities: List[OCCSketchEntity] = []
-
- # Source face for sketch-on-surface: the planar face the user picked
- # in the 3D viewer, plus its workplane (origin/normal/x_dir). Phase 3
- # projects this face's edges into UV and draws them as an underlay.
- self._source_face: Any = None
- self._source_workplane: Optional[Tuple[Tuple[float, float, float], ...]] = None
- self._source_underlay_uv: List[Any] = [] # cached UV polylines for paintEvent
- # Underlay visibility: the dashed construction lines projected from
- # the source face can be hidden/shown without losing the source
- # face reference (useful when the underlay is too busy). Toggled
- # from the "Underlay" button in the MainWindow UI.
- self._underlay_visible: bool = True
-
- self._snap_mode: Dict[str, bool] = {
- "point": True,
- "mpoint": False,
- "horiz": False,
- "vert": False,
- "grid": False,
- "angle": False,
- }
- self._snap_distance: int = 10
- self._angle_steps: int = 15
-
- self._zoom: float = 1.0
- self._offset: QPoint = QPoint(0, 0)
- self._panning: bool = False
- self._pan_start: Optional[QPoint] = None
-
- self._dynamic_line_end: Optional[QPoint] = None
- self._temp_entities: List[Any] = []
-
- self._constraint_distance_value: float = 10.0
- # Pending distance constraint input
- self._pending_distance_val: Optional[float] = None
-
- # Element move state (move tool / select mode)
- self._moving_points: List[OCCSketchEntity] = []
- self._move_anchor: Optional[OCCSketchEntity] = None
- self._move_anchor_orig: Optional[QPoint] = None
- self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
- self._move_active: bool = False
-
- # Auto-constraint tracking on snap
- self._snap_point_target: Optional[OCCSketchEntity] = None
- self._snap_line_target: Optional[OCCSketchEntity] = None
- self._snap_horizontal: bool = False
- self._snap_vertical: bool = False
-
- # Rectangle first-click snap target (stored so the second click
- # doesn't overwrite it and the correct corner gets constrained).
- self._rect_first_snap_target: Optional[OCCSketchEntity] = None
-
- # Offset preview state (live preview while the OffsetDialog is open).
- self._offset_preview_points: List[Tuple[float, float]] = []
- self._offset_preview_active: bool = False
-
- self.setFocusPolicy(Qt.StrongFocus)
- self._setup_ui()
-
- def _setup_ui(self):
- self.setStyleSheet("background-color: #1e1e2e;")
-
- def set_sketch(self, sketch: Optional[OCCSketch]):
- self._sketch = sketch
- self._rebuild_from_sketch()
- self._draw_buffer = []
- self._clear_face_state()
- # If the new sketch carries a workplane, refresh the source underlay.
- self._refresh_source_underlay()
- # A brand new sketch has no external entities — strip the old
- # underlay from the previous sketch so the construction lines
- # don't bleed into the new sketch. (set_source_face will reimport
- # them if the new sketch is on a face too.)
- if self._sketch is not None and self._sketch is not sketch:
- self._sketch.remove_external_entities()
- self.update()
-
- def clear_source_face(self) -> None:
- """Forget the picked source face and remove the underlay entities.
-
- Use this when the user wants to drop the face reference (e.g. they
- want to draw a free-standing sketch without the body's outline
- showing through). Removes the underlay entities from the solver,
- clears the cached polyline data, and resets the view to whatever
- zoom the user had before the face was set.
- """
- if self._sketch is not None:
- self._sketch.remove_external_entities()
- self._source_face = None
- self._source_workplane = None
- self._source_underlay_uv = []
- self._rebuild_from_sketch()
- self.clear_offset_preview()
- self._hovered_point = None
- self._hovered_point_entity = None
- self._hovered_line = None
- self._hovered_line_entity = None
- self._selected_entities = []
- self.update()
-
- def _convert_underlay_to_sketch(self) -> None:
- """Convert underlay (external/construction) entities into regular geometry.
-
- Creates new non-construction point and line entities at the same
- positions as every external entity in the sketch, coincident-
- constrained to their external counterparts so they stay aligned.
- The underlay reference entities are left intact (the user can still
- toggle them on/off).
-
- After calling this, the newly created regular geometry participates
- in face detection and can be selected for offset, extrude, or cut
- operations — without the user having to manually trace the
- projected outlines.
- """
- if self._sketch is None:
- return
-
- # Map external point IDs → newly created regular point entities
- ext_to_new: Dict[int, OCCSketchEntity] = {}
-
- # Snapshot the entity list before modifying (add_point adds to
- # _entities, which would change dict size mid-iteration).
- for eid, entity in list(self._sketch._entities.items()):
- if (
- entity.is_external
- and entity.entity_type == "point"
- and entity.geometry is not None
- ):
- x, y = entity.geometry
- new_pt = self._sketch.add_point(x, y)
- new_pt.is_construction = False
- self._points.append(new_pt)
- self._sketch.constrain_coincident(new_pt, entity)
- ext_to_new[eid] = new_pt
-
- if not ext_to_new:
- return
-
- # Create matching lines for each external line segment
- for eid, (sid, eid2) in list(self._sketch._lines.items()):
- s_ent = self._sketch._entities.get(sid)
- e_ent = self._sketch._entities.get(eid2)
- if s_ent and e_ent and s_ent.is_external and e_ent.is_external:
- new_s = ext_to_new.get(sid)
- new_e = ext_to_new.get(eid2)
- if new_s is not None and new_e is not None:
- new_line = self._sketch.add_line(new_s, new_e)
- self._lines.append((new_s, new_e))
-
- self._solve_and_sync()
- self._rebuild_from_sketch()
- # Hide the underlay so the user can see the new solid geometry
- self._underlay_visible = False
- self.update()
-
- def set_source_face(
- self,
- face: Any,
- origin: Tuple[float, float, float],
- normal: Tuple[float, float, float],
- x_dir: Tuple[float, float, float],
- ) -> None:
- """Store the picked 3D face and reorient the 2D view to its plane.
-
- Called by MainWindow after a face pick. Projects the face's boundary
- edges into the sketch's UV frame, caches them for the underlay fill,
- *and* imports them as construction-line entities in the underlying
- OCCSketch. Those entities are fixed in the solver, so the user can
- snap to them and add distance / horizontal / vertical / parallel /
- perpendicular / midpoint / coincident constraints against them —
- e.g. place a hole "50 mm from the body's top edge" by clicking the
- underay corner, the hole centre, and entering 50.
-
- Also re-centres/scales the 2D view to look down the plane.
- """
- self._source_face = face
- self._source_workplane = (tuple(origin), tuple(normal), tuple(x_dir))
- # Ensure the OCCSketch shares the same workplane so UV↔world agrees.
- if self._sketch is not None:
- self._sketch.set_workplane(origin, normal, x_dir)
- self._refresh_source_underlay()
- self._import_underlay_as_construction_lines()
- self._orient_view_to_plane()
- self.update()
-
- def _refresh_source_underlay(self) -> None:
- """Project the source face's boundary edges into UV for the underlay."""
- self._source_underlay_uv = []
- if self._source_face is None or self._source_workplane is None:
- return
- if self._sketch is None:
- return
- try:
- self._source_underlay_uv = _project_face_to_uv(
- self._source_face, self._source_workplane
- )
- except Exception:
- logger.debug("source underlay projection failed", exc_info=True)
-
- def set_underlay_visible(self, visible: bool) -> None:
- """Show or hide the underlay (face-projected construction lines).
-
- When hidden, the external entities stay in the OCCSketch solver
- (constraints referencing them keep working) but they're not
- rendered, snapped to, or hit-tested in the 2D view.
- """
- self._underlay_visible = bool(visible)
- # Re-render and drop any in-flight hover that pointed at an underlay
- # entity (otherwise the cursor would freeze on a no-longer-drawn
- # underlay element after the user hides it).
- if not self._underlay_visible:
- self._hovered_point = None
- self._hovered_point_entity = None
- self._hovered_line = None
- self._hovered_line_entity = None
- self.update()
-
- def _import_underlay_as_construction_lines(self) -> None:
- """Convert the projected face edges into real construction-line entities.
-
- Each polyline in ``_source_underlay_uv`` becomes a chain of external
- point entities and external line segment entities in the underlying
- OCCSketch. External points/lines are tagged ``is_external`` and
- ``is_construction`` so the paintEvent renders them as dashed
- construction lines, and so the sketch profile path (detect_faces /
- get_geometry) skips them. The solver marks every external point
- fixed via ``dragged``, so a user drag of a related entity never moves
- the underlay.
-
- If a previous underlay was already imported it is cleared first so
- we don't accumulate duplicates on a re-pick of the same face.
- """
- if self._sketch is None or not self._source_underlay_uv:
- return
- # Clear any prior external entities before importing fresh ones so a
- # repeated face pick doesn't pile up duplicate construction lines.
- self._sketch.remove_external_entities()
- imported = 0
- for poly in self._source_underlay_uv:
- if len(poly) < 2:
- continue
- try:
- _, lines = self._sketch.add_external_polyline(
- [(float(u), float(v)) for (u, v) in poly]
- )
- imported += len(lines)
- except Exception as exc:
- logger.debug("underlay polyline import failed: %s", exc)
- logger.info(
- "Imported %d construction-line segments from source face", imported
- )
- # Pull the new external entities into the UI lists so they're
- # snap/hover/paint targets.
- self._rebuild_from_sketch()
-
- def _orient_view_to_plane(self) -> None:
- """Centre & scale the 2D view to fit the source face's UV bounds."""
- if not self._source_underlay_uv:
- return
- # Collect all UV points across all cached polylines.
- all_pts = [pt for poly in self._source_underlay_uv for pt in poly]
- if not all_pts:
- return
- us = [p[0] for p in all_pts]
- vs = [p[1] for p in all_pts]
- umin, umax = min(us), max(us)
- vmin, vmax = min(vs), max(vs)
- cu, cv = (umin + umax) / 2.0, (vmin + vmax) / 2.0
- du, dv = max(umax - umin, 1e-6), max(vmax - vmin, 1e-6)
- # Zoom so the face fits with ~20% margin; offset so the face centre
- # maps to the widget centre (world (cu,cv) → screen centre).
- w, h = max(self.width(), 100), max(self.height(), 100)
- self._zoom = min(w / (du * 1.2), h / (dv * 1.2))
- # _world_to_screen: screen = world*zoom + centre + offset.
- # We want world (cu,cv) → screen (w/2, h/2). Solve for offset.
- # screen_x = cu*zoom + w/2 + offset_x → offset_x = -cu*zoom
- # screen_y = h/2 - cv*zoom + offset_y → offset_y = cv*zoom
- self._offset = QPoint(int(-cu * self._zoom), int(cv * self._zoom))
- self.update()
-
- def _clear_face_state(self):
- self._hovered_face = None
- self._selected_face = None
-
- def get_selected_face_geometry(self) -> Any:
- """Return the OCCGeometryObject for the currently selected face, or None."""
- if self._selected_face is not None and self._sketch is not None:
- return self._sketch.build_face_geometry(self._selected_face)
- return None
-
- def clear_selected_face(self):
- self._selected_face = None
- self._hovered_face = None
-
- @staticmethod
- def _faces_match(a: Any, b: Any) -> bool:
- """Content-based face comparison (dicts may be from different calls to detect_faces)."""
- if a is None or b is None:
- return False
- return Sketch2DWidget._loop_match(a.get("outer"), b.get("outer"))
-
- @staticmethod
- def _loop_match(a: Any, b: Any) -> bool:
- if a is None or b is None or a["type"] != b["type"]:
- return False
- if a["type"] == "polygon":
- return a["points"][0] == b["points"][0] and len(a["points"]) == len(b["points"])
- else: # circle
- return a["center"] == b["center"] and a["radius"] == b["radius"]
-
- def _rebuild_from_sketch(self):
- """Rebuild UI point/line lists from the OCCSketch entity data.
-
- External (underlay) entities are included in the UI lists so they
- are valid pick targets for constraints and snap. The hit-test /
- move / delete paths all check ``is_external`` and either skip them
- (delete) or refuse to start a drag on them (move).
- """
- self._points = []
- self._lines = []
- self._circles = []
- self._arcs = []
- self._selected_entities = []
- if self._sketch:
- # Collect points in creation order (user + external).
- for eid, entity in self._sketch._entities.items():
- if entity.entity_type == "point":
- self._points.append(entity)
- for eid, entity in self._sketch._entities.items():
- if entity.entity_type == "line" and eid in self._sketch._lines:
- sid, eid2 = self._sketch._lines[eid]
- s_ent = self._sketch._entities.get(sid)
- e_ent = self._sketch._entities.get(eid2)
- if s_ent and e_ent:
- self._lines.append((s_ent, e_ent))
- for eid, (cid, r) in self._sketch._circles.items():
- c_ent = self._sketch._entities.get(cid)
- if c_ent:
- self._circles.append((c_ent, r))
- # Rebuild arcs: arc data is stored as {"center": cid, "start": sid, "end": eid2, "radius": r}
- for eid, arc_data in self._sketch._arcs.items():
- center_id = arc_data.get("center")
- start_id = arc_data.get("start")
- end_id = arc_data.get("end")
- radius = arc_data.get("radius", 0)
- sweep = arc_data.get("sweep") # may be None (legacy arcs)
- c_ent = self._sketch._entities.get(center_id) if center_id else None
- s_ent = self._sketch._entities.get(start_id) if start_id else None
- e_ent = self._sketch._entities.get(end_id) if end_id else None
- if c_ent and s_ent and e_ent:
- self._arcs.append((c_ent, radius, s_ent, e_ent, sweep))
-
- @staticmethod
- def _is_external(entity: Any) -> bool:
- """True if an entity is a face-projected underlay / reference entity.
-
- External entities live in the solver so constraints can reference
- them, but they're protected from user deletion, dragging, and
- profile extrusion.
- """
- return bool(entity is not None and getattr(entity, "is_external", False))
-
- def _is_centerline(self, entity: Any) -> bool:
- """True if an entity is a centerline reference axis (X or Y).
-
- Centerlines are the permanent X (horizontal) and Y (vertical)
- construction lines through the sketch origin. They are rendered
- with distinct colors (red for X, green for Y) as viewport-spanning
- dashed lines, and are protected from deletion.
- """
- if entity is None or self._sketch is None:
- return False
- return entity.id in self._sketch._centerline_ids
-
- def get_sketch(self) -> Optional[OCCSketch]:
- return self._sketch
-
- def create_sketch(self) -> OCCSketch:
- self._sketch = OCCSketch()
- self._sketch.add_centerlines()
- # Sync widget tracking lists from sketch so centerlines (and any
- # future auto-created entities) are immediately pickable.
- self._rebuild_from_sketch()
- self.update()
- return self._sketch
-
- def _ensure_sketch_with_centerlines(self) -> None:
- """Create a sketch with centerlines if none exists yet.
-
- Drawing handlers (line, rect, circle, arc, slot) auto-create an
- OCCSketch when the user starts drawing without first pressing
- WP Origin — but the old code used ``OCCSketch()`` directly,
- bypassing ``create_sketch()`` and leaving the sketch without
- reference axes. This helper ensures centerlines are always
- present, whether the sketch was created via WP Origin, WP Face,
- or on-the-fly by a drawing tool.
- """
- if self._sketch is None:
- self._sketch = OCCSketch()
- self._sketch.add_centerlines()
- self._rebuild_from_sketch()
-
- def reset_buffers(self):
- self._draw_buffer = []
- self._dynamic_line_end = None
- self._mode = None
- self._clear_move_state()
- self._rect_first_snap_target = None
- self.clear_offset_preview()
- self.update()
-
- def set_offset_preview(self, points: Optional[List[Tuple[float, float]]]) -> None:
- """Show or clear the offset preview overlay in the 2D view.
-
- *points* is a list of (x, y) tuples forming a closed polygon, or
- *None* / empty to clear the preview. Used by the OffsetDialog
- live-preview callback to show the offset result in real time.
- """
- if points:
- self._offset_preview_points = list(points)
- self._offset_preview_active = True
- else:
- self._offset_preview_points = []
- self._offset_preview_active = False
- self.update()
-
- def clear_offset_preview(self) -> None:
- """Remove the offset preview overlay."""
- self._offset_preview_points = []
- self._offset_preview_active = False
- self.update()
-
- def _clear_move_state(self):
- """Reset all element-move drag state."""
- self._moving_points = []
- self._move_anchor = None
- self._move_anchor_orig = None
- self._move_orig_positions = {}
- self._move_active = False
- self._hovered_face = None
- self._snap_point_target = None
- self._snap_line_target = None
- self._snap_horizontal = False
- self._snap_vertical = False
- self._rect_first_snap_target = None
- self._arc_accum_sweep = 0.0
- self._arc_prev_angle = None
-
- def set_mode(self, mode: Optional[str]):
- self._mode = mode
- self._draw_buffer = []
- self._dynamic_line_end = None
- self._selected_entities = []
- self._hovered_constraint_idx = -1
- self._hovered_face = None
- self._arc_accum_sweep = 0.0
- self._arc_prev_angle = None
- self.clear_offset_preview()
- # Cancel an ongoing move when switching modes
- if self._move_active:
- self._clear_move_state()
- self.setCursor(Qt.ArrowCursor)
- self.update()
-
- def set_construct_mode(self, enabled: bool):
- self._is_construct = enabled
-
- def set_snap_mode(self, snap_type: str, enabled: bool):
- self._snap_mode[snap_type] = enabled
-
- def set_snap_distance(self, distance: int):
- self._snap_distance = distance
-
- def set_angle_steps(self, steps: int):
- self._angle_steps = steps
-
- def set_constraint_distance(self, distance: float):
- self._constraint_distance_value = distance
-
- # ─── Coordinate transforms ────────────────────────────────────────────
-
- def _screen_to_world(self, pos: QPoint) -> QPoint:
- return QPoint(
- int((pos.x() - self.width() / 2 - self._offset.x()) / self._zoom),
- int((self.height() / 2 - pos.y() + self._offset.y()) / self._zoom),
- )
-
- def _screen_to_world_f(self, pos: QPoint) -> Tuple[float, float]:
- """Float-precision version of ``_screen_to_world``.
-
- Used by face detection so that small shapes (sub-integer size at
- the current zoom) are pickable. The integer version truncates
- fractional world coords, which can place the hit point outside
- a tiny polygon.
- """
- wx = (pos.x() - self.width() / 2 - self._offset.x()) / self._zoom
- wy = (self.height() / 2 - pos.y() + self._offset.y()) / self._zoom
- return (wx, wy)
-
- def _world_to_screen(self, pos: QPoint) -> QPoint:
- return QPoint(
- int(pos.x() * self._zoom + self.width() / 2 + self._offset.x()),
- int(self.height() / 2 - pos.y() * self._zoom + self._offset.y()),
- )
-
- # ─── Snapping ─────────────────────────────────────────────────────────
-
- def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
- if not self._snap_mode.get("point", False):
- return None
- nearest = None
- min_dist = max_distance
- for entity in self._points:
- if entity.geometry:
- x, y = entity.geometry
- point = QPoint(int(round(x)), int(round(y)))
- screen_point = self._world_to_screen(point)
- dist = math.sqrt(
- (pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
- )
- if dist < min_dist:
- min_dist = dist
- nearest = point
- return nearest
-
- def _find_nearest_point_entity(self, pos: QPoint, max_distance: int = 15) -> Optional[OCCSketchEntity]:
- """Find the nearest point entity to a screen position.
-
- External (underlay) points are pickable when the underlay is visible
- so the user can use them as constraint anchors; they are skipped
- otherwise. The function still respects the ``point`` snap mode toggle
- so the user can disable snapping entirely.
- """
- if not self._snap_mode.get("point", False):
- return None
- nearest = None
- min_dist = max_distance
- for entity in self._points:
- if self._is_external(entity) and not self._underlay_visible:
- continue
- if entity.geometry:
- x, y = entity.geometry
- point = QPoint(int(round(x)), int(round(y)))
- screen_point = self._world_to_screen(point)
- dist = math.sqrt(
- (pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
- )
- if dist < min_dist:
- min_dist = dist
- nearest = entity
- return nearest
-
- def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
- if not self._snap_mode.get("mpoint", False):
- return None
- for p1, p2 in self._lines:
- if p1.geometry and p2.geometry:
- x1, y1 = p1.geometry
- x2, y2 = p2.geometry
- mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
- screen_mid = self._world_to_screen(mid)
- dist = math.sqrt((pos.x() - screen_mid.x()) ** 2 + (pos.y() - screen_mid.y()) ** 2)
- if dist < max_distance:
- return mid
- return None
-
- def _apply_angle_snap(self, start: QPoint, end: QPoint) -> QPoint:
- if not self._snap_mode.get("angle", False):
- return end
- dx = end.x() - start.x()
- dy = end.y() - start.y()
- angle = math.degrees(math.atan2(dy, dx))
- length = math.sqrt(dx**2 + dy**2)
- snapped_angle = round(angle / self._angle_steps) * self._angle_steps
- snapped_rad = math.radians(snapped_angle)
- return QPoint(
- int(start.x() + length * math.cos(snapped_rad)),
- int(start.y() + length * math.sin(snapped_rad)),
- )
-
- def _apply_horizontal_snap(self, start: QPoint, end: QPoint) -> QPoint:
- if not self._snap_mode.get("horiz", False):
- return end
- return QPoint(end.x(), start.y())
-
- def _apply_vertical_snap(self, start: QPoint, end: QPoint) -> QPoint:
- if not self._snap_mode.get("vert", False):
- return end
- return QPoint(start.x(), end.y())
-
- def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None) -> QPoint:
- result = pos
- # Reset auto-constraint tracking
- self._snap_point_target = None
- self._snap_line_target = None
- self._snap_horizontal = False
- self._snap_vertical = False
-
- point_snap = self._find_nearest_point(pos)
- if point_snap:
- self._snap_point_target = self._find_nearest_point_entity(pos)
- return self._world_to_screen(point_snap)
- if self._snap_mode.get("mpoint", False):
- mid_snap = self._find_midpoint_snap(pos)
- if mid_snap:
- return self._world_to_screen(mid_snap)
- if start:
- if self._snap_mode.get("horiz", False):
- horiz = self._apply_horizontal_snap(start, result)
- if abs(result.y() - start.y()) < 10:
- result = horiz
- self._snap_horizontal = True
- if self._snap_mode.get("vert", False):
- vert = self._apply_vertical_snap(start, result)
- if abs(result.x() - start.x()) < 10:
- result = vert
- self._snap_vertical = True
- if self._snap_mode.get("angle", False):
- result = self._apply_angle_snap(start, result)
- # Grid snap as a final fallback (only when nothing else applied)
- if result == pos and self._snap_mode.get("grid", False):
- grid_snap = self._find_grid_snap(pos)
- if grid_snap is not None:
- result = self._world_to_screen(grid_snap)
- return result
-
- def _find_grid_snap(self, pos: QPoint) -> Optional[QPoint]:
- """Return the nearest world-space grid intersection for a screen position."""
- if not self._snap_mode.get("grid", False):
- return None
- world = self._screen_to_world(pos)
- grid_step = 10 # 10mm world units per grid cell (matches 10mm minor grid)
- gx = round(world.x() / grid_step) * grid_step
- gy = round(world.y() / grid_step) * grid_step
- return QPoint(gx, gy)
-
- def _apply_move_snaps(
- self,
- mouse_screen: QPoint,
- anchor_orig_screen: QPoint,
- exclude_ids: set,
- ) -> QPoint:
- """Snapping used while moving an element.
-
- The moved element's own points/lines are excluded from being snap
- candidates so the element never snaps to itself. ``anchor_orig_screen``
- is the screen position of the grabbed anchor BEFORE the move started; it
- is used as the reference origin for horizontal/vertical/angle snapping.
- """
- pos = mouse_screen
-
- # Reset auto-constraint tracking
- self._snap_point_target = None
- self._snap_line_target = None
- self._snap_horizontal = False
- self._snap_vertical = False
-
- # Point snap (excluding moved points)
- if self._snap_mode.get("point", False):
- nearest = None
- nearest_entity = None
- min_dist = self._snap_distance
- for entity in self._points:
- if entity.id in exclude_ids or not entity.geometry:
- continue
- x, y = entity.geometry
- sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
- d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
- if d < min_dist:
- min_dist = d
- nearest = sp
- nearest_entity = entity
- if nearest is not None:
- self._snap_point_target = nearest_entity
- return nearest
-
- # Midpoint snap (excluding lines whose both endpoints are being moved)
- if self._snap_mode.get("mpoint", False):
- nearest = None
- min_dist = self._snap_distance
- for p1, p2 in self._lines:
- if p1.id in exclude_ids and p2.id in exclude_ids:
- continue
- if p1.geometry and p2.geometry:
- x1, y1 = p1.geometry
- x2, y2 = p2.geometry
- mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
- sm = self._world_to_screen(mid)
- d = math.sqrt((pos.x() - sm.x()) ** 2 + (pos.y() - sm.y()) ** 2)
- if d < min_dist:
- min_dist = d
- nearest = sm
- if nearest is not None:
- return nearest
-
- # Horizontal / vertical / angle snaps are relative to the original anchor
- result = pos
- if self._snap_mode.get("horiz", False):
- if abs(result.y() - anchor_orig_screen.y()) < 10:
- result = QPoint(result.x(), anchor_orig_screen.y())
- if self._snap_mode.get("vert", False):
- if abs(result.x() - anchor_orig_screen.x()) < 10:
- result = QPoint(anchor_orig_screen.x(), result.y())
- if self._snap_mode.get("angle", False):
- result = self._apply_angle_snap(anchor_orig_screen, result)
-
- # Grid snap as final fallback (only when nothing else changed)
- if result == pos and self._snap_mode.get("grid", False):
- gs = self._find_grid_snap(pos)
- if gs is not None:
- result = self._world_to_screen(gs)
- return result
-
- # ─── Solver helpers ───────────────────────────────────────────────────
-
- def _get_point_entity_at(self, world_pos: QPoint) -> Optional[OCCSketchEntity]:
- """Find nearest point entity to world position.
-
- External (underlay) points are pickable when the underlay is visible
- so the user can use them as constraint anchors (e.g. the corner of a
- projected face); they're skipped when the underlay is hidden.
- """
- for entity in self._points:
- if self._is_external(entity) and not self._underlay_visible:
- continue
- if entity.geometry:
- x, y = entity.geometry
- dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
- if dist < 5: # tolerance in world coords
- return entity
- return None
-
- def _get_line_entity_at(self, world_pos: QPoint) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
- """Find a line near the given world position.
-
- External (underlay) lines are pickable when the underlay is visible
- (so the user can add a distance / horizontal / vertical / parallel /
- perpendicular / midpoint constraint against them) and skipped when
- the underlay is hidden.
-
- Centerlines are treated as infinite reference axes — the hit test
- uses perpendicular distance to the infinite line (no segment
- clamping) with a zoom-adjusted tolerance so they are pickable at
- any zoom level.
- """
- for p1_ent, p2_ent in self._lines:
- line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- is_ext = bool(line_ent is not None and self._is_external(line_ent))
- if is_ext and not self._underlay_visible:
- continue
- if p1_ent.geometry and p2_ent.geometry:
- x1, y1 = p1_ent.geometry
- x2, y2 = p2_ent.geometry
- dx = x2 - x1
- dy = y2 - y1
- if dx == 0 and dy == 0:
- continue
-
- # Compute perpendicular distance to the infinite line.
- line_len_sq = dx * dx + dy * dy
- # Perpendicular distance: |(P - P1) × (P2 - P1)| / |P2 - P1|
- perp_dist = abs(dx * (y1 - world_pos.y()) - dy * (x1 - world_pos.x())) / math.sqrt(line_len_sq)
-
- is_cl = line_ent is not None and self._is_centerline(line_ent)
- if is_cl:
- # Centerlines are infinite reference axes: use the
- # perpendicular distance directly (no segment clamping).
- # Zoom-adjusted tolerance: at least 20 pixels in screen space
- # so the axes are easily pickable regardless of zoom level.
- tol = max(10.0, 20.0 / max(self._zoom, 0.01))
- if perp_dist < tol:
- return (p1_ent, p2_ent)
- else:
- # Regular lines: clamp the projection to the segment
- # and check distance to that clamped point.
- t = ((world_pos.x() - x1) * dx + (world_pos.y() - y1) * dy) / line_len_sq
- t = max(0.0, min(1.0, t))
- proj_x = x1 + t * dx
- proj_y = y1 + t * dy
- seg_dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2)
- if seg_dist < 10: # tolerance
- return (p1_ent, p2_ent)
- return None
-
- def _find_line_sketch_entity(
- self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
- ) -> Optional[OCCSketchEntity]:
- """Return the LINE sketch entity whose endpoints match the given point entities.
-
- ``OCCSketch._lines`` maps ``line_id -> (start_point_id, end_point_id)``;
- the line *entity* itself lives in ``_entities[line_id]`` and carries the
- solver line handle. The old handlers mistakenly returned the start
- *point* entity, whose solver handle is a point — passing that to
- ``horizontal``/``vertical``/``parallel``/``perpendicular``/``midpoint``/
- point-on-line/``symmetric`` raises ``TypeError: unsupported entities``.
- """
- if not self._sketch:
- return None
- for line_id, (sid, eid2) in self._sketch._lines.items():
- s_ent = self._sketch._entities.get(sid)
- e_ent = self._sketch._entities.get(eid2)
- if (s_ent == p1_ent and e_ent == p2_ent) or (s_ent == p2_ent and e_ent == p1_ent):
- return self._sketch._entities.get(line_id) # the line entity
- return None
-
- def _get_line_endpoints(
- self, line_ent: OCCSketchEntity
- ) -> Tuple[Optional[OCCSketchEntity], Optional[OCCSketchEntity]]:
- """Return ``(start_point_entity, end_point_entity)`` for a given line entity."""
- if not self._sketch:
- return None, None
- for line_id, (sid, eid2) in self._sketch._lines.items():
- if self._sketch._entities.get(line_id) is line_ent:
- return self._sketch._entities.get(sid), self._sketch._entities.get(eid2)
- return None, None
-
- def _find_line_entity_for_line_xy(self, p1_xy: Tuple[float, float], p2_xy: Tuple[float, float]) -> Optional[OCCSketchEntity]:
- """Find the OCCSketchEntity for a line defined by its endpoint tuples."""
- for eid, start_end in self._sketch._lines.items():
- sid, eid2 = start_end
- s_ent = self._sketch._entities.get(sid)
- e_ent = self._sketch._entities.get(eid2)
- if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
- sx, sy = s_ent.geometry
- ex, ey = e_ent.geometry
- if (abs(sx - p1_xy[0]) < 0.1 and abs(sy - p1_xy[1]) < 0.1 and
- abs(ex - p2_xy[0]) < 0.1 and abs(ey - p2_xy[1]) < 0.1):
- return s_ent # Return the line entity reference
- if (abs(sx - p2_xy[0]) < 0.1 and abs(sy - p2_xy[1]) < 0.1 and
- abs(ex - p1_xy[0]) < 0.1 and abs(ey - p1_xy[1]) < 0.1):
- return s_ent
- return None
-
- def _get_constraints_for_line(self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity) -> List[str]:
- """Get constraint labels from both endpoint entities."""
- return list(set(p1_ent.constraints + p2_ent.constraints))
-
- # ─── Constraint tags (display + delete) ───────────────────────────────
-
- def _line_world_mid(self, line_id: int) -> Optional[QPoint]:
- """World-space midpoint of the line with the given entity id."""
- if not self._sketch or line_id not in self._sketch._lines:
- return None
- sid, eid2 = self._sketch._lines[line_id]
- s_ent = self._sketch._entities.get(sid)
- e_ent = self._sketch._entities.get(eid2)
- if not s_ent or not e_ent or not s_ent.geometry or not e_ent.geometry:
- return None
- x1, y1 = s_ent.geometry
- x2, y2 = e_ent.geometry
- return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
-
- def _point_world(self, pid: int) -> Optional[QPoint]:
- """World-space position of the point entity with the given id.
-
- Defensive: returns *None* if the entity is missing, has no
- geometry, or its geometry is not a 2-tuple of numbers. The last
- check matters because the solver log also contains line ids
- (e.g. for point-on-line coincident or distance to a line), and a
- line's geometry is ``((x1,y1), (x2,y2))`` — naively unpacking
- that as ``(x, y)`` and calling ``round()`` on the inner tuples
- raises ``TypeError: type tuple doesn't define __round__ method``.
-
- The final ``try/except`` is a last-resort safety net for exotic
- cases (numpy scalars, complex numbers, badly-typed solver
- output) that the explicit type checks above might miss. Better
- to drop a constraint tag than to take down the entire paint
- event.
- """
- if not self._sketch:
- return None
- ent = self._sketch._entities.get(pid)
- if not ent or not ent.geometry:
- return None
- geom = ent.geometry
- # A point's geometry is a flat 2-tuple of numbers; a line's is
- # ((x1, y1), (x2, y2)). Reject anything that doesn't look like
- # a point so callers don't crash on line/circle/arc ids.
- if not isinstance(geom, tuple) or len(geom) != 2:
- return None
- x, y = geom
- if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
- return None
- try:
- return QPoint(int(round(x)), int(round(y)))
- except (TypeError, ValueError):
- # Last-resort guard: exotic x/y types (numpy scalars, NaN,
- # etc.) can still slip through. Returning None means the
- # tag is dropped rather than the paint event crashing.
- logger.debug(
- "_point_world: could not round geometry for entity %s (%r, %r)",
- pid, x, y,
- )
- return None
-
- def _entity_anchor(self, eid: int) -> Optional[QPoint]:
- """Return a sensible world-space anchor for *any* entity id.
-
- Points → their position. Lines → the midpoint. Anything else
- (missing / unrecognised) → None. Used by constraint tag rendering
- so a coincident or distance constraint that involves a line (e.g.
- point-on-line, distance to a line) can be labelled without
- crashing the paint event.
- """
- if not self._sketch:
- return None
- ent = self._sketch._entities.get(eid)
- if ent is None:
- return None
- if ent.entity_type == "line":
- return self._line_world_mid(eid)
- return self._point_world(eid)
-
- def _compute_constraint_tags(self) -> List[Dict[str, Any]]:
- """Build the list of constraint-tag descriptors from the constraint log.
-
- Each tag maps a displayed label + on-screen rect to the index of its
- entry in ``OCCSketch._constraint_log`` so the user can hover a tag and
- press Delete to remove exactly that constraint. Anchors are chosen per
- constraint type: line-attached tags sit at the line midpoint, point tags
- at the relevant point, point-pair tags between the two points.
- """
- tags: List[Dict[str, Any]] = []
- if not self._sketch:
- return tags
- fm = QFontMetrics(QFont("Monospace", 9))
- # Track how many tags already share an anchor so we stack them vertically.
- stack_count: Dict[Tuple[int, int], int] = {}
-
- for idx, entry in enumerate(self._sketch._constraint_log):
- # One bad log entry (e.g. a dangling id after a delete, an
- # exotic geometry type, a numpy scalar that didn't round) must
- # not take down the entire paint event. Catch any failure
- # here, log it at debug, skip the tag, and keep painting.
- try:
- ctype = entry["type"]
- ids = entry["ids"]
- params = entry["params"]
- anchor: Optional[QPoint] = None
- label = ""
-
- if ctype == "horizontal":
- anchor = self._line_world_mid(ids[0]); label = "hrz"
- elif ctype == "vertical":
- anchor = self._line_world_mid(ids[0]); label = "vrt"
- elif ctype == "midpoint":
- anchor = self._line_world_mid(ids[1]); label = "mid"
- elif ctype == "distance":
- # Distance may be point-to-point OR point-to-line (e.g.
- # point-on-line coincident surfaces as a coincident entry;
- # a future point-to-line distance would do the same).
- # Use _entity_anchor so a line id routes to the line
- # midpoint instead of crashing on round().
- a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
- # NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
- if a is not None and b is not None:
- anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
- label = f"dst {params[0]:.1f}" if params else "dst"
- elif ctype == "parallel":
- anchor = self._line_world_mid(ids[0]); label = "par"
- elif ctype == "perpendicular":
- anchor = self._line_world_mid(ids[0]); label = "perp"
- elif ctype == "angle":
- anchor = self._line_world_mid(ids[0])
- label = f"ang {params[0]:.0f}" if params else "ang"
- elif ctype == "equal":
- anchor = self._line_world_mid(ids[0]); label = "eql"
- elif ctype == "coincident":
- # Coincident can be point-to-point OR point-on-line (when
- # a line is one of the targets). Use _entity_anchor so
- # the line's midpoint is used as a fallback anchor when
- # one of the ids is a line.
- a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
- if a is not None and b is not None:
- anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
- label = "coin"
- elif ctype == "symmetric":
- anchor = self._line_world_mid(ids[2]); label = "sym"
- elif ctype == "fixed":
- anchor = self._point_world(ids[0]); label = "fix"
- elif ctype == "equal_radius":
- anchor = self._point_world(ids[0]); label = "eqr"
- else:
- continue
-
- if anchor is None:
- continue
- sc = self._world_to_screen(anchor)
- key = (sc.x(), sc.y())
- slot = stack_count.get(key, 0)
- stack_count[key] = slot + 1
-
- text = f"> {label} <"
- w = fm.horizontalAdvance(text) + 10
- h = 16
- # Stack successive tags above the anchor so they don't overlap.
- cx = sc.x()
- cy = sc.y() - 14 - slot * (h + 2)
- rect = QRect(cx - w // 2, cy - h // 2, w, h)
- tags.append({"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)})
- except Exception as exc:
- # Catch any failure while building this one tag (bad
- # geometry, missing entity, numpy round weirdness, etc.)
- # so a single bad entry can't take down the whole paint
- # event. Drop the tag and move on; the user sees the
- # other tags as normal.
- logger.debug(
- "Skipped constraint tag #%s (%s) due to %s: %s",
- idx, entry.get("type"), type(exc).__name__, exc,
- )
- continue
- return tags
-
- # ─── Element move helpers ─────────────────────────────────────────────
-
- def _collect_connected_points(self, anchor: OCCSketchEntity) -> List[OCCSketchEntity]:
- """Return anchor plus all point entities connected through lines AND arcs.
-
- ``OCCSketch._lines`` maps line id -> (start_point_id, end_point_id) and
- ``OCCSketch._arcs`` maps arc id -> {"center": cid, "start": sid, "end": eid, ...}.
- We BFS over that graph to gather the whole element (e.g. all 4 corners of a
- rectangle, or both endpoints of a single line, or all 6 points of a slot).
- """
- if not self._sketch:
- return [anchor]
- adjacency: Dict[int, List[int]] = {}
- # Line connections
- for _line_id, (sid, eid2) in self._sketch._lines.items():
- adjacency.setdefault(sid, []).append(eid2)
- adjacency.setdefault(eid2, []).append(sid)
- # Arc connections — an arc ties its centre to its start and end, and
- # also ties start to end so the whole arc body moves as a single unit.
- for _arc_id, arc_data in self._sketch._arcs.items():
- center_id = arc_data.get("center")
- start_id = arc_data.get("start")
- end_id = arc_data.get("end")
- if center_id is not None:
- if start_id is not None:
- adjacency.setdefault(center_id, []).append(start_id)
- adjacency.setdefault(start_id, []).append(center_id)
- if end_id is not None:
- adjacency.setdefault(center_id, []).append(end_id)
- adjacency.setdefault(end_id, []).append(center_id)
- # Connect start <-> end so the arc body moves as a unit even
- # without traversing through centre (e.g. grabbing an endpoint
- # pulls the other endpoint and the centre together).
- if start_id is not None and end_id is not None:
- adjacency.setdefault(start_id, []).append(end_id)
- adjacency.setdefault(end_id, []).append(start_id)
- seen = {anchor.id}
- result: List[OCCSketchEntity] = [anchor]
- queue = [anchor.id]
- while queue:
- cur = queue.pop()
- for nbr in adjacency.get(cur, []):
- if nbr in seen:
- continue
- seen.add(nbr)
- ent = self._sketch._entities.get(nbr)
- if ent is not None:
- result.append(ent)
- queue.append(nbr)
- return result
-
- def _find_move_target(self, pos: QPoint) -> Optional[Tuple[OCCSketchEntity, QPoint]]:
- """Find the element under ``pos`` and return ``(anchor_entity, anchor_world)``.
-
- Click priority: nearest point > nearest line (anchor = closer endpoint) >
- circle circumference (anchor = center). The anchor's current world
- position is returned so snapping can use it as the reference origin.
- """
- if not self._sketch:
- return None
- ent = self._find_nearest_point_entity(pos, max_distance=self._snap_distance)
- if ent is not None and ent.geometry:
- x, y = ent.geometry
- return ent, QPoint(int(round(x)), int(round(y)))
- world = self._screen_to_world(pos)
- line_hit = self._get_line_entity_at(world)
- if line_hit:
- p1_ent, p2_ent = line_hit
- if p1_ent.geometry and p2_ent.geometry:
- x1, y1 = p1_ent.geometry
- x2, y2 = p2_ent.geometry
- d1 = (world.x() - x1) ** 2 + (world.y() - y1) ** 2
- d2 = (world.x() - x2) ** 2 + (world.y() - y2) ** 2
- anchor = p1_ent if d1 <= d2 else p2_ent
- ax, ay = anchor.geometry
- return anchor, QPoint(int(round(ax)), int(round(ay)))
- for c_ent, r in self._circles:
- if c_ent.geometry and r > 0:
- cx, cy = c_ent.geometry
- d = math.sqrt((world.x() - cx) ** 2 + (world.y() - cy) ** 2)
- if abs(d - r) < 8:
- return c_ent, QPoint(int(round(cx)), int(round(cy)))
- return None
-
- def _sync_solved_positions(self):
- """Sync solver positions back to UI points and lines."""
- if not self._sketch:
- return
- for entity in self._points:
- if entity.handle is not None:
- try:
- x, y = self._sketch.solver.params(entity.handle.params)
- entity.geometry = (float(x), float(y))
- except Exception:
- pass
- # Update line geometries from their endpoint positions
- for p1_ent, p2_ent in self._lines:
- if p1_ent.geometry and p2_ent.geometry:
- pass # geometry already updated via point sync
-
- def _solve_and_sync(self) -> bool:
- """Solve constraints, sync positions, update UI. Returns True if solved OK."""
- if not self._sketch:
- return True
- ok = self._sketch.solve()
- self._sync_solved_positions()
- self.update()
- return ok
-
- # ─── Mouse events ─────────────────────────────────────────────────────
-
- def mousePressEvent(self, event):
- world_pos = self._screen_to_world(event.pos())
-
- if event.button() == Qt.MiddleButton:
- self._panning = True
- self._pan_start = event.pos()
- self.setCursor(Qt.ClosedHandCursor)
- return
-
- if event.button() == Qt.RightButton:
- self._mode = None
- self._draw_buffer = []
- self._dynamic_line_end = None
- self._selected_entities = []
- self._arc_accum_sweep = 0.0
- self._arc_prev_angle = None
- self.constrain_done.emit()
- self.update()
- return
-
- if event.button() == Qt.LeftButton:
- # Priority order: ① tight point-grab → ② face selection → ③ element move
- if self._mode in ("select", None) and self._sketch:
- # ① Tight point check (4 px) — a deliberate grab on a point.
- tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
- if (tight_ent is not None and tight_ent.geometry
- and not self._is_external(tight_ent)
- and not self._is_centerline(tight_ent)):
- x, y = tight_ent.geometry
- moving = self._collect_connected_points(tight_ent)
- orig: Dict[int, Tuple[float, float]] = {}
- for e in moving:
- if e.geometry:
- orig[e.id] = (e.geometry[0], e.geometry[1])
- self._moving_points = moving
- self._move_anchor = tight_ent
- self._move_anchor_orig = QPoint(int(round(x)), int(round(y)))
- self._move_orig_positions = orig
- self._move_active = True
- self.setCursor(Qt.ClosedHandCursor)
- return
-
- # ② Face region — click inside a closed face to select it.
- # Use float-precision world coords so small shapes (sub-integer
- # at the current zoom) are still pickable.
- fwx, fwy = self._screen_to_world_f(event.pos())
- face = self._sketch.find_face_at(fwx, fwy)
- if face is not None:
- if self._faces_match(face, self._selected_face):
- # Clicking the same face again toggles it off.
- self._selected_face = None
- else:
- self._selected_face = face
- self._hovered_face = None
- self.update()
- return
-
- # ③ Wider element-move check (lines and circles). External
- # (underlay) entities are fixed references and can't be
- # dragged — fall through to the constraint / draw handlers
- # so a click on an underlay edge is treated as a constraint
- # pick (the desired behavior) rather than a no-op.
- target = self._find_move_target(event.pos())
- if (target is not None
- and not self._is_external(target[0])
- and not self._is_centerline(target[0])):
- anchor_ent, anchor_world = target
- moving = self._collect_connected_points(anchor_ent)
- orig: Dict[int, Tuple[float, float]] = {}
- for e in moving:
- if e.geometry:
- orig[e.id] = (e.geometry[0], e.geometry[1])
- self._moving_points = moving
- self._move_anchor = anchor_ent
- self._move_anchor_orig = anchor_world
- self._move_orig_positions = orig
- self._move_active = True
- self.setCursor(Qt.ClosedHandCursor)
- return
-
- snapped_pos = self._apply_all_snaps(
- event.pos(), self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None
- )
- world_snapped = (
- self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
- )
-
- if self._mode == "line":
- self._handle_line_click(world_snapped)
- elif self._mode == "rectangle":
- self._handle_rectangle_click(world_snapped)
- elif self._mode == "circle":
- self._handle_circle_click(world_snapped)
- elif self._mode == "arc":
- self._handle_arc_click(world_snapped)
- elif self._mode == "slot":
- self._handle_slot_click(world_snapped)
- elif self._mode == "select":
- self._handle_select_click(world_snapped)
- elif self._mode == "constrain_coincident":
- self._handle_constraint_coincident(world_snapped)
- elif self._mode == "constrain_horizontal":
- self._handle_constraint_horizontal(world_snapped)
- elif self._mode == "constrain_vertical":
- self._handle_constraint_vertical(world_snapped)
- elif self._mode == "constrain_distance":
- self._handle_constraint_distance(world_snapped)
- elif self._mode == "constrain_midpoint":
- self._handle_constraint_midpoint(world_snapped)
- elif self._mode == "constrain_perpendicular":
- self._handle_constraint_perpendicular(world_snapped)
- elif self._mode == "constrain_parallel":
- self._handle_constraint_parallel(world_snapped)
- elif self._mode == "constrain_ptline":
- self._handle_constraint_ptline(world_snapped)
- elif self._mode == "constrain_symmetric":
- self._handle_constraint_symmetric(world_snapped)
-
- def mouseMoveEvent(self, event):
- if self._panning and self._pan_start:
- delta = event.pos() - self._pan_start
- self._offset += delta
- self._pan_start = event.pos()
- self.update()
- return
-
- # Handle element move (grab all points of the element, snap the anchor)
- if self._move_active and self._move_anchor is not None and self._move_anchor_orig is not None:
- anchor_orig_screen = self._world_to_screen(self._move_anchor_orig)
- exclude_ids = set(e.id for e in self._moving_points)
- snapped_screen = self._apply_move_snaps(
- event.pos(), anchor_orig_screen, exclude_ids
- )
- target_world = self._screen_to_world(snapped_screen)
- dx = target_world.x() - self._move_anchor_orig.x()
- dy = target_world.y() - self._move_anchor_orig.y()
- for ent in self._moving_points:
- if ent.id in self._move_orig_positions and ent.geometry is not None:
- ox, oy = self._move_orig_positions[ent.id]
- ent.geometry = (ox + dx, oy + dy)
- self.update()
- return
-
- world_pos = self._screen_to_world(event.pos())
-
- if self._draw_buffer and self._mode in ["line", "rectangle", "circle", "arc", "slot"]:
- snapped = self._apply_all_snaps(
- event.pos(), self._world_to_screen(self._draw_buffer[0])
- )
- self._dynamic_line_end = self._screen_to_world(snapped)
-
- # Project arc end onto the circle so the preview snaps to
- # the correct radius (matching the start point's distance).
- if self._mode == "arc" and len(self._draw_buffer) == 2:
- cw = self._draw_buffer[0]
- sw = self._draw_buffer[1]
- ew = self._dynamic_line_end
- cx_f, cy_f = cw.x(), cw.y()
- sx_f, sy_f = sw.x(), sw.y()
- r = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2) if (
- (sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2 > 0
- ) else 1.0
- dx = ew.x() - cx_f
- dy = ew.y() - cy_f
- d = math.sqrt(dx * dx + dy * dy)
- if d > 0:
- self._dynamic_line_end = QPoint(
- int(round(cx_f + dx / d * r)),
- int(round(cy_f + dy / d * r)),
- )
- else:
- self._dynamic_line_end = sw
-
- # Accumulated sweep tracking for arc (after click 2, before click 3)
- if self._mode == "arc" and len(self._draw_buffer) == 2:
- cw = self._draw_buffer[0]
- ew = self._dynamic_line_end
- current_angle = math.atan2(ew.y() - cw.y(), ew.x() - cw.x())
- if self._arc_prev_angle is not None:
- delta = current_angle - self._arc_prev_angle
- # Normalise delta to [-PI, PI] (handle atan2 wrap-around)
- while delta > math.pi:
- delta -= 2 * math.pi
- while delta < -math.pi:
- delta += 2 * math.pi
- self._arc_accum_sweep += delta
- self._arc_prev_angle = current_angle
-
- # Constraint-tag hover takes priority — if the cursor is over a tag,
- # we highlight it for delete and skip point/line/face hover this move.
- self._constraint_tags = self._compute_constraint_tags()
- tag_hit = None
- for tag in self._constraint_tags:
- if tag["rect"].contains(event.pos()):
- tag_hit = tag["idx"]
- break
- if tag_hit is not None:
- self._hovered_constraint_idx = tag_hit
- self._hovered_point = None
- self._hovered_point_entity = None
- self._hovered_line = None
- self._hovered_line_entity = None
- self._hovered_face = None
- self.setCursor(Qt.PointingHandCursor)
- self.update()
- return
- if self._hovered_constraint_idx != -1:
- self._hovered_constraint_idx = -1
-
- # Priority for select/move mode: point > face > line > circle.
- # Face is checked before line so the user can see and select the
- # wall region between two concentric boundaries (e.g. after offset).
- # In drawing modes, lines take priority so the user can snap to them.
- hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
-
- # ── ① Point hover (tightest, always first) ──
- point_snap = self._find_nearest_point(event.pos())
- if point_snap:
- self._hovered_point = point_snap
- self._hovered_point_entity = self._find_nearest_point_entity(event.pos())
- self._hovered_line = None
- self._hovered_line_entity = None
- self._hovered_face = None
- self.setCursor(hover_cursor)
- else:
- self._hovered_point = None
- self._hovered_point_entity = None
-
- # ── ② Face hover (checked before line in select mode) ──
- # In select/move mode the face is the primary interaction target
- # (select to extrude etc.). Lines remain selectable via click
- # (element move) but the visual highlight shows the face region.
- face_found = False
- if self._mode in ("select", None) and self._sketch is not None:
- fwx, fwy = self._screen_to_world_f(event.pos())
- face = self._sketch.find_face_at(fwx, fwy)
- if face is not None:
- self._hovered_face = face
- self._hovered_line = None
- self._hovered_line_entity = None
- self.setCursor(Qt.PointingHandCursor)
- face_found = True
-
- if not face_found:
- self._hovered_face = None
-
- # ── ③ Line hover (when no face under cursor) ──
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit:
- p1_ent, p2_ent = line_hit
- if p1_ent.geometry and p2_ent.geometry:
- self._hovered_line = (
- QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
- QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
- )
- self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
- self.setCursor(hover_cursor)
- else:
- self._hovered_line = None
- self._hovered_line_entity = None
- self.setCursor(Qt.ArrowCursor)
- else:
- self._hovered_line = None
- self._hovered_line_entity = None
- # ── ④ Circle hover ──
- if self._mode in ("select", None):
- over_circle = False
- for c_ent, r in self._circles:
- if c_ent.geometry and r > 0:
- cx, cy = c_ent.geometry
- d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
- if abs(d - r) < 8:
- over_circle = True
- break
- self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor)
- else:
- self.setCursor(Qt.ArrowCursor)
-
- self.update()
-
- def mouseReleaseEvent(self, event):
- # Element move release: commit positions into the solver, solve, and
- # revert on a constraint failure. Solving AFTER the release is what
- # decides whether a constrained element snaps (back) or stays moved.
- if self._move_active and event.button() == Qt.LeftButton:
- if self._sketch and self._moving_points:
- new_positions: Dict[int, Tuple[float, float]] = {}
- for ent in self._moving_points:
- if ent.geometry is not None:
- new_positions[ent.id] = ent.geometry
- original = dict(self._move_orig_positions)
- # Push the dragged positions into the solver's params so free
- # points keep their new location (otherwise solve reverts them).
- self._sketch.set_positions(new_positions)
- ok = self._solve_and_sync()
- if not ok:
- logger.warning("Constraint violation while moving element; reverting")
- self._sketch.set_positions(original)
- self._solve_and_sync()
- self._snap_point_target = None
- else:
- # Auto-constrain: point snap during move → coincident
- if self._snap_point_target is not None and self._move_anchor is not None:
- self._sketch.constrain_coincident(self._move_anchor, self._snap_point_target)
- self._solve_and_sync()
- # Snap modes are honoured during the move (see _apply_move_snaps
- # in mouseMoveEvent), so the committed positions are already snapped.
- self._clear_move_state()
- self.setCursor(Qt.ArrowCursor)
- self.sketch_updated.emit()
- self.update()
- return
-
- if event.button() == Qt.MiddleButton:
- self._panning = False
- self._pan_start = None
- self.setCursor(Qt.ArrowCursor)
-
- def wheelEvent(self, event):
- delta = event.angleDelta().y()
- factor = 1.1 if delta > 0 else 0.9
- self._zoom *= factor
- self._zoom = max(0.1, min(10.0, self._zoom))
- self.update()
-
- def keyPressEvent(self, event):
- # Delete / Backspace removes the entity currently under the cursor
- # and recomputes the surviving constraints. Priority: constraint tag >
- # line > point. Works in Move mode or when no tool is selected; ignored
- # while actively drawing so an in-progress line isn't clobbered.
- if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
- if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
- if self._hovered_constraint_idx >= 0:
- self._delete_hovered_constraint()
- event.accept()
- return
- if self._hovered_line_entity is not None:
- self._delete_hovered_line()
- event.accept()
- return
- if self._hovered_point_entity is not None:
- self._delete_hovered_point()
- event.accept()
- return
-
- # C key toggles the hovered line between normal and construction mode.
- if event.key() == Qt.Key_C:
- if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
- if self._hovered_line_entity is not None:
- self._toggle_hovered_line_construction()
- event.accept()
- return
-
- super().keyPressEvent(event)
-
- def _delete_hovered_constraint(self):
- """Delete the hovered constraint (by log index) and recompute the rest."""
- idx = self._hovered_constraint_idx
- if idx < 0 or self._sketch is None:
- return
- ok = self._sketch.remove_constraint_at(idx)
- logger.info(f"Deleted constraint #{idx}; recompute solved={ok}")
- self._hovered_constraint_idx = -1
- self._rebuild_from_sketch()
- self._solve_and_sync()
- self.sketch_updated.emit()
- self.update()
-
- def _delete_hovered_line(self):
- """Delete the hovered line, recompute constraints, and refresh the widget."""
- line_ent = self._hovered_line_entity
- if line_ent is None or self._sketch is None:
- return
- # External (underlay) lines are reference geometry from the source
- # face and can't be deleted one at a time. See delete_point for the
- # same guard — the whole underlay is cleared in one shot via
- # remove_external_entities when the source face is unset.
- if getattr(line_ent, "is_external", False):
- logger.debug("Refusing to delete external (underlay) line")
- return
- # Centerlines are permanent reference axes — refuse deletion.
- if self._is_centerline(line_ent):
- logger.debug("Refusing to delete centerline")
- return
- ok = self._sketch.delete_line(line_ent)
- logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
- # Refresh widget tracking from the pruned sketch and sync solved positions.
- self._rebuild_from_sketch()
- self._hovered_line = None
- self._hovered_line_entity = None
- self._hovered_point = None
- self._hovered_point_entity = None
- self._selected_entities = []
- self._solve_and_sync()
- self.sketch_updated.emit()
- self.update()
-
- def _delete_hovered_point(self):
- """Delete the hovered point (and lines using it), then recompute."""
- point_ent = self._hovered_point_entity
- if point_ent is None or self._sketch is None:
- return
- # External (underlay) points are reference geometry from the source
- # face — they can't be deleted individually. The whole underlay
- # is cleared via remove_external_entities when the source face is
- # removed; silently refuse here so the user gets no surprise
- # cascading deletion of every other underlay element.
- if getattr(point_ent, "is_external", False):
- logger.debug("Refusing to delete external (underlay) point")
- return
- # Centerline points are permanent reference anchors — refuse deletion.
- if self._is_centerline(point_ent):
- logger.debug("Refusing to delete centerline point")
- return
- ok = self._sketch.delete_point(point_ent)
- logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
- self._rebuild_from_sketch()
- self._hovered_point = None
- self._hovered_point_entity = None
- self._hovered_line = None
- self._hovered_line_entity = None
- self._selected_entities = []
- self._solve_and_sync()
- self.sketch_updated.emit()
- self.update()
-
- def _toggle_hovered_line_construction(self) -> None:
- """Toggle the hovered line between normal and construction mode.
-
- The line entity's ``is_construction`` flag is flipped. The line
- entity is then marked as the sole authority for its construction
- state — its endpoint points are NOT toggled, so that other lines
- sharing the same endpoint are unaffected. The paint code checks
- the line entity's flag in addition to the endpoint flags.
- """
- line_ent = self._hovered_line_entity
- if line_ent is None or self._sketch is None:
- return
- # External (underlay) lines are reference geometry from the source
- # face — they can't be toggled individually.
- if getattr(line_ent, "is_external", False):
- logger.debug("Refusing to toggle external (underlay) line")
- return
- # Centerlines are permanent reference axes — refuse toggling.
- if self._is_centerline(line_ent):
- logger.debug("Refusing to toggle centerline")
- return
- # Flip the construction flag on the line entity itself.
- new_val = not line_ent.is_construction
- line_ent.is_construction = new_val
- logger.info(
- f"Toggled line {line_ent.id} construction -> {new_val}"
- )
- self._hovered_line = None
- self._hovered_line_entity = None
- self._solve_and_sync()
- self.sketch_updated.emit()
- self.update()
-
- # ─── Drawing handlers ─────────────────────────────────────────────────
-
- def _handle_line_click(self, pos: QPoint):
- self._ensure_sketch_with_centerlines()
-
- if not self._draw_buffer:
- point = self._sketch.add_point(pos.x(), pos.y())
- point.is_construction = self._is_construct
- self._points.append(point)
- self._draw_buffer.append(pos)
-
- # Auto-constrain: point snap → coincident on start point
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(point, self._snap_point_target)
- self._solve_and_sync()
- else:
- point = self._sketch.add_point(pos.x(), pos.y())
- point.is_construction = self._is_construct
- self._points.append(point)
-
- if len(self._points) >= 2:
- line = self._sketch.add_line(self._points[-2], self._points[-1])
- self._lines.append((self._points[-2], self._points[-1]))
-
- # Auto-constrain: point snap → coincident on end point
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(self._points[-1], self._snap_point_target)
-
- # Auto-constrain: detect horizontal / vertical from geometry
- if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
- p1_geom = self._points[-2].geometry
- p2_geom = self._points[-1].geometry
- if p1_geom is not None and p2_geom is not None:
- x1, y1 = p1_geom
- x2, y2 = p2_geom
- if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6:
- self._sketch.constrain_horizontal(line)
- elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6:
- self._sketch.constrain_vertical(line)
-
- self._solve_and_sync()
-
- self._draw_buffer = [pos]
-
- self.sketch_updated.emit()
- self.update()
-
- def _handle_rectangle_click(self, pos: QPoint):
- self._ensure_sketch_with_centerlines()
-
- if not self._draw_buffer:
- self._draw_buffer.append(pos)
- self._rect_first_snap_target = self._snap_point_target
- else:
- p1 = self._draw_buffer[0]
- p2 = pos
- corners = [
- QPoint(p1.x(), p1.y()),
- QPoint(p2.x(), p1.y()),
- QPoint(p2.x(), p2.y()),
- QPoint(p1.x(), p2.y()),
- ]
- pts = []
- for corner in corners:
- pt = self._sketch.add_point(corner.x(), corner.y())
- pt.is_construction = self._is_construct
- self._points.append(pt)
- pts.append(pt)
-
- line_entities = []
- for i in range(4):
- line = self._sketch.add_line(pts[i], pts[(i + 1) % 4])
- self._lines.append((pts[i], pts[(i + 1) % 4]))
- line_entities.append(line)
-
- # Auto-constrain: point snap → coincident on the correct corners.
- # pts[0] = first click snapped position
- # pts[2] = second click snapped position
- if self._rect_first_snap_target is not None:
- self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target)
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(pts[2], self._snap_point_target)
-
- # Auto-constrain: detect horizontal / vertical from geometry
- if self._snap_mode.get("horiz", False):
- for idx in (0, 2):
- p_start = pts[idx]
- p_end = pts[(idx + 1) % 4]
- if p_start.geometry and p_end.geometry:
- dx = p_end.geometry[0] - p_start.geometry[0]
- if abs(dx) > 1e-6:
- self._sketch.constrain_horizontal(line_entities[idx])
- if self._snap_mode.get("vert", False):
- for idx in (1, 3):
- p_start = pts[idx]
- p_end = pts[(idx + 1) % 4]
- if p_start.geometry and p_end.geometry:
- dy = p_end.geometry[1] - p_start.geometry[1]
- if abs(dy) > 1e-6:
- self._sketch.constrain_vertical(line_entities[idx])
-
- self._solve_and_sync()
- self._draw_buffer = []
- self._mode = None
- self.constrain_done.emit()
-
- self.sketch_updated.emit()
- self.update()
-
- def _handle_circle_click(self, pos: QPoint):
- self._ensure_sketch_with_centerlines()
-
- if not self._draw_buffer:
- center = self._sketch.add_point(pos.x(), pos.y())
- center.is_construction = self._is_construct
- self._points.append(center)
- self._draw_buffer.append(pos)
-
- # Auto-constrain: point snap → coincident on center
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(center, self._snap_point_target)
- self._solve_and_sync()
- else:
- center = self._points[-1]
- cx, cy = center.geometry if center.geometry else (0, 0)
- radius = math.sqrt((pos.x() - cx) ** 2 + (pos.y() - cy) ** 2)
- if radius > 0:
- self._sketch.add_circle(center, radius)
- self._circles.append((center, radius))
- self._draw_buffer = []
- self._mode = None
- self.constrain_done.emit()
-
- self.sketch_updated.emit()
- self.update()
-
- def _handle_arc_click(self, pos: QPoint):
- """Three-click arc: center → start point (radius) → end point (sweep)."""
- self._ensure_sketch_with_centerlines()
-
- if not self._draw_buffer:
- # Click 1: place center point
- center = self._sketch.add_point(pos.x(), pos.y())
- center.is_construction = self._is_construct
- self._points.append(center)
- self._draw_buffer.append(pos)
-
- # Auto-constrain: point snap → coincident on center
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(center, self._snap_point_target)
- self._solve_and_sync()
- elif len(self._draw_buffer) == 1:
- # Click 2: place start point (defines radius + start angle)
- start = self._sketch.add_point(pos.x(), pos.y())
- start.is_construction = self._is_construct
- self._points.append(start)
- self._draw_buffer.append(pos)
-
- # Initialise accumulated sweep tracking
- cx = self._draw_buffer[0].x()
- cy = self._draw_buffer[0].y()
- self._arc_prev_angle = math.atan2(pos.y() - cy, pos.x() - cx)
- self._arc_accum_sweep = 0.0
-
- # Auto-constrain: point snap → coincident on start
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(start, self._snap_point_target)
-
- self._solve_and_sync()
- else:
- # Click 3: place end point, then create arc
- center = self._points[-2]
- start_point = self._points[-1]
-
- cx, cy = center.geometry if center.geometry else (0, 0)
- sx, sy = start_point.geometry if start_point.geometry else (0, 0)
- radius = math.sqrt((sx - cx) ** 2 + (sy - cy) ** 2)
-
- # Project the click onto the circle so the end point
- # stays exactly on the same radius as the start.
- dx = pos.x() - cx
- dy = pos.y() - cy
- dist = math.sqrt(dx * dx + dy * dy)
- if dist > 0:
- ex = cx + dx / dist * radius
- ey = cy + dy / dist * radius
- else:
- ex = cx + radius
- ey = cy
-
- end = self._sketch.add_point(ex, ey)
- end.is_construction = self._is_construct
- self._points.append(end)
-
- # Use accumulated sweep when significant; fall back to computed.
- sweep = self._arc_accum_sweep
- if abs(sweep) < 0.01:
- # Barely moved — compute shortest-path from geometry
- # using the projected end position.
- ea = math.atan2(ey - cy, ex - cx)
- sa = math.atan2(sy - cy, sx - cx)
- sweep = ea - sa
- while sweep > math.pi:
- sweep -= 2 * math.pi
- while sweep < -math.pi:
- sweep += 2 * math.pi
-
- if radius > 0:
- self._sketch.add_arc(center, radius, start_point, end, sweep=sweep)
- self._arcs.append((center, radius, start_point, end, sweep))
-
- # Auto-constrain: point snap → coincident on end point
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(end, self._snap_point_target)
-
- self._solve_and_sync()
-
- self._draw_buffer = []
- self._mode = None
- self.constrain_done.emit()
-
- self.sketch_updated.emit()
- self.update()
-
- def _handle_slot_click(self, pos: QPoint):
- """Three-click slot: center1 → center2 (centerline) → width radius.
-
- Builds two semicircular arcs and two connecting lines forming a
- closed slot profile suitable for extrusion.
- """
- self._ensure_sketch_with_centerlines()
-
- if not self._draw_buffer:
- # Click 1: place first arc center C1.
- c1 = self._sketch.add_point(pos.x(), pos.y())
- c1.is_construction = self._is_construct
- self._points.append(c1)
- self._draw_buffer.append(pos)
-
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(c1, self._snap_point_target)
- self._solve_and_sync()
-
- self.sketch_updated.emit()
- self.update()
- return
-
- if len(self._draw_buffer) == 1:
- # Click 2: place second arc center C2 (defines centerline).
- c2 = self._sketch.add_point(pos.x(), pos.y())
- c2.is_construction = self._is_construct
- self._points.append(c2)
- self._draw_buffer.append(pos)
-
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(c2, self._snap_point_target)
-
- self._solve_and_sync()
- self.sketch_updated.emit()
- self.update()
- return
-
- # Click 3: compute radius from perpendicular distance to centerline
- # and build the full slot profile.
- c1_pos = self._draw_buffer[0]
- c2_pos = self._draw_buffer[1]
- c1_ent = self._points[-2]
- c2_ent = self._points[-1]
-
- dx_c = c2_pos.x() - c1_pos.x()
- dy_c = c2_pos.y() - c1_pos.y()
- L_sq = dx_c * dx_c + dy_c * dy_c
-
- if L_sq < 1.0:
- # Degenerate: centers on top of each other → draw a circle
- # using the midpoint-to-cursor distance as radius.
- import math as _m
- r_fallback = _m.hypot(pos.x() - c1_pos.x(), pos.y() - c1_pos.y())
- if r_fallback > 0:
- self._sketch.add_circle(c1_ent, r_fallback)
- self._circles.append((c1_ent, r_fallback))
- self._draw_buffer = []
- self._mode = None
- self.constrain_done.emit()
- self.sketch_updated.emit()
- self.update()
- return
-
- # Perpendicular distance from cursor to the centerline C1↔C2.
- import math as _m
- r = abs((pos.x() - c1_pos.x()) * dy_c
- - (pos.y() - c1_pos.y()) * dx_c) / _m.sqrt(L_sq)
- if r < 0.5:
- r = 0.5 # minimum radius so the slot doesn't collapse
-
- L = _m.sqrt(L_sq)
- alpha = _m.atan2(dy_c, dx_c)
- # Unit perpendicular (rotate 90° CCW)
- perp_x = -dy_c / L
- perp_y = dx_c / L
-
- # Four corner points (world coords)
- def corner(cx, cy, sign):
- return QPoint(
- int(round(cx + sign * r * perp_x)),
- int(round(cy + sign * r * perp_y)),
- )
-
- # Top / bottom are relative to the perpendicular direction.
- # Perp points "up" from the centreline; -perp points "down".
- t1 = corner(c1_pos.x(), c1_pos.y(), 1) # C1 + perp
- b1 = corner(c1_pos.x(), c1_pos.y(), -1) # C1 - perp
- t2 = corner(c2_pos.x(), c2_pos.y(), 1) # C2 + perp
- b2 = corner(c2_pos.x(), c2_pos.y(), -1) # C2 - perp
-
- # Create point entities
- pt1 = self._sketch.add_point(t1.x(), t1.y())
- pt1.is_construction = self._is_construct
- self._points.append(pt1)
- pb1 = self._sketch.add_point(b1.x(), b1.y())
- pb1.is_construction = self._is_construct
- self._points.append(pb1)
- pt2 = self._sketch.add_point(t2.x(), t2.y())
- pt2.is_construction = self._is_construct
- self._points.append(pt2)
- pb2 = self._sketch.add_point(b2.x(), b2.y())
- pb2.is_construction = self._is_construct
- self._points.append(pb2)
-
- # Auto-constrain: coincident on each corner point if it snaps onto
- # an existing point entity (e.g. vertex of another shape).
- for corner_pt in (pt1, pb1, pt2, pb2):
- if corner_pt.geometry is not None:
- cgx, cgy = corner_pt.geometry
- snap_target = self._get_point_entity_at(
- QPoint(int(round(cgx)), int(round(cgy)))
- )
- if snap_target is not None and snap_target.id != corner_pt.id:
- self._sketch.constrain_coincident(corner_pt, snap_target)
-
- # Arc 1 (at C1): exterior sweep from top to bottom (CCW)
- arc1_ent = None
- arc2_ent = None
- if r > 0:
- arc1_ent = self._sketch.add_arc(c1_ent, r, pt1, pb1, sweep=_m.pi)
- self._arcs.append((c1_ent, r, pt1, pb1, _m.pi))
-
- # Arc 2 (at C2): exterior sweep from bottom to top (CCW)
- arc2_ent = self._sketch.add_arc(c2_ent, r, pb2, pt2, sweep=_m.pi)
- self._arcs.append((c2_ent, r, pb2, pt2, _m.pi))
-
- # Lines connecting the arc endpoints
- line1 = self._sketch.add_line(pb1, pb2) # bottom line
- self._lines.append((pb1, pb2))
- line2 = self._sketch.add_line(pt2, pt1) # top line
- self._lines.append((pt2, pt1))
-
- # ── Parametric construction: perpendicular reference per arc ──
- # Each arc needs a construction line from its centre, perpendicular
- # to the connecting lines, to define the arc-endpoint direction and
- # maintain smooth tangency between arc and line. The corner points
- # sit on this line at distance r from the centre (the arc radius).
- # Without this the solver can rotate the endpoints around the
- # centre, breaking the visual arc-line tangency, and bare distance
- # constraints lock the slot because the centre is the sketch's
- # first (reference-fixed) point.
-
- # Arc 1 (C1): construction line from centre to top endpoint
- acl1 = self._sketch.add_line(c1_ent, pt1)
- acl1.is_construction = True
- self._lines.append((c1_ent, pt1))
- # ⟂ to the connecting lines (line1 ∥ line2, so ⟂to one = ⟂to both)
- self._sketch.constrain_perpendicular(acl1, line1)
- # Bottom endpoint sits on this same line (diametrically opposite)
- self._sketch.constrain_coincident(pb1, acl1)
- # Arc radius: both endpoints at distance r from centre
- self._sketch.constrain_distance(pt1, c1_ent, r)
- self._sketch.constrain_distance(pb1, c1_ent, r)
-
- # Arc 2 (C2): construction line from centre to top endpoint
- acl2 = self._sketch.add_line(c2_ent, pt2)
- acl2.is_construction = True
- self._lines.append((c2_ent, pt2))
- self._sketch.constrain_perpendicular(acl2, line1)
- self._sketch.constrain_coincident(pb2, acl2)
- self._sketch.constrain_distance(pt2, c2_ent, r)
- self._sketch.constrain_distance(pb2, c2_ent, r)
-
- # Auto-constrain: horizontal / vertical on the two slot lines
- if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
- for line_ent, pa, pb in [(line1, pb1, pb2), (line2, pt2, pt1)]:
- if pa.geometry and pb.geometry:
- x1, y1 = pa.geometry
- x2, y2 = pb.geometry
- if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6:
- self._sketch.constrain_horizontal(line_ent)
- elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6:
- self._sketch.constrain_vertical(line_ent)
-
- # Auto-constrain: the two slot lines are always parallel
- self._sketch.constrain_parallel(line1, line2)
-
- # Auto-constrain: the two arcs have equal radius
- if arc1_ent is not None and arc2_ent is not None:
- self._sketch.constrain_equal_radius(arc1_ent, arc2_ent)
-
- self._solve_and_sync()
-
- self._draw_buffer = []
- self._mode = None
- self.constrain_done.emit()
- self.sketch_updated.emit()
- self.update()
-
- def _handle_select_click(self, pos: QPoint):
- """Handle select mode - find and highlight points/lines for selection."""
- # Check if clicking on an existing point
- nearest_entity = self._get_point_entity_at(pos)
- if nearest_entity:
- if nearest_entity not in self._selected_entities:
- self._selected_entities.append(nearest_entity)
- else:
- self._selected_entities.remove(nearest_entity)
- else:
- # Check if clicking on a line
- line_hit = self._get_line_entity_at(pos)
- if line_hit:
- p1_ent, p2_ent = line_hit
- for entity in [p1_ent, p2_ent]:
- if entity and entity not in self._selected_entities:
- self._selected_entities.append(entity)
-
- # ─── Constraint handlers (with solver calls) ──────────────────────────
-
- def _handle_constraint_coincident(self, world_pos: QPoint):
- ent = self._get_point_entity_at(world_pos)
- if ent is None:
- return
- self._selected_entities.append(ent)
- if len(self._selected_entities) >= 2:
- e1, e2 = self._selected_entities[:2]
- if self._sketch:
- self._sketch.constrain_coincident(e1, e2)
- ok = self._solve_and_sync()
- if ok:
- logger.info("Coincident constraint added")
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
- self.update()
-
- def _handle_constraint_horizontal(self, world_pos: QPoint):
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit is None:
- return
- p1_ent, p2_ent = line_hit
- # SolveSpace's horizontal() needs the LINE entity's handle, not a
- # point's. Look up the line sketch entity by its endpoints.
- line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if line_ent is None:
- return
- if self._sketch:
- self._sketch.constrain_horizontal(line_ent)
- # Tag endpoints so paintEvent renders the "> hrz <" label.
- for ent in (p1_ent, p2_ent):
- if "hrz" not in ent.constraints:
- ent.constraints.append("hrz")
- ok = self._solve_and_sync()
- if ok:
- logger.info("Horizontal constraint added")
- else:
- logger.warning("Horizontal constraint failed to solve")
- self._mode = None
- self.constrain_done.emit()
- self.update()
-
- def _handle_constraint_vertical(self, world_pos: QPoint):
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit is None:
- return
- p1_ent, p2_ent = line_hit
- line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if line_ent is None:
- return
- if self._sketch:
- self._sketch.constrain_vertical(line_ent)
- for ent in (p1_ent, p2_ent):
- if "vrt" not in ent.constraints:
- ent.constraints.append("vrt")
- ok = self._solve_and_sync()
- if ok:
- logger.info("Vertical constraint added")
- else:
- logger.warning("Vertical constraint failed to solve")
- self._mode = None
- self.constrain_done.emit()
- self.update()
-
- def _handle_constraint_distance(self, world_pos: QPoint):
- point_ent = self._get_point_entity_at(world_pos)
- if point_ent:
- # Point clicked: collect points; constraint applied after 2nd point.
- self._selected_entities.append(point_ent)
- else:
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit:
- p1_ent, p2_ent = line_hit
- line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if line_ent is not None:
- # Line clicked: constrain its length (endpoint distance).
- dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
- self._constraint_distance_value, 0, 10000, 2)
- if ok and self._sketch:
- self._sketch.constrain_distance(p1_ent, p2_ent, dist)
- self._solve_and_sync()
- logger.info(f"Line distance {dist:.2f}mm")
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
- self.update()
- return
-
- if len(self._selected_entities) >= 2:
- e1, e2 = self._selected_entities[:2]
- dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
- self._constraint_distance_value, 0, 10000, 2)
- if ok and self._sketch:
- self._sketch.constrain_distance(e1, e2, dist)
- self._solve_and_sync()
- logger.info(f"Distance {dist:.2f}mm")
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
- self.update()
-
- def _handle_constraint_midpoint(self, world_pos: QPoint):
- point_ent = self._get_point_entity_at(world_pos)
- if point_ent and not self._selected_entities:
- self._selected_entities.append(point_ent)
- elif self._selected_entities:
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit:
- p1_ent, p2_ent = line_hit
- line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if line_ent is not None and self._sketch and self._selected_entities:
- self._sketch.constrain_midpoint(self._selected_entities[0], line_ent)
- for ent in (p1_ent, p2_ent):
- if "mid" not in ent.constraints:
- ent.constraints.append("mid")
- self._solve_and_sync()
- logger.info("Midpoint constraint added")
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
- self.update()
-
- def _handle_constraint_perpendicular(self, world_pos: QPoint):
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit is None:
- return
- p1_ent, p2_ent = line_hit
- target_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if target_ent is None:
- return
-
- if not self._selected_entities:
- # First click: store this line ENTITY (not a point):
- self._selected_entities.append(target_ent)
- else:
- # Second click: apply perpendicular constraint between two LINE entities
- prev_ent = self._selected_entities[0]
- if self._sketch:
- self._sketch.constrain_perpendicular(prev_ent, target_ent)
- self._solve_and_sync()
- logger.info("Perpendicular constraint added")
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
- self.update()
-
- def _handle_constraint_parallel(self, world_pos: QPoint):
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit is None:
- return
- p1_ent, p2_ent = line_hit
- target_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if target_ent is None:
- return
-
- if not self._selected_entities:
- self._selected_entities.append(target_ent)
- else:
- prev_ent = self._selected_entities[0]
- if self._sketch:
- self._sketch.constrain_parallel(prev_ent, target_ent)
- self._solve_and_sync()
- logger.info("Parallel constraint added")
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
- self.update()
-
- def _handle_constraint_ptline(self, world_pos: QPoint):
- """Point-on-line coincident (point on line)."""
- point_ent = self._get_point_entity_at(world_pos)
- if point_ent and not self._selected_entities:
- self._selected_entities.append(point_ent)
- elif self._selected_entities:
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit:
- p1_ent, p2_ent = line_hit
- line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if line_ent is not None and self._sketch and self._selected_entities:
- # coincident(point, line) = point-on-line; needs the line handle
- self._sketch.constrain_coincident(self._selected_entities[0], line_ent)
- self._solve_and_sync()
- logger.info("Point-on-line constraint added")
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
- self.update()
-
- def _handle_constraint_symmetric(self, world_pos: QPoint):
- """Symmetric constraint: select entity1, entity2, then mirror line."""
- # Click 3: mirror line
- if len(self._selected_entities) == 2:
- line_hit = self._get_line_entity_at(world_pos)
- if line_hit:
- p1_ent, p2_ent = line_hit
- mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
- if mirror_line is not None and self._sketch:
- self._sketch.constrain_symmetric(
- self._selected_entities[0], self._selected_entities[1], mirror_line
- )
- ok = self._solve_and_sync()
- if ok:
- logger.info("Symmetric constraint added")
- else:
- logger.warning("Symmetric constraint failed to solve")
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
- self.update()
- return
- # Clicks 1-2: select point entities
- point_ent = self._get_point_entity_at(world_pos)
- if point_ent:
- self._selected_entities.append(point_ent)
- n_selected = len(self._selected_entities)
- if n_selected < 2:
- logger.info(f"Select entity {n_selected + 1} for symmetry (or line for mirror)")
- elif n_selected == 2:
- logger.info("Click on the mirror line")
- self.update()
-
- # ─── Painting ─────────────────────────────────────────────────────────
-
- def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF:
- return QPointF((p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0)
-
- def _point_distance(self, p1: QPoint, p2: QPoint) -> float:
- return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2)
-
- def _draw_distance_measurement(self, painter: QPainter, p1: QPoint, p2: QPoint):
- """Draw dimension lines and distance value between two world-coord points."""
- sp1 = self._world_to_screen(p1)
- sp2 = self._world_to_screen(p2)
-
- dx = sp2.x() - sp1.x()
- dy = sp2.y() - sp1.y()
- length = math.sqrt(dx * dx + dy * dy)
- if length == 0:
- return
-
- # Perpendicular direction for offset lines
- perp_dx = -dy / length
- perp_dy = dx / length
- offset = 25.0
-
- p1a = QPointF(sp1.x() + perp_dx, sp1.y() + perp_dy)
- p1b = QPointF(sp1.x() + perp_dx * offset, sp1.y() + perp_dy * offset)
- p2a = QPointF(sp2.x() + perp_dx, sp2.y() + perp_dy)
- p2b = QPointF(sp2.x() + perp_dx * offset, sp2.y() + perp_dy * offset)
- mid = QPointF((p1b.x() + p2b.x()) / 2, (p1b.y() + p2b.y()) / 2)
-
- pen_dim = QPen(QColor("#a6e3a1"), 1.5, Qt.DotLine)
- painter.setPen(pen_dim)
- painter.drawLine(p1a.toPoint(), p1b.toPoint())
- painter.drawLine(p2a.toPoint(), p2b.toPoint())
- painter.drawLine(p1b.toPoint(), p2b.toPoint())
-
- # Draw distance text
- dist = self._point_distance(p1, p2)
- painter.save()
- painter.translate(mid)
- painter.scale(1, -1)
- painter.setPen(QPen(QColor("#a6e3a1"), 1))
- painter.drawText(0, 0, f"{dist:.2f}")
- painter.restore()
-
- def paintEvent(self, event):
- painter = QPainter(self)
- painter.setRenderHint(QPainter.Antialiasing)
- painter.fillRect(self.rect(), QColor("#1e1e2e"))
-
- # ── Grid (fixed 10mm world-units spacing) ──
- # Minor grid: 10 world-unit (10mm) spacing.
- # Major grid: 100 world-unit (100mm) spacing drawn bolder.
- # Grid lines are drawn in WORLD space so they keep their meaning
- # regardless of zoom. When zoomed out too far to show 10mm lines
- # clearly, only the 100mm major grid is drawn.
- MIN_PX_SPACING = 6.0 # skip a grid level if screen spacing is below this
- grid_10 = 10 # 10mm minor grid
- grid_100 = 100 # 100mm major grid
-
- # Compute screen-space spacing
- px_10 = grid_10 * self._zoom
- px_100 = grid_100 * self._zoom
-
- # Viewport bounds in world coordinates
- top_left = self._screen_to_world(QPoint(0, 0))
- bottom_right = self._screen_to_world(QPoint(self.width(), self.height()))
-
- pen_major = QPen(QColor("#45475a"), 1)
- pen_minor = QPen(QColor("#3a3a4e"), 0.5)
-
- # ── Draw 100mm major grid (always if visible) ──
- if px_100 >= MIN_PX_SPACING:
- start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_100) * grid_100
- end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_100) * grid_100
- start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_100) * grid_100
- end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_100) * grid_100
- painter.setPen(pen_major)
- wx = start_x
- while wx <= end_x:
- sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
- painter.drawLine(sx, 0, sx, self.height())
- wx += grid_100
- wy = start_y
- while wy <= end_y:
- sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
- painter.drawLine(0, sy, self.width(), sy)
- wy += grid_100
-
- # ── Draw 10mm minor grid (only when wide enough spacing) ──
- if px_10 >= MIN_PX_SPACING:
- start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_10) * grid_10
- end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_10) * grid_10
- start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_10) * grid_10
- end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_10) * grid_10
- painter.setPen(pen_minor)
- wx = start_x
- while wx <= end_x:
- sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
- painter.drawLine(sx, 0, sx, self.height())
- wx += grid_10
- wy = start_y
- while wy <= end_y:
- sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
- painter.drawLine(0, sy, self.width(), sy)
- wy += grid_10
-
- # ── Centerlines (X and Y reference axes through origin) ──
- # X centerline = horizontal (red dashed), Y centerline = vertical (green dashed).
- # Both span the full viewport so they are always visible as reference guides.
- origin_sc = self._world_to_screen(QPoint(0, 0))
- # X centerline (red) — horizontal through origin
- painter.setPen(QPen(QColor("#f38ba8"), 1.5, Qt.DashLine))
- painter.drawLine(0, origin_sc.y(), self.width(), origin_sc.y())
- # Y centerline (green) — vertical through origin
- painter.setPen(QPen(QColor("#a6e3a1"), 1.5, Qt.DashLine))
- painter.drawLine(origin_sc.x(), 0, origin_sc.x(), self.height())
- # Origin intersection point (small ring)
- painter.setPen(QPen(QColor("#cdd6f4"), 1))
- painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(origin_sc, 3, 3)
-
- # ── Source-face underlay fill (sketch-on-surface) ──
- # The underlay is now drawn as real construction-line entities
- # (rendered below in the Points / Lines sections with an orange
- # dashed style) so the user can pick them for constraints. We
- # still draw a faint fill over the *outer* loop of the projected
- # face here for visual context, but the lines themselves are NOT
- # drawn from this cache any more — that would double-paint the
- # underlay on top of the entity-based lines.
- if self._source_underlay_uv and self._underlay_visible:
- if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
- fill_poly = QPolygonF([
- self._world_to_screen(QPoint(int(round(u)), int(round(v))))
- for (u, v) in self._source_underlay_uv[0]
- ])
- painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
- painter.setPen(Qt.NoPen)
- painter.drawPolygon(fill_poly)
-
- # ── Points ──
- for entity in self._points:
- # External / underlay points are rendered in the dedicated
- # underlay block below (with an orange style) and skipped when
- # the underlay is hidden. Skip them here to avoid double draw.
- if self._is_external(entity):
- continue
- # Centerline points are rendered as part of the viewport-spanning
- # axes above; skip them here to avoid double-drawing tiny dots
- # at the endpoints far out of view.
- if self._is_centerline(entity):
- continue
- if entity.geometry:
- x, y = entity.geometry
- screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
- if entity.is_construction:
- painter.setPen(QPen(QColor("#6c7086"), 1))
- painter.setBrush(QBrush(QColor("#6c7086")))
- else:
- painter.setPen(QPen(QColor("#89b4fa"), 2))
- painter.setBrush(QBrush(QColor("#89b4fa")))
- size = 4 if entity.is_construction else 6
- painter.drawEllipse(screen_pos, size, size)
-
- # ── Underlay (face-projected) construction lines & points ──
- # Drawn with an orange dashed style so the user can visually tell
- # them apart from user-drawn construction lines (grey). Gated by
- # the underlay visibility toggle.
- if self._underlay_visible:
- for p1_ent, p2_ent in self._lines:
- line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if not self._is_external(line_ent):
- continue
- if p1_ent.geometry and p2_ent.geometry:
- x1, y1 = p1_ent.geometry
- x2, y2 = p2_ent.geometry
- sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
- sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
- painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
- painter.drawLine(sp1, sp2)
- for entity in self._points:
- if not self._is_external(entity):
- continue
- if entity.geometry:
- x, y = entity.geometry
- screen_pos = self._world_to_screen(
- QPoint(int(round(x)), int(round(y)))
- )
- painter.setPen(QPen(QColor("#fab387"), 1))
- painter.setBrush(QBrush(QColor("#fab387")))
- painter.drawEllipse(screen_pos, 4, 4)
-
- # ── Lines ──
- for p1_ent, p2_ent in self._lines:
- # External lines are drawn above; skip here to avoid double draw.
- line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
- if self._is_external(line_ent):
- continue
- # Centerlines are rendered as viewport-spanning axes above;
- # skip the finite-endpoint version here.
- if line_ent is not None and self._is_centerline(line_ent):
- continue
- if p1_ent.geometry and p2_ent.geometry:
- x1, y1 = p1_ent.geometry
- x2, y2 = p2_ent.geometry
- sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
- sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
-
- is_construction = p1_ent.is_construction or p2_ent.is_construction or (line_ent is not None and line_ent.is_construction)
- if is_construction:
- painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine))
- else:
- painter.setPen(QPen(QColor("#cdd6f4"), 2))
- painter.drawLine(sp1, sp2)
-
- # ── Constraint tags (log-driven; drawn upright in screen space) ──
- # Tags are recomputed here so paint stays in sync with the latest solve.
- self._constraint_tags = self._compute_constraint_tags()
- tag_font = QFont("Monospace", 9)
- painter.setFont(tag_font)
- for tag in self._constraint_tags:
- rect: QRect = tag["rect"]
- hovered = tag["idx"] == self._hovered_constraint_idx
- # Background pill so the label is readable over the sketch.
- painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
- painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
- painter.drawRoundedRect(rect, 6, 6)
- painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
- painter.drawText(rect, Qt.AlignCenter, tag["label"])
-
- # ── Circles ──
- for center_ent, radius in self._circles:
- if center_ent.geometry:
- cx, cy = center_ent.geometry
- sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
- sr = radius * self._zoom
- painter.setPen(QPen(QColor("#cdd6f4"), 2))
- painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(sc, int(sr), int(sr))
-
- # ── Arcs ──
- for arc_item in self._arcs:
- center_ent, radius, start_ent, end_ent, sweep = arc_item[:5]
- if not (center_ent.geometry and start_ent.geometry and end_ent.geometry):
- continue
- cx, cy = center_ent.geometry
- sx, sy = start_ent.geometry
-
- sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
- sr = int(radius * self._zoom)
-
- # Use stored sweep if available; fall back to shortest-path.
- if sweep is None:
- ex, ey = end_ent.geometry
- sa = math.atan2(sy - cy, sx - cx)
- ea = math.atan2(ey - cy, ex - cx)
- sweep = ea - sa
- while sweep > math.pi:
- sweep -= 2 * math.pi
- while sweep < -math.pi:
- sweep += 2 * math.pi
-
- start_angle = math.atan2(sy - cy, sx - cx)
-
- # QPainter: 1/16 degree, positive = CCW; 0° = 3 o'clock
- start_deg_16 = int(math.degrees(start_angle) * 16)
- span_deg_16 = int(math.degrees(sweep) * 16)
-
- rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
- painter.setPen(QPen(QColor("#cdd6f4"), 2))
- painter.setBrush(Qt.NoBrush)
- painter.drawArc(rect, start_deg_16, span_deg_16)
-
- # ── Offset preview (live preview from OffsetDialog) ──
- if self._offset_preview_active and self._offset_preview_points:
- preview = self._offset_preview_points
- painter.setPen(QPen(QColor("#f9e2af"), 2, Qt.DashLine))
- painter.setBrush(Qt.NoBrush)
- for i in range(len(preview)):
- p1 = self._world_to_screen(
- QPoint(int(round(preview[i][0])), int(round(preview[i][1])))
- )
- p2 = self._world_to_screen(
- QPoint(int(round(preview[(i + 1) % len(preview)][0])),
- int(round(preview[(i + 1) % len(preview)][1])))
- )
- painter.drawLine(p1, p2)
-
- # ── Dynamic drawing previews ──
- if self._draw_buffer and self._dynamic_line_end and self._mode == "line":
- start = self._world_to_screen(self._draw_buffer[0])
- end = self._world_to_screen(self._dynamic_line_end)
- painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
- painter.drawLine(start, end)
-
- if self._draw_buffer and self._dynamic_line_end and self._mode == "rectangle":
- p1 = self._world_to_screen(self._draw_buffer[0])
- p2 = self._world_to_screen(self._dynamic_line_end)
- painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
- painter.drawRect(min(p1.x(), p2.x()), min(p1.y(), p2.y()),
- abs(p2.x() - p1.x()), abs(p2.y() - p1.y()))
-
- if self._draw_buffer and self._dynamic_line_end and self._mode == "circle":
- center = self._world_to_screen(self._draw_buffer[0])
- end = self._world_to_screen(self._dynamic_line_end)
- r = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2)
- painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
- painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(center, int(r), int(r))
-
- if self._draw_buffer and self._dynamic_line_end and self._mode == "arc":
- if len(self._draw_buffer) == 1:
- # Click 1 done: show circle preview (center to cursor)
- center = self._world_to_screen(self._draw_buffer[0])
- end = self._world_to_screen(self._dynamic_line_end)
- r = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2)
- painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
- painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(center, int(r), int(r))
- elif len(self._draw_buffer) == 2:
- # Click 2 done: show arc from start to cursor
- cw = self._draw_buffer[0] # center world
- sw = self._draw_buffer[1] # start world
- ew = self._dynamic_line_end # end world (mouse)
-
- cx_f, cy_f = cw.x(), cw.y()
- sx_f, sy_f = sw.x(), sw.y()
-
- arc_radius = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2)
- sr = int(arc_radius * self._zoom)
-
- sc = self._world_to_screen(QPoint(int(round(cx_f)), int(round(cy_f))))
-
- # Use accumulated sweep so the arc follows the mouse
- # smoothly even past 180° (no shortest-path flip).
- start_angle = math.atan2(sy_f - cy_f, sx_f - cx_f)
- sweep = self._arc_accum_sweep
-
- start_deg_16 = int(math.degrees(start_angle) * 16)
- span_deg_16 = int(math.degrees(sweep) * 16)
-
- rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
- painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
- painter.setBrush(Qt.NoBrush)
- painter.drawArc(rect, start_deg_16, span_deg_16)
-
- # Also draw a helper line from center to cursor so the
- # user can see the sweep angle being defined
- painter.setPen(QPen(QColor("#a6e3a1"), 1, Qt.DotLine))
- painter.drawLine(sc, self._world_to_screen(ew))
-
- # ── Slot preview ──
- if self._draw_buffer and self._dynamic_line_end and self._mode == "slot":
- if len(self._draw_buffer) == 1:
- # Click 1 done: preview centerline
- c1 = self._world_to_screen(self._draw_buffer[0])
- c2 = self._world_to_screen(self._dynamic_line_end)
- painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
- painter.drawLine(c1, c2)
- elif len(self._draw_buffer) == 2:
- # Click 2 done: preview full slot outline
- c1 = self._draw_buffer[0]
- c2 = self._draw_buffer[1]
- cursor = self._dynamic_line_end
-
- dx_c = c2.x() - c1.x()
- dy_c = c2.y() - c1.y()
- L = math.sqrt(dx_c * dx_c + dy_c * dy_c)
- if L > 0:
- r = abs((cursor.x() - c1.x()) * dy_c
- - (cursor.y() - c1.y()) * dx_c) / L
- if r < 0.5:
- r = 0.5
- perp_x = -dy_c / L
- perp_y = dx_c / L
-
- def sc(pt):
- return self._world_to_screen(QPoint(int(round(pt[0])), int(round(pt[1]))))
-
- c1s = (c1.x(), c1.y())
- c2s = (c2.x(), c2.y())
- t1 = (c1.x() + r * perp_x, c1.y() + r * perp_y)
- b1 = (c1.x() - r * perp_x, c1.y() - r * perp_y)
- t2 = (c2.x() + r * perp_x, c2.y() + r * perp_y)
- b2 = (c2.x() - r * perp_x, c2.y() - r * perp_y)
-
- painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
- painter.setBrush(Qt.NoBrush)
-
- # Bottom line
- painter.drawLine(sc(b1), sc(b2))
- # Top line
- painter.drawLine(sc(t2), sc(t1))
- # Arc 1 (center C1, from t1 to b1, CCW exterior)
- sa1 = math.atan2(t1[1] - c1s[1], t1[0] - c1s[0])
- sweep1 = math.pi
- n_seg = 16
- for i in range(n_seg):
- a1 = sa1 + (i / n_seg) * sweep1
- a2 = sa1 + ((i + 1) / n_seg) * sweep1
- p1 = (c1s[0] + r * math.cos(a1), c1s[1] + r * math.sin(a1))
- p2 = (c1s[0] + r * math.cos(a2), c1s[1] + r * math.sin(a2))
- painter.drawLine(sc(p1), sc(p2))
- # Arc 2 (center C2, from b2 to t2)
- sa2 = math.atan2(b2[1] - c2s[1], b2[0] - c2s[0])
- sweep2 = math.pi
- for i in range(n_seg):
- a1 = sa2 + (i / n_seg) * sweep2
- a2 = sa2 + ((i + 1) / n_seg) * sweep2
- p1 = (c2s[0] + r * math.cos(a1), c2s[1] + r * math.sin(a1))
- p2 = (c2s[0] + r * math.cos(a2), c2s[1] + r * math.sin(a2))
- painter.drawLine(sc(p1), sc(p2))
-
- # ── Hovered point highlight ──
- if self._hovered_point:
- screen_pos = self._world_to_screen(self._hovered_point)
- painter.setPen(QPen(QColor("#f9e2af"), 2))
- painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(screen_pos, 10, 10)
-
- # ── Hovered line distance measurement ──
- if self._hovered_line and not self._hovered_point:
- p1, p2 = self._hovered_line
- sp1 = self._world_to_screen(p1)
- sp2 = self._world_to_screen(p2)
- painter.setPen(QPen(QColor("#a6e3a1"), 2))
- painter.drawLine(sp1, sp2)
- self._draw_distance_measurement(painter, p1, p2)
-
- # ── Moved-element highlight ──
- if self._move_active and self._moving_points:
- painter.setPen(QPen(QColor("#f38ba8"), 2))
- painter.setBrush(Qt.NoBrush)
- for ent in self._moving_points:
- if ent.geometry:
- x, y = ent.geometry
- sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
- painter.drawEllipse(sp, 10, 10)
- if self._move_anchor is not None and self._move_anchor.geometry:
- x, y = self._move_anchor.geometry
- sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
- painter.setPen(QPen(QColor("#f9e2af"), 2))
- painter.drawEllipse(sp, 12, 12)
-
- # ── Selected face preview (detected regions) ──
- if self._sketch:
- faces = self._sketch.detect_faces()
- for face in faces:
- is_hovered = self._faces_match(face, self._hovered_face)
- is_selected = self._faces_match(face, self._selected_face)
- if not (is_hovered or is_selected):
- continue
- path = QPainterPath()
- outer = face["outer"]
- if outer["type"] == "polygon":
- pts = outer["points"]
- sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1]))))
- path.moveTo(sp0.x(), sp0.y())
- for (px, py) in pts[1:]:
- sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
- path.lineTo(sp.x(), sp.y())
- path.closeSubpath()
- else: # circle
- cx, cy = outer["center"]
- spc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
- sr = outer["radius"] * self._zoom
- path.addEllipse(spc, sr, sr)
- # Add holes as sub-paths (odd-even fill punches them out).
- for hole in face["holes"]:
- if hole["type"] == "polygon":
- pts = hole["points"]
- sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1]))))
- path.moveTo(sp0.x(), sp0.y())
- for (px, py) in pts[1:]:
- sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
- path.lineTo(sp.x(), sp.y())
- path.closeSubpath()
- else:
- hcx, hcy = hole["center"]
- hspc = self._world_to_screen(QPoint(int(round(hcx)), int(round(hcy))))
- hsr = hole["radius"] * self._zoom
- path.addEllipse(hspc, hsr, hsr)
- path.setFillRule(Qt.OddEvenFill)
-
- # Determine colours
- if is_selected and not is_hovered:
- fill = QColor(137, 180, 250, 100) # blue-ish
- stroke = QPen(QColor("#89b4fa"), 2)
- elif is_hovered:
- fill = QColor(249, 226, 175, 120) # gold-ish
- stroke = QPen(QColor("#f9e2af"), 2)
- else:
- fill = QColor(166, 227, 161, 80) # green-ish
- stroke = QPen(QColor("#a6e3a1"), 1)
- painter.setPen(stroke)
- painter.setBrush(fill)
- painter.drawPath(path)
-
- # ── Selected entities ──
- for entity in self._selected_entities:
- if entity.geometry:
- x, y = entity.geometry
- screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
- painter.setPen(QPen(QColor("#f9e2af"), 2))
- painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(screen_pos, 12, 12)
-
- # ── DOF display ──
- if self._sketch:
- try:
- dof = self._sketch.get_solver_dof()
- painter.save()
- painter.setPen(QPen(QColor("#a6adc8"), 1))
- font = QFont("Monospace", 9)
- painter.setFont(font)
- painter.drawText(10, 20, f"DOF: {dof}")
- painter.restore()
- except Exception:
- pass
-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()
-
- # Emitted when the user picks an entity for a connector point (assembly).
- # Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
- connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
- # Emitted when connector pick mode is cancelled.
- connectorPickCancelled = Signal()
- # Emitted on mouse move in connector mode to show snap preview.
- # Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
- connectorHover = Signal(object)
-
- # Emitted when a body is clicked in assembly move mode.
- # Payload: owner_obj_id.
- assemblyComponentActivated = Signal(str)
- # Emitted during a drag move: owner_obj_id, world dx, dy, dz.
- assemblyComponentDragged = Signal(str, float, float, float)
- # Emitted when a drag move finishes.
- assemblyMoveFinished = Signal(str)
-
- 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)
- # Enable mouse tracking so ``mouseMoveEvent`` fires even without a
- # button held — required for the connector-pick hover gizmo (and any
- # status-bar hover feedback) to show under the cursor as the user
- # moves the mouse over candidate snap entities before clicking.
- self.setMouseTracking(True)
- # 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
- # When True, a left-click picks an entity for a connector point
- # (assembly component connection).
- self._connector_pick_mode: bool = False
- # Current snap highlight object id (for hover during connector mode).
- self._connector_snap_id: Optional[str] = None
- # When True, left-click on a body activates assembly drag-to-move.
- self._assembly_move_mode: bool = False
- # State for ongoing assembly drag.
- self._move_drag_active: bool = False
- self._move_owner_obj_id: str = ""
- self._move_click_3d: Optional[Tuple[float, float, float]] = None
- self._move_click_screen: Optional[Any] = None
- self._move_plane_normal: Optional[Tuple[float, float, float]] = None
- self._move_initial_position: Optional[Tuple[float, float, float]] = None
- # 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()
-
- # ─── Workplane visualization ───────────────────────────────────────────
-
- def show_workplane(
- self,
- origin: Tuple[float, float, float] = (0, 0, 0),
- normal: Tuple[float, float, float] = (0, 0, 1),
- x_dir: Tuple[float, float, float] = (1, 0, 0),
- size: float = 200.0,
- name: Optional[str] = None,
- ) -> Optional[str]:
- """Display a semi-transparent workplane plane in the 3D view.
-
- Returns the object ID (for later removal) or None if the renderer
- doesn't support workplane planes.
- """
- self._ensure_initialized()
- fn = getattr(self._renderer, "show_workplane_plane", None)
- if fn is None:
- return None
- oid = fn(origin, normal, x_dir, size, name)
- self._renderer.render()
- return oid
-
- def remove_workplane(self, obj_id: str) -> bool:
- """Remove a workplane plane visual by its ID."""
- self._ensure_initialized()
- fn = getattr(self._renderer, "remove_workplane_plane", None)
- if fn is None:
- return False
- ok = fn(obj_id)
- if ok:
- self._renderer.render()
- return ok
-
- 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
- # Connector pick mode: a left-click selects a face for a connection point.
- if self._connector_pick_mode and event.button() == Qt.LeftButton:
- self._handle_connector_pick(event)
- return
- # Assembly move mode: start dragging the clicked body.
- if self._assembly_move_mode and event.button() == Qt.LeftButton:
- self._handle_assembly_move_press(event)
- return
- self._renderer.handle_mouse_press(event)
- super().mousePressEvent(event)
-
- def mouseMoveEvent(self, event):
- self._ensure_initialized()
- # In connector mode, show snap hover.
- if self._connector_pick_mode:
- self._handle_connector_hover(event)
- super().mouseMoveEvent(event)
- return
- # In face-pick mode, keep dynamic highlighting.
- if self._pick_face_mode:
- if hasattr(self._renderer, "handle_mouse_move"):
- self._renderer.handle_mouse_move(event)
- super().mouseMoveEvent(event)
- return
- # Active drag in assembly move mode.
- if self._move_drag_active:
- self._handle_assembly_move_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()
- # Finish assembly drag.
- if self._move_drag_active:
- self._handle_assembly_move_release(event)
- return
- 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()
-
- # ─── Connector pick mode (assembly) ────────────────────────────────────
-
- def set_connector_pick_mode(self, enabled: bool) -> None:
- """Toggle connector pick mode for placing connection points.
-
- When enabled, clicking an entity (face, edge, vertex, hole)
- on a body in the assembly view captures its position and
- direction as a connection point for the SolveSpace solver.
- """
- self._connector_pick_mode = bool(enabled)
- if enabled:
- self.setCursor(Qt.CrossCursor)
- elif not self._pick_face_mode:
- self.unsetCursor()
- if not enabled:
- self._clear_connector_snap()
-
- def is_connector_pick_mode(self) -> bool:
- return self._connector_pick_mode
-
- def _clear_connector_snap(self) -> None:
- """Remove the hover gizmo."""
- fn = getattr(self._renderer, "clear_entity_gizmo", None)
- if fn is not None:
- fn()
- # Backwards compat: also try the old method.
- if self._connector_snap_id is not None:
- fn2 = getattr(self._renderer, "remove_highlight_snap", None)
- if fn2 is not None:
- fn2(self._connector_snap_id)
- self._connector_snap_id = None
-
- def _handle_connector_hover(self, event) -> None:
- """Update the hover snap gizmo during connector pick mode.
-
- Probes a small neighbourhood around the cursor for ALL nearby snap
- candidates (vertices, edge midpoints, face centres, hole openings)
- and renders a dim marker on each plus a bright primary on the nearest
- one — the general snap indicator. Clicking then selects the
- primary's position.
- """
- self._ensure_initialized()
- probe = getattr(self._renderer, "probe_snap_candidates", None)
- pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
-
- if probe is not None:
- candidates = probe(pos.x(), pos.y())
- if not candidates:
- self._clear_connector_snap()
- self.connectorHover.emit(None)
- return
- # Primary = the nearest candidate (probe sorts nearest-first).
- info = candidates[0]
- else:
- # Fall back to single-pixel pick on renderers without the probe.
- picker = getattr(self._renderer, "pick_entity", None)
- if picker is None:
- return
- info = picker(pos.x(), pos.y())
- candidates = [info] if info else []
- if info is None or info.get("owner_obj_id") is None:
- self._clear_connector_snap()
- self.connectorHover.emit(None)
- return
-
- origin = info["position"]
- normal = info.get("normal")
- entity_type = info["type"]
- owner = info.get("owner_obj_id", "")
-
- # Show smart entity gizmo — dim candidate markers + bright primary.
- self._clear_connector_snap()
- gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
- if gizmo_fn is not None:
- gizmo_fn(
- entity_type=entity_type,
- position=origin,
- normal=normal,
- x_dir=info.get("x_dir"),
- radius=info.get("radius"),
- candidates=candidates,
- )
- else:
- # Fallback to old highlight_snap.
- fn = getattr(self._renderer, "highlight_snap", None)
- if fn is not None:
- colors = {
- "planar_face": (0.0, 0.8, 1.0), # cyan
- "cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
- "edge": (0.0, 1.0, 0.4), # green
- "vertex": (1.0, 1.0, 0.0), # yellow
- }
- c = colors.get(entity_type, (1.0, 0.6, 0.0))
- self._connector_snap_id = fn(origin, color=c, size=3.0)
-
- self.connectorHover.emit({
- "origin": origin,
- "normal": normal,
- "type": entity_type,
- "owner_obj_id": owner,
- })
-
- def _handle_connector_pick(self, event) -> None:
- """Detect an entity under the click and emit connectorPicked.
-
- Uses the multi-pixel ``probe_snap_candidates`` so a click selects the
- PRIMARY (nearest) snap target — the same one the hover gizmo
- emphasised. Falls back to single-pixel ``pick_entity`` then to
- ``pick_planar_face`` on renderers without the probe.
- """
- self._ensure_initialized()
- pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
- info: Optional[Dict[str, Any]] = None
-
- probe = getattr(self._renderer, "probe_snap_candidates", None)
- if probe is not None:
- candidates = probe(pos.x(), pos.y())
- if candidates:
- info = candidates[0] # nearest = primary
-
- if info is None:
- picker = getattr(self._renderer, "pick_entity", None)
- if picker is None:
- # Fallback to planar face only.
- picker = getattr(self._renderer, "pick_planar_face", None)
- if picker is None:
- logger.warning("Renderer has no entity picking support")
- return
- pinfo = picker(pos.x(), pos.y())
- if pinfo is None:
- logger.info("Connector pick: no planar face under cursor")
- return
- owner_obj_id = pinfo.get("owner_obj_id", "")
- self.connectorPicked.emit(
- tuple(pinfo["origin"]),
- tuple(pinfo["normal"]),
- tuple(pinfo["x_dir"]),
- "planar_face",
- pinfo["face"],
- owner_obj_id,
- )
- return
- info = picker(pos.x(), pos.y())
-
- if info is None:
- logger.info("Connector pick: no entity under cursor")
- return
- owner_obj_id = info.get("owner_obj_id", "")
- if not owner_obj_id:
- return
-
- entity_type = info["type"]
- origin = info["position"]
- normal = info.get("normal") or (0.0, 0.0, 1.0)
- x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
-
- # For vertices, pick a sensible normal from the parent face if possible.
- if entity_type == "vertex" and normal is None:
- normal = (0.0, 0.0, 1.0)
-
- # Package the raw shape appropriately.
- raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
-
- self.connectorPicked.emit(
- tuple(origin),
- tuple(normal),
- tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
- entity_type,
- raw_shape,
- owner_obj_id,
- )
-
- # ─── Assembly move mode (3D drag) ─────────────────────────────────────
-
- def set_assembly_move_mode(self, enabled: bool) -> None:
- """Toggle assembly move mode.
-
- When enabled, clicking on a body and dragging moves its
- assembly component in the view plane. Shift+drag moves in Z.
- """
- self._assembly_move_mode = bool(enabled)
- if enabled:
- self.setCursor(Qt.SizeAllCursor)
- elif not self._pick_face_mode and not self._connector_pick_mode:
- self.unsetCursor()
- if not enabled:
- self._move_drag_active = False
- self._move_owner_obj_id = ""
- self._move_click_3d = None
- self._move_click_screen = None
- self._move_plane_normal = None
- self._move_initial_position = None
-
- def _handle_assembly_move_press(self, event) -> None:
- """Start a drag-to-move for the body under the cursor."""
- self._ensure_initialized()
- picker = getattr(self._renderer, "pick_planar_face", None)
- if picker is None:
- return
- pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
- info = picker(pos.x(), pos.y())
- if info is None:
- return
-
- owner_obj_id = info.get("owner_obj_id", "")
- if not owner_obj_id or not owner_obj_id.startswith("asm_"):
- return
-
- # Store drag state.
- self._move_drag_active = True
- self._move_owner_obj_id = owner_obj_id
- self._move_click_3d = tuple(info["origin"])
- self._move_click_screen = pos
- self._move_plane_normal = tuple(info["normal"])
-
- # Emit activation signal so MainWindow stores initial position.
- self.assemblyComponentActivated.emit(owner_obj_id)
-
- def _handle_assembly_move_move(self, event) -> None:
- """Continue the drag: project mouse delta to world-space and emit."""
- if not self._move_drag_active or self._move_click_screen is None:
- return
-
- pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
-
- # Screen delta (Qt Y is inverted vs OCC).
- dx = pos.x() - self._move_click_screen.x()
- dy = -(pos.y() - self._move_click_screen.y()) # invert Y
-
- # Convert screen delta to world units using the view scale.
- # view.Scale() returns a scale factor — the smaller the value the
- # more world distance per pixel. We use an empirical conversion:
- # at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
- scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
- world_per_pixel = 2.0 / max(scale, 0.001)
-
- # Get camera vectors for proper view-plane projection.
- import numpy as np
- from OCP.V3d import V3d_TypeOfOrientation
- try:
- # Get camera direction and up from the OCC view.
- camera = self._renderer._view.Camera()
- dir_ = camera.Direction()
- up_ = camera.Up()
- cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
- cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
- cam_right = np.cross(cam_dir, cam_up)
- cam_right = cam_right / np.linalg.norm(cam_right)
- cam_up = cam_up / np.linalg.norm(cam_up)
- except Exception:
- # Fallback: assume XY plane.
- cam_right = np.array([1.0, 0.0, 0.0])
- cam_up = np.array([0.0, 0.0, 1.0])
-
- # Compute world-space delta.
- modifiers = event.modifiers()
- if modifiers & Qt.ShiftModifier:
- # Shift+drag: move along camera direction (Z-depth).
- dz_world = dx * world_per_pixel
- dx_world = 0.0
- dy_world = 0.0
- else:
- # Normal drag: move in view plane.
- dx_world = float(cam_right[0] * dx * world_per_pixel +
- cam_up[0] * dy * world_per_pixel)
- dy_world = float(cam_right[1] * dx * world_per_pixel +
- cam_up[1] * dy * world_per_pixel)
- dz_world = float(cam_right[2] * dx * world_per_pixel +
- cam_up[2] * dy * world_per_pixel)
-
- self.assemblyComponentDragged.emit(
- self._move_owner_obj_id, dx_world, dy_world, dz_world
- )
-
- def _handle_assembly_move_release(self, event) -> None:
- """Finish the drag, emit final position."""
- self.assemblyMoveFinished.emit(self._move_owner_obj_id)
- self._move_drag_active = False
- self._move_owner_obj_id = ""
- self._move_click_3d = None
- self._move_click_screen = None
- self._move_plane_normal = None
- self._move_initial_position = None
-
- 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
- # Esc cancels connector pick mode.
- if self._connector_pick_mode and event.key() == Qt.Key_Escape:
- self.set_connector_pick_mode(False)
- self.connectorPickCancelled.emit()
- return
- # Esc cancels assembly move mode.
- if self._assembly_move_mode and event.key() == Qt.Key_Escape:
- self.set_assembly_move_mode(False)
- 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
-
- # Assembly state
- self._assembly_component_buttons: List[QPushButton] = []
- self._assembly_component_group: Optional[QButtonGroup] = None
- self._assembly_view_active: bool = False
- self._selected_assembly_component_id: Optional[str] = None
-
- # Connector two-click state
- self._connector_first_pick: Optional[Dict[str, Any]] = None
- self._connector_second_ac_id: Optional[str] = None
- self._connector_align_pos: Any = None
-
- # Drag-move state for assembly components
- self._asm_move_ac_id: Optional[str] = None
- self._asm_move_start_pos: Any = None
- # Rigid-group drag state: maps every component id in the dragged
- # rigid group to its start position, so the whole group translates
- # together and connected partners keep their solved relative
- # transforms. Keyed by AssemblyComponent.id.
- self._asm_move_group_start: Dict[str, Any] = {}
- # Cached rigid-group membership for the current drag (avoids recomputing
- # the BFS graph on every mouse-move event).
- self._asm_move_group_ids: List[str] = []
- # Cache of render object IDs per assembly component, so drag updates
- # can replace only the moved component's shapes without clearing the
- # entire scene (avoids camera flicker).
- self._asm_render_objects: Dict[str, List[str]] = {}
-
- self._setup_ui()
- self._setup_connections()
- self._create_initial_component()
- self._create_initial_assembly()
- 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()
- self._setup_ui_aliases()
-
- logger.info("Ready")
-
- def _create_menus(self):
- # Remove UI-built menu actions to avoid duplication
- menubar = self.menuBar()
- for action in menubar.actions():
- menubar.removeAction(action)
-
- 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):
- """Load the compiled UI file and add programmatic custom widgets."""
- self._ui = Ui_fluencyCAD()
- self._ui.setupUi(self)
-
- # Keep a reference to the grid for panel-focus management.
- self._grid = self._ui.gridLayout
-
- # -- Add programmatic custom widgets to their placeholder locations --
-
- # Sketch2DWidget goes in the sketch tab’s QVBoxLayout.
- self._sketch_widget = Sketch2DWidget()
- self._ui.sketch_tab.layout().addWidget(self._sketch_widget)
-
- # Viewer3DWidget goes in the gl_box QHBoxLayout.
- self._viewer_3d = Viewer3DWidget()
- self._ui.gl_box.layout().addWidget(self._viewer_3d)
-
- # Code editor — use the UI’s textEdit with our custom font.
- self._code_edit = self._ui.textEdit
- self._code_edit.setFont(QFont("Monaco", 10))
- self._code_edit.setPlaceholderText("# Enter Python code here...")
-
- # Component buttons (dynamically generated per component, not in UI).
- 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)
- # Add to the Components group box from the UI.
- compo_layout = self._ui.compo_box.layout()
- if compo_layout is None:
- compo_layout = QHBoxLayout(self._ui.compo_box)
- compo_layout.setContentsMargins(0, 0, 0, 0)
- compo_layout.addWidget(self._component_box)
- compo_layout.addStretch()
-
- # ── Assembly box (dynamic buttons like component box) ──
- self._assembly_box = QWidget()
- self._assembly_box_layout = QHBoxLayout(self._assembly_box)
- self._assembly_box_layout.setAlignment(Qt.AlignLeft)
- self._assembly_component_group = QButtonGroup(self)
- self._assembly_component_group.setExclusive(True)
- # Add to the Assembly group box from the UI.
- asm_layout = self._ui.assembly_box.layout()
- if asm_layout is None:
- asm_layout = QHBoxLayout(self._ui.assembly_box)
- asm_layout.setContentsMargins(0, 0, 0, 0)
- asm_layout.addWidget(self._assembly_box)
- asm_layout.addStretch()
-
- # ── Assembly Move button (programmatic, in assembly_tools) ──
- self._btn_asm_move = QPushButton("Pos")
- self._btn_asm_move.setCheckable(True)
- self._btn_asm_move.setMinimumSize(QSize(50, 50))
- self._btn_asm_move.setMaximumSize(QSize(50, 50))
- self._btn_asm_move.setToolTip("Toggle: click a body in 3D and drag to move the assembly component")
- asm_tools_layout = self._ui.assembly_tools.layout()
- if asm_tools_layout is not None:
- asm_tools_layout.addWidget(self._btn_asm_move, 0, 2, 1, 1)
-
- # Panel-focus mode (equal | sketch | viewer).
- self._panel_focus: str = "equal"
-
- def _setup_ui_aliases(self):
- """Create _btn_* aliases pointing to the UI-loaded widgets.
-
- The rest of the application references widgets via ``self._btn_*``
- names. This method maps those to the ``pb_*`` / ``pushButton_*``
- names created by the compiled UI file so existing signal connections
- and mode-switching code continues to work unchanged.
- """
- ui = self._ui
- # ── Workplanes ──
- self._btn_wp_origin = ui.pb_origin_wp
- self._btn_wp_face = ui.pb_origin_face
- self._btn_wp_flip = ui.pb_flip_face
- self._btn_wp_new = ui.pb_wp_new
- self._btn_underlay = ui.pb_underlay
- self._btn_clr_face = ui.pb_clr_face
- self._btn_to_sketch = ui.pb_to_sketch
- # ── Drawing ──
- self._btn_line = ui.pb_linetool
- self._btn_rect = ui.pb_rectool
- self._btn_circle = ui.pb_circtool
- self._btn_slot = ui.pb_slotool
- self._btn_arc = ui.pb_arc_tool
- self._btn_construct = ui.pb_enable_construct
- self._btn_snap = ui.pb_enable_snap
- self._btn_offset = ui.pb_offset_tool
- # ── Constrain ──
- self._btn_con_ptpt = ui.pb_con_ptpt
- self._btn_con_ptline = ui.pb_con_line
- self._btn_con_mid = ui.pb_con_mid
- self._btn_con_perp = ui.pb_con_perp
- self._btn_con_horiz = ui.pb_con_horiz
- self._btn_con_vert = ui.pb_con_vert
- self._btn_con_dist = ui.pb_con_dist
- self._btn_con_sym = ui.pb_con_sym
- # ── Snaps ──
- self._btn_snap_point = ui.pushButton_8
- self._btn_snap_mid = ui.pb_snap_midp
- self._btn_snap_horiz = ui.pb_snap_horiz
- self._btn_snap_vert = ui.pb_snap_vert
- self._btn_snap_angle = ui.pb_snap_angle
- self._btn_snap_grid = ui.pushButton_7
- self._spin_snap_dist = ui.spinbox_snap_distance
- self._spin_angle = ui.spinbox_angle_steps
- # ── Modify ──
- self._btn_extrude = ui.pb_extrdop
- self._btn_cut = ui.pb_cutop
- self._btn_combine = ui.pb_combop
- self._btn_move = ui.pb_moveop
- self._btn_revolve = ui.pb_revop
- self._btn_array = ui.pb_arrayop
- # ── Export ──
- self._btn_export_stl = ui.pushButton_2
- self._btn_export_step = ui.pb_export_step
- self._btn_export_iges = ui.pb_export_iges
- # ── Sketch list tools ──
- self._btn_add_sketch = ui.pb_nw_sktch
- self._btn_edit_sketch = ui.pb_edt_sktch
- self._btn_del_sketch = ui.pb_del_sketch
- # ── Body tools ──
- self._btn_update_body = ui.pb_update_body
- self._btn_edit_sketch_3 = ui.pb_edt_sktch_3
- self._btn_del_body = ui.pb_del_body
- # ── Component tools ──
- self._btn_new_compo = ui.pb_new_compo
- self._btn_del_compo = ui.pb_del_compo
- # ── Assembly / Connector ──
- self._btn_compo_to_assembly = ui.pb_compo_to_assembly
- self._btn_remove_compo_from_assembly = ui.pb_remove_compo_from_assembly
- self._btn_add_connector = ui.pb_add_connector
- self._btn_add_connector.setCheckable(True)
- self._btn_del_connector = ui.pb_remove_connector
- # ── Code tab ──
- self._btn_apply_code = ui.pb_apply_code
- self._btn_load_code = ui.pushButton_5
- self._btn_save_code = ui.pushButton_4
- self._btn_del_code = ui.pushButton
- # ── List views & tabs ──
- self._sketch_list = ui.sketch_list
- self._body_list = ui.body_list
- self._input_tabs = ui.InputTab
-
- 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_arc.clicked.connect(lambda: self._set_sketch_mode("arc"))
- self._btn_slot.clicked.connect(lambda: self._set_sketch_mode("slot"))
- 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_compo_to_assembly.clicked.connect(self._add_component_to_assembly)
- self._btn_remove_compo_from_assembly.clicked.connect(self._remove_component_from_assembly)
- self._btn_asm_move.toggled.connect(self._on_assembly_move_toggled)
- self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
- self._btn_del_connector.clicked.connect(self._on_delete_connector)
- self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
- self._viewer_3d.connectorHover.connect(self._on_connector_hover)
- self._viewer_3d.connectorPickCancelled.connect(
- lambda: self._btn_add_connector.setChecked(False)
- )
- self._viewer_3d.assemblyComponentActivated.connect(
- self._on_assembly_move_activated
- )
- self._viewer_3d.assemblyComponentDragged.connect(
- self._on_assembly_move_dragged
- )
- self._viewer_3d.assemblyMoveFinished.connect(
- self._on_assembly_move_finished
- )
-
- 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_new.clicked.connect(self._new_workplane)
- self._btn_wp_flip.clicked.connect(self._flip_workplane)
- # Underlay show/hide, ClrFace, and ToSketch — all stay in sync
- # with the source face state managed by set_source_face /
- # clear_source_face / _project_body_to_active_wp.
- self._btn_underlay.toggled.connect(self._on_underlay_toggled)
- self._btn_clr_face.clicked.connect(self._on_clear_source_face)
- self._btn_to_sketch.clicked.connect(self._on_convert_underlay_to_sketch)
-
- # Generic buttons
- self._btn_move.clicked.connect(self._translate_body)
- self._btn_array.clicked.connect(self._pattern_array)
- self._btn_offset.clicked.connect(self._offset_sketch)
- 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 _create_initial_assembly(self):
- """Create the initial assembly in the project."""
- self._project.add_assembly()
- logger.info("Created initial assembly")
-
- 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_arc,
- self._btn_slot,
- 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", "arc", "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 == "arc":
- self._btn_arc.setChecked(True)
- elif mode == "slot":
- self._btn_slot.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_arc,
- self._btn_slot,
- 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._assembly_view_active = False
- 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}")
-
- # Re-add workplane visuals after the clear.
- for wp_id, wp in self._current_component.workplanes.items():
- if wp.visible:
- wp.render_object = self._viewer_3d.show_workplane(
- origin=wp.origin,
- normal=wp.normal,
- x_dir=wp.x_dir,
- size=250.0,
- name=f"workplane_{wp.id}",
- )
-
- self._viewer_3d.fit_camera()
-
- # ────────────────────────────────────────────────────────────────────
- # Assembly methods
- # ────────────────────────────────────────────────────────────────────
-
- def _get_assembly(self) -> Optional[Any]:
- """Get the active assembly from the project."""
- assembly = self._project.get_active_assembly()
- if assembly is None:
- assembly = self._project.add_assembly()
- return assembly
-
- def _add_component_to_assembly(self):
- """Add the currently selected component to the assembly.
-
- Creates a new button in the assembly box and stores the
- component instance in the assembly model.
- """
- if self._current_component is None:
- logger.warning("No active component to add to assembly")
- return
-
- assembly = self._get_assembly()
-
- # Create an instance of the current component in the assembly.
- ac = assembly.add_component_instance(
- component_id=self._current_component.id,
- name=f"{self._current_component.name}",
- )
- logger.info(
- f"Added component '{self._current_component.name}' "
- f"to assembly '{assembly.name}' (instance={ac.id})"
- )
-
- # Create a button for this assembly component.
- instance_num = len(assembly.components)
- label = f"{instance_num}"
- btn = QPushButton(label)
- btn.setCheckable(True)
- btn.setFixedSize(QSize(40, 40))
- btn.setToolTip(f"{ac.name} (instance {list(assembly.components.keys()).index(ac.id) + 1})")
- # Store the assembly component id in the button.
- btn._assembly_component_id = ac.id
- btn.clicked.connect(self._on_assembly_component_clicked)
-
- # Uncheck all other assembly buttons, check this one.
- for b in self._assembly_component_buttons:
- b.setChecked(False)
- btn.setChecked(True)
-
- self._assembly_component_buttons.append(btn)
- self._assembly_component_group.addButton(btn)
- self._assembly_box_layout.addWidget(btn)
-
- # Store the selected id and activate assembly view.
- self._selected_assembly_component_id = ac.id
- self._assembly_view_active = True
-
- # Show the assembly in the viewer, framing all components.
- self._show_assembly_in_viewer(fit=True)
-
- def _remove_component_from_assembly(self):
- """Remove the currently selected assembly component."""
- assembly = self._get_assembly()
- if not assembly or not assembly.components:
- logger.warning("Assembly is empty, nothing to remove")
- return
-
- # Find the active assembly component id from the checked button.
- active_id = self._get_active_assembly_component_id()
- if active_id is None:
- logger.warning("No assembly component selected to remove")
- return
-
- # Find the button index for this assembly component.
- idx = -1
- for i, btn in enumerate(self._assembly_component_buttons):
- if getattr(btn, '_assembly_component_id', None) == active_id:
- idx = i
- break
-
- if idx >= 0:
- assembly.remove_component_instance(active_id)
-
- btn = self._assembly_component_buttons.pop(idx)
- self._assembly_component_group.removeButton(btn)
- btn.deleteLater()
-
- # Select the first remaining button if any.
- if self._assembly_component_buttons:
- self._assembly_component_buttons[0].setChecked(True)
- first_id = getattr(self._assembly_component_buttons[0], '_assembly_component_id', None)
- self._selected_assembly_component_id = first_id
- self._assembly_view_active = True
- else:
- self._selected_assembly_component_id = None
- self._assembly_view_active = False
- # Fall back to normal component view.
- self._redraw_bodies()
- return
-
- logger.info(f"Removed assembly component instance {active_id}")
- self._show_assembly_in_viewer(fit=True)
-
- def _get_active_assembly_component_id(self) -> Optional[str]:
- """Get the assembly component id of the currently checked button."""
- for btn in self._assembly_component_buttons:
- if btn.isChecked():
- return getattr(btn, '_assembly_component_id', None)
- return None
-
- def _on_assembly_component_clicked(self):
- """Handle an assembly component button click.
-
- Shows all components from the assembly in the 3D viewer,
- with the clicked component highlighted/selected.
- """
- # Find which assembly component id was clicked.
- active_id = self._get_active_assembly_component_id()
- if active_id is None:
- return
-
- self._selected_assembly_component_id = active_id
- self._assembly_view_active = True
-
- self._show_assembly_in_viewer(fit=True)
-
- def _apply_transform(self, shape: Any, position, rotation) -> Any:
- """Apply a position translation and rotation matrix to a shape.
-
- Returns a new transformed TopoDS_Shape. If position is zero and
- rotation is identity the original shape is returned unchanged.
- """
- import numpy as np
- from OCP.gp import gp_Trsf
- from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
-
- pos = np.asarray(position, dtype=float)
- rot = np.asarray(rotation, dtype=float)
-
- # Skip if identity.
- if np.allclose(pos, 0.0) and np.allclose(rot, np.eye(3)):
- return shape
-
- # Use SetValues for a combined rotation + translation transform.
- # gp_Trsf.SetValues takes 12 values forming a 3x4 matrix:
- # [R11 R12 R13 Tx]
- # [R21 R22 R23 Ty]
- # [R31 R32 R33 Tz]
- trsf = gp_Trsf()
- trsf.SetValues(
- float(rot[0, 0]), float(rot[0, 1]), float(rot[0, 2]), float(pos[0]),
- float(rot[1, 0]), float(rot[1, 1]), float(rot[1, 2]), float(pos[1]),
- float(rot[2, 0]), float(rot[2, 1]), float(rot[2, 2]), float(pos[2]),
- )
-
- transformer = BRepBuilderAPI_Transform(shape, trsf, False)
- transformer.Build()
- return transformer.Shape()
-
- def _make_connector_marker(self, position: Tuple[float, float, float],
- color: Tuple[float, float, float] = (1.0, 0.3, 0.0)) -> Optional[Any]:
- """Create a small sphere marker for a connector at *position*.
-
- Returns the TopoDS_Shape of a tiny sphere, or None on failure.
- """
- try:
- from OCP.gp import gp_Pnt
- from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
- sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), 2.0).Shape()
- return sphere
- except Exception as exc:
- logger.debug(f"Failed to create connector marker: {exc}")
- return None
-
- def _show_assembly_in_viewer(self, fit: bool = False):
- """Show all components from the assembly in the 3D viewer.
-
- All bodies from all assembly component instances are displayed
- together with their position/rotation transforms applied.
- The component whose button is checked gets a highlight color;
- the rest are shown in a neutral/dimmed color.
- Connector markers (small orange spheres) are also shown.
-
- Pass *fit=True* to also frame all visible components with the
- camera (use when switching to assembly view via button clicks;
- omit during drag to avoid camera flicker).
- """
- assembly = self._get_assembly()
- if not assembly or not assembly.components:
- self._viewer_3d.clear_scene()
- return
-
- self._viewer_3d.clear_scene()
- # Reset the render-object cache; it will be rebuilt below.
- self._asm_render_objects.clear()
-
- highlight_color = (0.2, 0.6, 1.0) # Bright blue for selected
- dim_color = (0.5, 0.5, 0.5) # Grey for non-selected
-
- shown_any = False
- for ac_id, ac in assembly.components.items():
- comp = self._project.get_component_by_id(ac.component_id)
- if comp is None:
- logger.debug(f"Assembly component {ac_id} references missing component {ac.component_id}")
- continue
-
- is_selected = (ac_id == self._selected_assembly_component_id)
- color = highlight_color if is_selected else dim_color
-
- render_ids: List[str] = []
- for body_id, body in comp.bodies.items():
- if body.geometry:
- try:
- shape = self._kernel._get_shape(body.geometry)
- # Apply component instance transform.
- transformed = self._apply_transform(
- shape, ac.position, ac.rotation
- )
- obj_id = f"asm_{ac_id}_{body_id}"
- render_obj = self._viewer_3d.show_shape(
- transformed,
- color=color,
- name=obj_id,
- )
- render_ids.append(obj_id)
- shown_any = True
- except Exception as exc:
- logger.debug(f"Failed to show body {body_id} in assembly: {exc}")
-
- self._asm_render_objects[ac_id] = render_ids
-
- # Show connector markers for this instance.
- for conn_id, conn in ac.connectors.items():
- try:
- sphere_shape = self._make_connector_marker(conn.position)
- if sphere_shape is not None:
- self._viewer_3d.show_shape(
- sphere_shape,
- color=(1.0, 0.3, 0.0), # Orange
- name=f"conn_{ac_id}_{conn_id}",
- )
- except Exception as exc:
- logger.debug(f"Failed to show connector {conn_id}: {exc}")
-
- if shown_any and fit:
- self._viewer_3d.fit_camera()
-
- def _update_assembly_component_in_viewer(self, ac_id: str):
- """Replace only the shapes of a single assembly component in-place.
-
- Removes the existing render objects for *ac_id* from the viewer
- and recreates them at the component's current position/rotation.
- Other components and connector markers are left untouched — no
- scene clear, so the camera stays perfectly still.
- """
- assembly = self._get_assembly()
- ac = assembly.components.get(ac_id) if assembly else None
- if ac is None:
- return
-
- comp = self._project.get_component_by_id(ac.component_id)
- if comp is None:
- return
-
- # Remove old render objects for this component.
- old_ids = self._asm_render_objects.pop(ac_id, [])
- for oid in old_ids:
- try:
- self._viewer_3d.remove_mesh(oid)
- except Exception:
- pass
-
- is_selected = (ac_id == self._selected_assembly_component_id)
- color = (0.2, 0.6, 1.0) if is_selected else (0.5, 0.5, 0.5)
-
- new_ids: List[str] = []
- for body_id, body in comp.bodies.items():
- if body.geometry:
- try:
- shape = self._kernel._get_shape(body.geometry)
- transformed = self._apply_transform(
- shape, ac.position, ac.rotation
- )
- obj_id = f"asm_{ac_id}_{body_id}"
- self._viewer_3d.show_shape(
- transformed,
- color=color,
- name=obj_id,
- )
- new_ids.append(obj_id)
- except Exception as exc:
- logger.debug(f"Failed to update body {body_id}: {exc}")
-
- self._asm_render_objects[ac_id] = new_ids
-
- # ────────────────────────────────────────────────────────────────────
- # Assembly 3D drag-move
- # ────────────────────────────────────────────────────────────────────
-
- def _on_assembly_move_toggled(self, checked: bool):
- """Toggle 3D drag-to-move mode in the viewer.
-
- When active, clicking a body in the assembly view and dragging
- moves its assembly component in real-time. Shift+drag moves in Z.
- """
- if checked and not self._assembly_view_active:
- self._btn_asm_move.setChecked(False)
- QMessageBox.warning(self, "Assembly View",
- "Switch to assembly view first by clicking an assembly component button.")
- return
- self._viewer_3d.set_assembly_move_mode(checked)
- if checked:
- self._viewer_3d.setFocus()
- self._viewer_3d.activateWindow()
- self.setStatusTip("Drag a body to move it; Shift+drag for Z depth")
- else:
- self.setStatusTip("")
-
- def _on_assembly_move_activated(self, owner_obj_id: str):
- """Called when the user clicks a body in move mode.
-
- Parse the assembly component id, compute the rigid group it belongs
- to (transitively via mated connectors), and snapshot EVERY member's
- start position so the whole group can translate together during the
- drag. The first-picked component of each mated pair stays as the
- grounded reference frame for the solver; for a pure-translation
- drag that just means we preserve all current relative transforms.
- """
- import numpy as np
-
- ac_id = self._parse_ac_id(owner_obj_id)
- if ac_id is None:
- return
-
- assembly = self._get_assembly()
- ac = assembly.components.get(ac_id)
- if ac is None:
- return
-
- self._asm_move_ac_id = ac_id
- # Rigid group membership (BFS over mated-connector connections).
- group_ids = assembly.get_rigid_group(ac_id)
- self._asm_move_group_ids = group_ids
- self._asm_move_group_start = {}
- for gid in group_ids:
- g_ac = assembly.components.get(gid)
- if g_ac is not None:
- self._asm_move_group_start[gid] = np.array(g_ac.position, dtype=float)
- # Keep the legacy single-component start for backwards compatibility.
- self._asm_move_start_pos = np.array(ac.position, dtype=float)
-
- def _on_assembly_move_dragged(self, owner_obj_id: str, dx: float, dy: float, dz: float):
- """Propagate a drag move across the entire rigid group, in-place.
-
- Every component in the dragged rigid group receives the SAME world
- translation delta (relative to its own start position), so the mated
- relative transforms are preserved exactly and SolveSpace's solved
- alignment stays valid throughout the drag. Each member is updated
- in-place via ``_update_assembly_component_in_viewer`` so the camera
- never flickers.
- """
- if self._asm_move_ac_id is None or self._asm_move_start_pos is None:
- return
-
- ac_id = self._asm_move_ac_id
- assembly = self._get_assembly()
- ac = assembly.components.get(ac_id)
- if ac is None:
- return
-
- import numpy as np
- delta = np.array([dx, dy, dz], dtype=float)
- # Propagate the same delta to every rigid-group member.
- group_ids = self._asm_move_group_ids or [ac_id]
- for gid in group_ids:
- start = self._asm_move_group_start.get(gid)
- if start is None:
- continue
- g_ac = assembly.components.get(gid)
- if g_ac is None:
- continue
- g_ac.position = start + delta
- # Update only this component's shapes — no scene clear.
- self._update_assembly_component_in_viewer(gid)
-
- def _on_assembly_move_finished(self, owner_obj_id: str):
- """Finalize the drag move."""
- if self._asm_move_ac_id is not None:
- members = len(self._asm_move_group_ids) if self._asm_move_group_ids else 1
- logger.info(
- f"Moved assembly rigid group led by {self._asm_move_ac_id} "
- f"({members} member(s)) to final position"
- )
- self._asm_move_ac_id = None
- self._asm_move_start_pos = None
- self._asm_move_group_start = {}
- self._asm_move_group_ids = []
-
- # ────────────────────────────────────────────────────────────────────
- # Connector methods — two-click selection + preview dialog
- # ────────────────────────────────────────────────────────────────────
-
- @staticmethod
- def _parse_ac_id(owner_obj_id: str) -> Optional[str]:
- """Extract the assembly component id from a renderer owner_obj_id.
-
- Format: asm_{ac_id}_{body_id}
- """
- if not owner_obj_id or not owner_obj_id.startswith("asm_"):
- return None
- parts = owner_obj_id.split("_")
- if len(parts) >= 3:
- return parts[1]
- return owner_obj_id[4:]
-
- def _on_start_connector_placement(self, checked: bool):
- """Toggle connector pick mode.
-
- First click selects the first component's connection entity.
- Second click selects the second component and triggers SolveSpace alignment.
- """
- if not self._assembly_view_active:
- self._btn_add_connector.setChecked(False)
- QMessageBox.warning(self, "Assembly View",
- "Switch to assembly view first by clicking an assembly component button.")
- return
-
- # Reset any in-progress two-click state.
- self._connector_first_pick = None
- self._connector_second_ac_id = None
- self._connector_align_pos = None
-
- self._viewer_3d.set_connector_pick_mode(checked)
- if checked:
- self._viewer_3d.setFocus()
- self._viewer_3d.activateWindow()
- self.setStatusTip("Click on the first component's connection point/face/edge/hole")
- else:
- self.setStatusTip("")
-
- def _on_connector_hover(self, info) -> None:
- """Show entity-type feedback in the status bar during connector pick.
-
- The gizmo itself is drawn by the viewer; this just reports what
- entity is under the cursor so the user knows what they will snap to.
- """
- if info is None:
- self.statusBar().showMessage("Move over a face / edge / hole / vertex to snap")
- return
- entity_type = info.get("type", "")
- names = {
- "planar_face": "Face",
- "cylindrical_face": "Hole",
- "edge": "Edge",
- "vertex": "Vertex",
- }
- name = names.get(entity_type, "Entity")
- ac_id = self._parse_ac_id(info.get("owner_obj_id", ""))
- comp_name = ""
- if ac_id is not None:
- assembly = self._get_assembly()
- ac = assembly.components.get(ac_id) if assembly else None
- if ac is not None:
- comp_name = f" on {ac.name}"
- self.statusBar().showMessage(f"Snap target: {name}{comp_name} — click to pick")
-
- def _on_connector_picked(self, origin, normal, x_dir, entity_type, raw_shape, owner_obj_id):
- """Handle a connector entity pick — first or second click.
-
- Snaps to faces, cylindrical holes, edges, or vertices.
- Stores connector in component-local coordinates so it stays
- valid when the component is moved by the solver.
- """
- import numpy as np
-
- ac_id = self._parse_ac_id(owner_obj_id)
- if ac_id is None:
- QMessageBox.warning(self, "Pick Error",
- "Could not identify which assembly component was clicked.")
- return
-
- assembly = self._get_assembly()
- ac = assembly.components.get(ac_id)
- if ac is None:
- QMessageBox.warning(self, "Pick Error",
- "The clicked component was not found in the assembly.")
- return
-
- # Convert world-space connector to component-local coordinates.
- # p_local = R^T @ (p_world - P)
- pos_world = np.array(origin, dtype=float)
- rot = ac.rotation
- pos_local = rot.T @ (pos_world - ac.position)
-
- n_world = np.array(normal, dtype=float)
- n_local = rot.T @ n_world
- n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
-
- x_world = np.array(x_dir, dtype=float) if x_dir else np.array([1.0, 0.0, 0.0])
- x_local = rot.T @ x_world
- x_local = x_local / max(np.linalg.norm(x_local), 1e-12)
-
- # ── First pick ──
- if self._connector_first_pick is None:
- self._connector_first_pick = {
- "ac_id": ac_id,
- "origin_local": tuple(pos_local),
- "normal_local": tuple(n_local),
- "x_dir_local": tuple(x_local),
- "origin_world": tuple(origin),
- "normal_world": tuple(normal),
- "entity_type": entity_type,
- "owner_obj_id": owner_obj_id,
- }
- # Highlight the first face if planar.
- if entity_type in ("planar_face", "cylindrical_face"):
- self._viewer_3d.highlight_face(raw_shape)
- self.setStatusTip("Now click on the second component's connection point/face/edge/hole")
- logger.info(f"Connector first pick: {ac.name} at {origin} ({entity_type})")
- return
-
- # ── Second pick ──
- first = self._connector_first_pick
-
- # Don't allow picking the same component twice.
- if ac_id == first["ac_id"]:
- QMessageBox.warning(self, "Same Component",
- "Pick a different component for the second connection point.")
- return
-
- self._connector_second_ac_id = ac_id
- self._viewer_3d.clear_face_highlight()
- self._viewer_3d.set_connector_pick_mode(False)
- self._btn_add_connector.setChecked(False)
- self.setStatusTip("")
-
- logger.info(f"Connector second pick: {ac.name} at {origin} ({entity_type})")
-
- # Build connector records (local coords).
- second_pick = {
- "ac_id": ac_id,
- "origin_local": tuple(pos_local),
- "normal_local": tuple(n_local),
- "x_dir_local": tuple(x_local),
- "origin_world": tuple(origin),
- "normal_world": tuple(normal),
- "entity_type": entity_type,
- "owner_obj_id": owner_obj_id,
- }
-
- # SolveSpace alignment: move second component so its connector
- # aligns with the first. First component is fixed.
- first_ac = assembly.components.get(first["ac_id"])
- second_ac = ac
-
- # Compute the world target for the second connector.
- # It's at the first connector world position.
- target_pos = np.array(first["origin_world"], dtype=float)
- target_normal = np.array(first["normal_world"], dtype=float)
- target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
-
- # SolveSpace solver call.
- solved = self._solve_assembly_alignment(
- first_ac=first_ac,
- second_ac=second_ac,
- first_pick=first,
- second_pick=second_pick,
- )
-
- if solved is None:
- QMessageBox.warning(self, "Solver Error",
- "SolveSpace could not align the components.")
- self._connector_first_pick = None
- self._connector_second_ac_id = None
- self._show_assembly_in_viewer(fit=True)
- return
-
- # Apply solved transform to second component.
- second_ac.position = solved["position"]
- second_ac.rotation = solved["rotation"]
-
- # Show dialog with live preview (rotation offset along normal).
- rotation, offset, flip = self._show_connector_dialog_with_preview(
- first_ac=first_ac,
- second_ac=second_ac,
- first_pick=first,
- second_pick=second_pick,
- solved=solved,
- )
-
- if rotation is None:
- # User cancelled — restore original position.
- second_ac.position = np.array(solved["original_position"], dtype=float)
- second_ac.rotation = np.array(solved["original_rotation"], dtype=float)
- self._connector_first_pick = None
- self._connector_second_ac_id = None
- self._show_assembly_in_viewer(fit=True)
- return
-
- # Apply dialog adjustments (rotation + offset + flip).
- import numpy as np
- # Build rotation matrix: rotate second connector normal around
- # the target normal axis by rotation degrees.
- angle_rad = np.radians(rotation)
- # Rodrigues' rotation formula around target_normal.
- k = target_normal
- K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
- R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
-
- # Apply axis rotation to the solved rotation.
- second_ac.rotation = R_axis @ second_ac.rotation
-
- # Offset along the (possibly flipped) target normal.
- flip_sign = -1.0 if flip else 1.0
- second_ac.position = second_ac.position + flip_sign * target_normal * offset
-
- # Create connectors on both components and link them as a mated pair.
- conn1 = None
- conn2 = None
- if first_ac:
- conn1 = first_ac.add_connector(
- position=first["origin_world"],
- normal=first["normal_world"],
- x_dir=first["x_dir_local"],
- source_obj_id=first["owner_obj_id"],
- name=f"Conn {entity_type} A",
- )
- conn1.axis_rotation = rotation
- conn1.offset = offset
- # The first-picked connector is the grounded reference of the pair.
- conn1.is_grounded = True
-
- if second_ac:
- conn2 = second_ac.add_connector(
- position=tuple(second_ac.position + second_ac.rotation @ np.array(second_pick["origin_local"])),
- normal=tuple(second_ac.rotation @ np.array(second_pick["normal_local"])),
- x_dir=tuple(second_ac.rotation @ np.array(second_pick["x_dir_local"])),
- source_obj_id=owner_obj_id,
- name=f"Conn {entity_type} B",
- )
- conn2.axis_rotation = rotation
- conn2.offset = offset
-
- # Cross-link the partners so the rigid-group move handler can follow
- # the edge, and register the pair on the assembly graph.
- if conn1 is not None and conn2 is not None:
- conn1.partner_ac_id = second_ac.id
- conn1.partner_connector_id = conn2.id
- conn2.partner_ac_id = first_ac.id
- conn2.partner_connector_id = conn1.id
- assembly.add_connection(first_ac.id, second_ac.id)
-
- logger.info(f"Connected component pair: {first['ac_id']} ↔ {ac_id}, rotation={rotation}°, offset={offset}mm, flip={flip}")
- self._connector_first_pick = None
- self._connector_second_ac_id = None
- self._show_assembly_in_viewer(fit=True)
-
- @staticmethod
- def _rotation_between_vectors(a, b):
- """Return a 3×3 rotation that maps vector *a* onto vector *b*.
-
- Handles the two degenerate cases that plain Rodrigues' formula gets
- wrong when the cross-product axis collapses to zero:
-
- * ``a ≈ b`` → identity (no rotation needed).
- * ``a ≈ -b`` → a 180° rotation about any axis orthogonal to *a*
- (picked by a stable reference-vector projection).
-
- Vectors are internally normalized so callers may pass non-unit input.
- """
- import numpy as _np
- import math as _math
- a = _np.asarray(a, dtype=float)
- b = _np.asarray(b, dtype=float)
- an = _np.linalg.norm(a); bn = _np.linalg.norm(b)
- if an < 1e-12 or bn < 1e-12:
- return _np.eye(3)
- a = a / an; b = b / bn
- dot = float(_np.dot(a, b))
- cross = _np.cross(a, b)
- cross_norm = _np.linalg.norm(cross)
- if cross_norm < 1e-9:
- if dot > 0.0:
- # Already aligned.
- return _np.eye(3)
- # Anti-parallel: 180° about an axis orthogonal to *a*.
- ref = _np.array([1.0, 0.0, 0.0]) if abs(a[0]) < 0.9 else _np.array([0.0, 1.0, 0.0])
- axis = ref - a * _np.dot(ref, a)
- axis = axis / max(_np.linalg.norm(axis), 1e-12)
- K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
- # sin(180°)=0, 1-cos(180°)=2 → R = I + 2 (K @ K)
- return _np.eye(3) + 2.0 * (K @ K)
- axis = cross / cross_norm
- angle = _math.acos(max(-1.0, min(1.0, dot)))
- K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
- return _np.eye(3) + _np.sin(angle) * K + (1.0 - _np.cos(angle)) * (K @ K)
-
- def _solve_assembly_alignment(
- self,
- first_ac: Any,
- second_ac: Any,
- first_pick: Dict[str, Any],
- second_pick: Dict[str, Any],
- ) -> Optional[Dict[str, Any]]:
- """Use SolveSpace to align the second component to the first.
-
- The first component is treated as fixed (grounded). The second
- component is moved so that its connector coincides with the first
- connector (position + normal alignment).
-
- Returns a dict with:
- * ``position`` — new world position for second component.
- * ``rotation`` — new 3×3 rotation matrix for second component.
- * ``original_position`` / ``original_rotation`` — for cancellation.
- """
- import numpy as np
- try:
- from python_solvespace import SolverSystem, ResultFlag, Entity
- except ImportError:
- logger.warning("python_solvespace not available, falling back to direct alignment")
- return self._align_direct(first_ac, second_ac, first_pick, second_pick)
-
- # Save original transform for cancellation.
- orig_pos = np.array(second_ac.position, dtype=float)
- orig_rot = np.array(second_ac.rotation, dtype=float)
-
- # World positions of connectors.
- p1_world = np.array(first_pick["origin_world"], dtype=float)
- n1_world = np.array(first_pick["normal_world"], dtype=float)
- n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12)
-
- p2_local = np.array(second_pick["origin_local"], dtype=float)
- n2_local = np.array(second_pick["normal_local"], dtype=float)
- n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12)
-
- # Build solver.
- #
- # IMPORTANT: SolveSpace's SLVS_C_PARALLEL and SLVS_C_SAME_ORIENTATION
- # both generate multi-equation residuals that trigger a hard C-level
- # assertion in this python_solvespace build's Newton iterator
- # ("Expected constraint to generate a single equation"), aborting the
- # whole process. We therefore avoid line-parallel / orientation
- # constraints entirely and instead drive BOTH translation AND axis
- # alignment with a pair of coincident point constraints:
- #
- # * coincident(pt1, pt2) — forces the connector points together
- # (3 translational DOF)
- # * coincident(pt1b, tip2) — pins the *axis tip* of component 2
- # onto a fixed point on component 1's
- # connector axis, which forces the
- # rotated axis direction to align
- # with n1 (2 rotational DOF)
- #
- # That's 6 single-equation-coincident residuals against 6 free point
- # parameters — a well-posed 0-DOF system — so it converges cleanly.
- # The remaining free rotation around the axis is left for the
- # rotation_spinner in the dialog.
- sys = SolverSystem()
-
- # Component 1 reference frame — fully grounded (dragged). pt1 is the
- # connector pivot, pt1b is one unit along the connector normal.
- pt1 = sys.add_point_3d(float(p1_world[0]), float(p1_world[1]), float(p1_world[2]))
- sys.dragged(pt1, Entity.FREE_IN_3D)
- pt1b = sys.add_point_3d(
- float(p1_world[0] + n1_world[0]),
- float(p1_world[1] + n1_world[1]),
- float(p1_world[2] + n1_world[2]),
- )
- sys.dragged(pt1b, Entity.FREE_IN_3D)
-
- # Component 2 — free points, seeded near the current world connector.
- p2_world_current = orig_pos + orig_rot @ p2_local
- pt2 = sys.add_point_3d(float(p2_world_current[0]), float(p2_world_current[1]), float(p2_world_current[2]))
- n2_world_current = orig_rot @ n2_local
- tip2 = sys.add_point_3d(
- float(p2_world_current[0] + n2_world_current[0]),
- float(p2_world_current[1] + n2_world_current[1]),
- float(p2_world_current[2] + n2_world_current[2]),
- )
-
- # Constraints: pivot coincidence + axis-tip coincidence.
- sys.coincident(pt1, pt2, Entity.FREE_IN_3D)
- sys.coincident(pt1b, tip2, Entity.FREE_IN_3D)
-
- # Solve.
- result = sys.solve()
- if result != ResultFlag.OKAY:
- logger.warning(f"SolveSpace solve failed: {result}")
- return self._align_direct(first_ac, second_ac, first_pick, second_pick)
-
- # Extract solved positions from the point entities' parameter tables.
- # ``Entity`` does not expose .x/.y/.z — read them via SolverSystem.params.
- p2_solved = np.array(sys.params(pt2.params), dtype=float)
- tip2_solved = np.array(sys.params(tip2.params), dtype=float)
- n2_solved = tip2_solved - p2_solved
- n2_solved = n2_solved / max(np.linalg.norm(n2_solved), 1e-12)
-
- # Compute the new component transform.
- # The second connector in local coords is at p2_local with normal n2_local.
- # In world space: P + R @ p2_local = p2_solved
- # R @ n2_local = n2_solved
- # We need to find R and P.
-
- # R must map n2_local → n2_solved.
- # Use a rotation that aligns the two vectors.
- from OCP.gp import gp_Vec, gp_Dir, gp_Ax1, gp_Trsf
- # Compute the rotation mapping the connector's local axis to its
- # solved world direction. Use the robust helper so the degenerate
- # anti-parallel case (cross → 0 but angle = 180°) is handled properly.
- R_align = self._rotation_between_vectors(n2_local, n2_solved)
-
- # The full rotation for the component.
- new_rot = R_align @ orig_rot
-
- # New position: P = p2_solved - R @ p2_local
- new_pos = p2_solved - new_rot @ p2_local
-
- return {
- "position": new_pos,
- "rotation": new_rot,
- "original_position": orig_pos,
- "original_rotation": orig_rot,
- }
-
- def _align_direct(
- self,
- first_ac: Any,
- second_ac: Any,
- first_pick: Dict[str, Any],
- second_pick: Dict[str, Any],
- ) -> Optional[Dict[str, Any]]:
- """Direct geometric alignment (fallback when SolveSpace unavailable).
-
- Moves the second component so its connector matches the first.
- """
- import numpy as np
- orig_pos = np.array(second_ac.position, dtype=float)
- orig_rot = np.array(second_ac.rotation, dtype=float)
-
- p1_world = np.array(first_pick["origin_world"], dtype=float)
- n1_world = np.array(first_pick["normal_world"], dtype=float)
- n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12)
-
- p2_local = np.array(second_pick["origin_local"], dtype=float)
- n2_local = np.array(second_pick["normal_local"], dtype=float)
- n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12)
-
- # Align normals through the robust rotation helper so the
- # anti-parallel case is handled correctly (see _rotation_between_vectors).
- R_align = self._rotation_between_vectors(n2_local, n1_world)
-
- new_rot = R_align @ orig_rot
- p2_world_target = p1_world
- new_pos = p2_world_target - new_rot @ p2_local
-
- return {
- "position": new_pos,
- "rotation": new_rot,
- "original_position": orig_pos,
- "original_rotation": orig_rot,
- }
-
- def _show_connector_dialog_with_preview(
- self,
- first_ac: Any,
- second_ac: Any,
- first_pick: Dict[str, Any],
- second_pick: Dict[str, Any],
- solved: Dict[str, Any],
- ) -> Tuple[Optional[float], Optional[float], bool]:
- """Show connector dialog with live 3D preview of the alignment.
-
- Returns (rotation_degrees, offset_mm, flip) or (None, None, False) if cancelled.
- """
- from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
- QLabel, QDoubleSpinBox, QPushButton,
- QFrame, QCheckBox)
-
- if second_ac is None:
- return (None, None, False)
-
- dialog = QDialog(self)
- dialog.setWindowTitle("Connector — Connection Properties")
- dialog.setMinimumWidth(340)
-
- layout = QVBoxLayout(dialog)
-
- entity_names = {
- "planar_face": "Face",
- "cylindrical_face": "Hole",
- "edge": "Edge",
- "vertex": "Vertex",
- }
- t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity")
- t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity")
- layout.addWidget(QLabel(f"{t1} on {first_ac.name} → {t2} on {second_ac.name}"))
- layout.addWidget(QLabel("Adjust the connection:"))
-
- # Rotation around normal axis.
- rot_layout = QHBoxLayout()
- rot_layout.addWidget(QLabel("Rotation around axis (°):"))
- rotation_spin = QDoubleSpinBox()
- rotation_spin.setDecimals(1)
- rotation_spin.setRange(-360, 360)
- rotation_spin.setValue(0.0)
- rotation_spin.setSuffix("°")
- rot_layout.addWidget(rotation_spin)
- layout.addLayout(rot_layout)
-
- # Offset along normal.
- off_layout = QHBoxLayout()
- off_layout.addWidget(QLabel("Offset along normal (mm):"))
- offset_spin = QDoubleSpinBox()
- offset_spin.setDecimals(2)
- offset_spin.setRange(-10000, 10000)
- offset_spin.setValue(0.0)
- off_layout.addWidget(offset_spin)
- layout.addLayout(off_layout)
-
- # Flip alignment direction.
- flip_check = QCheckBox("Flip connection direction (normals opposed)")
- flip_check.setChecked(False)
- layout.addWidget(flip_check)
-
- line = QFrame()
- line.setFrameShape(QFrame.HLine)
- layout.addWidget(line)
-
- btn_layout = QHBoxLayout()
- ok_btn = QPushButton("Connect")
- cancel_btn = QPushButton("Cancel")
- btn_layout.addWidget(ok_btn)
- btn_layout.addWidget(cancel_btn)
- layout.addLayout(btn_layout)
-
- import numpy as np
- target_normal = np.array(first_pick["normal_world"], dtype=float)
- target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
-
- # ── Live preview callback ──
- def _update_preview(*args):
- rot_deg = rotation_spin.value()
- off = offset_spin.value()
- flip = flip_check.isChecked()
-
- # Start from solved transform.
- base_pos = np.array(solved["position"], dtype=float)
- base_rot = np.array(solved["rotation"], dtype=float)
-
- # Apply axis rotation around target_normal.
- angle_rad = np.radians(rot_deg)
- k = target_normal
- K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
- R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
- preview_rot = R_axis @ base_rot
-
- # Apply offset (with flip).
- flip_sign = -1.0 if flip else 1.0
- preview_pos = base_pos + flip_sign * target_normal * off
-
- second_ac.position = preview_pos
- second_ac.rotation = preview_rot
- self._show_assembly_in_viewer() # no fit — keep camera steady
-
- rotation_spin.valueChanged.connect(_update_preview)
- offset_spin.valueChanged.connect(_update_preview)
- flip_check.toggled.connect(_update_preview)
-
- # Initial preview.
- _update_preview()
-
- ok_btn.clicked.connect(dialog.accept)
- cancel_btn.clicked.connect(dialog.reject)
-
- if dialog.exec():
- return (rotation_spin.value(), offset_spin.value(), flip_check.isChecked())
- return (None, None, False)
-
- def _on_delete_connector(self):
- """Delete the connector nearest to the selected assembly component."""
- active_id = self._get_active_assembly_component_id()
- if active_id is None:
- QMessageBox.warning(self, "No Selection",
- "Select an assembly component first")
- return
-
- assembly = self._get_assembly()
- ac = assembly.components.get(active_id)
- if ac is None or not ac.connectors:
- QMessageBox.information(self, "No Connectors",
- "This component has no connectors to remove.")
- return
-
- # List connectors in a simple choice dialog.
- conn_names = list(ac.connectors.keys())
- conn_labels = [f"{c.name} at {c.position}" for c in ac.connectors.values()]
-
- from PySide6.QtWidgets import QInputDialog
- label, ok = QInputDialog.getItem(
- self, "Remove Connector", "Select connector:", conn_labels, 0, False
- )
- if ok and label:
- idx = conn_labels.index(label)
- conn_id = conn_names[idx]
- conn = ac.connectors.get(conn_id)
- # Un-partner the mate and drop the rigid-group edge so stale
- # connections don't linger in the BFS graph.
- if conn is not None:
- partner_ac_id = conn.partner_ac_id
- partner_conn_id = conn.partner_connector_id
- if partner_ac_id is not None and partner_conn_id is not None:
- partner_ac = assembly.components.get(partner_ac_id)
- if partner_ac is not None and partner_conn_id in partner_ac.connectors:
- pc = partner_ac.connectors[partner_conn_id]
- pc.partner_ac_id = None
- pc.partner_connector_id = None
- pc.is_grounded = False
- # Remove the connection edge either side references this pair.
- assembly.connections = [
- c for c in assembly.connections
- if not (
- (c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
- or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
- )
- ] if partner_ac_id is not None else assembly.connections
- ac.remove_connector(conn_id)
- logger.info(f"Removed connector {conn_id}")
- self._show_assembly_in_viewer(fit=True)
-
- def _new_workplane(self):
- """Open the orientation dialog and create a new independent workplane.
-
- The workplane is shown as a semi-transparent plane in the 3D view
- (with live preview as the user adjusts options in the dialog).
- A fresh sketch is created on it ready for drawing, and body outlines
- are projected as underlay construction lines for precise alignment.
- """
- dialog = WorkplaneOrientationDialog(self)
- origin = (0.0, 0.0, 0.0)
- _preview_obj_id: Optional[str] = None
-
- def _preview_callback(orientation):
- """Live-preview the workplane orientation in the 3D viewer."""
- nonlocal _preview_obj_id
- if orientation is None:
- # Dialog closing — clear the preview.
- if _preview_obj_id is not None:
- self._viewer_3d.remove_workplane(_preview_obj_id)
- _preview_obj_id = None
- return
- normal, x_dir = orientation
- # Replace the previous preview (same ID = update in place).
- if _preview_obj_id is not None:
- self._viewer_3d.remove_workplane(_preview_obj_id)
- _preview_obj_id = self._viewer_3d.show_workplane(
- origin=origin,
- normal=normal,
- x_dir=x_dir,
- size=250.0,
- name="__wp_preview__",
- )
-
- dialog.set_preview_callback(_preview_callback)
-
- if not dialog.exec():
- # Preview already cleared by dialog.hideEvent → callback(None).
- return
-
- normal, x_dir, wp_name = dialog.get_orientation()
-
- if not self._current_component:
- self._current_component = self._project.add_component()
-
- # Create the Workplane model.
- from fluency.models.data_model import Workplane
- wp = self._current_component.add_workplane(
- Workplane(
- name=wp_name,
- origin=origin,
- normal=normal,
- x_dir=x_dir,
- )
- )
-
- # The preview visual becomes the permanent workplane; just update
- # its name so it can be found later for removal.
- if _preview_obj_id is not None:
- # Store the render object ID in the workplane model.
- wp.render_object = _preview_obj_id
- # The show_workplane_plane method replaced the preview already,
- # so the visual is showing the final orientation.
- else:
- # Fallback: create a new visual (shouldn't happen).
- wp.render_object = self._viewer_3d.show_workplane(
- origin=origin,
- normal=normal,
- x_dir=x_dir,
- size=250.0,
- name=f"workplane_{wp.id}",
- )
-
- # Create a sketch on this workplane and set up the 2D widget.
- sketch = self._current_component.add_sketch()
- sketch.name = f"Sketch on {wp.name}"
- sketch.set_workplane(origin, normal, x_dir)
- sketch._source_workplane_id = wp.id
-
- # Prepare the OCC sketch in the widget.
- 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._current_sketch = sketch
-
- # Project body outlines onto the workplane for alignment.
- self._project_body_to_active_wp()
-
- self._sketch_widget.set_mode("line")
- self._btn_line.setChecked(True)
-
- self._refresh_lists()
- self._set_panel_focus("sketch")
- self.statusBar().showMessage(
- f"Workplane '{wp.name}' created — sketch on it to draw. "
- f"Body outlines projected as underlay.",
- 6000,
- )
- logger.info(f"New workplane '{wp.name}' with orientation n={normal} x={x_dir}")
-
- def _project_body_to_active_wp(self) -> None:
- """Project all body outlines in the current component onto the active
- sketch's workplane as underlay construction lines.
-
- This lets the user see the 3D body's silhouette from the workplane's
- perspective and position their 2D sketch precisely relative to the
- existing geometry. Uses the same external-entity mechanism as
- face-projected underlay (``set_source_face``).
- """
- if not self._current_component or not self._current_sketch:
- return
- occ_sketch = self._current_sketch.occ_sketch
- if occ_sketch is None:
- return
- wp = occ_sketch.get_workplane()
- if not wp:
- return
- origin = wp[0] # (ox, oy, oz)
- normal = wp[1] # (nx, ny, nz)
- x_dir = wp[2] # (xx, xy, xz)
-
- # Collect all body shapes from the current component.
- body_shapes = []
- kernel = self._kernel
- for body in self._current_component.bodies.values():
- if body.geometry is not None:
- shape = kernel._get_shape(body.geometry)
- if shape is not None:
- body_shapes.append(shape)
-
- if not body_shapes:
- self._sketch_widget.clear_source_face()
- self._btn_underlay.setEnabled(False)
- self._btn_underlay.setChecked(True)
- self._btn_clr_face.setEnabled(False)
- self._btn_to_sketch.setEnabled(False)
- return
-
- # Project edges of all bodies onto the workplane.
- workplane_data = (origin, normal, x_dir)
- all_polylines: List[List[Tuple[float, float]]] = []
- for shape in body_shapes:
- try:
- polys = _project_body_to_workplane(shape, workplane_data)
- all_polylines.extend(polys)
- except Exception as exc:
- logger.debug("body projection failed for a shape: %s", exc)
-
- if not all_polylines:
- return
-
- # Import the polylines as external/underlay entities in the sketch.
- # First clear any existing external entities, then add the new ones.
- occ_sketch.remove_external_entities()
- imported_count = 0
- for poly in all_polylines:
- if len(poly) < 2:
- continue
- try:
- _, lines = occ_sketch.add_external_polyline(
- [(float(u), float(v)) for (u, v) in poly]
- )
- imported_count += len(lines)
- except Exception as exc:
- logger.debug("workplane underlay polyline import failed: %s", exc)
-
- if imported_count > 0:
- logger.info(
- "Imported %d construction-line segments from body outlines",
- imported_count,
- )
- # Refresh the 2D widget's entity tracking. We do NOT set
- # _source_underlay_uv here because body projections produce
- # many disjoint polylines — the fill paintEvent draws from
- # _source_underlay_uv[0] would look wrong. The external
- # entities themselves (orange dashed lines) provide the
- # visual underlay.
- self._sketch_widget._rebuild_from_sketch()
- self._sketch_widget._source_workplane = workplane_data
- self._sketch_widget._source_underlay_uv = []
- self._sketch_widget._underlay_visible = True
- self._sketch_widget.update()
- # Enable the underlay toggle so the user can hide lines.
- self._btn_underlay.setEnabled(True)
- self._btn_underlay.setChecked(True)
- self._btn_clr_face.setEnabled(True)
- self._btn_to_sketch.setEnabled(True)
-
- 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")
-
- # ─── Offset sketch ─────────────────────────────────────────────────────
-
- @staticmethod
- def _find_parent_point_entities(
- sketch: OCCSketch,
- positions: List[Tuple[float, float]],
- tolerance: float = 0.01,
- ) -> List[Optional[OCCSketchEntity]]:
- """Match position tuples to the corresponding point entities in the sketch.
-
- Searches ``sketch._entities`` for point entities whose geometry
- matches each entry in *positions* within *tolerance*. Returns a
- list parallel to *positions*; unmatched entries are *None*.
- Skips external / centerline / construction entities so we only
- pick up user-drawn boundary points.
- """
- matches: List[Optional[OCCSketchEntity]] = []
- for (tx, ty) in positions:
- found: Optional[OCCSketchEntity] = None
- for eid, entity in sketch._entities.items():
- if entity.entity_type != "point":
- continue
- if entity.is_external or entity.is_construction:
- continue
- if entity.id in sketch._centerline_ids:
- continue
- if entity.geometry is not None:
- ex, ey = entity.geometry
- if abs(ex - tx) < tolerance and abs(ey - ty) < tolerance:
- found = entity
- break
- matches.append(found)
- return matches
-
- def _offset_sketch(self) -> None:
- """Open the offset dialog and apply an offset to the selected sketch face.
-
- The user must first select a closed face (region) in the 2D sketch.
- When the Offset button is pressed:
- 1. The selected face's outer boundary is read.
- 2. An OffsetDialog appears with a number spinner.
- 3. Live preview shows the offset result in the 2D view.
- 4. On OK, new point & line entities are created in the sketch
- at the offset position, duplicating the original boundary.
- 5. Distance constraints auto-connect each offset point to its
- parent so the offset stays parametric.
- 6. The region between the original and offset boundaries forms
- a selectable wall face (e.g. for extrusion into a thin wall).
- """
- # Ensure we have a sketch and a selected face.
- sketch = self._sketch_widget.get_sketch()
- if sketch is None:
- QMessageBox.warning(self, "No Sketch", "Please create and select a sketch first.")
- return
-
- selected_face = self._sketch_widget._selected_face
- if selected_face is None:
- QMessageBox.warning(
- self, "No Face Selected",
- "Click inside a closed face (region) in the sketch to select it, "
- "then press Offset."
- )
- return
-
- outer = selected_face.get("outer")
- if outer is None:
- QMessageBox.warning(self, "No Outer Boundary", "Selected face has no outer boundary.")
- return
-
- # ── Extract boundary points ──
- if outer["type"] == "circle":
- cx, cy = outer["center"]
- radius = outer["radius"]
- is_circle = True
- elif outer["type"] == "polygon":
- pts = list(outer["points"])
- if len(pts) < 3:
- QMessageBox.warning(self, "Invalid Polygon", "Face boundary has fewer than 3 points.")
- return
- # Remove closing duplicate (last == first) if present.
- if len(pts) > 1 and pts[-1] == pts[0]:
- pts.pop()
- is_circle = False
- else:
- QMessageBox.warning(self, "Unsupported Face", f"Face type '{outer.get('type')}' not supported.")
- return
-
- # ── Find the ORIGINAL point entities so we can constrain to them ──
- if is_circle:
- parent_center = self._find_parent_point_entities(sketch, [(cx, cy)], tolerance=0.01)
- parent_center_entity = parent_center[0] if parent_center else None
- else:
- parent_entities = self._find_parent_point_entities(sketch, pts, tolerance=0.01)
-
- # ── Open dialog with live preview ──
- dialog = OffsetDialog(self)
-
- def _compute_offset_preview(distance: float, inward: bool) -> Optional[List[Tuple[float, float]]]:
- """Return offset polygon points, or None for circles."""
- d = -distance if inward else distance
- if is_circle:
- return None # circles not drawn as polygon preview
- try:
- return _offset_polygon(pts, d)
- except Exception as exc:
- logger.debug("offset preview compute failed: %s", exc)
- return None
-
- def _preview_callback(values):
- if values is None:
- self._sketch_widget.clear_offset_preview()
- return
- distance, inward = values
- preview_pts = _compute_offset_preview(distance, inward)
- self._sketch_widget.set_offset_preview(preview_pts)
-
- dialog.set_preview_callback(_preview_callback)
-
- if not dialog.exec():
- # Preview already cleared by hideEvent.
- self._sketch_widget.clear_offset_preview()
- return
-
- self._sketch_widget.clear_offset_preview()
-
- distance, inward = dialog.get_values()
- d = -distance if inward else distance
- logger.info(f"Offset distance: {abs(d):.2f} mm {'inward' if inward else 'outward'}")
-
- try:
- # ── Apply offset: create new entities in the sketch ──
- if is_circle:
- self._apply_circle_offset(
- sketch, cx, cy, radius, d, selected_face,
- parent_center_entity=parent_center_entity,
- offset_distance=abs(d),
- )
- else:
- self._apply_polygon_offset(
- sketch, pts, d, selected_face,
- parent_entities=parent_entities,
- offset_distance=abs(d),
- )
-
- self._sketch_widget._rebuild_from_sketch()
- self._sketch_widget._solve_and_sync()
- self._sketch_widget.sketch_updated.emit()
- self._sketch_widget.update()
-
- self.statusBar().showMessage(
- f"Offset sketch by {abs(d):.2f} mm {'inward' if inward else 'outward'}", 4000
- )
- logger.info("Offset complete")
-
- except Exception as e:
- logger.exception(f"Offset failed: {e}")
- QMessageBox.critical(self, "Error", f"Offset failed: {e}")
-
- def _apply_polygon_offset(
- self, sketch: OCCSketch,
- pts: List[Tuple[float, float]],
- distance: float,
- face: Dict[str, Any],
- parent_entities: Optional[List[Optional[OCCSketchEntity]]] = None,
- offset_distance: float = 0.0,
- ) -> None:
- """Duplicate a polygon boundary at *distance* offset and add to the sketch.
-
- Creates new point + line entities for the offset boundary and
- re-applies any holes from the original face (offset by the same
- distance, clipped if they collapse). The region between the
- original boundary and the offset boundary becomes a selectable
- face (e.g. a thin wall for extrusion). When *parent_entities*
- is provided, a distance constraint is added between each parent
- point and the corresponding offset point with the
- *offset_distance* value.
- """
- offset_pts = _offset_polygon(pts, distance)
-
- # Create new point entities at the offset positions.
- new_points = []
- for (x, y) in offset_pts:
- pt = sketch.add_point(float(x), float(y))
- new_points.append(pt)
-
- # Create line entities connecting the new points.
- new_lines = []
- for i in range(len(new_points)):
- j = (i + 1) % len(new_points)
- line = sketch.add_line(new_points[i], new_points[j])
- new_lines.append(line)
-
- # ── Auto-constrain: distance constraint between each parent
- # point and its corresponding offset point ──
- if parent_entities and offset_distance > 0:
- constrained = 0
- for parent_ent, new_pt in zip(parent_entities, new_points):
- if parent_ent is not None:
- try:
- sketch.constrain_distance(parent_ent, new_pt, offset_distance)
- constrained += 1
- except Exception as exc:
- logger.debug(
- "distance constraint failed for parent id=%s: %s",
- parent_ent.id, exc,
- )
- if constrained:
- logger.info("Added %d distance constraints to offset polygon", constrained)
-
- # ── Offset holes ──
- holes = face.get("holes", [])
- for hole in holes:
- if hole["type"] != "polygon":
- continue
- hole_pts = list(hole["points"])
- if len(hole_pts) < 3:
- continue
- if len(hole_pts) > 1 and hole_pts[-1] == hole_pts[0]:
- hole_pts.pop()
- # Holes are offset in the OPPOSITE direction (a positive outer
- # offset should make holes smaller, not larger).
- offset_hole = _offset_polygon(hole_pts, -distance)
- if len(offset_hole) < 3:
- logger.debug("Hole offset collapsed — skipping")
- continue
- hole_points = []
- for (x, y) in offset_hole:
- pt = sketch.add_point(float(x), float(y))
- hole_points.append(pt)
- for i in range(len(hole_points)):
- j = (i + 1) % len(hole_points)
- sketch.add_line(hole_points[i], hole_points[j])
-
- logger.info(
- "Created %d offset points and %d offset lines for polygon boundary + %d holes",
- len(offset_pts), len(offset_pts),
- len([h for h in holes if h.get("type") == "polygon"]),
- )
-
- def _apply_circle_offset(
- self, sketch: OCCSketch,
- cx: float, cy: float, radius: float,
- distance: float,
- face: Dict[str, Any],
- parent_center_entity: Optional[OCCSketchEntity] = None,
- offset_distance: float = 0.0,
- ) -> None:
- """Duplicate a circle at *distance* offset and add to the sketch.
-
- For circles the offset is simply a new circle with (radius ± distance).
- A new center point is created so the original is not disturbed.
- The region between the original and offset circles becomes a
- selectable face (annular wall for extrusion). When
- *parent_center_entity* is provided a distance constraint links
- it to the new center.
- """
- new_radius = radius + distance
- if new_radius <= 0:
- logger.warning("Offset radius would be non-positive — skipping")
- return
-
- # Create a new center point (slightly nudged so it's distinct).
- new_cx = cx + 0.001 if abs(distance) < 0.01 else cx
- new_cy = cy + 0.001 if abs(distance) < 0.01 else cy
- center_pt = sketch.add_point(float(new_cx), float(new_cy))
- sketch.add_circle(center_pt, float(new_radius))
-
- # ── Auto-constrain: distance from parent center to new center ──
- if parent_center_entity is not None and offset_distance > 0:
- try:
- sketch.constrain_distance(parent_center_entity, center_pt, offset_distance)
- logger.info("Added distance constraint to offset circle center")
- except Exception as exc:
- logger.debug("circle distance constraint failed: %s", exc)
-
- # Also offset any holes.
- holes = face.get("holes", [])
- for hole in holes:
- if hole["type"] != "circle":
- continue
- h_cx, h_cy = hole["center"]
- h_r = hole["radius"]
- new_h_r = h_r - distance # holes shrink when outer grows
- if new_h_r <= 0:
- logger.debug("Hole circle offset collapsed — skipping")
- continue
- h_center = sketch.add_point(float(h_cx), float(h_cy))
- sketch.add_circle(h_center, float(new_h_r))
-
- logger.info(
- "Created offset circle: center=(%.2f, %.2f), radius=%.2f",
- new_cx, new_cy, new_radius,
- )
-
- # ─── 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, ClrFace, and ToSketch buttons.
- self._btn_underlay.setEnabled(True)
- self._btn_underlay.setChecked(True)
- self._btn_clr_face.setEnabled(True)
- self._btn_to_sketch.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)
- self._btn_to_sketch.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 _on_convert_underlay_to_sketch(self) -> None:
- """Convert the underlay/projected construction lines into real sketch geometry.
-
- Delegates to the widget's ``_convert_underlay_to_sketch`` which
- creates regular (non-construction, non-external) point and line
- entities at every underlay position. The underlay reference stays
- intact so the user can still toggle it on/off.
- """
- self._sketch_widget._convert_underlay_to_sketch()
- # Sync the main window's underlay toggle to match the widget
- # (the conversion auto-hides the underlay).
- self._btn_underlay.setChecked(False)
- self.statusBar().showMessage(
- "Underlay converted to sketch geometry — now you can select faces, offset, and extrude",
- 5000,
- )
-
- 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)
- self._btn_to_sketch.setEnabled(True)
- elif getattr(sketch, "_source_workplane_id", None) is not None:
- # Sketch on an independent workplane: project body outlines.
- self._project_body_to_active_wp()
- self._btn_underlay.setEnabled(True)
- self._btn_underlay.setChecked(True)
- self._btn_clr_face.setEnabled(True)
- self._btn_to_sketch.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._btn_to_sketch.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._btn_to_sketch.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",
- )
+from fluency.ui.main_window import MainWindow
+from fluency.ui.sketch_widget import Sketch2DWidget
+from fluency.ui.viewer_widget import Viewer3DWidget
+
+__all__ = [
+ "MainWindow",
+ "Sketch2DWidget",
+ "Viewer3DWidget",
+ "ExtrudeDialog",
+ "RevolveDialog",
+ "OffsetDialog",
+ "WorkplaneOrientationDialog",
+ "main",
+]
def main() -> int:
+ """Launch the Fluency CAD application.
+
+ Returns the ``QApplication.exec()`` exit code so that the console-script
+ entry point declared in ``pyproject.toml`` can forward it.
+ """
app = QApplication(sys.argv)
app.setStyle("Fusion")
diff --git a/src/fluency/tests/test_project_io.py b/src/fluency/tests/test_project_io.py
new file mode 100644
index 0000000..cc0cdc6
--- /dev/null
+++ b/src/fluency/tests/test_project_io.py
@@ -0,0 +1,240 @@
+"""Smoke test for project_io save/load round-trip.
+
+Builds a small project (a Component with a sketch, an extrude body, a
+workplane, plus an assembly with two instances and a connector) and
+verifies that saving then loading it preserves the data.
+"""
+
+import os
+import sys
+import tempfile
+import unittest
+
+# Allow running this file directly: ``python tests/test_project_io.py``.
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
+
+from fluency.io.project_io import save_project, load_project
+from fluency.models.data_model import (
+ Project,
+ Component,
+ Body,
+ Workplane,
+ Assembly,
+)
+
+
+class TestProjectIO(unittest.TestCase):
+ """Round-trip the same project through save/load and check equivalence."""
+
+ def _build_project(self) -> Project:
+ project = Project(name="Test Project", description="A tiny test")
+ project.file_path = None # simulate untitled
+
+ comp = project.add_component(Component(name="Part1"))
+
+ # Add a workplane.
+ wp = Workplane(
+ name="Top",
+ origin=(0.0, 0.0, 0.0),
+ normal=(0.0, 0.0, 1.0),
+ x_dir=(1.0, 0.0, 0.0),
+ )
+ comp.add_workplane(wp)
+
+ # Add a sketch with a square.
+ sk = comp.add_sketch()
+ sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0))
+ sk.solve()
+ # Build a face geometry for the sketch (needed for export / restore).
+ faces = sk.occ_sketch.detect_faces()
+ if faces:
+ sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
+
+ # Extrude into a body.
+ if sk.geometry:
+ kernel = project.kernel
+ body_shape = kernel.extrude(sk.geometry, height=20.0)
+ body = comp.add_body(
+ Body(
+ name="Block",
+ geometry=body_shape,
+ source_sketch=sk,
+ source_operation="extrude",
+ )
+ )
+ body.color = (0.4, 0.2, 0.8)
+
+ # Add an assembly with two instances and a mated connector pair.
+ asm = project.add_assembly(Assembly(name="Asm1"))
+ ac1 = asm.add_component_instance(comp.id, name="Inst1")
+ ac2 = asm.add_component_instance(comp.id, name="Inst2")
+ c1 = ac1.add_connector(
+ position=(5.0, 5.0, 0.0),
+ normal=(0.0, 0.0, 1.0),
+ x_dir=(1.0, 0.0, 0.0),
+ )
+ c2 = ac2.add_connector(
+ position=(10.0, 10.0, 5.0),
+ normal=(0.0, 0.0, -1.0),
+ x_dir=(1.0, 0.0, 0.0),
+ )
+ # Record a mated pair (UI normally does this on connector-pick).
+ conn = asm.add_connection(ac1.id, ac2.id)
+ conn.first_connector_id = c1.id
+ conn.second_connector_id = c2.id
+ c1.partner_ac_id = ac2.id
+ c1.partner_connector_id = c2.id
+ c2.partner_ac_id = ac1.id
+ c2.partner_connector_id = c1.id
+ c1.is_grounded = True
+
+ return project
+
+ def test_round_trip(self):
+ original = self._build_project()
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "test.fluency")
+ saved_path = save_project(original, path)
+ self.assertTrue(os.path.exists(saved_path))
+ self.assertGreater(os.path.getsize(saved_path), 100)
+
+ loaded, view_state = load_project(saved_path)
+
+ # ── Project metadata ──
+ self.assertEqual(loaded.name, "Test Project")
+ self.assertEqual(loaded.description, "A tiny test")
+ self.assertEqual(len(loaded.components), 1)
+ self.assertEqual(len(loaded.assemblies), 1)
+
+ # ── Component / Workplane / Sketch / Body ──
+ comp = next(iter(loaded.components.values()))
+ self.assertEqual(comp.name, "Part1")
+ self.assertEqual(len(comp.workplanes), 1)
+ self.assertEqual(len(comp.sketches), 1)
+ self.assertEqual(len(comp.bodies), 1)
+
+ sk = next(iter(comp.sketches.values()))
+ self.assertIsNotNone(sk.occ_sketch)
+ # OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit
+ # origin anchor (first-point-fix). We only check that the entities
+ # exist.
+ self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4)
+ # Solved geometry should round-trip through STEP.
+ self.assertIsNotNone(sk.geometry)
+
+ body = next(iter(comp.bodies.values()))
+ self.assertIsNotNone(body.geometry)
+ self.assertEqual(body.name, "Block")
+ self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8))
+ # BRep topology should still be valid.
+ self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0)
+
+ # ── Assembly / connector / connection ──
+ asm = next(iter(loaded.assemblies.values()))
+ self.assertEqual(len(asm.components), 2)
+ self.assertEqual(len(asm.connections), 1)
+ ac1, ac2 = list(asm.components.values())
+ self.assertEqual(len(ac1.connectors), 1)
+ conn = next(iter(ac1.connectors.values()))
+ self.assertEqual(conn.position, (5.0, 5.0, 0.0))
+ # The grounded-reference flag was re-applied to the first connector.
+ self.assertTrue(conn.is_grounded)
+ # Rigid-group BFS should still link the two instances.
+ self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id})
+
+
+class TestProjectIOWithConstraints(unittest.TestCase):
+ """Round-trip with parametric constraints on the sketch.
+
+ Builds a slightly more interesting sketch (rectangle with horizontal +
+ vertical constraints + a distance) so the constraint-log replay path
+ is exercised, not just the entity-construction path.
+ """
+
+ def _build_project(self) -> Project:
+ project = Project(name="Constraints Project", description="")
+
+ comp = project.add_component(Component(name="Part1"))
+
+ # A square with a 25.4mm horizontal distance + a vertical distance,
+ # both anchored on a single fixed corner. This is the minimum
+ # number of constraints to fully define a square in 2D.
+ sk = comp.add_sketch()
+ sk.occ_sketch.set_workplane(
+ (0.0, 0.0, 0.0),
+ (0.0, 0.0, 1.0),
+ (1.0, 0.0, 0.0),
+ )
+ p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto)
+ p2 = sk.occ_sketch.add_point(25.4, 0.0)
+ p3 = sk.occ_sketch.add_point(25.4, 25.4)
+ p4 = sk.occ_sketch.add_point(0.0, 25.4)
+ sk.occ_sketch.add_line(p1, p2)
+ sk.occ_sketch.add_line(p2, p3)
+ sk.occ_sketch.add_line(p3, p4)
+ sk.occ_sketch.add_line(p4, p1)
+ # Constraint the right side to a known length (instead of relying
+ # on the construction positions, which would just be redundant).
+ sk.occ_sketch.constrain_distance(p2, p3, 25.4)
+ sk.solve()
+ sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained()
+ faces = sk.occ_sketch.detect_faces()
+ if faces:
+ sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
+
+ # View state to persist.
+ view_state = {
+ "active_tab": 0,
+ "active_component_id": comp.id,
+ "active_sketch_id": sk.id,
+ "camera_eye": [50.0, 50.0, 50.0],
+ "camera_at": [0.0, 0.0, 0.0],
+ "camera_up": [0.0, 0.0, 1.0],
+ "panel_focus": "sketch",
+ "assembly_view_active": False,
+ }
+ self._view_state = view_state
+ return project
+
+ def test_round_trip_with_view_state(self):
+ original = self._build_project()
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "constrained.fluency")
+ save_project(original, path, view_state=self._view_state)
+ loaded, view_state = load_project(path)
+
+ # View state must survive (modulo float-to-list round-tripping).
+ self.assertEqual(view_state.get("active_component_id"),
+ self._view_state["active_component_id"])
+ self.assertEqual(view_state.get("active_sketch_id"),
+ self._view_state["active_sketch_id"])
+ self.assertEqual(view_state.get("camera_eye"),
+ self._view_state["camera_eye"])
+ self.assertEqual(view_state.get("panel_focus"),
+ self._view_state["panel_focus"])
+
+ # Sketch must have replayed its constraints and remain solvable.
+ comp = next(iter(loaded.components.values()))
+ sk = next(iter(comp.sketches.values()))
+ # Re-solve on the loaded sketch to confirm the post-replay
+ # configuration is still consistent.
+ self.assertTrue(sk.occ_sketch.solve())
+ # The right edge of the square should still be 25.4mm tall.
+ import math
+ for lid, line in sk.occ_sketch._lines.items():
+ sid, eid = line
+ sx, sy = sk.occ_sketch._points[sid]
+ ex, ey = sk.occ_sketch._points[eid]
+ length = math.hypot(ex - sx, ey - sy)
+ if abs(length) > 1e-3:
+ self.assertAlmostEqual(
+ length, 25.4, places=3,
+ msg=f"Constraint replay broke: line {lid} = {length}",
+ )
+ break
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/src/fluency/ui/__init__.py b/src/fluency/ui/__init__.py
new file mode 100644
index 0000000..7e06020
--- /dev/null
+++ b/src/fluency/ui/__init__.py
@@ -0,0 +1,15 @@
+"""Fluency CAD UI package.
+
+Contains the Qt widgets, dialogs, and the main window:
+
+ ui/
+ dialogs.py – All 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
+ sketch_widget.py – Sketch2DWidget (2D sketcher with constraint solver)
+ viewer_widget.py – Viewer3DWidget (3D viewer / OCC canvas)
+ main_window.py – MainWindow (application shell)
+
+The public classes are re-exported from `fluency.main` so existing code that
+does `from fluency.main import MainWindow` continues to work.
+"""
+
+__all__: list[str] = []
diff --git a/src/fluency/ui/dialogs.py b/src/fluency/ui/dialogs.py
new file mode 100644
index 0000000..d2fd2ce
--- /dev/null
+++ b/src/fluency/ui/dialogs.py
@@ -0,0 +1,494 @@
+"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation."""
+
+from __future__ import annotations
+
+import logging
+import math
+from typing import Any, Dict, List, Optional, Tuple
+
+from PySide6.QtCore import Qt, QPoint, QPointF
+from PySide6.QtGui import QColor, QFont, QKeySequence
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QRadioButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+logger = logging.getLogger(__name__)
+
+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 OffsetDialog(QDialog):
+ """Dialog for 2D sketch offset options.
+
+ Shows a number input for the offset distance with a live preview
+ callback so the sketch widget can render the offset result in real
+ time. On accept the caller retrieves ``get_values()`` → distance.
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Offset Sketch")
+ self.setMinimumWidth(300)
+
+ self._preview_callback = None
+
+ layout = QVBoxLayout(self)
+
+ dist_layout = QHBoxLayout()
+ dist_layout.addWidget(QLabel("Offset Distance (mm):"))
+ self.distance_input = QDoubleSpinBox()
+ self.distance_input.setDecimals(2)
+ self.distance_input.setRange(-10000, 10000)
+ self.distance_input.setValue(10.0)
+ self.distance_input.setSingleStep(0.5)
+ dist_layout.addWidget(self.distance_input)
+ layout.addLayout(dist_layout)
+
+ self.inward_checkbox = QCheckBox("Offset Inward (negative)")
+ self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
+ layout.addWidget(self.inward_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 on every value change.
+ self.distance_input.valueChanged.connect(self._emit_preview)
+ self.inward_checkbox.stateChanged.connect(self._emit_preview)
+
+ def set_preview_callback(self, callback) -> None:
+ """Install the live-preview callback (or *None* to disable)."""
+ self._preview_callback = callback
+ 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:
+ logger.debug("offset preview callback raised: %s", exc)
+
+ def hideEvent(self, event):
+ 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]:
+ return (self.distance_input.value(), self.inward_checkbox.isChecked())
+
+
+class WorkplaneOrientationDialog(QDialog):
+ """Modal dialog to choose the orientation of a new workplane.
+
+ Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
+ can retrieve the chosen orientation via :meth:`get_orientation`, which
+ returns (normal, x_dir) pair (both as 3-tuples).
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("New Workplane Orientation")
+ self.setMinimumWidth(320)
+
+ self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
+ self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
+
+ # Optional callback for live 3D preview of the workplane.
+ # The host installs it via ``set_preview_callback``. The callback
+ # receives ``(normal, x_dir)`` or *None* to clear.
+ self._preview_callback = None
+
+ layout = QVBoxLayout(self)
+
+ # ── Orientation presets ──
+ lbl = QLabel("Choose orientation:")
+ layout.addWidget(lbl)
+
+ self._preset_group = QButtonGroup(self)
+ preset_layout = QGridLayout()
+ presets = [
+ ("XY (Top)", (0, 0, 1), (1, 0, 0)),
+ ("XZ (Front)", (0, 1, 0), (1, 0, 0)),
+ ("YZ (Right)", (1, 0, 0), (0, 1, 0)),
+ ("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
+ ("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
+ ("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
+ ]
+ for idx, (label, normal, x_dir) in enumerate(presets):
+ btn = QRadioButton(label)
+ btn.setChecked(idx == 0)
+ self._preset_group.addButton(btn, idx)
+ btn.normal = normal
+ btn.x_dir = x_dir
+ preset_layout.addWidget(btn, idx // 2, idx % 2)
+ layout.addLayout(preset_layout)
+
+ # ── Custom angle (offset from XY) ──
+ line = QFrame()
+ line.setFrameShape(QFrame.HLine)
+ line.setFrameShadow(QFrame.Sunken)
+ layout.addWidget(line)
+
+ self._custom_radio = QRadioButton("Custom (angle from XY):")
+ self._custom_radio.setChecked(False)
+ layout.addWidget(self._custom_radio)
+
+ angle_layout = QHBoxLayout()
+ angle_layout.addWidget(QLabel("Angle X (°):"))
+ self._angle_x = QDoubleSpinBox()
+ self._angle_x.setDecimals(1)
+ self._angle_x.setRange(-360, 360)
+ self._angle_x.setValue(0.0)
+ self._angle_x.setSuffix("°")
+ angle_layout.addWidget(self._angle_x)
+ angle_layout.addWidget(QLabel("Angle Y (°):"))
+ self._angle_y = QDoubleSpinBox()
+ self._angle_y.setDecimals(1)
+ self._angle_y.setRange(-360, 360)
+ self._angle_y.setValue(0.0)
+ self._angle_y.setSuffix("°")
+ angle_layout.addWidget(self._angle_y)
+ layout.addLayout(angle_layout)
+
+ self._name_label = QLabel("Workplane Name:")
+ layout.addWidget(self._name_label)
+
+ self._name_input = QLineEdit()
+ self._name_input.setText("Workplane 1")
+ layout.addWidget(self._name_input)
+
+ # ── Buttons ──
+ line2 = QFrame()
+ line2.setFrameShape(QFrame.HLine)
+ line2.setFrameShadow(QFrame.Sunken)
+ layout.addWidget(line2)
+
+ button_layout = QHBoxLayout()
+ ok_button = QPushButton("Create")
+ ok_button.clicked.connect(self._on_ok)
+ 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: update the 3D view whenever the user changes
+ # the preset, custom radio toggle, or angle values.
+ self._preset_group.buttonClicked.connect(self._on_preset_changed)
+ self._custom_radio.toggled.connect(self._emit_preview)
+ self._angle_x.valueChanged.connect(self._emit_preview)
+ self._angle_y.valueChanged.connect(self._emit_preview)
+
+ def set_preview_callback(self, callback) -> None:
+ """Install a callback for live 3D preview of the workplane orientation.
+
+ *callback* is called with ``(normal, x_dir)`` whenever the user
+ changes the selection, or with *None* when the dialog closes.
+ """
+ self._preview_callback = callback
+ # Emit once so the initial state shows a preview right away.
+ self._emit_preview()
+
+ def _emit_preview(self, *args) -> None:
+ """Call the preview callback with the current orientation, if installed."""
+ if self._preview_callback is None:
+ return
+ try:
+ normal, x_dir, _name = self.get_orientation()
+ self._preview_callback((normal, x_dir))
+ except Exception as exc:
+ logger.debug("workplane preview callback raised: %s", exc)
+
+ def hideEvent(self, event):
+ """Clear the live preview when the dialog closes."""
+ if self._preview_callback is not None:
+ try:
+ self._preview_callback(None)
+ except Exception:
+ pass
+ super().hideEvent(event)
+
+ def _on_preset_changed(self, btn):
+ """When a preset is selected, deselect the custom radio and emit preview."""
+ self._custom_radio.setChecked(False)
+ self._emit_preview()
+
+ def _on_ok(self):
+ """Compute the final orientation and accept."""
+ import numpy as np
+ import math
+
+ if self._custom_radio.isChecked():
+ # Custom: start from XY normal and rotate by the two angles.
+ ax = math.radians(self._angle_x.value())
+ ay = math.radians(self._angle_y.value())
+ # Start from +Z normal, rotate around X then Y
+ n = np.array([0.0, 0.0, 1.0])
+ # Rotate around X
+ rx = np.array([
+ [1, 0, 0],
+ [0, math.cos(ax), -math.sin(ax)],
+ [0, math.sin(ax), math.cos(ax)],
+ ])
+ n = rx @ n
+ # Rotate around Y
+ ry = np.array([
+ [math.cos(ay), 0, math.sin(ay)],
+ [0, 1, 0],
+ [-math.sin(ay), 0, math.cos(ay)],
+ ])
+ n = ry @ n
+ n = n / np.linalg.norm(n)
+ # x_dir: cross product of normal with world Y, or world Z if normal ~ Y
+ world_y = np.array([0.0, 1.0, 0.0])
+ if abs(np.dot(n, world_y)) > 0.99:
+ world_y = np.array([0.0, 0.0, 1.0])
+ x = np.cross(world_y, n)
+ x_norm = np.linalg.norm(x)
+ if x_norm > 1e-9:
+ x = x / x_norm
+ else:
+ x = np.array([1.0, 0.0, 0.0])
+ self._normal = tuple(float(v) for v in n)
+ self._x_dir = tuple(float(v) for v in x)
+
+ else:
+ btn = self._preset_group.checkedButton()
+ if btn is not None:
+ self._normal = btn.normal
+ self._x_dir = btn.x_dir
+ self.accept()
+
+ def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
+ """Return (normal, x_dir, name) for the chosen workplane.
+
+ Computes the current selection from the UI state so it works
+ whether called before or after ``_on_ok``.
+ """
+ import numpy as np
+ import math
+
+ if self._custom_radio.isChecked():
+ ax = math.radians(self._angle_x.value())
+ ay = math.radians(self._angle_y.value())
+ n = np.array([0.0, 0.0, 1.0])
+ rx = np.array([
+ [1, 0, 0],
+ [0, math.cos(ax), -math.sin(ax)],
+ [0, math.sin(ax), math.cos(ax)],
+ ])
+ n = rx @ n
+ ry = np.array([
+ [math.cos(ay), 0, math.sin(ay)],
+ [0, 1, 0],
+ [-math.sin(ay), 0, math.cos(ay)],
+ ])
+ n = ry @ n
+ n = n / np.linalg.norm(n)
+ world_y = np.array([0.0, 1.0, 0.0])
+ if abs(np.dot(n, world_y)) > 0.99:
+ world_y = np.array([0.0, 0.0, 1.0])
+ x = np.cross(world_y, n)
+ x_norm = np.linalg.norm(x)
+ if x_norm > 1e-9:
+ x = x / x_norm
+ else:
+ x = np.array([1.0, 0.0, 0.0])
+ return (
+ tuple(float(v) for v in n),
+ tuple(float(v) for v in x),
+ self._name_input.text().strip() or "Workplane",
+ )
+ else:
+ btn = self._preset_group.checkedButton()
+ if btn is not None:
+ return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
+ # Fallback: XY default.
+ return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
+
+
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
new file mode 100644
index 0000000..30fab5a
--- /dev/null
+++ b/src/fluency/ui/main_window.py
@@ -0,0 +1,3654 @@
+"""Main application window — application shell, menus, panels, operations."""
+
+from __future__ import annotations
+
+import math
+import logging
+import os
+import sys
+import tempfile
+import uuid
+from datetime import datetime
+from typing import Any, Dict, List, Optional, Tuple
+
+from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
+from PySide6.QtGui import (
+ QAction,
+ QColor,
+ QFont,
+ QIcon,
+ QKeySequence,
+ QPainter,
+ QPainterPath,
+ QPen,
+)
+from PySide6.QtWidgets import (
+ QApplication,
+ QButtonGroup,
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QDockWidget,
+ QDoubleSpinBox,
+ QFileDialog,
+ QFrame,
+ QGridLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QInputDialog,
+ QLabel,
+ QLineEdit,
+ QListWidget,
+ QListWidgetItem,
+ QMainWindow,
+ QMenu,
+ QMenuBar,
+ QMessageBox,
+ QPushButton,
+ QRadioButton,
+ QSizePolicy,
+ QSplitter,
+ QSpinBox,
+ QStatusBar,
+ QTabWidget,
+ QTextEdit,
+ QToolBar,
+ QTreeWidget,
+ QTreeWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+from fluency.geometry_occ.sketch import OCCSketch
+from fluency.geometry.base import Point2D, Point3D
+from fluency.io.project_io import load_project, project_zip_path, save_project
+from fluency.models.data_model import Project, Component, Sketch, Body, Workplane
+from fluency.rendering.occ_renderer import OCCRenderer
+
+from fluency.ui.dialogs import (
+ ExtrudeDialog,
+ OffsetDialog,
+ RevolveDialog,
+ WorkplaneOrientationDialog,
+)
+from fluency.ui.sketch_widget import Sketch2DWidget
+from fluency.ui.viewer_widget import Viewer3DWidget
+
+from gui_ui import Ui_fluencyCAD # auto-generated Qt form (project root on sys.path)
+
+logger = logging.getLogger(__name__)
+
+def _project_face_to_uv(
+ face: Any,
+ workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
+) -> List[List[Tuple[float, float]]]:
+ """Project a planar ``TopoDS_Face``'s boundary edges into the UV frame.
+
+ *workplane* is (origin, normal, x_dir). Returns a list of polylines,
+ each a list of (u, v) points, one per boundary edge (lines → 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
+
+
+def _project_body_to_workplane(
+ body_shape: Any,
+ workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
+) -> List[List[Tuple[float, float]]]:
+ """Project ALL edges of a 3D body onto a workplane, returning UV polylines.
+
+ *workplane* is (origin, normal, x_dir). Every edge (linear and curved)
+ of every face of *body_shape* is projected onto the workplane by mapping
+ each sample point from 3D \u2192 UV (orthographic projection along the
+ workplane normal). The result is a list of polylines, each a list of
+ (u, v) points, suitable as underlay construction lines in the 2D sketch.
+
+ This lets the user see the body's silhouette from the workplane's
+ perspective and draw sketches precisely aligned to the body's features.
+ """
+ import numpy as np
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_WIRE
+ from OCP.TopoDS import TopoDS
+ from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
+ from OCP.GeomAbs import GeomAbs_Line
+ from OCP.gp import gp_Pnt
+
+ origin = np.asarray(workplane[0], dtype=float)
+ normal = np.asarray(workplane[1], dtype=float)
+ x_dir = np.asarray(workplane[2], dtype=float)
+ 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 every face of the body, then each wire/edge within.
+ face_expl = TopExp_Explorer(body_shape, TopAbs_FACE)
+ while face_expl.More():
+ face = face_expl.Current()
+ 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 24 segments \u2014 enough for smooth curves.
+ pts = [crv.Value(f + (l - f) * i / 24.0) for i in range(25)]
+ poly = [world_to_uv(p) for p in pts]
+ polylines.append(poly)
+ except Exception:
+ pass
+ edge_expl.Next()
+ wire_expl.Next()
+ face_expl.Next()
+
+ return polylines
+
+
+def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[Tuple[float, float]]:
+ """Offset a closed polygon by *distance* (positive = outward).
+
+ Uses the edge-normal method: each edge is offset along its outward
+ normal, then adjacent offset edges are intersected to find the new
+ vertex positions. Handles convex polygons well; concave (reflex)
+ corners may produce self-intersecting results for large offsets.
+
+ Returns the offset polygon as a list of (x, y) tuples (same length
+ as *points*, closed).
+ """
+ import math
+ n = len(points)
+ if n < 3:
+ return list(points)
+
+ # Determine polygon orientation (signed area).
+ area = 0.0
+ for i in range(n):
+ j = (i + 1) % n
+ area += points[i][0] * points[j][1] - points[j][0] * points[i][1]
+ is_ccw = area > 0.0
+
+ # Compute edge directions and left normals.
+ edges: List[Tuple[float, float]] = []
+ normals: List[Tuple[float, float]] = []
+ for i in range(n):
+ j = (i + 1) % n
+ dx = points[j][0] - points[i][0]
+ dy = points[j][1] - points[i][1]
+ length = math.hypot(dx, dy)
+ if length < 1e-9:
+ edges.append((0.0, 0.0))
+ normals.append((0.0, 0.0))
+ else:
+ ux = dx / length
+ uy = dy / length
+ edges.append((ux, uy))
+ # Left normal: (-uy, ux)
+ normals.append((-uy, ux))
+
+ # For CCW polygons the left normal points *inward*; flip for outward.
+ if is_ccw:
+ normals = [(-nx, -ny) for (nx, ny) in normals]
+
+ result: List[Tuple[float, float]] = []
+ for i in range(n):
+ prev_i = (i - 1 + n) % n
+ n_prev = normals[prev_i] # outward normal of edge (prev, i)
+ n_curr = normals[i] # outward normal of edge (i, next)
+
+ # Intersect the two offset edge lines to find the new vertex.
+ # Line 1: through points[prev_i] + d*n_prev, direction = edges[prev_i]
+ # Line 2: through points[i] + d*n_curr, direction = edges[i]
+ p1x = points[prev_i][0] + distance * n_prev[0]
+ p1y = points[prev_i][1] + distance * n_prev[1]
+ d1x, d1y = edges[prev_i]
+
+ p2x = points[i][0] + distance * n_curr[0]
+ p2y = points[i][1] + distance * n_curr[1]
+ d2x, d2y = edges[i]
+
+ det = d1x * d2y - d1y * d2x
+ if abs(det) < 1e-9:
+ # Parallel edges — fall back to normal offset.
+ result.append((points[i][0] + distance * n_curr[0],
+ points[i][1] + distance * n_curr[1]))
+ else:
+ diff_x = p2x - p1x
+ diff_y = p2y - p1y
+ t1 = (diff_x * d2y - diff_y * d2x) / det
+ result.append((p1x + t1 * d1x, p1y + t1 * d1y))
+
+ return result
+
+
+
+
+
+
+
+
+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
+
+ # Assembly state
+ self._assembly_component_buttons: List[QPushButton] = []
+ self._assembly_component_group: Optional[QButtonGroup] = None
+ self._assembly_view_active: bool = False
+ self._selected_assembly_component_id: Optional[str] = None
+
+ # Connector two-click state
+ self._connector_first_pick: Optional[Dict[str, Any]] = None
+ self._connector_second_ac_id: Optional[str] = None
+ self._connector_align_pos: Any = None
+
+ # Drag-move state for assembly components
+ self._asm_move_ac_id: Optional[str] = None
+ self._asm_move_start_pos: Any = None
+ # Rigid-group drag state: maps every component id in the dragged
+ # rigid group to its start position, so the whole group translates
+ # together and connected partners keep their solved relative
+ # transforms. Keyed by AssemblyComponent.id.
+ self._asm_move_group_start: Dict[str, Any] = {}
+ # Cached rigid-group membership for the current drag (avoids recomputing
+ # the BFS graph on every mouse-move event).
+ self._asm_move_group_ids: List[str] = []
+ # Cache of render object IDs per assembly component, so drag updates
+ # can replace only the moved component's shapes without clearing the
+ # entire scene (avoids camera flicker).
+ self._asm_render_objects: Dict[str, List[str]] = {}
+
+ # ── Project file state ──
+ # The path the project was loaded from / last saved to. None means
+ # the project is unsaved (the title bar will show "Untitled").
+ # ``_dirty`` is set on any edit; cleared after a successful save.
+ self._project_path: Optional[str] = None
+ self._dirty: bool = False
+ # Suppresses ``_mark_dirty`` while we're setting up the default
+ # project (init / new / open), so a freshly-created empty project
+ # doesn't immediately show as "modified" in the title bar.
+ self._suspend_dirty: bool = True
+
+ self._setup_ui()
+ self._setup_connections()
+ self._create_initial_component()
+ self._create_initial_assembly()
+ self._suspend_dirty = False
+ self._update_window_title()
+ logger.info("MainWindow initialization complete")
+
+ def _setup_ui(self):
+ self.setWindowTitle("Fluency CAD 2.0")
+ self.setMinimumSize(1400, 900)
+
+ # Central widget first so ``self._ui`` is populated when we wire
+ # the File menu actions to their handlers.
+ self._create_central_widget()
+ self._create_menus()
+ self._create_dock_widgets()
+ self._setup_ui_aliases()
+
+ logger.info("Ready")
+
+ def _create_menus(self):
+ """Wire up the File menu actions defined in the .ui file.
+
+ The File menu and its QActions (``actionNew_Project``,
+ ``actionOpen_Project``, ``actionSave_Project``, etc.) are all
+ declared in ``gui.ui`` so they integrate cleanly with the macOS
+ system menubar. This method only connects the actions to their
+ handlers and adds the runtime-only View / Help menus.
+ """
+ # ── File menu actions (defined in gui.ui) ──
+ self._ui.actionNew_Project.triggered.connect(self._new_project)
+ self._ui.actionOpen_Project.triggered.connect(self._open_project)
+ self._ui.actionSave_Project.triggered.connect(self._save_project)
+ self._ui.actionSave_Project_As.triggered.connect(self._save_project_as)
+ self._ui.actionImport_File.triggered.connect(self._import_file)
+ self._ui.actionExport_Step.triggered.connect(self._export_step)
+ self._ui.actionExport_Iges.triggered.connect(self._export_iges)
+ self._ui.actionExport_Stl.triggered.connect(self._export_stl)
+ self._ui.actionExit.triggered.connect(self.close)
+
+ # ── View menu (runtime-only, not in the .ui) ──
+ view_menu = self.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 (runtime-only, not in the .ui) ──
+ help_menu = self.menuBar().addMenu("&Help")
+ help_menu.addAction("About", self._show_about)
+
+ def _create_central_widget(self):
+ """Load the compiled UI file and add programmatic custom widgets."""
+ self._ui = Ui_fluencyCAD()
+ self._ui.setupUi(self)
+
+ # Keep a reference to the grid for panel-focus management.
+ self._grid = self._ui.gridLayout
+
+ # -- Add programmatic custom widgets to their placeholder locations --
+
+ # Sketch2DWidget goes in the sketch tab’s QVBoxLayout.
+ self._sketch_widget = Sketch2DWidget()
+ self._ui.sketch_tab.layout().addWidget(self._sketch_widget)
+
+ # Viewer3DWidget goes in the gl_box QHBoxLayout.
+ self._viewer_3d = Viewer3DWidget()
+ self._ui.gl_box.layout().addWidget(self._viewer_3d)
+
+ # Code editor — use the UI’s textEdit with our custom font.
+ self._code_edit = self._ui.textEdit
+ self._code_edit.setFont(QFont("Monaco", 10))
+ self._code_edit.setPlaceholderText("# Enter Python code here...")
+
+ # Component buttons (dynamically generated per component, not in UI).
+ 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)
+ # Add to the Components group box from the UI.
+ compo_layout = self._ui.compo_box.layout()
+ if compo_layout is None:
+ compo_layout = QHBoxLayout(self._ui.compo_box)
+ compo_layout.setContentsMargins(0, 0, 0, 0)
+ compo_layout.addWidget(self._component_box)
+ compo_layout.addStretch()
+
+ # ── Assembly box (dynamic buttons like component box) ──
+ self._assembly_box = QWidget()
+ self._assembly_box_layout = QHBoxLayout(self._assembly_box)
+ self._assembly_box_layout.setAlignment(Qt.AlignLeft)
+ self._assembly_component_group = QButtonGroup(self)
+ self._assembly_component_group.setExclusive(True)
+ # Add to the Assembly group box from the UI.
+ asm_layout = self._ui.assembly_box.layout()
+ if asm_layout is None:
+ asm_layout = QHBoxLayout(self._ui.assembly_box)
+ asm_layout.setContentsMargins(0, 0, 0, 0)
+ asm_layout.addWidget(self._assembly_box)
+ asm_layout.addStretch()
+
+ # ── Assembly Move button (programmatic, in assembly_tools) ──
+ self._btn_asm_move = QPushButton("Pos")
+ self._btn_asm_move.setCheckable(True)
+ self._btn_asm_move.setMinimumSize(QSize(50, 50))
+ self._btn_asm_move.setMaximumSize(QSize(50, 50))
+ self._btn_asm_move.setToolTip("Toggle: click a body in 3D and drag to move the assembly component")
+ asm_tools_layout = self._ui.assembly_tools.layout()
+ if asm_tools_layout is not None:
+ asm_tools_layout.addWidget(self._btn_asm_move, 0, 2, 1, 1)
+
+ # Panel-focus mode (equal | sketch | viewer).
+ self._panel_focus: str = "equal"
+
+ def _setup_ui_aliases(self):
+ """Create _btn_* aliases pointing to the UI-loaded widgets.
+
+ The rest of the application references widgets via ``self._btn_*``
+ names. This method maps those to the ``pb_*`` / ``pushButton_*``
+ names created by the compiled UI file so existing signal connections
+ and mode-switching code continues to work unchanged.
+ """
+ ui = self._ui
+ # ── Workplanes ──
+ self._btn_wp_origin = ui.pb_origin_wp
+ self._btn_wp_face = ui.pb_origin_face
+ self._btn_wp_flip = ui.pb_flip_face
+ self._btn_wp_new = ui.pb_wp_new
+ self._btn_underlay = ui.pb_underlay
+ self._btn_clr_face = ui.pb_clr_face
+ self._btn_to_sketch = ui.pb_to_sketch
+ # ── Drawing ──
+ self._btn_line = ui.pb_linetool
+ self._btn_rect = ui.pb_rectool
+ self._btn_circle = ui.pb_circtool
+ self._btn_slot = ui.pb_slotool
+ self._btn_arc = ui.pb_arc_tool
+ self._btn_construct = ui.pb_enable_construct
+ self._btn_snap = ui.pb_enable_snap
+ self._btn_offset = ui.pb_offset_tool
+ # ── Constrain ──
+ self._btn_con_ptpt = ui.pb_con_ptpt
+ self._btn_con_ptline = ui.pb_con_line
+ self._btn_con_mid = ui.pb_con_mid
+ self._btn_con_perp = ui.pb_con_perp
+ self._btn_con_horiz = ui.pb_con_horiz
+ self._btn_con_vert = ui.pb_con_vert
+ self._btn_con_dist = ui.pb_con_dist
+ self._btn_con_sym = ui.pb_con_sym
+ # ── Snaps ──
+ self._btn_snap_point = ui.pushButton_8
+ self._btn_snap_mid = ui.pb_snap_midp
+ self._btn_snap_horiz = ui.pb_snap_horiz
+ self._btn_snap_vert = ui.pb_snap_vert
+ self._btn_snap_angle = ui.pb_snap_angle
+ self._btn_snap_grid = ui.pushButton_7
+ self._spin_snap_dist = ui.spinbox_snap_distance
+ self._spin_angle = ui.spinbox_angle_steps
+ # ── Modify ──
+ self._btn_extrude = ui.pb_extrdop
+ self._btn_cut = ui.pb_cutop
+ self._btn_combine = ui.pb_combop
+ self._btn_move = ui.pb_moveop
+ self._btn_revolve = ui.pb_revop
+ self._btn_array = ui.pb_arrayop
+ # ── Export ──
+ self._btn_export_stl = ui.pushButton_2
+ self._btn_export_step = ui.pb_export_step
+ self._btn_export_iges = ui.pb_export_iges
+ # ── Sketch list tools ──
+ self._btn_add_sketch = ui.pb_nw_sktch
+ self._btn_edit_sketch = ui.pb_edt_sktch
+ self._btn_del_sketch = ui.pb_del_sketch
+ # ── Body tools ──
+ self._btn_update_body = ui.pb_update_body
+ self._btn_edit_sketch_3 = ui.pb_edt_sktch_3
+ self._btn_del_body = ui.pb_del_body
+ # ── Component tools ──
+ self._btn_new_compo = ui.pb_new_compo
+ self._btn_del_compo = ui.pb_del_compo
+ # ── Assembly / Connector ──
+ self._btn_compo_to_assembly = ui.pb_compo_to_assembly
+ self._btn_remove_compo_from_assembly = ui.pb_remove_compo_from_assembly
+ self._btn_add_connector = ui.pb_add_connector
+ self._btn_add_connector.setCheckable(True)
+ self._btn_del_connector = ui.pb_remove_connector
+ # ── Code tab ──
+ self._btn_apply_code = ui.pb_apply_code
+ self._btn_load_code = ui.pushButton_5
+ self._btn_save_code = ui.pushButton_4
+ self._btn_del_code = ui.pushButton
+ # ── List views & tabs ──
+ self._sketch_list = ui.sketch_list
+ self._body_list = ui.body_list
+ self._input_tabs = ui.InputTab
+
+ 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_arc.clicked.connect(lambda: self._set_sketch_mode("arc"))
+ self._btn_slot.clicked.connect(lambda: self._set_sketch_mode("slot"))
+ 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_compo_to_assembly.clicked.connect(self._add_component_to_assembly)
+ self._btn_remove_compo_from_assembly.clicked.connect(self._remove_component_from_assembly)
+ self._btn_asm_move.toggled.connect(self._on_assembly_move_toggled)
+ self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
+ self._btn_del_connector.clicked.connect(self._on_delete_connector)
+ self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
+ self._viewer_3d.connectorHover.connect(self._on_connector_hover)
+ self._viewer_3d.connectorPickCancelled.connect(
+ lambda: self._btn_add_connector.setChecked(False)
+ )
+ self._viewer_3d.assemblyComponentActivated.connect(
+ self._on_assembly_move_activated
+ )
+ self._viewer_3d.assemblyComponentDragged.connect(
+ self._on_assembly_move_dragged
+ )
+ self._viewer_3d.assemblyMoveFinished.connect(
+ self._on_assembly_move_finished
+ )
+
+ 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_new.clicked.connect(self._new_workplane)
+ self._btn_wp_flip.clicked.connect(self._flip_workplane)
+ # Underlay show/hide, ClrFace, and ToSketch — all stay in sync
+ # with the source face state managed by set_source_face /
+ # clear_source_face / _project_body_to_active_wp.
+ self._btn_underlay.toggled.connect(self._on_underlay_toggled)
+ self._btn_clr_face.clicked.connect(self._on_clear_source_face)
+ self._btn_to_sketch.clicked.connect(self._on_convert_underlay_to_sketch)
+
+ # Generic buttons
+ self._btn_move.clicked.connect(self._translate_body)
+ self._btn_array.clicked.connect(self._pattern_array)
+ self._btn_offset.clicked.connect(self._offset_sketch)
+ 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 _create_initial_assembly(self):
+ """Create the initial assembly in the project."""
+ self._project.add_assembly()
+ logger.info("Created initial assembly")
+
+ 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_arc,
+ self._btn_slot,
+ 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", "arc", "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 == "arc":
+ self._btn_arc.setChecked(True)
+ elif mode == "slot":
+ self._btn_slot.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_arc,
+ self._btn_slot,
+ 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
+ self._mark_dirty()
+ 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._assembly_view_active = False
+ 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}")
+
+ # Re-add workplane visuals after the clear.
+ for wp_id, wp in self._current_component.workplanes.items():
+ if wp.visible:
+ wp.render_object = self._viewer_3d.show_workplane(
+ origin=wp.origin,
+ normal=wp.normal,
+ x_dir=wp.x_dir,
+ size=250.0,
+ name=f"workplane_{wp.id}",
+ )
+
+ self._viewer_3d.fit_camera()
+
+ # ────────────────────────────────────────────────────────────────────
+ # Assembly methods
+ # ────────────────────────────────────────────────────────────────────
+
+ def _get_assembly(self) -> Optional[Any]:
+ """Get the active assembly from the project."""
+ assembly = self._project.get_active_assembly()
+ if assembly is None:
+ assembly = self._project.add_assembly()
+ return assembly
+
+ def _add_component_to_assembly(self):
+ """Add the currently selected component to the assembly.
+
+ Creates a new button in the assembly box and stores the
+ component instance in the assembly model.
+ """
+ if self._current_component is None:
+ logger.warning("No active component to add to assembly")
+ return
+
+ assembly = self._get_assembly()
+
+ # Create an instance of the current component in the assembly.
+ ac = assembly.add_component_instance(
+ component_id=self._current_component.id,
+ name=f"{self._current_component.name}",
+ )
+ logger.info(
+ f"Added component '{self._current_component.name}' "
+ f"to assembly '{assembly.name}' (instance={ac.id})"
+ )
+
+ # Create a button for this assembly component.
+ instance_num = len(assembly.components)
+ label = f"{instance_num}"
+ btn = QPushButton(label)
+ btn.setCheckable(True)
+ btn.setFixedSize(QSize(40, 40))
+ btn.setToolTip(f"{ac.name} (instance {list(assembly.components.keys()).index(ac.id) + 1})")
+ # Store the assembly component id in the button.
+ btn._assembly_component_id = ac.id
+ btn.clicked.connect(self._on_assembly_component_clicked)
+
+ # Uncheck all other assembly buttons, check this one.
+ for b in self._assembly_component_buttons:
+ b.setChecked(False)
+ btn.setChecked(True)
+
+ self._assembly_component_buttons.append(btn)
+ self._assembly_component_group.addButton(btn)
+ self._assembly_box_layout.addWidget(btn)
+
+ # Store the selected id and activate assembly view.
+ self._selected_assembly_component_id = ac.id
+ self._assembly_view_active = True
+ self._mark_dirty()
+
+ # Show the assembly in the viewer, framing all components.
+ self._show_assembly_in_viewer(fit=True)
+
+ def _remove_component_from_assembly(self):
+ """Remove the currently selected assembly component."""
+ assembly = self._get_assembly()
+ if not assembly or not assembly.components:
+ logger.warning("Assembly is empty, nothing to remove")
+ return
+
+ # Find the active assembly component id from the checked button.
+ active_id = self._get_active_assembly_component_id()
+ if active_id is None:
+ logger.warning("No assembly component selected to remove")
+ return
+
+ # Find the button index for this assembly component.
+ idx = -1
+ for i, btn in enumerate(self._assembly_component_buttons):
+ if getattr(btn, '_assembly_component_id', None) == active_id:
+ idx = i
+ break
+
+ if idx >= 0:
+ assembly.remove_component_instance(active_id)
+
+ btn = self._assembly_component_buttons.pop(idx)
+ self._assembly_component_group.removeButton(btn)
+ btn.deleteLater()
+
+ # Select the first remaining button if any.
+ if self._assembly_component_buttons:
+ self._assembly_component_buttons[0].setChecked(True)
+ first_id = getattr(self._assembly_component_buttons[0], '_assembly_component_id', None)
+ self._selected_assembly_component_id = first_id
+ self._assembly_view_active = True
+ else:
+ self._selected_assembly_component_id = None
+ self._assembly_view_active = False
+ # Fall back to normal component view.
+ self._redraw_bodies()
+ return
+
+ logger.info(f"Removed assembly component instance {active_id}")
+ self._mark_dirty()
+ self._show_assembly_in_viewer(fit=True)
+
+ def _get_active_assembly_component_id(self) -> Optional[str]:
+ """Get the assembly component id of the currently checked button."""
+ for btn in self._assembly_component_buttons:
+ if btn.isChecked():
+ return getattr(btn, '_assembly_component_id', None)
+ return None
+
+ def _on_assembly_component_clicked(self):
+ """Handle an assembly component button click.
+
+ Shows all components from the assembly in the 3D viewer,
+ with the clicked component highlighted/selected.
+ """
+ # Find which assembly component id was clicked.
+ active_id = self._get_active_assembly_component_id()
+ if active_id is None:
+ return
+
+ self._selected_assembly_component_id = active_id
+ self._assembly_view_active = True
+
+ self._show_assembly_in_viewer(fit=True)
+
+ def _apply_transform(self, shape: Any, position, rotation) -> Any:
+ """Apply a position translation and rotation matrix to a shape.
+
+ Returns a new transformed TopoDS_Shape. If position is zero and
+ rotation is identity the original shape is returned unchanged.
+ """
+ import numpy as np
+ from OCP.gp import gp_Trsf
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
+
+ pos = np.asarray(position, dtype=float)
+ rot = np.asarray(rotation, dtype=float)
+
+ # Skip if identity.
+ if np.allclose(pos, 0.0) and np.allclose(rot, np.eye(3)):
+ return shape
+
+ # Use SetValues for a combined rotation + translation transform.
+ # gp_Trsf.SetValues takes 12 values forming a 3x4 matrix:
+ # [R11 R12 R13 Tx]
+ # [R21 R22 R23 Ty]
+ # [R31 R32 R33 Tz]
+ trsf = gp_Trsf()
+ trsf.SetValues(
+ float(rot[0, 0]), float(rot[0, 1]), float(rot[0, 2]), float(pos[0]),
+ float(rot[1, 0]), float(rot[1, 1]), float(rot[1, 2]), float(pos[1]),
+ float(rot[2, 0]), float(rot[2, 1]), float(rot[2, 2]), float(pos[2]),
+ )
+
+ transformer = BRepBuilderAPI_Transform(shape, trsf, False)
+ transformer.Build()
+ return transformer.Shape()
+
+ def _make_connector_marker(self, position: Tuple[float, float, float],
+ color: Tuple[float, float, float] = (1.0, 0.3, 0.0)) -> Optional[Any]:
+ """Create a small sphere marker for a connector at *position*.
+
+ Returns the TopoDS_Shape of a tiny sphere, or None on failure.
+ """
+ try:
+ from OCP.gp import gp_Pnt
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
+ sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), 2.0).Shape()
+ return sphere
+ except Exception as exc:
+ logger.debug(f"Failed to create connector marker: {exc}")
+ return None
+
+ def _show_assembly_in_viewer(self, fit: bool = False):
+ """Show all components from the assembly in the 3D viewer.
+
+ All bodies from all assembly component instances are displayed
+ together with their position/rotation transforms applied.
+ The component whose button is checked gets a highlight color;
+ the rest are shown in a neutral/dimmed color.
+ Connector markers (small orange spheres) are also shown.
+
+ Pass *fit=True* to also frame all visible components with the
+ camera (use when switching to assembly view via button clicks;
+ omit during drag to avoid camera flicker).
+ """
+ assembly = self._get_assembly()
+ if not assembly or not assembly.components:
+ self._viewer_3d.clear_scene()
+ return
+
+ self._viewer_3d.clear_scene()
+ # Reset the render-object cache; it will be rebuilt below.
+ self._asm_render_objects.clear()
+
+ highlight_color = (0.2, 0.6, 1.0) # Bright blue for selected
+ dim_color = (0.5, 0.5, 0.5) # Grey for non-selected
+
+ shown_any = False
+ for ac_id, ac in assembly.components.items():
+ comp = self._project.get_component_by_id(ac.component_id)
+ if comp is None:
+ logger.debug(f"Assembly component {ac_id} references missing component {ac.component_id}")
+ continue
+
+ is_selected = (ac_id == self._selected_assembly_component_id)
+ color = highlight_color if is_selected else dim_color
+
+ render_ids: List[str] = []
+ for body_id, body in comp.bodies.items():
+ if body.geometry:
+ try:
+ shape = self._kernel._get_shape(body.geometry)
+ # Apply component instance transform.
+ transformed = self._apply_transform(
+ shape, ac.position, ac.rotation
+ )
+ obj_id = f"asm_{ac_id}_{body_id}"
+ render_obj = self._viewer_3d.show_shape(
+ transformed,
+ color=color,
+ name=obj_id,
+ )
+ render_ids.append(obj_id)
+ shown_any = True
+ except Exception as exc:
+ logger.debug(f"Failed to show body {body_id} in assembly: {exc}")
+
+ self._asm_render_objects[ac_id] = render_ids
+
+ # Show connector markers for this instance.
+ for conn_id, conn in ac.connectors.items():
+ try:
+ sphere_shape = self._make_connector_marker(conn.position)
+ if sphere_shape is not None:
+ self._viewer_3d.show_shape(
+ sphere_shape,
+ color=(1.0, 0.3, 0.0), # Orange
+ name=f"conn_{ac_id}_{conn_id}",
+ )
+ except Exception as exc:
+ logger.debug(f"Failed to show connector {conn_id}: {exc}")
+
+ if shown_any and fit:
+ self._viewer_3d.fit_camera()
+
+ def _update_assembly_component_in_viewer(self, ac_id: str):
+ """Replace only the shapes of a single assembly component in-place.
+
+ Removes the existing render objects for *ac_id* from the viewer
+ and recreates them at the component's current position/rotation.
+ Other components and connector markers are left untouched — no
+ scene clear, so the camera stays perfectly still.
+ """
+ assembly = self._get_assembly()
+ ac = assembly.components.get(ac_id) if assembly else None
+ if ac is None:
+ return
+
+ comp = self._project.get_component_by_id(ac.component_id)
+ if comp is None:
+ return
+
+ # Remove old render objects for this component.
+ old_ids = self._asm_render_objects.pop(ac_id, [])
+ for oid in old_ids:
+ try:
+ self._viewer_3d.remove_mesh(oid)
+ except Exception:
+ pass
+
+ is_selected = (ac_id == self._selected_assembly_component_id)
+ color = (0.2, 0.6, 1.0) if is_selected else (0.5, 0.5, 0.5)
+
+ new_ids: List[str] = []
+ for body_id, body in comp.bodies.items():
+ if body.geometry:
+ try:
+ shape = self._kernel._get_shape(body.geometry)
+ transformed = self._apply_transform(
+ shape, ac.position, ac.rotation
+ )
+ obj_id = f"asm_{ac_id}_{body_id}"
+ self._viewer_3d.show_shape(
+ transformed,
+ color=color,
+ name=obj_id,
+ )
+ new_ids.append(obj_id)
+ except Exception as exc:
+ logger.debug(f"Failed to update body {body_id}: {exc}")
+
+ self._asm_render_objects[ac_id] = new_ids
+
+ # ────────────────────────────────────────────────────────────────────
+ # Assembly 3D drag-move
+ # ────────────────────────────────────────────────────────────────────
+
+ def _on_assembly_move_toggled(self, checked: bool):
+ """Toggle 3D drag-to-move mode in the viewer.
+
+ When active, clicking a body in the assembly view and dragging
+ moves its assembly component in real-time. Shift+drag moves in Z.
+ """
+ if checked and not self._assembly_view_active:
+ self._btn_asm_move.setChecked(False)
+ QMessageBox.warning(self, "Assembly View",
+ "Switch to assembly view first by clicking an assembly component button.")
+ return
+ self._viewer_3d.set_assembly_move_mode(checked)
+ if checked:
+ self._viewer_3d.setFocus()
+ self._viewer_3d.activateWindow()
+ self.setStatusTip("Drag a body to move it; Shift+drag for Z depth")
+ else:
+ self.setStatusTip("")
+
+ def _on_assembly_move_activated(self, owner_obj_id: str):
+ """Called when the user clicks a body in move mode.
+
+ Parse the assembly component id, compute the rigid group it belongs
+ to (transitively via mated connectors), and snapshot EVERY member's
+ start position so the whole group can translate together during the
+ drag. The first-picked component of each mated pair stays as the
+ grounded reference frame for the solver; for a pure-translation
+ drag that just means we preserve all current relative transforms.
+ """
+ import numpy as np
+
+ ac_id = self._parse_ac_id(owner_obj_id)
+ if ac_id is None:
+ return
+
+ assembly = self._get_assembly()
+ ac = assembly.components.get(ac_id)
+ if ac is None:
+ return
+
+ self._asm_move_ac_id = ac_id
+ # Rigid group membership (BFS over mated-connector connections).
+ group_ids = assembly.get_rigid_group(ac_id)
+ self._asm_move_group_ids = group_ids
+ self._asm_move_group_start = {}
+ for gid in group_ids:
+ g_ac = assembly.components.get(gid)
+ if g_ac is not None:
+ self._asm_move_group_start[gid] = np.array(g_ac.position, dtype=float)
+ # Keep the legacy single-component start for backwards compatibility.
+ self._asm_move_start_pos = np.array(ac.position, dtype=float)
+
+ def _on_assembly_move_dragged(self, owner_obj_id: str, dx: float, dy: float, dz: float):
+ """Propagate a drag move across the entire rigid group, in-place.
+
+ Every component in the dragged rigid group receives the SAME world
+ translation delta (relative to its own start position), so the mated
+ relative transforms are preserved exactly and SolveSpace's solved
+ alignment stays valid throughout the drag. Each member is updated
+ in-place via ``_update_assembly_component_in_viewer`` so the camera
+ never flickers.
+ """
+ if self._asm_move_ac_id is None or self._asm_move_start_pos is None:
+ return
+
+ ac_id = self._asm_move_ac_id
+ assembly = self._get_assembly()
+ ac = assembly.components.get(ac_id)
+ if ac is None:
+ return
+
+ import numpy as np
+ delta = np.array([dx, dy, dz], dtype=float)
+ # Propagate the same delta to every rigid-group member.
+ group_ids = self._asm_move_group_ids or [ac_id]
+ for gid in group_ids:
+ start = self._asm_move_group_start.get(gid)
+ if start is None:
+ continue
+ g_ac = assembly.components.get(gid)
+ if g_ac is None:
+ continue
+ g_ac.position = start + delta
+ # Update only this component's shapes — no scene clear.
+ self._update_assembly_component_in_viewer(gid)
+
+ def _on_assembly_move_finished(self, owner_obj_id: str):
+ """Finalize the drag move."""
+ if self._asm_move_ac_id is not None:
+ members = len(self._asm_move_group_ids) if self._asm_move_group_ids else 1
+ logger.info(
+ f"Moved assembly rigid group led by {self._asm_move_ac_id} "
+ f"({members} member(s)) to final position"
+ )
+ self._mark_dirty()
+ self._asm_move_ac_id = None
+ self._asm_move_start_pos = None
+ self._asm_move_group_start = {}
+ self._asm_move_group_ids = []
+
+ # ────────────────────────────────────────────────────────────────────
+ # Connector methods — two-click selection + preview dialog
+ # ────────────────────────────────────────────────────────────────────
+
+ @staticmethod
+ def _parse_ac_id(owner_obj_id: str) -> Optional[str]:
+ """Extract the assembly component id from a renderer owner_obj_id.
+
+ Format: asm_{ac_id}_{body_id}
+ """
+ if not owner_obj_id or not owner_obj_id.startswith("asm_"):
+ return None
+ parts = owner_obj_id.split("_")
+ if len(parts) >= 3:
+ return parts[1]
+ return owner_obj_id[4:]
+
+ def _on_start_connector_placement(self, checked: bool):
+ """Toggle connector pick mode.
+
+ First click selects the first component's connection entity.
+ Second click selects the second component and triggers SolveSpace alignment.
+ """
+ if not self._assembly_view_active:
+ self._btn_add_connector.setChecked(False)
+ QMessageBox.warning(self, "Assembly View",
+ "Switch to assembly view first by clicking an assembly component button.")
+ return
+
+ # Reset any in-progress two-click state.
+ self._connector_first_pick = None
+ self._connector_second_ac_id = None
+ self._connector_align_pos = None
+
+ self._viewer_3d.set_connector_pick_mode(checked)
+ if checked:
+ self._viewer_3d.setFocus()
+ self._viewer_3d.activateWindow()
+ self.setStatusTip("Click on the first component's connection point/face/edge/hole")
+ else:
+ self.setStatusTip("")
+
+ def _on_connector_hover(self, info) -> None:
+ """Show entity-type feedback in the status bar during connector pick.
+
+ The gizmo itself is drawn by the viewer; this just reports what
+ entity is under the cursor so the user knows what they will snap to.
+ """
+ if info is None:
+ self.statusBar().showMessage("Move over a face / edge / hole / vertex to snap")
+ return
+ entity_type = info.get("type", "")
+ names = {
+ "planar_face": "Face",
+ "cylindrical_face": "Hole",
+ "edge": "Edge",
+ "vertex": "Vertex",
+ }
+ name = names.get(entity_type, "Entity")
+ ac_id = self._parse_ac_id(info.get("owner_obj_id", ""))
+ comp_name = ""
+ if ac_id is not None:
+ assembly = self._get_assembly()
+ ac = assembly.components.get(ac_id) if assembly else None
+ if ac is not None:
+ comp_name = f" on {ac.name}"
+ self.statusBar().showMessage(f"Snap target: {name}{comp_name} — click to pick")
+
+ def _on_connector_picked(self, origin, normal, x_dir, entity_type, raw_shape, owner_obj_id):
+ """Handle a connector entity pick — first or second click.
+
+ Snaps to faces, cylindrical holes, edges, or vertices.
+ Stores connector in component-local coordinates so it stays
+ valid when the component is moved by the solver.
+ """
+ import numpy as np
+
+ ac_id = self._parse_ac_id(owner_obj_id)
+ if ac_id is None:
+ QMessageBox.warning(self, "Pick Error",
+ "Could not identify which assembly component was clicked.")
+ return
+
+ assembly = self._get_assembly()
+ ac = assembly.components.get(ac_id)
+ if ac is None:
+ QMessageBox.warning(self, "Pick Error",
+ "The clicked component was not found in the assembly.")
+ return
+
+ # Convert world-space connector to component-local coordinates.
+ # p_local = R^T @ (p_world - P)
+ pos_world = np.array(origin, dtype=float)
+ rot = ac.rotation
+ pos_local = rot.T @ (pos_world - ac.position)
+
+ n_world = np.array(normal, dtype=float)
+ n_local = rot.T @ n_world
+ n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
+
+ x_world = np.array(x_dir, dtype=float) if x_dir else np.array([1.0, 0.0, 0.0])
+ x_local = rot.T @ x_world
+ x_local = x_local / max(np.linalg.norm(x_local), 1e-12)
+
+ # ── First pick ──
+ if self._connector_first_pick is None:
+ self._connector_first_pick = {
+ "ac_id": ac_id,
+ "origin_local": tuple(pos_local),
+ "normal_local": tuple(n_local),
+ "x_dir_local": tuple(x_local),
+ "origin_world": tuple(origin),
+ "normal_world": tuple(normal),
+ "entity_type": entity_type,
+ "owner_obj_id": owner_obj_id,
+ }
+ # Highlight the first face if planar.
+ if entity_type in ("planar_face", "cylindrical_face"):
+ self._viewer_3d.highlight_face(raw_shape)
+ self.setStatusTip("Now click on the second component's connection point/face/edge/hole")
+ logger.info(f"Connector first pick: {ac.name} at {origin} ({entity_type})")
+ return
+
+ # ── Second pick ──
+ first = self._connector_first_pick
+
+ # Don't allow picking the same component twice.
+ if ac_id == first["ac_id"]:
+ QMessageBox.warning(self, "Same Component",
+ "Pick a different component for the second connection point.")
+ return
+
+ self._connector_second_ac_id = ac_id
+ self._viewer_3d.clear_face_highlight()
+ self._viewer_3d.set_connector_pick_mode(False)
+ self._btn_add_connector.setChecked(False)
+ self.setStatusTip("")
+
+ logger.info(f"Connector second pick: {ac.name} at {origin} ({entity_type})")
+
+ # Build connector records (local coords).
+ second_pick = {
+ "ac_id": ac_id,
+ "origin_local": tuple(pos_local),
+ "normal_local": tuple(n_local),
+ "x_dir_local": tuple(x_local),
+ "origin_world": tuple(origin),
+ "normal_world": tuple(normal),
+ "entity_type": entity_type,
+ "owner_obj_id": owner_obj_id,
+ }
+
+ # SolveSpace alignment: move second component so its connector
+ # aligns with the first. First component is fixed.
+ first_ac = assembly.components.get(first["ac_id"])
+ second_ac = ac
+
+ # Compute the world target for the second connector.
+ # It's at the first connector world position.
+ target_pos = np.array(first["origin_world"], dtype=float)
+ target_normal = np.array(first["normal_world"], dtype=float)
+ target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
+
+ # SolveSpace solver call.
+ solved = self._solve_assembly_alignment(
+ first_ac=first_ac,
+ second_ac=second_ac,
+ first_pick=first,
+ second_pick=second_pick,
+ )
+
+ if solved is None:
+ QMessageBox.warning(self, "Solver Error",
+ "SolveSpace could not align the components.")
+ self._connector_first_pick = None
+ self._connector_second_ac_id = None
+ self._show_assembly_in_viewer(fit=True)
+ return
+
+ # Apply solved transform to second component.
+ second_ac.position = solved["position"]
+ second_ac.rotation = solved["rotation"]
+
+ # Show dialog with live preview (rotation offset along normal).
+ rotation, offset, flip = self._show_connector_dialog_with_preview(
+ first_ac=first_ac,
+ second_ac=second_ac,
+ first_pick=first,
+ second_pick=second_pick,
+ solved=solved,
+ )
+
+ if rotation is None:
+ # User cancelled — restore original position.
+ second_ac.position = np.array(solved["original_position"], dtype=float)
+ second_ac.rotation = np.array(solved["original_rotation"], dtype=float)
+ self._connector_first_pick = None
+ self._connector_second_ac_id = None
+ self._show_assembly_in_viewer(fit=True)
+ return
+
+ # Apply dialog adjustments (rotation + offset + flip).
+ import numpy as np
+ # Build rotation matrix: rotate second connector normal around
+ # the target normal axis by rotation degrees.
+ angle_rad = np.radians(rotation)
+ # Rodrigues' rotation formula around target_normal.
+ k = target_normal
+ K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
+ R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
+
+ # Apply axis rotation to the solved rotation.
+ second_ac.rotation = R_axis @ second_ac.rotation
+
+ # Offset along the (possibly flipped) target normal.
+ flip_sign = -1.0 if flip else 1.0
+ second_ac.position = second_ac.position + flip_sign * target_normal * offset
+
+ # Create connectors on both components and link them as a mated pair.
+ conn1 = None
+ conn2 = None
+ if first_ac:
+ conn1 = first_ac.add_connector(
+ position=first["origin_world"],
+ normal=first["normal_world"],
+ x_dir=first["x_dir_local"],
+ source_obj_id=first["owner_obj_id"],
+ name=f"Conn {entity_type} A",
+ )
+ conn1.axis_rotation = rotation
+ conn1.offset = offset
+ # The first-picked connector is the grounded reference of the pair.
+ conn1.is_grounded = True
+
+ if second_ac:
+ conn2 = second_ac.add_connector(
+ position=tuple(second_ac.position + second_ac.rotation @ np.array(second_pick["origin_local"])),
+ normal=tuple(second_ac.rotation @ np.array(second_pick["normal_local"])),
+ x_dir=tuple(second_ac.rotation @ np.array(second_pick["x_dir_local"])),
+ source_obj_id=owner_obj_id,
+ name=f"Conn {entity_type} B",
+ )
+ conn2.axis_rotation = rotation
+ conn2.offset = offset
+
+ # Cross-link the partners so the rigid-group move handler can follow
+ # the edge, and register the pair on the assembly graph.
+ if conn1 is not None and conn2 is not None:
+ conn1.partner_ac_id = second_ac.id
+ conn1.partner_connector_id = conn2.id
+ conn2.partner_ac_id = first_ac.id
+ conn2.partner_connector_id = conn1.id
+ assembly.add_connection(first_ac.id, second_ac.id)
+
+ logger.info(f"Connected component pair: {first['ac_id']} ↔ {ac_id}, rotation={rotation}°, offset={offset}mm, flip={flip}")
+ self._connector_first_pick = None
+ self._connector_second_ac_id = None
+ self._mark_dirty()
+ self._show_assembly_in_viewer(fit=True)
+
+ @staticmethod
+ def _rotation_between_vectors(a, b):
+ """Return a 3×3 rotation that maps vector *a* onto vector *b*.
+
+ Handles the two degenerate cases that plain Rodrigues' formula gets
+ wrong when the cross-product axis collapses to zero:
+
+ * ``a ≈ b`` → identity (no rotation needed).
+ * ``a ≈ -b`` → a 180° rotation about any axis orthogonal to *a*
+ (picked by a stable reference-vector projection).
+
+ Vectors are internally normalized so callers may pass non-unit input.
+ """
+ import numpy as _np
+ import math as _math
+ a = _np.asarray(a, dtype=float)
+ b = _np.asarray(b, dtype=float)
+ an = _np.linalg.norm(a); bn = _np.linalg.norm(b)
+ if an < 1e-12 or bn < 1e-12:
+ return _np.eye(3)
+ a = a / an; b = b / bn
+ dot = float(_np.dot(a, b))
+ cross = _np.cross(a, b)
+ cross_norm = _np.linalg.norm(cross)
+ if cross_norm < 1e-9:
+ if dot > 0.0:
+ # Already aligned.
+ return _np.eye(3)
+ # Anti-parallel: 180° about an axis orthogonal to *a*.
+ ref = _np.array([1.0, 0.0, 0.0]) if abs(a[0]) < 0.9 else _np.array([0.0, 1.0, 0.0])
+ axis = ref - a * _np.dot(ref, a)
+ axis = axis / max(_np.linalg.norm(axis), 1e-12)
+ K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
+ # sin(180°)=0, 1-cos(180°)=2 → R = I + 2 (K @ K)
+ return _np.eye(3) + 2.0 * (K @ K)
+ axis = cross / cross_norm
+ angle = _math.acos(max(-1.0, min(1.0, dot)))
+ K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
+ return _np.eye(3) + _np.sin(angle) * K + (1.0 - _np.cos(angle)) * (K @ K)
+
+ def _solve_assembly_alignment(
+ self,
+ first_ac: Any,
+ second_ac: Any,
+ first_pick: Dict[str, Any],
+ second_pick: Dict[str, Any],
+ ) -> Optional[Dict[str, Any]]:
+ """Use SolveSpace to align the second component to the first.
+
+ The first component is treated as fixed (grounded). The second
+ component is moved so that its connector coincides with the first
+ connector (position + normal alignment).
+
+ Returns a dict with:
+ * ``position`` — new world position for second component.
+ * ``rotation`` — new 3×3 rotation matrix for second component.
+ * ``original_position`` / ``original_rotation`` — for cancellation.
+ """
+ import numpy as np
+ try:
+ from python_solvespace import SolverSystem, ResultFlag, Entity
+ except ImportError:
+ logger.warning("python_solvespace not available, falling back to direct alignment")
+ return self._align_direct(first_ac, second_ac, first_pick, second_pick)
+
+ # Save original transform for cancellation.
+ orig_pos = np.array(second_ac.position, dtype=float)
+ orig_rot = np.array(second_ac.rotation, dtype=float)
+
+ # World positions of connectors.
+ p1_world = np.array(first_pick["origin_world"], dtype=float)
+ n1_world = np.array(first_pick["normal_world"], dtype=float)
+ n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12)
+
+ p2_local = np.array(second_pick["origin_local"], dtype=float)
+ n2_local = np.array(second_pick["normal_local"], dtype=float)
+ n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12)
+
+ # Build solver.
+ #
+ # IMPORTANT: SolveSpace's SLVS_C_PARALLEL and SLVS_C_SAME_ORIENTATION
+ # both generate multi-equation residuals that trigger a hard C-level
+ # assertion in this python_solvespace build's Newton iterator
+ # ("Expected constraint to generate a single equation"), aborting the
+ # whole process. We therefore avoid line-parallel / orientation
+ # constraints entirely and instead drive BOTH translation AND axis
+ # alignment with a pair of coincident point constraints:
+ #
+ # * coincident(pt1, pt2) — forces the connector points together
+ # (3 translational DOF)
+ # * coincident(pt1b, tip2) — pins the *axis tip* of component 2
+ # onto a fixed point on component 1's
+ # connector axis, which forces the
+ # rotated axis direction to align
+ # with n1 (2 rotational DOF)
+ #
+ # That's 6 single-equation-coincident residuals against 6 free point
+ # parameters — a well-posed 0-DOF system — so it converges cleanly.
+ # The remaining free rotation around the axis is left for the
+ # rotation_spinner in the dialog.
+ sys = SolverSystem()
+
+ # Component 1 reference frame — fully grounded (dragged). pt1 is the
+ # connector pivot, pt1b is one unit along the connector normal.
+ pt1 = sys.add_point_3d(float(p1_world[0]), float(p1_world[1]), float(p1_world[2]))
+ sys.dragged(pt1, Entity.FREE_IN_3D)
+ pt1b = sys.add_point_3d(
+ float(p1_world[0] + n1_world[0]),
+ float(p1_world[1] + n1_world[1]),
+ float(p1_world[2] + n1_world[2]),
+ )
+ sys.dragged(pt1b, Entity.FREE_IN_3D)
+
+ # Component 2 — free points, seeded near the current world connector.
+ p2_world_current = orig_pos + orig_rot @ p2_local
+ pt2 = sys.add_point_3d(float(p2_world_current[0]), float(p2_world_current[1]), float(p2_world_current[2]))
+ n2_world_current = orig_rot @ n2_local
+ tip2 = sys.add_point_3d(
+ float(p2_world_current[0] + n2_world_current[0]),
+ float(p2_world_current[1] + n2_world_current[1]),
+ float(p2_world_current[2] + n2_world_current[2]),
+ )
+
+ # Constraints: pivot coincidence + axis-tip coincidence.
+ sys.coincident(pt1, pt2, Entity.FREE_IN_3D)
+ sys.coincident(pt1b, tip2, Entity.FREE_IN_3D)
+
+ # Solve.
+ result = sys.solve()
+ if result != ResultFlag.OKAY:
+ logger.warning(f"SolveSpace solve failed: {result}")
+ return self._align_direct(first_ac, second_ac, first_pick, second_pick)
+
+ # Extract solved positions from the point entities' parameter tables.
+ # ``Entity`` does not expose .x/.y/.z — read them via SolverSystem.params.
+ p2_solved = np.array(sys.params(pt2.params), dtype=float)
+ tip2_solved = np.array(sys.params(tip2.params), dtype=float)
+ n2_solved = tip2_solved - p2_solved
+ n2_solved = n2_solved / max(np.linalg.norm(n2_solved), 1e-12)
+
+ # Compute the new component transform.
+ # The second connector in local coords is at p2_local with normal n2_local.
+ # In world space: P + R @ p2_local = p2_solved
+ # R @ n2_local = n2_solved
+ # We need to find R and P.
+
+ # R must map n2_local → n2_solved.
+ # Use a rotation that aligns the two vectors.
+ from OCP.gp import gp_Vec, gp_Dir, gp_Ax1, gp_Trsf
+ # Compute the rotation mapping the connector's local axis to its
+ # solved world direction. Use the robust helper so the degenerate
+ # anti-parallel case (cross → 0 but angle = 180°) is handled properly.
+ R_align = self._rotation_between_vectors(n2_local, n2_solved)
+
+ # The full rotation for the component.
+ new_rot = R_align @ orig_rot
+
+ # New position: P = p2_solved - R @ p2_local
+ new_pos = p2_solved - new_rot @ p2_local
+
+ return {
+ "position": new_pos,
+ "rotation": new_rot,
+ "original_position": orig_pos,
+ "original_rotation": orig_rot,
+ }
+
+ def _align_direct(
+ self,
+ first_ac: Any,
+ second_ac: Any,
+ first_pick: Dict[str, Any],
+ second_pick: Dict[str, Any],
+ ) -> Optional[Dict[str, Any]]:
+ """Direct geometric alignment (fallback when SolveSpace unavailable).
+
+ Moves the second component so its connector matches the first.
+ """
+ import numpy as np
+ orig_pos = np.array(second_ac.position, dtype=float)
+ orig_rot = np.array(second_ac.rotation, dtype=float)
+
+ p1_world = np.array(first_pick["origin_world"], dtype=float)
+ n1_world = np.array(first_pick["normal_world"], dtype=float)
+ n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12)
+
+ p2_local = np.array(second_pick["origin_local"], dtype=float)
+ n2_local = np.array(second_pick["normal_local"], dtype=float)
+ n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12)
+
+ # Align normals through the robust rotation helper so the
+ # anti-parallel case is handled correctly (see _rotation_between_vectors).
+ R_align = self._rotation_between_vectors(n2_local, n1_world)
+
+ new_rot = R_align @ orig_rot
+ p2_world_target = p1_world
+ new_pos = p2_world_target - new_rot @ p2_local
+
+ return {
+ "position": new_pos,
+ "rotation": new_rot,
+ "original_position": orig_pos,
+ "original_rotation": orig_rot,
+ }
+
+ def _show_connector_dialog_with_preview(
+ self,
+ first_ac: Any,
+ second_ac: Any,
+ first_pick: Dict[str, Any],
+ second_pick: Dict[str, Any],
+ solved: Dict[str, Any],
+ ) -> Tuple[Optional[float], Optional[float], bool]:
+ """Show connector dialog with live 3D preview of the alignment.
+
+ Returns (rotation_degrees, offset_mm, flip) or (None, None, False) if cancelled.
+ """
+ from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
+ QLabel, QDoubleSpinBox, QPushButton,
+ QFrame, QCheckBox)
+
+ if second_ac is None:
+ return (None, None, False)
+
+ dialog = QDialog(self)
+ dialog.setWindowTitle("Connector — Connection Properties")
+ dialog.setMinimumWidth(340)
+
+ layout = QVBoxLayout(dialog)
+
+ entity_names = {
+ "planar_face": "Face",
+ "cylindrical_face": "Hole",
+ "edge": "Edge",
+ "vertex": "Vertex",
+ }
+ t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity")
+ t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity")
+ layout.addWidget(QLabel(f"{t1} on {first_ac.name} → {t2} on {second_ac.name}"))
+ layout.addWidget(QLabel("Adjust the connection:"))
+
+ # Rotation around normal axis.
+ rot_layout = QHBoxLayout()
+ rot_layout.addWidget(QLabel("Rotation around axis (°):"))
+ rotation_spin = QDoubleSpinBox()
+ rotation_spin.setDecimals(1)
+ rotation_spin.setRange(-360, 360)
+ rotation_spin.setValue(0.0)
+ rotation_spin.setSuffix("°")
+ rot_layout.addWidget(rotation_spin)
+ layout.addLayout(rot_layout)
+
+ # Offset along normal.
+ off_layout = QHBoxLayout()
+ off_layout.addWidget(QLabel("Offset along normal (mm):"))
+ offset_spin = QDoubleSpinBox()
+ offset_spin.setDecimals(2)
+ offset_spin.setRange(-10000, 10000)
+ offset_spin.setValue(0.0)
+ off_layout.addWidget(offset_spin)
+ layout.addLayout(off_layout)
+
+ # Flip alignment direction.
+ flip_check = QCheckBox("Flip connection direction (normals opposed)")
+ flip_check.setChecked(False)
+ layout.addWidget(flip_check)
+
+ line = QFrame()
+ line.setFrameShape(QFrame.HLine)
+ layout.addWidget(line)
+
+ btn_layout = QHBoxLayout()
+ ok_btn = QPushButton("Connect")
+ cancel_btn = QPushButton("Cancel")
+ btn_layout.addWidget(ok_btn)
+ btn_layout.addWidget(cancel_btn)
+ layout.addLayout(btn_layout)
+
+ import numpy as np
+ target_normal = np.array(first_pick["normal_world"], dtype=float)
+ target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
+
+ # ── Live preview callback ──
+ def _update_preview(*args):
+ rot_deg = rotation_spin.value()
+ off = offset_spin.value()
+ flip = flip_check.isChecked()
+
+ # Start from solved transform.
+ base_pos = np.array(solved["position"], dtype=float)
+ base_rot = np.array(solved["rotation"], dtype=float)
+
+ # Apply axis rotation around target_normal.
+ angle_rad = np.radians(rot_deg)
+ k = target_normal
+ K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
+ R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
+ preview_rot = R_axis @ base_rot
+
+ # Apply offset (with flip).
+ flip_sign = -1.0 if flip else 1.0
+ preview_pos = base_pos + flip_sign * target_normal * off
+
+ second_ac.position = preview_pos
+ second_ac.rotation = preview_rot
+ self._show_assembly_in_viewer() # no fit — keep camera steady
+
+ rotation_spin.valueChanged.connect(_update_preview)
+ offset_spin.valueChanged.connect(_update_preview)
+ flip_check.toggled.connect(_update_preview)
+
+ # Initial preview.
+ _update_preview()
+
+ ok_btn.clicked.connect(dialog.accept)
+ cancel_btn.clicked.connect(dialog.reject)
+
+ if dialog.exec():
+ return (rotation_spin.value(), offset_spin.value(), flip_check.isChecked())
+ return (None, None, False)
+
+ def _on_delete_connector(self):
+ """Delete the connector nearest to the selected assembly component."""
+ active_id = self._get_active_assembly_component_id()
+ if active_id is None:
+ QMessageBox.warning(self, "No Selection",
+ "Select an assembly component first")
+ return
+
+ assembly = self._get_assembly()
+ ac = assembly.components.get(active_id)
+ if ac is None or not ac.connectors:
+ QMessageBox.information(self, "No Connectors",
+ "This component has no connectors to remove.")
+ return
+
+ # List connectors in a simple choice dialog.
+ conn_names = list(ac.connectors.keys())
+ conn_labels = [f"{c.name} at {c.position}" for c in ac.connectors.values()]
+
+ from PySide6.QtWidgets import QInputDialog
+ label, ok = QInputDialog.getItem(
+ self, "Remove Connector", "Select connector:", conn_labels, 0, False
+ )
+ if ok and label:
+ idx = conn_labels.index(label)
+ conn_id = conn_names[idx]
+ conn = ac.connectors.get(conn_id)
+ # Un-partner the mate and drop the rigid-group edge so stale
+ # connections don't linger in the BFS graph.
+ if conn is not None:
+ partner_ac_id = conn.partner_ac_id
+ partner_conn_id = conn.partner_connector_id
+ if partner_ac_id is not None and partner_conn_id is not None:
+ partner_ac = assembly.components.get(partner_ac_id)
+ if partner_ac is not None and partner_conn_id in partner_ac.connectors:
+ pc = partner_ac.connectors[partner_conn_id]
+ pc.partner_ac_id = None
+ pc.partner_connector_id = None
+ pc.is_grounded = False
+ # Remove the connection edge either side references this pair.
+ assembly.connections = [
+ c for c in assembly.connections
+ if not (
+ (c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
+ or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
+ )
+ ] if partner_ac_id is not None else assembly.connections
+ ac.remove_connector(conn_id)
+ logger.info(f"Removed connector {conn_id}")
+ self._show_assembly_in_viewer(fit=True)
+
+ def _new_workplane(self):
+ """Open the orientation dialog and create a new independent workplane.
+
+ The workplane is shown as a semi-transparent plane in the 3D view
+ (with live preview as the user adjusts options in the dialog).
+ A fresh sketch is created on it ready for drawing, and body outlines
+ are projected as underlay construction lines for precise alignment.
+ """
+ dialog = WorkplaneOrientationDialog(self)
+ origin = (0.0, 0.0, 0.0)
+ _preview_obj_id: Optional[str] = None
+
+ def _preview_callback(orientation):
+ """Live-preview the workplane orientation in the 3D viewer."""
+ nonlocal _preview_obj_id
+ if orientation is None:
+ # Dialog closing — clear the preview.
+ if _preview_obj_id is not None:
+ self._viewer_3d.remove_workplane(_preview_obj_id)
+ _preview_obj_id = None
+ return
+ normal, x_dir = orientation
+ # Replace the previous preview (same ID = update in place).
+ if _preview_obj_id is not None:
+ self._viewer_3d.remove_workplane(_preview_obj_id)
+ _preview_obj_id = self._viewer_3d.show_workplane(
+ origin=origin,
+ normal=normal,
+ x_dir=x_dir,
+ size=250.0,
+ name="__wp_preview__",
+ )
+
+ dialog.set_preview_callback(_preview_callback)
+
+ if not dialog.exec():
+ # Preview already cleared by dialog.hideEvent → callback(None).
+ return
+
+ normal, x_dir, wp_name = dialog.get_orientation()
+
+ if not self._current_component:
+ self._current_component = self._project.add_component()
+
+ # Create the Workplane model.
+ from fluency.models.data_model import Workplane
+ wp = self._current_component.add_workplane(
+ Workplane(
+ name=wp_name,
+ origin=origin,
+ normal=normal,
+ x_dir=x_dir, )
+ )
+
+ # The preview visual becomes the permanent workplane; just update
+ # its name so it can be found later for removal.
+ if _preview_obj_id is not None:
+ # Store the render object ID in the workplane model.
+ wp.render_object = _preview_obj_id
+ # The show_workplane_plane method replaced the preview already,
+ # so the visual is showing the final orientation.
+ else:
+ # Fallback: create a new visual (shouldn't happen).
+ wp.render_object = self._viewer_3d.show_workplane(
+ origin=origin,
+ normal=normal,
+ x_dir=x_dir,
+ size=250.0,
+ name=f"workplane_{wp.id}",
+ )
+ self._mark_dirty()
+
+ # Create a sketch on this workplane and set up the 2D widget.
+ sketch = self._current_component.add_sketch()
+ self._mark_dirty()
+ sketch.name = f"Sketch on {wp.name}"
+ sketch.set_workplane(origin, normal, x_dir)
+ sketch._source_workplane_id = wp.id
+
+ # Prepare the OCC sketch in the widget.
+ 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._current_sketch = sketch
+
+ # Project body outlines onto the workplane for alignment.
+ self._project_body_to_active_wp()
+
+ self._sketch_widget.set_mode("line")
+ self._btn_line.setChecked(True)
+
+ self._refresh_lists()
+ self._set_panel_focus("sketch")
+ self.statusBar().showMessage(
+ f"Workplane '{wp.name}' created — sketch on it to draw. "
+ f"Body outlines projected as underlay.",
+ 6000,
+ )
+ logger.info(f"New workplane '{wp.name}' with orientation n={normal} x={x_dir}")
+
+ def _project_body_to_active_wp(self) -> None:
+ """Project all body outlines in the current component onto the active
+ sketch's workplane as underlay construction lines.
+
+ This lets the user see the 3D body's silhouette from the workplane's
+ perspective and position their 2D sketch precisely relative to the
+ existing geometry. Uses the same external-entity mechanism as
+ face-projected underlay (``set_source_face``).
+ """
+ if not self._current_component or not self._current_sketch:
+ return
+ occ_sketch = self._current_sketch.occ_sketch
+ if occ_sketch is None:
+ return
+ wp = occ_sketch.get_workplane()
+ if not wp:
+ return
+ origin = wp[0] # (ox, oy, oz)
+ normal = wp[1] # (nx, ny, nz)
+ x_dir = wp[2] # (xx, xy, xz)
+
+ # Collect all body shapes from the current component.
+ body_shapes = []
+ kernel = self._kernel
+ for body in self._current_component.bodies.values():
+ if body.geometry is not None:
+ shape = kernel._get_shape(body.geometry)
+ if shape is not None:
+ body_shapes.append(shape)
+
+ if not body_shapes:
+ self._sketch_widget.clear_source_face()
+ self._btn_underlay.setEnabled(False)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(False)
+ self._btn_to_sketch.setEnabled(False)
+ return
+
+ # Project edges of all bodies onto the workplane.
+ workplane_data = (origin, normal, x_dir)
+ all_polylines: List[List[Tuple[float, float]]] = []
+ for shape in body_shapes:
+ try:
+ polys = _project_body_to_workplane(shape, workplane_data)
+ all_polylines.extend(polys)
+ except Exception as exc:
+ logger.debug("body projection failed for a shape: %s", exc)
+
+ if not all_polylines:
+ return
+
+ # Import the polylines as external/underlay entities in the sketch.
+ # First clear any existing external entities, then add the new ones.
+ occ_sketch.remove_external_entities()
+ imported_count = 0
+ for poly in all_polylines:
+ if len(poly) < 2:
+ continue
+ try:
+ _, lines = occ_sketch.add_external_polyline(
+ [(float(u), float(v)) for (u, v) in poly]
+ )
+ imported_count += len(lines)
+ except Exception as exc:
+ logger.debug("workplane underlay polyline import failed: %s", exc)
+
+ if imported_count > 0:
+ logger.info(
+ "Imported %d construction-line segments from body outlines",
+ imported_count,
+ )
+ # Refresh the 2D widget's entity tracking. We do NOT set
+ # _source_underlay_uv here because body projections produce
+ # many disjoint polylines — the fill paintEvent draws from
+ # _source_underlay_uv[0] would look wrong. The external
+ # entities themselves (orange dashed lines) provide the
+ # visual underlay.
+ self._sketch_widget._rebuild_from_sketch()
+ self._sketch_widget._source_workplane = workplane_data
+ self._sketch_widget._source_underlay_uv = []
+ self._sketch_widget._underlay_visible = True
+ self._sketch_widget.update()
+ # Enable the underlay toggle so the user can hide lines.
+ self._btn_underlay.setEnabled(True)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(True)
+ self._btn_to_sketch.setEnabled(True)
+
+ 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")
+
+ # ─── Offset sketch ─────────────────────────────────────────────────────
+
+ @staticmethod
+ def _find_parent_point_entities(
+ sketch: OCCSketch,
+ positions: List[Tuple[float, float]],
+ tolerance: float = 0.01,
+ ) -> List[Optional[OCCSketchEntity]]:
+ """Match position tuples to the corresponding point entities in the sketch.
+
+ Searches ``sketch._entities`` for point entities whose geometry
+ matches each entry in *positions* within *tolerance*. Returns a
+ list parallel to *positions*; unmatched entries are *None*.
+ Skips external / centerline / construction entities so we only
+ pick up user-drawn boundary points.
+ """
+ matches: List[Optional[OCCSketchEntity]] = []
+ for (tx, ty) in positions:
+ found: Optional[OCCSketchEntity] = None
+ for eid, entity in sketch._entities.items():
+ if entity.entity_type != "point":
+ continue
+ if entity.is_external or entity.is_construction:
+ continue
+ if entity.id in sketch._centerline_ids:
+ continue
+ if entity.geometry is not None:
+ ex, ey = entity.geometry
+ if abs(ex - tx) < tolerance and abs(ey - ty) < tolerance:
+ found = entity
+ break
+ matches.append(found)
+ return matches
+
+ def _offset_sketch(self) -> None:
+ """Open the offset dialog and apply an offset to the selected sketch face.
+
+ The user must first select a closed face (region) in the 2D sketch.
+ When the Offset button is pressed:
+ 1. The selected face's outer boundary is read.
+ 2. An OffsetDialog appears with a number spinner.
+ 3. Live preview shows the offset result in the 2D view.
+ 4. On OK, new point & line entities are created in the sketch
+ at the offset position, duplicating the original boundary.
+ 5. Distance constraints auto-connect each offset point to its
+ parent so the offset stays parametric.
+ 6. The region between the original and offset boundaries forms
+ a selectable wall face (e.g. for extrusion into a thin wall).
+ """
+ # Ensure we have a sketch and a selected face.
+ sketch = self._sketch_widget.get_sketch()
+ if sketch is None:
+ QMessageBox.warning(self, "No Sketch", "Please create and select a sketch first.")
+ return
+
+ selected_face = self._sketch_widget._selected_face
+ if selected_face is None:
+ QMessageBox.warning(
+ self, "No Face Selected",
+ "Click inside a closed face (region) in the sketch to select it, "
+ "then press Offset."
+ )
+ return
+
+ outer = selected_face.get("outer")
+ if outer is None:
+ QMessageBox.warning(self, "No Outer Boundary", "Selected face has no outer boundary.")
+ return
+
+ # ── Extract boundary points ──
+ if outer["type"] == "circle":
+ cx, cy = outer["center"]
+ radius = outer["radius"]
+ is_circle = True
+ elif outer["type"] == "polygon":
+ pts = list(outer["points"])
+ if len(pts) < 3:
+ QMessageBox.warning(self, "Invalid Polygon", "Face boundary has fewer than 3 points.")
+ return
+ # Remove closing duplicate (last == first) if present.
+ if len(pts) > 1 and pts[-1] == pts[0]:
+ pts.pop()
+ is_circle = False
+ else:
+ QMessageBox.warning(self, "Unsupported Face", f"Face type '{outer.get('type')}' not supported.")
+ return
+
+ # ── Find the ORIGINAL point entities so we can constrain to them ──
+ if is_circle:
+ parent_center = self._find_parent_point_entities(sketch, [(cx, cy)], tolerance=0.01)
+ parent_center_entity = parent_center[0] if parent_center else None
+ else:
+ parent_entities = self._find_parent_point_entities(sketch, pts, tolerance=0.01)
+
+ # ── Open dialog with live preview ──
+ dialog = OffsetDialog(self)
+
+ def _compute_offset_preview(distance: float, inward: bool) -> Optional[List[Tuple[float, float]]]:
+ """Return offset polygon points, or None for circles."""
+ d = -distance if inward else distance
+ if is_circle:
+ return None # circles not drawn as polygon preview
+ try:
+ return _offset_polygon(pts, d)
+ except Exception as exc:
+ logger.debug("offset preview compute failed: %s", exc)
+ return None
+
+ def _preview_callback(values):
+ if values is None:
+ self._sketch_widget.clear_offset_preview()
+ return
+ distance, inward = values
+ preview_pts = _compute_offset_preview(distance, inward)
+ self._sketch_widget.set_offset_preview(preview_pts)
+
+ dialog.set_preview_callback(_preview_callback)
+
+ if not dialog.exec():
+ # Preview already cleared by hideEvent.
+ self._sketch_widget.clear_offset_preview()
+ return
+
+ self._sketch_widget.clear_offset_preview()
+
+ distance, inward = dialog.get_values()
+ d = -distance if inward else distance
+ logger.info(f"Offset distance: {abs(d):.2f} mm {'inward' if inward else 'outward'}")
+
+ try:
+ # ── Apply offset: create new entities in the sketch ──
+ if is_circle:
+ self._apply_circle_offset(
+ sketch, cx, cy, radius, d, selected_face,
+ parent_center_entity=parent_center_entity,
+ offset_distance=abs(d),
+ )
+ else:
+ self._apply_polygon_offset(
+ sketch, pts, d, selected_face,
+ parent_entities=parent_entities,
+ offset_distance=abs(d),
+ )
+
+ self._sketch_widget._rebuild_from_sketch()
+ self._sketch_widget._solve_and_sync()
+ self._sketch_widget.sketch_updated.emit()
+ self._sketch_widget.update()
+
+ self.statusBar().showMessage(
+ f"Offset sketch by {abs(d):.2f} mm {'inward' if inward else 'outward'}", 4000
+ )
+ logger.info("Offset complete")
+
+ except Exception as e:
+ logger.exception(f"Offset failed: {e}")
+ QMessageBox.critical(self, "Error", f"Offset failed: {e}")
+
+ def _apply_polygon_offset(
+ self, sketch: OCCSketch,
+ pts: List[Tuple[float, float]],
+ distance: float,
+ face: Dict[str, Any],
+ parent_entities: Optional[List[Optional[OCCSketchEntity]]] = None,
+ offset_distance: float = 0.0,
+ ) -> None:
+ """Duplicate a polygon boundary at *distance* offset and add to the sketch.
+
+ Creates new point + line entities for the offset boundary and
+ re-applies any holes from the original face (offset by the same
+ distance, clipped if they collapse). The region between the
+ original boundary and the offset boundary becomes a selectable
+ face (e.g. a thin wall for extrusion). When *parent_entities*
+ is provided, a distance constraint is added between each parent
+ point and the corresponding offset point with the
+ *offset_distance* value.
+ """
+ offset_pts = _offset_polygon(pts, distance)
+
+ # Create new point entities at the offset positions.
+ new_points = []
+ for (x, y) in offset_pts:
+ pt = sketch.add_point(float(x), float(y))
+ new_points.append(pt)
+
+ # Create line entities connecting the new points.
+ new_lines = []
+ for i in range(len(new_points)):
+ j = (i + 1) % len(new_points)
+ line = sketch.add_line(new_points[i], new_points[j])
+ new_lines.append(line)
+
+ # ── Auto-constrain: distance constraint between each parent
+ # point and its corresponding offset point ──
+ if parent_entities and offset_distance > 0:
+ constrained = 0
+ for parent_ent, new_pt in zip(parent_entities, new_points):
+ if parent_ent is not None:
+ try:
+ sketch.constrain_distance(parent_ent, new_pt, offset_distance)
+ constrained += 1
+ except Exception as exc:
+ logger.debug(
+ "distance constraint failed for parent id=%s: %s",
+ parent_ent.id, exc,
+ )
+ if constrained:
+ logger.info("Added %d distance constraints to offset polygon", constrained)
+
+ # ── Offset holes ──
+ holes = face.get("holes", [])
+ for hole in holes:
+ if hole["type"] != "polygon":
+ continue
+ hole_pts = list(hole["points"])
+ if len(hole_pts) < 3:
+ continue
+ if len(hole_pts) > 1 and hole_pts[-1] == hole_pts[0]:
+ hole_pts.pop()
+ # Holes are offset in the OPPOSITE direction (a positive outer
+ # offset should make holes smaller, not larger).
+ offset_hole = _offset_polygon(hole_pts, -distance)
+ if len(offset_hole) < 3:
+ logger.debug("Hole offset collapsed — skipping")
+ continue
+ hole_points = []
+ for (x, y) in offset_hole:
+ pt = sketch.add_point(float(x), float(y))
+ hole_points.append(pt)
+ for i in range(len(hole_points)):
+ j = (i + 1) % len(hole_points)
+ sketch.add_line(hole_points[i], hole_points[j])
+
+ logger.info(
+ "Created %d offset points and %d offset lines for polygon boundary + %d holes",
+ len(offset_pts), len(offset_pts),
+ len([h for h in holes if h.get("type") == "polygon"]),
+ )
+
+ def _apply_circle_offset(
+ self, sketch: OCCSketch,
+ cx: float, cy: float, radius: float,
+ distance: float,
+ face: Dict[str, Any],
+ parent_center_entity: Optional[OCCSketchEntity] = None,
+ offset_distance: float = 0.0,
+ ) -> None:
+ """Duplicate a circle at *distance* offset and add to the sketch.
+
+ For circles the offset is simply a new circle with (radius ± distance).
+ A new center point is created so the original is not disturbed.
+ The region between the original and offset circles becomes a
+ selectable face (annular wall for extrusion). When
+ *parent_center_entity* is provided a distance constraint links
+ it to the new center.
+ """
+ new_radius = radius + distance
+ if new_radius <= 0:
+ logger.warning("Offset radius would be non-positive — skipping")
+ return
+
+ # Create a new center point (slightly nudged so it's distinct).
+ new_cx = cx + 0.001 if abs(distance) < 0.01 else cx
+ new_cy = cy + 0.001 if abs(distance) < 0.01 else cy
+ center_pt = sketch.add_point(float(new_cx), float(new_cy))
+ sketch.add_circle(center_pt, float(new_radius))
+
+ # ── Auto-constrain: distance from parent center to new center ──
+ if parent_center_entity is not None and offset_distance > 0:
+ try:
+ sketch.constrain_distance(parent_center_entity, center_pt, offset_distance)
+ logger.info("Added distance constraint to offset circle center")
+ except Exception as exc:
+ logger.debug("circle distance constraint failed: %s", exc)
+
+ # Also offset any holes.
+ holes = face.get("holes", [])
+ for hole in holes:
+ if hole["type"] != "circle":
+ continue
+ h_cx, h_cy = hole["center"]
+ h_r = hole["radius"]
+ new_h_r = h_r - distance # holes shrink when outer grows
+ if new_h_r <= 0:
+ logger.debug("Hole circle offset collapsed — skipping")
+ continue
+ h_center = sketch.add_point(float(h_cx), float(h_cy))
+ sketch.add_circle(h_center, float(new_h_r))
+
+ logger.info(
+ "Created offset circle: center=(%.2f, %.2f), radius=%.2f",
+ new_cx, new_cy, new_radius,
+ )
+
+ # ─── 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()
+ self._mark_dirty()
+ 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, ClrFace, and ToSketch buttons.
+ self._btn_underlay.setEnabled(True)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(True)
+ self._btn_to_sketch.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)
+ self._btn_to_sketch.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 _on_convert_underlay_to_sketch(self) -> None:
+ """Convert the underlay/projected construction lines into real sketch geometry.
+
+ Delegates to the widget's ``_convert_underlay_to_sketch`` which
+ creates regular (non-construction, non-external) point and line
+ entities at every underlay position. The underlay reference stays
+ intact so the user can still toggle it on/off.
+ """
+ self._sketch_widget._convert_underlay_to_sketch()
+ # Sync the main window's underlay toggle to match the widget
+ # (the conversion auto-hides the underlay).
+ self._btn_underlay.setChecked(False)
+ self.statusBar().showMessage(
+ "Underlay converted to sketch geometry — now you can select faces, offset, and extrude",
+ 5000,
+ )
+
+ 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()
+ self._mark_dirty()
+ 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)
+ self._btn_to_sketch.setEnabled(True)
+ elif getattr(sketch, "_source_workplane_id", None) is not None:
+ # Sketch on an independent workplane: project body outlines.
+ self._project_body_to_active_wp()
+ self._btn_underlay.setEnabled(True)
+ self._btn_underlay.setChecked(True)
+ self._btn_clr_face.setEnabled(True)
+ self._btn_to_sketch.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._btn_to_sketch.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._mark_dirty()
+ 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",
+ )
+ )
+ self._mark_dirty()
+ 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",
+ )
+ )
+ self._mark_dirty()
+
+ 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",
+ )
+ )
+ self._mark_dirty()
+
+ 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",
+ )
+ )
+ self._mark_dirty()
+
+ 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._mark_dirty()
+ self._refresh_lists()
+ logger.info(f"Deleted body: {name}")
+
+ def _new_project(self):
+ if not self._confirm_discard_changes():
+ return
+ # Suppress dirty while we reset the scene; the new project starts
+ # as a fresh empty one and shouldn't show as "modified".
+ self._suspend_dirty = True
+ try:
+ 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._btn_to_sketch.setEnabled(False)
+
+ self._create_initial_component()
+ finally:
+ self._suspend_dirty = False
+ self._project_path = None
+ self._dirty = False
+ self._update_window_title()
+ logger.info("New project created")
+
+ # ────────────────────────────────────────────────────────────────────
+ # Project save / load
+ # ────────────────────────────────────────────────────────────────────
+
+ def _mark_dirty(self) -> None:
+ """Mark the project as having unsaved changes.
+
+ Called from any UI path that mutates the model (adding components,
+ sketches, bodies, etc.). The setter is intentionally a no-op if the
+ project is already dirty to keep the title-bar updates cheap — the
+ title flips from "Untitled" / "name.fluency" to "Untitled*" /
+ "name.fluency*" on the first edit and stays there until :meth:`_save_project`
+ clears it.
+
+ When :attr:`_suspend_dirty` is set (during programmatic init or a
+ ``_new_project`` reset) the call is a no-op so the freshly-created
+ default project doesn't immediately appear as "modified" in the
+ title bar.
+ """
+ if self._dirty or self._suspend_dirty:
+ return
+ self._dirty = True
+ self._update_window_title()
+
+ def _update_window_title(self) -> None:
+ """Refresh the title bar to reflect current file + dirty state."""
+ if self._project_path:
+ name = os.path.basename(self._project_path)
+ else:
+ name = "Untitled"
+ suffix = " *" if self._dirty else ""
+ self.setWindowTitle(f"Fluency CAD 2.0 — {name}{suffix}")
+
+ def _collect_view_state(self) -> Dict[str, Any]:
+ """Snapshot the camera + active-tab state for the saved view_state."""
+ try:
+ eye, at_, up = self._viewer_3d.get_camera_position()
+ except Exception:
+ eye = [1.0, 1.0, 1.0]
+ at_ = [0.0, 0.0, 0.0]
+ up = [0.0, 0.0, 1.0]
+ # eye / at / up may be tuples, lists, or numpy arrays depending on
+ # the renderer — coerce to plain 3-lists.
+ def _flat3(v):
+ if v is None:
+ return [0.0, 0.0, 0.0]
+ if hasattr(v, "tolist"):
+ v = v.tolist()
+ seq = list(v)
+ if len(seq) < 3:
+ seq = seq + [0.0] * (3 - len(seq))
+ return [float(seq[0]), float(seq[1]), float(seq[2])]
+ return {
+ "active_tab": self._input_tabs.currentIndex() if hasattr(self, "_input_tabs") else 0,
+ "active_component_id": self._current_component.id if self._current_component else None,
+ "active_sketch_id": self._current_sketch.id if self._current_sketch else None,
+ "selected_body_id": self._selected_body.id if self._selected_body else None,
+ "camera_eye": _flat3(eye),
+ "camera_at": _flat3(at_),
+ "camera_up": _flat3(up),
+ "panel_focus": getattr(self, "_panel_focus", "equal"),
+ "assembly_view_active": bool(self._assembly_view_active),
+ "selected_assembly_component_id": self._selected_assembly_component_id,
+ }
+
+ def _restore_view_state(self, view_state: Dict[str, Any]) -> None:
+ """Apply a saved view_state dict to the camera + UI selection."""
+ if not view_state:
+ return
+ try:
+ eye = view_state.get("camera_eye")
+ at_ = view_state.get("camera_at")
+ up = view_state.get("camera_up")
+ if eye and at_ and up:
+ self._viewer_3d.set_camera_position(
+ (float(eye[0]), float(eye[1]), float(eye[2])),
+ (float(at_[0]), float(at_[1]), float(at_[2])),
+ up=(float(up[0]), float(up[1]), float(up[2])),
+ )
+ except Exception as exc:
+ logger.debug("Failed to restore camera: %s", exc)
+ # Active tab.
+ try:
+ tab_idx = int(view_state.get("active_tab", 0))
+ if hasattr(self, "_input_tabs"):
+ self._input_tabs.setCurrentIndex(max(0, tab_idx))
+ except Exception:
+ pass
+ # Panel focus.
+ try:
+ focus = view_state.get("panel_focus")
+ if focus in ("equal", "sketch", "viewer"):
+ self._set_panel_focus(focus)
+ except Exception:
+ pass
+
+ def _confirm_discard_changes(self) -> bool:
+ """Return True if it's safe to discard the current project.
+
+ Pops a Save / Discard / Cancel dialog when the project is dirty.
+ Returns True (= proceed with discard) for the Save and Discard
+ choices; False for Cancel. When the project is clean, returns
+ True immediately so the call site doesn't have to special-case it.
+ """
+ if not self._dirty:
+ return True
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Warning)
+ box.setWindowTitle("Unsaved Changes")
+ box.setText("This project has unsaved changes.")
+ box.setInformativeText("Save before continuing?")
+ box.setStandardButtons(
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
+ )
+ box.setDefaultButton(QMessageBox.Save)
+ choice = box.exec()
+ if choice == QMessageBox.Cancel:
+ return False
+ if choice == QMessageBox.Save:
+ return self._save_project()
+ return True # Discard
+
+ def _save_project(self) -> bool:
+ """Save the current project. Returns True on success."""
+ if not self._project_path:
+ return self._save_project_as()
+ return self._write_project_to_disk(self._project_path)
+
+ def _save_project_as(self) -> bool:
+ """Prompt for a path and save. Returns True on success."""
+ # Default to the current file name so Save-As is one click away
+ # from a normal Save.
+ default = self._project_path or os.path.join(
+ os.path.expanduser("~"), "untitled.fluency"
+ )
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Save Project",
+ default,
+ "Fluency Project (*.fluency)",
+ )
+ if not path:
+ return False
+ path = project_zip_path(path)
+ return self._write_project_to_disk(path)
+
+ def _write_project_to_disk(self, path: str) -> bool:
+ """Write the project to *path* and update internal state on success."""
+ try:
+ view_state = self._collect_view_state()
+ save_project(
+ self._project,
+ path,
+ view_state=view_state,
+ kernel=self._kernel,
+ )
+ self._project_path = path
+ self._project.file_path = path
+ self._dirty = False
+ self._update_window_title()
+ self.statusBar().showMessage(f"Saved: {os.path.basename(path)}", 5000)
+ logger.info("Saved project: %s", path)
+ return True
+ except Exception as exc:
+ QMessageBox.critical(
+ self, "Save Failed", f"Could not save the project:\n{exc}"
+ )
+ return False
+
+ def _open_project(self) -> bool:
+ """Prompt for and open a ``.fluency`` file. Returns True on success."""
+ if not self._confirm_discard_changes():
+ return False
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Open Project",
+ os.path.expanduser("~"),
+ "Fluency Project (*.fluency);;All files (*)",
+ )
+ if not path:
+ return False
+ return self._open_project_file(path)
+
+ def _open_project_file(self, path: str) -> bool:
+ """Load *path* into the running app. Returns True on success."""
+ try:
+ project, view_state = load_project(path)
+ except Exception as exc:
+ QMessageBox.critical(
+ self, "Open Failed", f"Could not open the project:\n{exc}"
+ )
+ return False
+
+ # Suppress dirty tracking while we replace the in-memory project
+ # and rebuild the UI. The loaded project starts clean until the
+ # user touches it again.
+ self._suspend_dirty = True
+ try:
+ # Replace the in-memory project + UI state. This is the same path
+ # that New Project would take, but populated with the loaded data.
+ self._project = project
+ # Reuse the running kernel so we keep the same OCC viewer context.
+ # The loaded body's STEP data has already been parsed by the new
+ # kernel inside load_project; we copy those bodies' references in
+ # via the dict already, but we still want the live ``_kernel`` in
+ # this window to match for new operations.
+ self._kernel = project.kernel
+
+ # Reset all UI state.
+ for btn in self._component_buttons:
+ btn.deleteLater()
+ self._component_buttons.clear()
+ for btn in self._assembly_component_buttons:
+ btn.deleteLater()
+ self._assembly_component_buttons.clear()
+
+ self._current_component = None
+ self._current_sketch = None
+ self._selected_body = None
+ self._selected_assembly_component_id = None
+ self._assembly_view_active = False
+ self._sketch_widget.clear_source_face()
+ self._sketch_widget.set_sketch(None)
+ self._viewer_3d.clear_scene()
+ self._refresh_lists()
+
+ # Rebuild component buttons (one per component, numbered).
+ for idx, comp in enumerate(self._project.components.values(), start=1):
+ btn = QPushButton(str(idx))
+ btn.setCheckable(True)
+ btn.setFixedSize(QSize(40, 40))
+ btn.clicked.connect(self._on_component_button_clicked)
+ self._component_buttons.append(btn)
+ self._component_group.addButton(btn)
+ self._component_box_layout.addWidget(btn)
+
+ # Pick which component to activate: explicit saved selection,
+ # falling back to the project's active_component, then the first.
+ target_comp_id: Optional[str] = None
+ if view_state.get("active_component_id") in self._project.components:
+ target_comp_id = view_state["active_component_id"]
+ elif self._project.active_component in self._project.components:
+ target_comp_id = self._project.active_component
+ elif self._project.components:
+ target_comp_id = next(iter(self._project.components.keys()))
+
+ if target_comp_id is not None:
+ self._current_component = self._project.components[target_comp_id]
+ idx = list(self._project.components.keys()).index(target_comp_id)
+ if 0 <= idx < len(self._component_buttons):
+ for b in self._component_buttons:
+ b.setChecked(False)
+ self._component_buttons[idx].setChecked(True)
+
+ # If the saved view says we're in assembly view, switch over.
+ if view_state.get("assembly_view_active") and self._project.assemblies:
+ self._assembly_view_active = True
+ self._show_assembly_in_viewer(fit=True)
+ else:
+ self._assembly_view_active = False
+ self._redraw_bodies()
+
+ # Restore camera + active tab.
+ self._restore_view_state(view_state)
+ self._refresh_lists()
+ # Try to activate the saved sketch (without re-rendering the
+ # underlay from a now-stale source face — we don't persist those).
+ target_sk_id = view_state.get("active_sketch_id")
+ if (
+ target_sk_id
+ and self._current_component
+ and target_sk_id in self._current_component.sketches
+ ):
+ sk = self._current_component.sketches[target_sk_id]
+ if sk.occ_sketch is not None:
+ self._sketch_widget.set_sketch(sk.occ_sketch)
+ self._current_sketch = sk
+ finally:
+ self._suspend_dirty = False
+
+ # Clear dirty + update title.
+ self._project_path = path
+ self._dirty = False
+ self._update_window_title()
+ self.statusBar().showMessage(f"Opened: {os.path.basename(path)}", 5000)
+ logger.info("Opened project: %s", path)
+ return True
+
+ def closeEvent(self, event) -> None:
+ """Prompt to save on window close if there are unsaved changes."""
+ if not self._confirm_discard_changes():
+ event.ignore()
+ return
+ event.accept()
+
+ 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")
+ )
+ self._mark_dirty()
+
+ 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",
+ )
+
+
diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py
new file mode 100644
index 0000000..6ba7d80
--- /dev/null
+++ b/src/fluency/ui/sketch_widget.py
@@ -0,0 +1,2821 @@
+"""2D sketch widget — paints the sketcher, handles drawing / snapping / constraints."""
+
+from __future__ import annotations
+
+import logging
+import math
+from typing import Any, Dict, List, Optional, Tuple
+
+from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
+from PySide6.QtGui import (
+ QBrush,
+ QColor,
+ QCursor,
+ QFont,
+ QFontMetrics,
+ QPainter,
+ QPainterPath,
+ QPen,
+ QPolygonF,
+)
+from PySide6.QtWidgets import (
+ QInputDialog,
+ QWidget,
+)
+
+from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
+
+logger = logging.getLogger(__name__)
+
+
+class Sketch2DWidget(QWidget):
+ """2D sketching widget with SolveSpace constraint solving and drawing tools."""
+
+ constrain_done = Signal()
+ sketch_updated = Signal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setMinimumSize(400, 300)
+ self.setMouseTracking(True)
+
+ self._sketch: Optional[OCCSketch] = None
+ self._mode: Optional[str] = None
+ self._is_construct: bool = False
+
+ self._points: List[OCCSketchEntity] = []
+ self._lines: List[Tuple[OCCSketchEntity, OCCSketchEntity]] = []
+ self._circles: List[Tuple[OCCSketchEntity, float]] = []
+ self._arcs: List[Tuple[OCCSketchEntity, float, OCCSketchEntity, OCCSketchEntity, float]] = [] # (center, radius, start_point, end_point, sweep)
+ # Accumulated sweep tracking during arc draw (smoothly follows cursor)
+ self._arc_accum_sweep: float = 0.0
+ self._arc_prev_angle: Optional[float] = None
+
+ self._draw_buffer: List[QPoint] = []
+ self._hovered_point: Optional[QPoint] = None
+ self._hovered_point_entity: Optional[OCCSketchEntity] = None # point entity under cursor (for Delete)
+ self._hovered_line: Optional[Tuple[QPoint, QPoint]] = None
+ self._hovered_line_entity: Optional[OCCSketchEntity] = None # line entity under cursor (for Delete)
+ self._hovered_constraint_idx: int = -1 # constraint-log index hovered over its tag (for Delete)
+ self._constraint_tags: List[Dict[str, Any]] = [] # cached tag rects for paint + hit-test
+ self._hovered_face: Any = None # detected face dict (see OCCSketch.detect_faces) or None
+ self._selected_face: Any = None
+ self._selected_entities: List[OCCSketchEntity] = []
+
+ # Source face for sketch-on-surface: the planar face the user picked
+ # in the 3D viewer, plus its workplane (origin/normal/x_dir). Phase 3
+ # projects this face's edges into UV and draws them as an underlay.
+ self._source_face: Any = None
+ self._source_workplane: Optional[Tuple[Tuple[float, float, float], ...]] = None
+ self._source_underlay_uv: List[Any] = [] # cached UV polylines for paintEvent
+ # Underlay visibility: the dashed construction lines projected from
+ # the source face can be hidden/shown without losing the source
+ # face reference (useful when the underlay is too busy). Toggled
+ # from the "Underlay" button in the MainWindow UI.
+ self._underlay_visible: bool = True
+
+ self._snap_mode: Dict[str, bool] = {
+ "point": True,
+ "mpoint": False,
+ "horiz": False,
+ "vert": False,
+ "grid": False,
+ "angle": False,
+ }
+ self._snap_distance: int = 10
+ self._angle_steps: int = 15
+
+ self._zoom: float = 1.0
+ self._offset: QPoint = QPoint(0, 0)
+ self._panning: bool = False
+ self._pan_start: Optional[QPoint] = None
+
+ self._dynamic_line_end: Optional[QPoint] = None
+ self._temp_entities: List[Any] = []
+
+ self._constraint_distance_value: float = 10.0
+ # Pending distance constraint input
+ self._pending_distance_val: Optional[float] = None
+
+ # Element move state (move tool / select mode)
+ self._moving_points: List[OCCSketchEntity] = []
+ self._move_anchor: Optional[OCCSketchEntity] = None
+ self._move_anchor_orig: Optional[QPoint] = None
+ self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
+ self._move_active: bool = False
+
+ # Auto-constraint tracking on snap
+ self._snap_point_target: Optional[OCCSketchEntity] = None
+ self._snap_line_target: Optional[OCCSketchEntity] = None
+ self._snap_horizontal: bool = False
+ self._snap_vertical: bool = False
+
+ # Rectangle first-click snap target (stored so the second click
+ # doesn't overwrite it and the correct corner gets constrained).
+ self._rect_first_snap_target: Optional[OCCSketchEntity] = None
+
+ # Offset preview state (live preview while the OffsetDialog is open).
+ self._offset_preview_points: List[Tuple[float, float]] = []
+ self._offset_preview_active: bool = False
+
+ self.setFocusPolicy(Qt.StrongFocus)
+ self._setup_ui()
+
+ def _setup_ui(self):
+ self.setStyleSheet("background-color: #1e1e2e;")
+
+ def set_sketch(self, sketch: Optional[OCCSketch]):
+ self._sketch = sketch
+ self._rebuild_from_sketch()
+ self._draw_buffer = []
+ self._clear_face_state()
+ # If the new sketch carries a workplane, refresh the source underlay.
+ self._refresh_source_underlay()
+ # A brand new sketch has no external entities — strip the old
+ # underlay from the previous sketch so the construction lines
+ # don't bleed into the new sketch. (set_source_face will reimport
+ # them if the new sketch is on a face too.)
+ if self._sketch is not None and self._sketch is not sketch:
+ self._sketch.remove_external_entities()
+ self.update()
+
+ def clear_source_face(self) -> None:
+ """Forget the picked source face and remove the underlay entities.
+
+ Use this when the user wants to drop the face reference (e.g. they
+ want to draw a free-standing sketch without the body's outline
+ showing through). Removes the underlay entities from the solver,
+ clears the cached polyline data, and resets the view to whatever
+ zoom the user had before the face was set.
+ """
+ if self._sketch is not None:
+ self._sketch.remove_external_entities()
+ self._source_face = None
+ self._source_workplane = None
+ self._source_underlay_uv = []
+ self._rebuild_from_sketch()
+ self.clear_offset_preview()
+ self._hovered_point = None
+ self._hovered_point_entity = None
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._selected_entities = []
+ self.update()
+
+ def _convert_underlay_to_sketch(self) -> None:
+ """Convert underlay (external/construction) entities into regular geometry.
+
+ Creates new non-construction point and line entities at the same
+ positions as every external entity in the sketch, coincident-
+ constrained to their external counterparts so they stay aligned.
+ The underlay reference entities are left intact (the user can still
+ toggle them on/off).
+
+ After calling this, the newly created regular geometry participates
+ in face detection and can be selected for offset, extrude, or cut
+ operations — without the user having to manually trace the
+ projected outlines.
+ """
+ if self._sketch is None:
+ return
+
+ # Map external point IDs → newly created regular point entities
+ ext_to_new: Dict[int, OCCSketchEntity] = {}
+
+ # Snapshot the entity list before modifying (add_point adds to
+ # _entities, which would change dict size mid-iteration).
+ for eid, entity in list(self._sketch._entities.items()):
+ if (
+ entity.is_external
+ and entity.entity_type == "point"
+ and entity.geometry is not None
+ ):
+ x, y = entity.geometry
+ new_pt = self._sketch.add_point(x, y)
+ new_pt.is_construction = False
+ self._points.append(new_pt)
+ self._sketch.constrain_coincident(new_pt, entity)
+ ext_to_new[eid] = new_pt
+
+ if not ext_to_new:
+ return
+
+ # Create matching lines for each external line segment
+ for eid, (sid, eid2) in list(self._sketch._lines.items()):
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if s_ent and e_ent and s_ent.is_external and e_ent.is_external:
+ new_s = ext_to_new.get(sid)
+ new_e = ext_to_new.get(eid2)
+ if new_s is not None and new_e is not None:
+ new_line = self._sketch.add_line(new_s, new_e)
+ self._lines.append((new_s, new_e))
+
+ self._solve_and_sync()
+ self._rebuild_from_sketch()
+ # Hide the underlay so the user can see the new solid geometry
+ self._underlay_visible = False
+ self.update()
+
+ def set_source_face(
+ self,
+ face: Any,
+ origin: Tuple[float, float, float],
+ normal: Tuple[float, float, float],
+ x_dir: Tuple[float, float, float],
+ ) -> None:
+ """Store the picked 3D face and reorient the 2D view to its plane.
+
+ Called by MainWindow after a face pick. Projects the face's boundary
+ edges into the sketch's UV frame, caches them for the underlay fill,
+ *and* imports them as construction-line entities in the underlying
+ OCCSketch. Those entities are fixed in the solver, so the user can
+ snap to them and add distance / horizontal / vertical / parallel /
+ perpendicular / midpoint / coincident constraints against them —
+ e.g. place a hole "50 mm from the body's top edge" by clicking the
+ underay corner, the hole centre, and entering 50.
+
+ Also re-centres/scales the 2D view to look down the plane.
+ """
+ self._source_face = face
+ self._source_workplane = (tuple(origin), tuple(normal), tuple(x_dir))
+ # Ensure the OCCSketch shares the same workplane so UV↔world agrees.
+ if self._sketch is not None:
+ self._sketch.set_workplane(origin, normal, x_dir)
+ self._refresh_source_underlay()
+ self._import_underlay_as_construction_lines()
+ self._orient_view_to_plane()
+ self.update()
+
+ def _refresh_source_underlay(self) -> None:
+ """Project the source face's boundary edges into UV for the underlay."""
+ self._source_underlay_uv = []
+ if self._source_face is None or self._source_workplane is None:
+ return
+ if self._sketch is None:
+ return
+ try:
+ self._source_underlay_uv = _project_face_to_uv(
+ self._source_face, self._source_workplane
+ )
+ except Exception:
+ logger.debug("source underlay projection failed", exc_info=True)
+
+ def set_underlay_visible(self, visible: bool) -> None:
+ """Show or hide the underlay (face-projected construction lines).
+
+ When hidden, the external entities stay in the OCCSketch solver
+ (constraints referencing them keep working) but they're not
+ rendered, snapped to, or hit-tested in the 2D view.
+ """
+ self._underlay_visible = bool(visible)
+ # Re-render and drop any in-flight hover that pointed at an underlay
+ # entity (otherwise the cursor would freeze on a no-longer-drawn
+ # underlay element after the user hides it).
+ if not self._underlay_visible:
+ self._hovered_point = None
+ self._hovered_point_entity = None
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self.update()
+
+ def _import_underlay_as_construction_lines(self) -> None:
+ """Convert the projected face edges into real construction-line entities.
+
+ Each polyline in ``_source_underlay_uv`` becomes a chain of external
+ point entities and external line segment entities in the underlying
+ OCCSketch. External points/lines are tagged ``is_external`` and
+ ``is_construction`` so the paintEvent renders them as dashed
+ construction lines, and so the sketch profile path (detect_faces /
+ get_geometry) skips them. The solver marks every external point
+ fixed via ``dragged``, so a user drag of a related entity never moves
+ the underlay.
+
+ If a previous underlay was already imported it is cleared first so
+ we don't accumulate duplicates on a re-pick of the same face.
+ """
+ if self._sketch is None or not self._source_underlay_uv:
+ return
+ # Clear any prior external entities before importing fresh ones so a
+ # repeated face pick doesn't pile up duplicate construction lines.
+ self._sketch.remove_external_entities()
+ imported = 0
+ for poly in self._source_underlay_uv:
+ if len(poly) < 2:
+ continue
+ try:
+ _, lines = self._sketch.add_external_polyline(
+ [(float(u), float(v)) for (u, v) in poly]
+ )
+ imported += len(lines)
+ except Exception as exc:
+ logger.debug("underlay polyline import failed: %s", exc)
+ logger.info(
+ "Imported %d construction-line segments from source face", imported
+ )
+ # Pull the new external entities into the UI lists so they're
+ # snap/hover/paint targets.
+ self._rebuild_from_sketch()
+
+ def _orient_view_to_plane(self) -> None:
+ """Centre & scale the 2D view to fit the source face's UV bounds."""
+ if not self._source_underlay_uv:
+ return
+ # Collect all UV points across all cached polylines.
+ all_pts = [pt for poly in self._source_underlay_uv for pt in poly]
+ if not all_pts:
+ return
+ us = [p[0] for p in all_pts]
+ vs = [p[1] for p in all_pts]
+ umin, umax = min(us), max(us)
+ vmin, vmax = min(vs), max(vs)
+ cu, cv = (umin + umax) / 2.0, (vmin + vmax) / 2.0
+ du, dv = max(umax - umin, 1e-6), max(vmax - vmin, 1e-6)
+ # Zoom so the face fits with ~20% margin; offset so the face centre
+ # maps to the widget centre (world (cu,cv) → screen centre).
+ w, h = max(self.width(), 100), max(self.height(), 100)
+ self._zoom = min(w / (du * 1.2), h / (dv * 1.2))
+ # _world_to_screen: screen = world*zoom + centre + offset.
+ # We want world (cu,cv) → screen (w/2, h/2). Solve for offset.
+ # screen_x = cu*zoom + w/2 + offset_x → offset_x = -cu*zoom
+ # screen_y = h/2 - cv*zoom + offset_y → offset_y = cv*zoom
+ self._offset = QPoint(int(-cu * self._zoom), int(cv * self._zoom))
+ self.update()
+
+ def _clear_face_state(self):
+ self._hovered_face = None
+ self._selected_face = None
+
+ def get_selected_face_geometry(self) -> Any:
+ """Return the OCCGeometryObject for the currently selected face, or None."""
+ if self._selected_face is not None and self._sketch is not None:
+ return self._sketch.build_face_geometry(self._selected_face)
+ return None
+
+ def clear_selected_face(self):
+ self._selected_face = None
+ self._hovered_face = None
+
+ @staticmethod
+ def _faces_match(a: Any, b: Any) -> bool:
+ """Content-based face comparison (dicts may be from different calls to detect_faces)."""
+ if a is None or b is None:
+ return False
+ return Sketch2DWidget._loop_match(a.get("outer"), b.get("outer"))
+
+ @staticmethod
+ def _loop_match(a: Any, b: Any) -> bool:
+ if a is None or b is None or a["type"] != b["type"]:
+ return False
+ if a["type"] == "polygon":
+ return a["points"][0] == b["points"][0] and len(a["points"]) == len(b["points"])
+ else: # circle
+ return a["center"] == b["center"] and a["radius"] == b["radius"]
+
+ def _rebuild_from_sketch(self):
+ """Rebuild UI point/line lists from the OCCSketch entity data.
+
+ External (underlay) entities are included in the UI lists so they
+ are valid pick targets for constraints and snap. The hit-test /
+ move / delete paths all check ``is_external`` and either skip them
+ (delete) or refuse to start a drag on them (move).
+ """
+ self._points = []
+ self._lines = []
+ self._circles = []
+ self._arcs = []
+ self._selected_entities = []
+ if self._sketch:
+ # Collect points in creation order (user + external).
+ for eid, entity in self._sketch._entities.items():
+ if entity.entity_type == "point":
+ self._points.append(entity)
+ for eid, entity in self._sketch._entities.items():
+ if entity.entity_type == "line" and eid in self._sketch._lines:
+ sid, eid2 = self._sketch._lines[eid]
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if s_ent and e_ent:
+ self._lines.append((s_ent, e_ent))
+ for eid, (cid, r) in self._sketch._circles.items():
+ c_ent = self._sketch._entities.get(cid)
+ if c_ent:
+ self._circles.append((c_ent, r))
+ # Rebuild arcs: arc data is stored as {"center": cid, "start": sid, "end": eid2, "radius": r}
+ for eid, arc_data in self._sketch._arcs.items():
+ center_id = arc_data.get("center")
+ start_id = arc_data.get("start")
+ end_id = arc_data.get("end")
+ radius = arc_data.get("radius", 0)
+ sweep = arc_data.get("sweep") # may be None (legacy arcs)
+ c_ent = self._sketch._entities.get(center_id) if center_id else None
+ s_ent = self._sketch._entities.get(start_id) if start_id else None
+ e_ent = self._sketch._entities.get(end_id) if end_id else None
+ if c_ent and s_ent and e_ent:
+ self._arcs.append((c_ent, radius, s_ent, e_ent, sweep))
+
+ @staticmethod
+ def _is_external(entity: Any) -> bool:
+ """True if an entity is a face-projected underlay / reference entity.
+
+ External entities live in the solver so constraints can reference
+ them, but they're protected from user deletion, dragging, and
+ profile extrusion.
+ """
+ return bool(entity is not None and getattr(entity, "is_external", False))
+
+ def _is_centerline(self, entity: Any) -> bool:
+ """True if an entity is a centerline reference axis (X or Y).
+
+ Centerlines are the permanent X (horizontal) and Y (vertical)
+ construction lines through the sketch origin. They are rendered
+ with distinct colors (red for X, green for Y) as viewport-spanning
+ dashed lines, and are protected from deletion.
+ """
+ if entity is None or self._sketch is None:
+ return False
+ return entity.id in self._sketch._centerline_ids
+
+ def get_sketch(self) -> Optional[OCCSketch]:
+ return self._sketch
+
+ def create_sketch(self) -> OCCSketch:
+ self._sketch = OCCSketch()
+ self._sketch.add_centerlines()
+ # Sync widget tracking lists from sketch so centerlines (and any
+ # future auto-created entities) are immediately pickable.
+ self._rebuild_from_sketch()
+ self.update()
+ return self._sketch
+
+ def _ensure_sketch_with_centerlines(self) -> None:
+ """Create a sketch with centerlines if none exists yet.
+
+ Drawing handlers (line, rect, circle, arc, slot) auto-create an
+ OCCSketch when the user starts drawing without first pressing
+ WP Origin — but the old code used ``OCCSketch()`` directly,
+ bypassing ``create_sketch()`` and leaving the sketch without
+ reference axes. This helper ensures centerlines are always
+ present, whether the sketch was created via WP Origin, WP Face,
+ or on-the-fly by a drawing tool.
+ """
+ if self._sketch is None:
+ self._sketch = OCCSketch()
+ self._sketch.add_centerlines()
+ self._rebuild_from_sketch()
+
+ def reset_buffers(self):
+ self._draw_buffer = []
+ self._dynamic_line_end = None
+ self._mode = None
+ self._clear_move_state()
+ self._rect_first_snap_target = None
+ self.clear_offset_preview()
+ self.update()
+
+ def set_offset_preview(self, points: Optional[List[Tuple[float, float]]]) -> None:
+ """Show or clear the offset preview overlay in the 2D view.
+
+ *points* is a list of (x, y) tuples forming a closed polygon, or
+ *None* / empty to clear the preview. Used by the OffsetDialog
+ live-preview callback to show the offset result in real time.
+ """
+ if points:
+ self._offset_preview_points = list(points)
+ self._offset_preview_active = True
+ else:
+ self._offset_preview_points = []
+ self._offset_preview_active = False
+ self.update()
+
+ def clear_offset_preview(self) -> None:
+ """Remove the offset preview overlay."""
+ self._offset_preview_points = []
+ self._offset_preview_active = False
+ self.update()
+
+ def _clear_move_state(self):
+ """Reset all element-move drag state."""
+ self._moving_points = []
+ self._move_anchor = None
+ self._move_anchor_orig = None
+ self._move_orig_positions = {}
+ self._move_active = False
+ self._hovered_face = None
+ self._snap_point_target = None
+ self._snap_line_target = None
+ self._snap_horizontal = False
+ self._snap_vertical = False
+ self._rect_first_snap_target = None
+ self._arc_accum_sweep = 0.0
+ self._arc_prev_angle = None
+
+ def set_mode(self, mode: Optional[str]):
+ self._mode = mode
+ self._draw_buffer = []
+ self._dynamic_line_end = None
+ self._selected_entities = []
+ self._hovered_constraint_idx = -1
+ self._hovered_face = None
+ self._arc_accum_sweep = 0.0
+ self._arc_prev_angle = None
+ self.clear_offset_preview()
+ # Cancel an ongoing move when switching modes
+ if self._move_active:
+ self._clear_move_state()
+ self.setCursor(Qt.ArrowCursor)
+ self.update()
+
+ def set_construct_mode(self, enabled: bool):
+ self._is_construct = enabled
+
+ def set_snap_mode(self, snap_type: str, enabled: bool):
+ self._snap_mode[snap_type] = enabled
+
+ def set_snap_distance(self, distance: int):
+ self._snap_distance = distance
+
+ def set_angle_steps(self, steps: int):
+ self._angle_steps = steps
+
+ def set_constraint_distance(self, distance: float):
+ self._constraint_distance_value = distance
+
+ # ─── Coordinate transforms ────────────────────────────────────────────
+
+ def _screen_to_world(self, pos: QPoint) -> QPoint:
+ return QPoint(
+ int((pos.x() - self.width() / 2 - self._offset.x()) / self._zoom),
+ int((self.height() / 2 - pos.y() + self._offset.y()) / self._zoom),
+ )
+
+ def _screen_to_world_f(self, pos: QPoint) -> Tuple[float, float]:
+ """Float-precision version of ``_screen_to_world``.
+
+ Used by face detection so that small shapes (sub-integer size at
+ the current zoom) are pickable. The integer version truncates
+ fractional world coords, which can place the hit point outside
+ a tiny polygon.
+ """
+ wx = (pos.x() - self.width() / 2 - self._offset.x()) / self._zoom
+ wy = (self.height() / 2 - pos.y() + self._offset.y()) / self._zoom
+ return (wx, wy)
+
+ def _world_to_screen(self, pos: QPoint) -> QPoint:
+ return QPoint(
+ int(pos.x() * self._zoom + self.width() / 2 + self._offset.x()),
+ int(self.height() / 2 - pos.y() * self._zoom + self._offset.y()),
+ )
+
+ # ─── Snapping ─────────────────────────────────────────────────────────
+
+ def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
+ if not self._snap_mode.get("point", False):
+ return None
+ nearest = None
+ min_dist = max_distance
+ for entity in self._points:
+ if entity.geometry:
+ x, y = entity.geometry
+ point = QPoint(int(round(x)), int(round(y)))
+ screen_point = self._world_to_screen(point)
+ dist = math.sqrt(
+ (pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
+ )
+ if dist < min_dist:
+ min_dist = dist
+ nearest = point
+ return nearest
+
+ def _find_nearest_point_entity(self, pos: QPoint, max_distance: int = 15) -> Optional[OCCSketchEntity]:
+ """Find the nearest point entity to a screen position.
+
+ External (underlay) points are pickable when the underlay is visible
+ so the user can use them as constraint anchors; they are skipped
+ otherwise. The function still respects the ``point`` snap mode toggle
+ so the user can disable snapping entirely.
+ """
+ if not self._snap_mode.get("point", False):
+ return None
+ nearest = None
+ min_dist = max_distance
+ for entity in self._points:
+ if self._is_external(entity) and not self._underlay_visible:
+ continue
+ if entity.geometry:
+ x, y = entity.geometry
+ point = QPoint(int(round(x)), int(round(y)))
+ screen_point = self._world_to_screen(point)
+ dist = math.sqrt(
+ (pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
+ )
+ if dist < min_dist:
+ min_dist = dist
+ nearest = entity
+ return nearest
+
+ def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
+ if not self._snap_mode.get("mpoint", False):
+ return None
+ for p1, p2 in self._lines:
+ if p1.geometry and p2.geometry:
+ x1, y1 = p1.geometry
+ x2, y2 = p2.geometry
+ mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
+ screen_mid = self._world_to_screen(mid)
+ dist = math.sqrt((pos.x() - screen_mid.x()) ** 2 + (pos.y() - screen_mid.y()) ** 2)
+ if dist < max_distance:
+ return mid
+ return None
+
+ def _apply_angle_snap(self, start: QPoint, end: QPoint) -> QPoint:
+ if not self._snap_mode.get("angle", False):
+ return end
+ dx = end.x() - start.x()
+ dy = end.y() - start.y()
+ angle = math.degrees(math.atan2(dy, dx))
+ length = math.sqrt(dx**2 + dy**2)
+ snapped_angle = round(angle / self._angle_steps) * self._angle_steps
+ snapped_rad = math.radians(snapped_angle)
+ return QPoint(
+ int(start.x() + length * math.cos(snapped_rad)),
+ int(start.y() + length * math.sin(snapped_rad)),
+ )
+
+ def _apply_horizontal_snap(self, start: QPoint, end: QPoint) -> QPoint:
+ if not self._snap_mode.get("horiz", False):
+ return end
+ return QPoint(end.x(), start.y())
+
+ def _apply_vertical_snap(self, start: QPoint, end: QPoint) -> QPoint:
+ if not self._snap_mode.get("vert", False):
+ return end
+ return QPoint(start.x(), end.y())
+
+ def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None) -> QPoint:
+ result = pos
+ # Reset auto-constraint tracking
+ self._snap_point_target = None
+ self._snap_line_target = None
+ self._snap_horizontal = False
+ self._snap_vertical = False
+
+ point_snap = self._find_nearest_point(pos)
+ if point_snap:
+ self._snap_point_target = self._find_nearest_point_entity(pos)
+ return self._world_to_screen(point_snap)
+ if self._snap_mode.get("mpoint", False):
+ mid_snap = self._find_midpoint_snap(pos)
+ if mid_snap:
+ return self._world_to_screen(mid_snap)
+ if start:
+ if self._snap_mode.get("horiz", False):
+ horiz = self._apply_horizontal_snap(start, result)
+ if abs(result.y() - start.y()) < 10:
+ result = horiz
+ self._snap_horizontal = True
+ if self._snap_mode.get("vert", False):
+ vert = self._apply_vertical_snap(start, result)
+ if abs(result.x() - start.x()) < 10:
+ result = vert
+ self._snap_vertical = True
+ if self._snap_mode.get("angle", False):
+ result = self._apply_angle_snap(start, result)
+ # Grid snap as a final fallback (only when nothing else applied)
+ if result == pos and self._snap_mode.get("grid", False):
+ grid_snap = self._find_grid_snap(pos)
+ if grid_snap is not None:
+ result = self._world_to_screen(grid_snap)
+ return result
+
+ def _find_grid_snap(self, pos: QPoint) -> Optional[QPoint]:
+ """Return the nearest world-space grid intersection for a screen position."""
+ if not self._snap_mode.get("grid", False):
+ return None
+ world = self._screen_to_world(pos)
+ grid_step = 10 # 10mm world units per grid cell (matches 10mm minor grid)
+ gx = round(world.x() / grid_step) * grid_step
+ gy = round(world.y() / grid_step) * grid_step
+ return QPoint(gx, gy)
+
+ def _apply_move_snaps(
+ self,
+ mouse_screen: QPoint,
+ anchor_orig_screen: QPoint,
+ exclude_ids: set,
+ ) -> QPoint:
+ """Snapping used while moving an element.
+
+ The moved element's own points/lines are excluded from being snap
+ candidates so the element never snaps to itself. ``anchor_orig_screen``
+ is the screen position of the grabbed anchor BEFORE the move started; it
+ is used as the reference origin for horizontal/vertical/angle snapping.
+ """
+ pos = mouse_screen
+
+ # Reset auto-constraint tracking
+ self._snap_point_target = None
+ self._snap_line_target = None
+ self._snap_horizontal = False
+ self._snap_vertical = False
+
+ # Point snap (excluding moved points)
+ if self._snap_mode.get("point", False):
+ nearest = None
+ nearest_entity = None
+ min_dist = self._snap_distance
+ for entity in self._points:
+ if entity.id in exclude_ids or not entity.geometry:
+ continue
+ x, y = entity.geometry
+ sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
+ if d < min_dist:
+ min_dist = d
+ nearest = sp
+ nearest_entity = entity
+ if nearest is not None:
+ self._snap_point_target = nearest_entity
+ return nearest
+
+ # Midpoint snap (excluding lines whose both endpoints are being moved)
+ if self._snap_mode.get("mpoint", False):
+ nearest = None
+ min_dist = self._snap_distance
+ for p1, p2 in self._lines:
+ if p1.id in exclude_ids and p2.id in exclude_ids:
+ continue
+ if p1.geometry and p2.geometry:
+ x1, y1 = p1.geometry
+ x2, y2 = p2.geometry
+ mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
+ sm = self._world_to_screen(mid)
+ d = math.sqrt((pos.x() - sm.x()) ** 2 + (pos.y() - sm.y()) ** 2)
+ if d < min_dist:
+ min_dist = d
+ nearest = sm
+ if nearest is not None:
+ return nearest
+
+ # Horizontal / vertical / angle snaps are relative to the original anchor
+ result = pos
+ if self._snap_mode.get("horiz", False):
+ if abs(result.y() - anchor_orig_screen.y()) < 10:
+ result = QPoint(result.x(), anchor_orig_screen.y())
+ if self._snap_mode.get("vert", False):
+ if abs(result.x() - anchor_orig_screen.x()) < 10:
+ result = QPoint(anchor_orig_screen.x(), result.y())
+ if self._snap_mode.get("angle", False):
+ result = self._apply_angle_snap(anchor_orig_screen, result)
+
+ # Grid snap as final fallback (only when nothing else changed)
+ if result == pos and self._snap_mode.get("grid", False):
+ gs = self._find_grid_snap(pos)
+ if gs is not None:
+ result = self._world_to_screen(gs)
+ return result
+
+ # ─── Solver helpers ───────────────────────────────────────────────────
+
+ def _get_point_entity_at(self, world_pos: QPoint) -> Optional[OCCSketchEntity]:
+ """Find nearest point entity to world position.
+
+ External (underlay) points are pickable when the underlay is visible
+ so the user can use them as constraint anchors (e.g. the corner of a
+ projected face); they're skipped when the underlay is hidden.
+ """
+ for entity in self._points:
+ if self._is_external(entity) and not self._underlay_visible:
+ continue
+ if entity.geometry:
+ x, y = entity.geometry
+ dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
+ if dist < 5: # tolerance in world coords
+ return entity
+ return None
+
+ def _get_line_entity_at(self, world_pos: QPoint) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
+ """Find a line near the given world position.
+
+ External (underlay) lines are pickable when the underlay is visible
+ (so the user can add a distance / horizontal / vertical / parallel /
+ perpendicular / midpoint constraint against them) and skipped when
+ the underlay is hidden.
+
+ Centerlines are treated as infinite reference axes — the hit test
+ uses perpendicular distance to the infinite line (no segment
+ clamping) with a zoom-adjusted tolerance so they are pickable at
+ any zoom level.
+ """
+ for p1_ent, p2_ent in self._lines:
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ is_ext = bool(line_ent is not None and self._is_external(line_ent))
+ if is_ext and not self._underlay_visible:
+ continue
+ if p1_ent.geometry and p2_ent.geometry:
+ x1, y1 = p1_ent.geometry
+ x2, y2 = p2_ent.geometry
+ dx = x2 - x1
+ dy = y2 - y1
+ if dx == 0 and dy == 0:
+ continue
+
+ # Compute perpendicular distance to the infinite line.
+ line_len_sq = dx * dx + dy * dy
+ # Perpendicular distance: |(P - P1) × (P2 - P1)| / |P2 - P1|
+ perp_dist = abs(dx * (y1 - world_pos.y()) - dy * (x1 - world_pos.x())) / math.sqrt(line_len_sq)
+
+ is_cl = line_ent is not None and self._is_centerline(line_ent)
+ if is_cl:
+ # Centerlines are infinite reference axes: use the
+ # perpendicular distance directly (no segment clamping).
+ # Zoom-adjusted tolerance: at least 20 pixels in screen space
+ # so the axes are easily pickable regardless of zoom level.
+ tol = max(10.0, 20.0 / max(self._zoom, 0.01))
+ if perp_dist < tol:
+ return (p1_ent, p2_ent)
+ else:
+ # Regular lines: clamp the projection to the segment
+ # and check distance to that clamped point.
+ t = ((world_pos.x() - x1) * dx + (world_pos.y() - y1) * dy) / line_len_sq
+ t = max(0.0, min(1.0, t))
+ proj_x = x1 + t * dx
+ proj_y = y1 + t * dy
+ seg_dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2)
+ if seg_dist < 10: # tolerance
+ return (p1_ent, p2_ent)
+ return None
+
+ def _find_line_sketch_entity(
+ self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
+ ) -> Optional[OCCSketchEntity]:
+ """Return the LINE sketch entity whose endpoints match the given point entities.
+
+ ``OCCSketch._lines`` maps ``line_id -> (start_point_id, end_point_id)``;
+ the line *entity* itself lives in ``_entities[line_id]`` and carries the
+ solver line handle. The old handlers mistakenly returned the start
+ *point* entity, whose solver handle is a point — passing that to
+ ``horizontal``/``vertical``/``parallel``/``perpendicular``/``midpoint``/
+ point-on-line/``symmetric`` raises ``TypeError: unsupported entities``.
+ """
+ if not self._sketch:
+ return None
+ for line_id, (sid, eid2) in self._sketch._lines.items():
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if (s_ent == p1_ent and e_ent == p2_ent) or (s_ent == p2_ent and e_ent == p1_ent):
+ return self._sketch._entities.get(line_id) # the line entity
+ return None
+
+ def _get_line_endpoints(
+ self, line_ent: OCCSketchEntity
+ ) -> Tuple[Optional[OCCSketchEntity], Optional[OCCSketchEntity]]:
+ """Return ``(start_point_entity, end_point_entity)`` for a given line entity."""
+ if not self._sketch:
+ return None, None
+ for line_id, (sid, eid2) in self._sketch._lines.items():
+ if self._sketch._entities.get(line_id) is line_ent:
+ return self._sketch._entities.get(sid), self._sketch._entities.get(eid2)
+ return None, None
+
+ def _find_line_entity_for_line_xy(self, p1_xy: Tuple[float, float], p2_xy: Tuple[float, float]) -> Optional[OCCSketchEntity]:
+ """Find the OCCSketchEntity for a line defined by its endpoint tuples."""
+ for eid, start_end in self._sketch._lines.items():
+ sid, eid2 = start_end
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
+ sx, sy = s_ent.geometry
+ ex, ey = e_ent.geometry
+ if (abs(sx - p1_xy[0]) < 0.1 and abs(sy - p1_xy[1]) < 0.1 and
+ abs(ex - p2_xy[0]) < 0.1 and abs(ey - p2_xy[1]) < 0.1):
+ return s_ent # Return the line entity reference
+ if (abs(sx - p2_xy[0]) < 0.1 and abs(sy - p2_xy[1]) < 0.1 and
+ abs(ex - p1_xy[0]) < 0.1 and abs(ey - p1_xy[1]) < 0.1):
+ return s_ent
+ return None
+
+ def _get_constraints_for_line(self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity) -> List[str]:
+ """Get constraint labels from both endpoint entities."""
+ return list(set(p1_ent.constraints + p2_ent.constraints))
+
+ # ─── Constraint tags (display + delete) ───────────────────────────────
+
+ def _line_world_mid(self, line_id: int) -> Optional[QPoint]:
+ """World-space midpoint of the line with the given entity id."""
+ if not self._sketch or line_id not in self._sketch._lines:
+ return None
+ sid, eid2 = self._sketch._lines[line_id]
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if not s_ent or not e_ent or not s_ent.geometry or not e_ent.geometry:
+ return None
+ x1, y1 = s_ent.geometry
+ x2, y2 = e_ent.geometry
+ return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
+
+ def _point_world(self, pid: int) -> Optional[QPoint]:
+ """World-space position of the point entity with the given id.
+
+ Defensive: returns *None* if the entity is missing, has no
+ geometry, or its geometry is not a 2-tuple of numbers. The last
+ check matters because the solver log also contains line ids
+ (e.g. for point-on-line coincident or distance to a line), and a
+ line's geometry is ``((x1,y1), (x2,y2))`` — naively unpacking
+ that as ``(x, y)`` and calling ``round()`` on the inner tuples
+ raises ``TypeError: type tuple doesn't define __round__ method``.
+
+ The final ``try/except`` is a last-resort safety net for exotic
+ cases (numpy scalars, complex numbers, badly-typed solver
+ output) that the explicit type checks above might miss. Better
+ to drop a constraint tag than to take down the entire paint
+ event.
+ """
+ if not self._sketch:
+ return None
+ ent = self._sketch._entities.get(pid)
+ if not ent or not ent.geometry:
+ return None
+ geom = ent.geometry
+ # A point's geometry is a flat 2-tuple of numbers; a line's is
+ # ((x1, y1), (x2, y2)). Reject anything that doesn't look like
+ # a point so callers don't crash on line/circle/arc ids.
+ if not isinstance(geom, tuple) or len(geom) != 2:
+ return None
+ x, y = geom
+ if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
+ return None
+ try:
+ return QPoint(int(round(x)), int(round(y)))
+ except (TypeError, ValueError):
+ # Last-resort guard: exotic x/y types (numpy scalars, NaN,
+ # etc.) can still slip through. Returning None means the
+ # tag is dropped rather than the paint event crashing.
+ logger.debug(
+ "_point_world: could not round geometry for entity %s (%r, %r)",
+ pid, x, y,
+ )
+ return None
+
+ def _entity_anchor(self, eid: int) -> Optional[QPoint]:
+ """Return a sensible world-space anchor for *any* entity id.
+
+ Points → their position. Lines → the midpoint. Anything else
+ (missing / unrecognised) → None. Used by constraint tag rendering
+ so a coincident or distance constraint that involves a line (e.g.
+ point-on-line, distance to a line) can be labelled without
+ crashing the paint event.
+ """
+ if not self._sketch:
+ return None
+ ent = self._sketch._entities.get(eid)
+ if ent is None:
+ return None
+ if ent.entity_type == "line":
+ return self._line_world_mid(eid)
+ return self._point_world(eid)
+
+ def _compute_constraint_tags(self) -> List[Dict[str, Any]]:
+ """Build the list of constraint-tag descriptors from the constraint log.
+
+ Each tag maps a displayed label + on-screen rect to the index of its
+ entry in ``OCCSketch._constraint_log`` so the user can hover a tag and
+ press Delete to remove exactly that constraint. Anchors are chosen per
+ constraint type: line-attached tags sit at the line midpoint, point tags
+ at the relevant point, point-pair tags between the two points.
+ """
+ tags: List[Dict[str, Any]] = []
+ if not self._sketch:
+ return tags
+ fm = QFontMetrics(QFont("Monospace", 9))
+ # Track how many tags already share an anchor so we stack them vertically.
+ stack_count: Dict[Tuple[int, int], int] = {}
+
+ for idx, entry in enumerate(self._sketch._constraint_log):
+ # One bad log entry (e.g. a dangling id after a delete, an
+ # exotic geometry type, a numpy scalar that didn't round) must
+ # not take down the entire paint event. Catch any failure
+ # here, log it at debug, skip the tag, and keep painting.
+ try:
+ ctype = entry["type"]
+ ids = entry["ids"]
+ params = entry["params"]
+ anchor: Optional[QPoint] = None
+ label = ""
+
+ if ctype == "horizontal":
+ anchor = self._line_world_mid(ids[0]); label = "hrz"
+ elif ctype == "vertical":
+ anchor = self._line_world_mid(ids[0]); label = "vrt"
+ elif ctype == "midpoint":
+ anchor = self._line_world_mid(ids[1]); label = "mid"
+ elif ctype == "distance":
+ # Distance may be point-to-point OR point-to-line (e.g.
+ # point-on-line coincident surfaces as a coincident entry;
+ # a future point-to-line distance would do the same).
+ # Use _entity_anchor so a line id routes to the line
+ # midpoint instead of crashing on round().
+ a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
+ # NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
+ if a is not None and b is not None:
+ anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
+ label = f"dst {params[0]:.1f}" if params else "dst"
+ elif ctype == "parallel":
+ anchor = self._line_world_mid(ids[0]); label = "par"
+ elif ctype == "perpendicular":
+ anchor = self._line_world_mid(ids[0]); label = "perp"
+ elif ctype == "angle":
+ anchor = self._line_world_mid(ids[0])
+ label = f"ang {params[0]:.0f}" if params else "ang"
+ elif ctype == "equal":
+ anchor = self._line_world_mid(ids[0]); label = "eql"
+ elif ctype == "coincident":
+ # Coincident can be point-to-point OR point-on-line (when
+ # a line is one of the targets). Use _entity_anchor so
+ # the line's midpoint is used as a fallback anchor when
+ # one of the ids is a line.
+ a = self._entity_anchor(ids[0]); b = self._entity_anchor(ids[1])
+ if a is not None and b is not None:
+ anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
+ label = "coin"
+ elif ctype == "symmetric":
+ anchor = self._line_world_mid(ids[2]); label = "sym"
+ elif ctype == "fixed":
+ anchor = self._point_world(ids[0]); label = "fix"
+ elif ctype == "equal_radius":
+ anchor = self._point_world(ids[0]); label = "eqr"
+ else:
+ continue
+
+ if anchor is None:
+ continue
+ sc = self._world_to_screen(anchor)
+ key = (sc.x(), sc.y())
+ slot = stack_count.get(key, 0)
+ stack_count[key] = slot + 1
+
+ text = f"> {label} <"
+ w = fm.horizontalAdvance(text) + 10
+ h = 16
+ # Stack successive tags above the anchor so they don't overlap.
+ cx = sc.x()
+ cy = sc.y() - 14 - slot * (h + 2)
+ rect = QRect(cx - w // 2, cy - h // 2, w, h)
+ tags.append({"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)})
+ except Exception as exc:
+ # Catch any failure while building this one tag (bad
+ # geometry, missing entity, numpy round weirdness, etc.)
+ # so a single bad entry can't take down the whole paint
+ # event. Drop the tag and move on; the user sees the
+ # other tags as normal.
+ logger.debug(
+ "Skipped constraint tag #%s (%s) due to %s: %s",
+ idx, entry.get("type"), type(exc).__name__, exc,
+ )
+ continue
+ return tags
+
+ # ─── Element move helpers ─────────────────────────────────────────────
+
+ def _collect_connected_points(self, anchor: OCCSketchEntity) -> List[OCCSketchEntity]:
+ """Return anchor plus all point entities connected through lines AND arcs.
+
+ ``OCCSketch._lines`` maps line id -> (start_point_id, end_point_id) and
+ ``OCCSketch._arcs`` maps arc id -> {"center": cid, "start": sid, "end": eid, ...}.
+ We BFS over that graph to gather the whole element (e.g. all 4 corners of a
+ rectangle, or both endpoints of a single line, or all 6 points of a slot).
+ """
+ if not self._sketch:
+ return [anchor]
+ adjacency: Dict[int, List[int]] = {}
+ # Line connections
+ for _line_id, (sid, eid2) in self._sketch._lines.items():
+ adjacency.setdefault(sid, []).append(eid2)
+ adjacency.setdefault(eid2, []).append(sid)
+ # Arc connections — an arc ties its centre to its start and end, and
+ # also ties start to end so the whole arc body moves as a single unit.
+ for _arc_id, arc_data in self._sketch._arcs.items():
+ center_id = arc_data.get("center")
+ start_id = arc_data.get("start")
+ end_id = arc_data.get("end")
+ if center_id is not None:
+ if start_id is not None:
+ adjacency.setdefault(center_id, []).append(start_id)
+ adjacency.setdefault(start_id, []).append(center_id)
+ if end_id is not None:
+ adjacency.setdefault(center_id, []).append(end_id)
+ adjacency.setdefault(end_id, []).append(center_id)
+ # Connect start <-> end so the arc body moves as a unit even
+ # without traversing through centre (e.g. grabbing an endpoint
+ # pulls the other endpoint and the centre together).
+ if start_id is not None and end_id is not None:
+ adjacency.setdefault(start_id, []).append(end_id)
+ adjacency.setdefault(end_id, []).append(start_id)
+ seen = {anchor.id}
+ result: List[OCCSketchEntity] = [anchor]
+ queue = [anchor.id]
+ while queue:
+ cur = queue.pop()
+ for nbr in adjacency.get(cur, []):
+ if nbr in seen:
+ continue
+ seen.add(nbr)
+ ent = self._sketch._entities.get(nbr)
+ if ent is not None:
+ result.append(ent)
+ queue.append(nbr)
+ return result
+
+ def _find_move_target(self, pos: QPoint) -> Optional[Tuple[OCCSketchEntity, QPoint]]:
+ """Find the element under ``pos`` and return ``(anchor_entity, anchor_world)``.
+
+ Click priority: nearest point > nearest line (anchor = closer endpoint) >
+ circle circumference (anchor = center). The anchor's current world
+ position is returned so snapping can use it as the reference origin.
+ """
+ if not self._sketch:
+ return None
+ ent = self._find_nearest_point_entity(pos, max_distance=self._snap_distance)
+ if ent is not None and ent.geometry:
+ x, y = ent.geometry
+ return ent, QPoint(int(round(x)), int(round(y)))
+ world = self._screen_to_world(pos)
+ line_hit = self._get_line_entity_at(world)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ if p1_ent.geometry and p2_ent.geometry:
+ x1, y1 = p1_ent.geometry
+ x2, y2 = p2_ent.geometry
+ d1 = (world.x() - x1) ** 2 + (world.y() - y1) ** 2
+ d2 = (world.x() - x2) ** 2 + (world.y() - y2) ** 2
+ anchor = p1_ent if d1 <= d2 else p2_ent
+ ax, ay = anchor.geometry
+ return anchor, QPoint(int(round(ax)), int(round(ay)))
+ for c_ent, r in self._circles:
+ if c_ent.geometry and r > 0:
+ cx, cy = c_ent.geometry
+ d = math.sqrt((world.x() - cx) ** 2 + (world.y() - cy) ** 2)
+ if abs(d - r) < 8:
+ return c_ent, QPoint(int(round(cx)), int(round(cy)))
+ return None
+
+ def _sync_solved_positions(self):
+ """Sync solver positions back to UI points and lines."""
+ if not self._sketch:
+ return
+ for entity in self._points:
+ if entity.handle is not None:
+ try:
+ x, y = self._sketch.solver.params(entity.handle.params)
+ entity.geometry = (float(x), float(y))
+ except Exception:
+ pass
+ # Update line geometries from their endpoint positions
+ for p1_ent, p2_ent in self._lines:
+ if p1_ent.geometry and p2_ent.geometry:
+ pass # geometry already updated via point sync
+
+ def _solve_and_sync(self) -> bool:
+ """Solve constraints, sync positions, update UI. Returns True if solved OK."""
+ if not self._sketch:
+ return True
+ ok = self._sketch.solve()
+ self._sync_solved_positions()
+ self.update()
+ return ok
+
+ # ─── Mouse events ─────────────────────────────────────────────────────
+
+ def mousePressEvent(self, event):
+ world_pos = self._screen_to_world(event.pos())
+
+ if event.button() == Qt.MiddleButton:
+ self._panning = True
+ self._pan_start = event.pos()
+ self.setCursor(Qt.ClosedHandCursor)
+ return
+
+ if event.button() == Qt.RightButton:
+ self._mode = None
+ self._draw_buffer = []
+ self._dynamic_line_end = None
+ self._selected_entities = []
+ self._arc_accum_sweep = 0.0
+ self._arc_prev_angle = None
+ self.constrain_done.emit()
+ self.update()
+ return
+
+ if event.button() == Qt.LeftButton:
+ # Priority order: ① tight point-grab → ② face selection → ③ element move
+ if self._mode in ("select", None) and self._sketch:
+ # ① Tight point check (4 px) — a deliberate grab on a point.
+ tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
+ if (tight_ent is not None and tight_ent.geometry
+ and not self._is_external(tight_ent)
+ and not self._is_centerline(tight_ent)):
+ x, y = tight_ent.geometry
+ moving = self._collect_connected_points(tight_ent)
+ orig: Dict[int, Tuple[float, float]] = {}
+ for e in moving:
+ if e.geometry:
+ orig[e.id] = (e.geometry[0], e.geometry[1])
+ self._moving_points = moving
+ self._move_anchor = tight_ent
+ self._move_anchor_orig = QPoint(int(round(x)), int(round(y)))
+ self._move_orig_positions = orig
+ self._move_active = True
+ self.setCursor(Qt.ClosedHandCursor)
+ return
+
+ # ② Face region — click inside a closed face to select it.
+ # Use float-precision world coords so small shapes (sub-integer
+ # at the current zoom) are still pickable.
+ fwx, fwy = self._screen_to_world_f(event.pos())
+ face = self._sketch.find_face_at(fwx, fwy)
+ if face is not None:
+ if self._faces_match(face, self._selected_face):
+ # Clicking the same face again toggles it off.
+ self._selected_face = None
+ else:
+ self._selected_face = face
+ self._hovered_face = None
+ self.update()
+ return
+
+ # ③ Wider element-move check (lines and circles). External
+ # (underlay) entities are fixed references and can't be
+ # dragged — fall through to the constraint / draw handlers
+ # so a click on an underlay edge is treated as a constraint
+ # pick (the desired behavior) rather than a no-op.
+ target = self._find_move_target(event.pos())
+ if (target is not None
+ and not self._is_external(target[0])
+ and not self._is_centerline(target[0])):
+ anchor_ent, anchor_world = target
+ moving = self._collect_connected_points(anchor_ent)
+ orig: Dict[int, Tuple[float, float]] = {}
+ for e in moving:
+ if e.geometry:
+ orig[e.id] = (e.geometry[0], e.geometry[1])
+ self._moving_points = moving
+ self._move_anchor = anchor_ent
+ self._move_anchor_orig = anchor_world
+ self._move_orig_positions = orig
+ self._move_active = True
+ self.setCursor(Qt.ClosedHandCursor)
+ return
+
+ snapped_pos = self._apply_all_snaps(
+ event.pos(), self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None
+ )
+ world_snapped = (
+ self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
+ )
+
+ if self._mode == "line":
+ self._handle_line_click(world_snapped)
+ elif self._mode == "rectangle":
+ self._handle_rectangle_click(world_snapped)
+ elif self._mode == "circle":
+ self._handle_circle_click(world_snapped)
+ elif self._mode == "arc":
+ self._handle_arc_click(world_snapped)
+ elif self._mode == "slot":
+ self._handle_slot_click(world_snapped)
+ elif self._mode == "select":
+ self._handle_select_click(world_snapped)
+ elif self._mode == "constrain_coincident":
+ self._handle_constraint_coincident(world_snapped)
+ elif self._mode == "constrain_horizontal":
+ self._handle_constraint_horizontal(world_snapped)
+ elif self._mode == "constrain_vertical":
+ self._handle_constraint_vertical(world_snapped)
+ elif self._mode == "constrain_distance":
+ self._handle_constraint_distance(world_snapped)
+ elif self._mode == "constrain_midpoint":
+ self._handle_constraint_midpoint(world_snapped)
+ elif self._mode == "constrain_perpendicular":
+ self._handle_constraint_perpendicular(world_snapped)
+ elif self._mode == "constrain_parallel":
+ self._handle_constraint_parallel(world_snapped)
+ elif self._mode == "constrain_ptline":
+ self._handle_constraint_ptline(world_snapped)
+ elif self._mode == "constrain_symmetric":
+ self._handle_constraint_symmetric(world_snapped)
+
+ def mouseMoveEvent(self, event):
+ if self._panning and self._pan_start:
+ delta = event.pos() - self._pan_start
+ self._offset += delta
+ self._pan_start = event.pos()
+ self.update()
+ return
+
+ # Handle element move (grab all points of the element, snap the anchor)
+ if self._move_active and self._move_anchor is not None and self._move_anchor_orig is not None:
+ anchor_orig_screen = self._world_to_screen(self._move_anchor_orig)
+ exclude_ids = set(e.id for e in self._moving_points)
+ snapped_screen = self._apply_move_snaps(
+ event.pos(), anchor_orig_screen, exclude_ids
+ )
+ target_world = self._screen_to_world(snapped_screen)
+ dx = target_world.x() - self._move_anchor_orig.x()
+ dy = target_world.y() - self._move_anchor_orig.y()
+ for ent in self._moving_points:
+ if ent.id in self._move_orig_positions and ent.geometry is not None:
+ ox, oy = self._move_orig_positions[ent.id]
+ ent.geometry = (ox + dx, oy + dy)
+ self.update()
+ return
+
+ world_pos = self._screen_to_world(event.pos())
+
+ if self._draw_buffer and self._mode in ["line", "rectangle", "circle", "arc", "slot"]:
+ snapped = self._apply_all_snaps(
+ event.pos(), self._world_to_screen(self._draw_buffer[0])
+ )
+ self._dynamic_line_end = self._screen_to_world(snapped)
+
+ # Project arc end onto the circle so the preview snaps to
+ # the correct radius (matching the start point's distance).
+ if self._mode == "arc" and len(self._draw_buffer) == 2:
+ cw = self._draw_buffer[0]
+ sw = self._draw_buffer[1]
+ ew = self._dynamic_line_end
+ cx_f, cy_f = cw.x(), cw.y()
+ sx_f, sy_f = sw.x(), sw.y()
+ r = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2) if (
+ (sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2 > 0
+ ) else 1.0
+ dx = ew.x() - cx_f
+ dy = ew.y() - cy_f
+ d = math.sqrt(dx * dx + dy * dy)
+ if d > 0:
+ self._dynamic_line_end = QPoint(
+ int(round(cx_f + dx / d * r)),
+ int(round(cy_f + dy / d * r)),
+ )
+ else:
+ self._dynamic_line_end = sw
+
+ # Accumulated sweep tracking for arc (after click 2, before click 3)
+ if self._mode == "arc" and len(self._draw_buffer) == 2:
+ cw = self._draw_buffer[0]
+ ew = self._dynamic_line_end
+ current_angle = math.atan2(ew.y() - cw.y(), ew.x() - cw.x())
+ if self._arc_prev_angle is not None:
+ delta = current_angle - self._arc_prev_angle
+ # Normalise delta to [-PI, PI] (handle atan2 wrap-around)
+ while delta > math.pi:
+ delta -= 2 * math.pi
+ while delta < -math.pi:
+ delta += 2 * math.pi
+ self._arc_accum_sweep += delta
+ self._arc_prev_angle = current_angle
+
+ # Constraint-tag hover takes priority — if the cursor is over a tag,
+ # we highlight it for delete and skip point/line/face hover this move.
+ self._constraint_tags = self._compute_constraint_tags()
+ tag_hit = None
+ for tag in self._constraint_tags:
+ if tag["rect"].contains(event.pos()):
+ tag_hit = tag["idx"]
+ break
+ if tag_hit is not None:
+ self._hovered_constraint_idx = tag_hit
+ self._hovered_point = None
+ self._hovered_point_entity = None
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._hovered_face = None
+ self.setCursor(Qt.PointingHandCursor)
+ self.update()
+ return
+ if self._hovered_constraint_idx != -1:
+ self._hovered_constraint_idx = -1
+
+ # Priority for select/move mode: point > face > line > circle.
+ # Face is checked before line so the user can see and select the
+ # wall region between two concentric boundaries (e.g. after offset).
+ # In drawing modes, lines take priority so the user can snap to them.
+ hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
+
+ # ── ① Point hover (tightest, always first) ──
+ point_snap = self._find_nearest_point(event.pos())
+ if point_snap:
+ self._hovered_point = point_snap
+ self._hovered_point_entity = self._find_nearest_point_entity(event.pos())
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._hovered_face = None
+ self.setCursor(hover_cursor)
+ else:
+ self._hovered_point = None
+ self._hovered_point_entity = None
+
+ # ── ② Face hover (checked before line in select mode) ──
+ # In select/move mode the face is the primary interaction target
+ # (select to extrude etc.). Lines remain selectable via click
+ # (element move) but the visual highlight shows the face region.
+ face_found = False
+ if self._mode in ("select", None) and self._sketch is not None:
+ fwx, fwy = self._screen_to_world_f(event.pos())
+ face = self._sketch.find_face_at(fwx, fwy)
+ if face is not None:
+ self._hovered_face = face
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self.setCursor(Qt.PointingHandCursor)
+ face_found = True
+
+ if not face_found:
+ self._hovered_face = None
+
+ # ── ③ Line hover (when no face under cursor) ──
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ if p1_ent.geometry and p2_ent.geometry:
+ self._hovered_line = (
+ QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
+ QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
+ )
+ self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
+ self.setCursor(hover_cursor)
+ else:
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self.setCursor(Qt.ArrowCursor)
+ else:
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ # ── ④ Circle hover ──
+ if self._mode in ("select", None):
+ over_circle = False
+ for c_ent, r in self._circles:
+ if c_ent.geometry and r > 0:
+ cx, cy = c_ent.geometry
+ d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
+ if abs(d - r) < 8:
+ over_circle = True
+ break
+ self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor)
+ else:
+ self.setCursor(Qt.ArrowCursor)
+
+ self.update()
+
+ def mouseReleaseEvent(self, event):
+ # Element move release: commit positions into the solver, solve, and
+ # revert on a constraint failure. Solving AFTER the release is what
+ # decides whether a constrained element snaps (back) or stays moved.
+ if self._move_active and event.button() == Qt.LeftButton:
+ if self._sketch and self._moving_points:
+ new_positions: Dict[int, Tuple[float, float]] = {}
+ for ent in self._moving_points:
+ if ent.geometry is not None:
+ new_positions[ent.id] = ent.geometry
+ original = dict(self._move_orig_positions)
+ # Push the dragged positions into the solver's params so free
+ # points keep their new location (otherwise solve reverts them).
+ self._sketch.set_positions(new_positions)
+ ok = self._solve_and_sync()
+ if not ok:
+ logger.warning("Constraint violation while moving element; reverting")
+ self._sketch.set_positions(original)
+ self._solve_and_sync()
+ self._snap_point_target = None
+ else:
+ # Auto-constrain: point snap during move → coincident
+ if self._snap_point_target is not None and self._move_anchor is not None:
+ self._sketch.constrain_coincident(self._move_anchor, self._snap_point_target)
+ self._solve_and_sync()
+ # Snap modes are honoured during the move (see _apply_move_snaps
+ # in mouseMoveEvent), so the committed positions are already snapped.
+ self._clear_move_state()
+ self.setCursor(Qt.ArrowCursor)
+ self.sketch_updated.emit()
+ self.update()
+ return
+
+ if event.button() == Qt.MiddleButton:
+ self._panning = False
+ self._pan_start = None
+ self.setCursor(Qt.ArrowCursor)
+
+ def wheelEvent(self, event):
+ delta = event.angleDelta().y()
+ factor = 1.1 if delta > 0 else 0.9
+ self._zoom *= factor
+ self._zoom = max(0.1, min(10.0, self._zoom))
+ self.update()
+
+ def keyPressEvent(self, event):
+ # Delete / Backspace removes the entity currently under the cursor
+ # and recomputes the surviving constraints. Priority: constraint tag >
+ # line > point. Works in Move mode or when no tool is selected; ignored
+ # while actively drawing so an in-progress line isn't clobbered.
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
+ if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
+ if self._hovered_constraint_idx >= 0:
+ self._delete_hovered_constraint()
+ event.accept()
+ return
+ if self._hovered_line_entity is not None:
+ self._delete_hovered_line()
+ event.accept()
+ return
+ if self._hovered_point_entity is not None:
+ self._delete_hovered_point()
+ event.accept()
+ return
+
+ # C key toggles the hovered line between normal and construction mode.
+ if event.key() == Qt.Key_C:
+ if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
+ if self._hovered_line_entity is not None:
+ self._toggle_hovered_line_construction()
+ event.accept()
+ return
+
+ super().keyPressEvent(event)
+
+ def _delete_hovered_constraint(self):
+ """Delete the hovered constraint (by log index) and recompute the rest."""
+ idx = self._hovered_constraint_idx
+ if idx < 0 or self._sketch is None:
+ return
+ ok = self._sketch.remove_constraint_at(idx)
+ logger.info(f"Deleted constraint #{idx}; recompute solved={ok}")
+ self._hovered_constraint_idx = -1
+ self._rebuild_from_sketch()
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+
+ def _delete_hovered_line(self):
+ """Delete the hovered line, recompute constraints, and refresh the widget."""
+ line_ent = self._hovered_line_entity
+ if line_ent is None or self._sketch is None:
+ return
+ # External (underlay) lines are reference geometry from the source
+ # face and can't be deleted one at a time. See delete_point for the
+ # same guard — the whole underlay is cleared in one shot via
+ # remove_external_entities when the source face is unset.
+ if getattr(line_ent, "is_external", False):
+ logger.debug("Refusing to delete external (underlay) line")
+ return
+ # Centerlines are permanent reference axes — refuse deletion.
+ if self._is_centerline(line_ent):
+ logger.debug("Refusing to delete centerline")
+ return
+ ok = self._sketch.delete_line(line_ent)
+ logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
+ # Refresh widget tracking from the pruned sketch and sync solved positions.
+ self._rebuild_from_sketch()
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._hovered_point = None
+ self._hovered_point_entity = None
+ self._selected_entities = []
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+
+ def _delete_hovered_point(self):
+ """Delete the hovered point (and lines using it), then recompute."""
+ point_ent = self._hovered_point_entity
+ if point_ent is None or self._sketch is None:
+ return
+ # External (underlay) points are reference geometry from the source
+ # face — they can't be deleted individually. The whole underlay
+ # is cleared via remove_external_entities when the source face is
+ # removed; silently refuse here so the user gets no surprise
+ # cascading deletion of every other underlay element.
+ if getattr(point_ent, "is_external", False):
+ logger.debug("Refusing to delete external (underlay) point")
+ return
+ # Centerline points are permanent reference anchors — refuse deletion.
+ if self._is_centerline(point_ent):
+ logger.debug("Refusing to delete centerline point")
+ return
+ ok = self._sketch.delete_point(point_ent)
+ logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
+ self._rebuild_from_sketch()
+ self._hovered_point = None
+ self._hovered_point_entity = None
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._selected_entities = []
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+
+ def _toggle_hovered_line_construction(self) -> None:
+ """Toggle the hovered line between normal and construction mode.
+
+ The line entity's ``is_construction`` flag is flipped. The line
+ entity is then marked as the sole authority for its construction
+ state — its endpoint points are NOT toggled, so that other lines
+ sharing the same endpoint are unaffected. The paint code checks
+ the line entity's flag in addition to the endpoint flags.
+ """
+ line_ent = self._hovered_line_entity
+ if line_ent is None or self._sketch is None:
+ return
+ # External (underlay) lines are reference geometry from the source
+ # face — they can't be toggled individually.
+ if getattr(line_ent, "is_external", False):
+ logger.debug("Refusing to toggle external (underlay) line")
+ return
+ # Centerlines are permanent reference axes — refuse toggling.
+ if self._is_centerline(line_ent):
+ logger.debug("Refusing to toggle centerline")
+ return
+ # Flip the construction flag on the line entity itself.
+ new_val = not line_ent.is_construction
+ line_ent.is_construction = new_val
+ logger.info(
+ f"Toggled line {line_ent.id} construction -> {new_val}"
+ )
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+
+ # ─── Drawing handlers ─────────────────────────────────────────────────
+
+ def _handle_line_click(self, pos: QPoint):
+ self._ensure_sketch_with_centerlines()
+
+ if not self._draw_buffer:
+ point = self._sketch.add_point(pos.x(), pos.y())
+ point.is_construction = self._is_construct
+ self._points.append(point)
+ self._draw_buffer.append(pos)
+
+ # Auto-constrain: point snap → coincident on start point
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(point, self._snap_point_target)
+ self._solve_and_sync()
+ else:
+ point = self._sketch.add_point(pos.x(), pos.y())
+ point.is_construction = self._is_construct
+ self._points.append(point)
+
+ if len(self._points) >= 2:
+ line = self._sketch.add_line(self._points[-2], self._points[-1])
+ self._lines.append((self._points[-2], self._points[-1]))
+
+ # Auto-constrain: point snap → coincident on end point
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(self._points[-1], self._snap_point_target)
+
+ # Auto-constrain: detect horizontal / vertical from geometry
+ if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
+ p1_geom = self._points[-2].geometry
+ p2_geom = self._points[-1].geometry
+ if p1_geom is not None and p2_geom is not None:
+ x1, y1 = p1_geom
+ x2, y2 = p2_geom
+ if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6:
+ self._sketch.constrain_horizontal(line)
+ elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6:
+ self._sketch.constrain_vertical(line)
+
+ self._solve_and_sync()
+
+ self._draw_buffer = [pos]
+
+ self.sketch_updated.emit()
+ self.update()
+
+ def _handle_rectangle_click(self, pos: QPoint):
+ self._ensure_sketch_with_centerlines()
+
+ if not self._draw_buffer:
+ self._draw_buffer.append(pos)
+ self._rect_first_snap_target = self._snap_point_target
+ else:
+ p1 = self._draw_buffer[0]
+ p2 = pos
+ corners = [
+ QPoint(p1.x(), p1.y()),
+ QPoint(p2.x(), p1.y()),
+ QPoint(p2.x(), p2.y()),
+ QPoint(p1.x(), p2.y()),
+ ]
+ pts = []
+ for corner in corners:
+ pt = self._sketch.add_point(corner.x(), corner.y())
+ pt.is_construction = self._is_construct
+ self._points.append(pt)
+ pts.append(pt)
+
+ line_entities = []
+ for i in range(4):
+ line = self._sketch.add_line(pts[i], pts[(i + 1) % 4])
+ self._lines.append((pts[i], pts[(i + 1) % 4]))
+ line_entities.append(line)
+
+ # Auto-constrain: point snap → coincident on the correct corners.
+ # pts[0] = first click snapped position
+ # pts[2] = second click snapped position
+ if self._rect_first_snap_target is not None:
+ self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target)
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(pts[2], self._snap_point_target)
+
+ # Auto-constrain: detect horizontal / vertical from geometry
+ if self._snap_mode.get("horiz", False):
+ for idx in (0, 2):
+ p_start = pts[idx]
+ p_end = pts[(idx + 1) % 4]
+ if p_start.geometry and p_end.geometry:
+ dx = p_end.geometry[0] - p_start.geometry[0]
+ if abs(dx) > 1e-6:
+ self._sketch.constrain_horizontal(line_entities[idx])
+ if self._snap_mode.get("vert", False):
+ for idx in (1, 3):
+ p_start = pts[idx]
+ p_end = pts[(idx + 1) % 4]
+ if p_start.geometry and p_end.geometry:
+ dy = p_end.geometry[1] - p_start.geometry[1]
+ if abs(dy) > 1e-6:
+ self._sketch.constrain_vertical(line_entities[idx])
+
+ self._solve_and_sync()
+ self._draw_buffer = []
+ self._mode = None
+ self.constrain_done.emit()
+
+ self.sketch_updated.emit()
+ self.update()
+
+ def _handle_circle_click(self, pos: QPoint):
+ self._ensure_sketch_with_centerlines()
+
+ if not self._draw_buffer:
+ center = self._sketch.add_point(pos.x(), pos.y())
+ center.is_construction = self._is_construct
+ self._points.append(center)
+ self._draw_buffer.append(pos)
+
+ # Auto-constrain: point snap → coincident on center
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(center, self._snap_point_target)
+ self._solve_and_sync()
+ else:
+ center = self._points[-1]
+ cx, cy = center.geometry if center.geometry else (0, 0)
+ radius = math.sqrt((pos.x() - cx) ** 2 + (pos.y() - cy) ** 2)
+ if radius > 0:
+ self._sketch.add_circle(center, radius)
+ self._circles.append((center, radius))
+ self._draw_buffer = []
+ self._mode = None
+ self.constrain_done.emit()
+
+ self.sketch_updated.emit()
+ self.update()
+
+ def _handle_arc_click(self, pos: QPoint):
+ """Three-click arc: center → start point (radius) → end point (sweep)."""
+ self._ensure_sketch_with_centerlines()
+
+ if not self._draw_buffer:
+ # Click 1: place center point
+ center = self._sketch.add_point(pos.x(), pos.y())
+ center.is_construction = self._is_construct
+ self._points.append(center)
+ self._draw_buffer.append(pos)
+
+ # Auto-constrain: point snap → coincident on center
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(center, self._snap_point_target)
+ self._solve_and_sync()
+ elif len(self._draw_buffer) == 1:
+ # Click 2: place start point (defines radius + start angle)
+ start = self._sketch.add_point(pos.x(), pos.y())
+ start.is_construction = self._is_construct
+ self._points.append(start)
+ self._draw_buffer.append(pos)
+
+ # Initialise accumulated sweep tracking
+ cx = self._draw_buffer[0].x()
+ cy = self._draw_buffer[0].y()
+ self._arc_prev_angle = math.atan2(pos.y() - cy, pos.x() - cx)
+ self._arc_accum_sweep = 0.0
+
+ # Auto-constrain: point snap → coincident on start
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(start, self._snap_point_target)
+
+ self._solve_and_sync()
+ else:
+ # Click 3: place end point, then create arc
+ center = self._points[-2]
+ start_point = self._points[-1]
+
+ cx, cy = center.geometry if center.geometry else (0, 0)
+ sx, sy = start_point.geometry if start_point.geometry else (0, 0)
+ radius = math.sqrt((sx - cx) ** 2 + (sy - cy) ** 2)
+
+ # Project the click onto the circle so the end point
+ # stays exactly on the same radius as the start.
+ dx = pos.x() - cx
+ dy = pos.y() - cy
+ dist = math.sqrt(dx * dx + dy * dy)
+ if dist > 0:
+ ex = cx + dx / dist * radius
+ ey = cy + dy / dist * radius
+ else:
+ ex = cx + radius
+ ey = cy
+
+ end = self._sketch.add_point(ex, ey)
+ end.is_construction = self._is_construct
+ self._points.append(end)
+
+ # Use accumulated sweep when significant; fall back to computed.
+ sweep = self._arc_accum_sweep
+ if abs(sweep) < 0.01:
+ # Barely moved — compute shortest-path from geometry
+ # using the projected end position.
+ ea = math.atan2(ey - cy, ex - cx)
+ sa = math.atan2(sy - cy, sx - cx)
+ sweep = ea - sa
+ while sweep > math.pi:
+ sweep -= 2 * math.pi
+ while sweep < -math.pi:
+ sweep += 2 * math.pi
+
+ if radius > 0:
+ self._sketch.add_arc(center, radius, start_point, end, sweep=sweep)
+ self._arcs.append((center, radius, start_point, end, sweep))
+
+ # Auto-constrain: point snap → coincident on end point
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(end, self._snap_point_target)
+
+ self._solve_and_sync()
+
+ self._draw_buffer = []
+ self._mode = None
+ self.constrain_done.emit()
+
+ self.sketch_updated.emit()
+ self.update()
+
+ def _handle_slot_click(self, pos: QPoint):
+ """Three-click slot: center1 → center2 (centerline) → width radius.
+
+ Builds two semicircular arcs and two connecting lines forming a
+ closed slot profile suitable for extrusion.
+ """
+ self._ensure_sketch_with_centerlines()
+
+ if not self._draw_buffer:
+ # Click 1: place first arc center C1.
+ c1 = self._sketch.add_point(pos.x(), pos.y())
+ c1.is_construction = self._is_construct
+ self._points.append(c1)
+ self._draw_buffer.append(pos)
+
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(c1, self._snap_point_target)
+ self._solve_and_sync()
+
+ self.sketch_updated.emit()
+ self.update()
+ return
+
+ if len(self._draw_buffer) == 1:
+ # Click 2: place second arc center C2 (defines centerline).
+ c2 = self._sketch.add_point(pos.x(), pos.y())
+ c2.is_construction = self._is_construct
+ self._points.append(c2)
+ self._draw_buffer.append(pos)
+
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(c2, self._snap_point_target)
+
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+ return
+
+ # Click 3: compute radius from perpendicular distance to centerline
+ # and build the full slot profile.
+ c1_pos = self._draw_buffer[0]
+ c2_pos = self._draw_buffer[1]
+ c1_ent = self._points[-2]
+ c2_ent = self._points[-1]
+
+ dx_c = c2_pos.x() - c1_pos.x()
+ dy_c = c2_pos.y() - c1_pos.y()
+ L_sq = dx_c * dx_c + dy_c * dy_c
+
+ if L_sq < 1.0:
+ # Degenerate: centers on top of each other → draw a circle
+ # using the midpoint-to-cursor distance as radius.
+ import math as _m
+ r_fallback = _m.hypot(pos.x() - c1_pos.x(), pos.y() - c1_pos.y())
+ if r_fallback > 0:
+ self._sketch.add_circle(c1_ent, r_fallback)
+ self._circles.append((c1_ent, r_fallback))
+ self._draw_buffer = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.sketch_updated.emit()
+ self.update()
+ return
+
+ # Perpendicular distance from cursor to the centerline C1↔C2.
+ import math as _m
+ r = abs((pos.x() - c1_pos.x()) * dy_c
+ - (pos.y() - c1_pos.y()) * dx_c) / _m.sqrt(L_sq)
+ if r < 0.5:
+ r = 0.5 # minimum radius so the slot doesn't collapse
+
+ L = _m.sqrt(L_sq)
+ alpha = _m.atan2(dy_c, dx_c)
+ # Unit perpendicular (rotate 90° CCW)
+ perp_x = -dy_c / L
+ perp_y = dx_c / L
+
+ # Four corner points (world coords)
+ def corner(cx, cy, sign):
+ return QPoint(
+ int(round(cx + sign * r * perp_x)),
+ int(round(cy + sign * r * perp_y)),
+ )
+
+ # Top / bottom are relative to the perpendicular direction.
+ # Perp points "up" from the centreline; -perp points "down".
+ t1 = corner(c1_pos.x(), c1_pos.y(), 1) # C1 + perp
+ b1 = corner(c1_pos.x(), c1_pos.y(), -1) # C1 - perp
+ t2 = corner(c2_pos.x(), c2_pos.y(), 1) # C2 + perp
+ b2 = corner(c2_pos.x(), c2_pos.y(), -1) # C2 - perp
+
+ # Create point entities
+ pt1 = self._sketch.add_point(t1.x(), t1.y())
+ pt1.is_construction = self._is_construct
+ self._points.append(pt1)
+ pb1 = self._sketch.add_point(b1.x(), b1.y())
+ pb1.is_construction = self._is_construct
+ self._points.append(pb1)
+ pt2 = self._sketch.add_point(t2.x(), t2.y())
+ pt2.is_construction = self._is_construct
+ self._points.append(pt2)
+ pb2 = self._sketch.add_point(b2.x(), b2.y())
+ pb2.is_construction = self._is_construct
+ self._points.append(pb2)
+
+ # Auto-constrain: coincident on each corner point if it snaps onto
+ # an existing point entity (e.g. vertex of another shape).
+ for corner_pt in (pt1, pb1, pt2, pb2):
+ if corner_pt.geometry is not None:
+ cgx, cgy = corner_pt.geometry
+ snap_target = self._get_point_entity_at(
+ QPoint(int(round(cgx)), int(round(cgy)))
+ )
+ if snap_target is not None and snap_target.id != corner_pt.id:
+ self._sketch.constrain_coincident(corner_pt, snap_target)
+
+ # Arc 1 (at C1): exterior sweep from top to bottom (CCW)
+ arc1_ent = None
+ arc2_ent = None
+ if r > 0:
+ arc1_ent = self._sketch.add_arc(c1_ent, r, pt1, pb1, sweep=_m.pi)
+ self._arcs.append((c1_ent, r, pt1, pb1, _m.pi))
+
+ # Arc 2 (at C2): exterior sweep from bottom to top (CCW)
+ arc2_ent = self._sketch.add_arc(c2_ent, r, pb2, pt2, sweep=_m.pi)
+ self._arcs.append((c2_ent, r, pb2, pt2, _m.pi))
+
+ # Lines connecting the arc endpoints
+ line1 = self._sketch.add_line(pb1, pb2) # bottom line
+ self._lines.append((pb1, pb2))
+ line2 = self._sketch.add_line(pt2, pt1) # top line
+ self._lines.append((pt2, pt1))
+
+ # ── Parametric construction: perpendicular reference per arc ──
+ # Each arc needs a construction line from its centre, perpendicular
+ # to the connecting lines, to define the arc-endpoint direction and
+ # maintain smooth tangency between arc and line. The corner points
+ # sit on this line at distance r from the centre (the arc radius).
+ # Without this the solver can rotate the endpoints around the
+ # centre, breaking the visual arc-line tangency, and bare distance
+ # constraints lock the slot because the centre is the sketch's
+ # first (reference-fixed) point.
+
+ # Arc 1 (C1): construction line from centre to top endpoint
+ acl1 = self._sketch.add_line(c1_ent, pt1)
+ acl1.is_construction = True
+ self._lines.append((c1_ent, pt1))
+ # ⟂ to the connecting lines (line1 ∥ line2, so ⟂to one = ⟂to both)
+ self._sketch.constrain_perpendicular(acl1, line1)
+ # Bottom endpoint sits on this same line (diametrically opposite)
+ self._sketch.constrain_coincident(pb1, acl1)
+ # Arc radius: both endpoints at distance r from centre
+ self._sketch.constrain_distance(pt1, c1_ent, r)
+ self._sketch.constrain_distance(pb1, c1_ent, r)
+
+ # Arc 2 (C2): construction line from centre to top endpoint
+ acl2 = self._sketch.add_line(c2_ent, pt2)
+ acl2.is_construction = True
+ self._lines.append((c2_ent, pt2))
+ self._sketch.constrain_perpendicular(acl2, line1)
+ self._sketch.constrain_coincident(pb2, acl2)
+ self._sketch.constrain_distance(pt2, c2_ent, r)
+ self._sketch.constrain_distance(pb2, c2_ent, r)
+
+ # Auto-constrain: horizontal / vertical on the two slot lines
+ if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
+ for line_ent, pa, pb in [(line1, pb1, pb2), (line2, pt2, pt1)]:
+ if pa.geometry and pb.geometry:
+ x1, y1 = pa.geometry
+ x2, y2 = pb.geometry
+ if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6:
+ self._sketch.constrain_horizontal(line_ent)
+ elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6:
+ self._sketch.constrain_vertical(line_ent)
+
+ # Auto-constrain: the two slot lines are always parallel
+ self._sketch.constrain_parallel(line1, line2)
+
+ # Auto-constrain: the two arcs have equal radius
+ if arc1_ent is not None and arc2_ent is not None:
+ self._sketch.constrain_equal_radius(arc1_ent, arc2_ent)
+
+ self._solve_and_sync()
+
+ self._draw_buffer = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.sketch_updated.emit()
+ self.update()
+
+ def _handle_select_click(self, pos: QPoint):
+ """Handle select mode - find and highlight points/lines for selection."""
+ # Check if clicking on an existing point
+ nearest_entity = self._get_point_entity_at(pos)
+ if nearest_entity:
+ if nearest_entity not in self._selected_entities:
+ self._selected_entities.append(nearest_entity)
+ else:
+ self._selected_entities.remove(nearest_entity)
+ else:
+ # Check if clicking on a line
+ line_hit = self._get_line_entity_at(pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ for entity in [p1_ent, p2_ent]:
+ if entity and entity not in self._selected_entities:
+ self._selected_entities.append(entity)
+
+ # ─── Constraint handlers (with solver calls) ──────────────────────────
+
+ def _handle_constraint_coincident(self, world_pos: QPoint):
+ ent = self._get_point_entity_at(world_pos)
+ if ent is None:
+ return
+ self._selected_entities.append(ent)
+ if len(self._selected_entities) >= 2:
+ e1, e2 = self._selected_entities[:2]
+ if self._sketch:
+ self._sketch.constrain_coincident(e1, e2)
+ ok = self._solve_and_sync()
+ if ok:
+ logger.info("Coincident constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_horizontal(self, world_pos: QPoint):
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit is None:
+ return
+ p1_ent, p2_ent = line_hit
+ # SolveSpace's horizontal() needs the LINE entity's handle, not a
+ # point's. Look up the line sketch entity by its endpoints.
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if line_ent is None:
+ return
+ if self._sketch:
+ self._sketch.constrain_horizontal(line_ent)
+ # Tag endpoints so paintEvent renders the "> hrz <" label.
+ for ent in (p1_ent, p2_ent):
+ if "hrz" not in ent.constraints:
+ ent.constraints.append("hrz")
+ ok = self._solve_and_sync()
+ if ok:
+ logger.info("Horizontal constraint added")
+ else:
+ logger.warning("Horizontal constraint failed to solve")
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_vertical(self, world_pos: QPoint):
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit is None:
+ return
+ p1_ent, p2_ent = line_hit
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if line_ent is None:
+ return
+ if self._sketch:
+ self._sketch.constrain_vertical(line_ent)
+ for ent in (p1_ent, p2_ent):
+ if "vrt" not in ent.constraints:
+ ent.constraints.append("vrt")
+ ok = self._solve_and_sync()
+ if ok:
+ logger.info("Vertical constraint added")
+ else:
+ logger.warning("Vertical constraint failed to solve")
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_distance(self, world_pos: QPoint):
+ point_ent = self._get_point_entity_at(world_pos)
+ if point_ent:
+ # Point clicked: collect points; constraint applied after 2nd point.
+ self._selected_entities.append(point_ent)
+ else:
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if line_ent is not None:
+ # Line clicked: constrain its length (endpoint distance).
+ dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
+ self._constraint_distance_value, 0, 10000, 2)
+ if ok and self._sketch:
+ self._sketch.constrain_distance(p1_ent, p2_ent, dist)
+ self._solve_and_sync()
+ logger.info(f"Line distance {dist:.2f}mm")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+ return
+
+ if len(self._selected_entities) >= 2:
+ e1, e2 = self._selected_entities[:2]
+ dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
+ self._constraint_distance_value, 0, 10000, 2)
+ if ok and self._sketch:
+ self._sketch.constrain_distance(e1, e2, dist)
+ self._solve_and_sync()
+ logger.info(f"Distance {dist:.2f}mm")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_midpoint(self, world_pos: QPoint):
+ point_ent = self._get_point_entity_at(world_pos)
+ if point_ent and not self._selected_entities:
+ self._selected_entities.append(point_ent)
+ elif self._selected_entities:
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if line_ent is not None and self._sketch and self._selected_entities:
+ self._sketch.constrain_midpoint(self._selected_entities[0], line_ent)
+ for ent in (p1_ent, p2_ent):
+ if "mid" not in ent.constraints:
+ ent.constraints.append("mid")
+ self._solve_and_sync()
+ logger.info("Midpoint constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_perpendicular(self, world_pos: QPoint):
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit is None:
+ return
+ p1_ent, p2_ent = line_hit
+ target_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if target_ent is None:
+ return
+
+ if not self._selected_entities:
+ # First click: store this line ENTITY (not a point):
+ self._selected_entities.append(target_ent)
+ else:
+ # Second click: apply perpendicular constraint between two LINE entities
+ prev_ent = self._selected_entities[0]
+ if self._sketch:
+ self._sketch.constrain_perpendicular(prev_ent, target_ent)
+ self._solve_and_sync()
+ logger.info("Perpendicular constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_parallel(self, world_pos: QPoint):
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit is None:
+ return
+ p1_ent, p2_ent = line_hit
+ target_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if target_ent is None:
+ return
+
+ if not self._selected_entities:
+ self._selected_entities.append(target_ent)
+ else:
+ prev_ent = self._selected_entities[0]
+ if self._sketch:
+ self._sketch.constrain_parallel(prev_ent, target_ent)
+ self._solve_and_sync()
+ logger.info("Parallel constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_ptline(self, world_pos: QPoint):
+ """Point-on-line coincident (point on line)."""
+ point_ent = self._get_point_entity_at(world_pos)
+ if point_ent and not self._selected_entities:
+ self._selected_entities.append(point_ent)
+ elif self._selected_entities:
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if line_ent is not None and self._sketch and self._selected_entities:
+ # coincident(point, line) = point-on-line; needs the line handle
+ self._sketch.constrain_coincident(self._selected_entities[0], line_ent)
+ self._solve_and_sync()
+ logger.info("Point-on-line constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_symmetric(self, world_pos: QPoint):
+ """Symmetric constraint: select entity1, entity2, then mirror line."""
+ # Click 3: mirror line
+ if len(self._selected_entities) == 2:
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if mirror_line is not None and self._sketch:
+ self._sketch.constrain_symmetric(
+ self._selected_entities[0], self._selected_entities[1], mirror_line
+ )
+ ok = self._solve_and_sync()
+ if ok:
+ logger.info("Symmetric constraint added")
+ else:
+ logger.warning("Symmetric constraint failed to solve")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+ return
+ # Clicks 1-2: select point entities
+ point_ent = self._get_point_entity_at(world_pos)
+ if point_ent:
+ self._selected_entities.append(point_ent)
+ n_selected = len(self._selected_entities)
+ if n_selected < 2:
+ logger.info(f"Select entity {n_selected + 1} for symmetry (or line for mirror)")
+ elif n_selected == 2:
+ logger.info("Click on the mirror line")
+ self.update()
+
+ # ─── Painting ─────────────────────────────────────────────────────────
+
+ def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF:
+ return QPointF((p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0)
+
+ def _point_distance(self, p1: QPoint, p2: QPoint) -> float:
+ return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2)
+
+ def _draw_distance_measurement(self, painter: QPainter, p1: QPoint, p2: QPoint):
+ """Draw dimension lines and distance value between two world-coord points."""
+ sp1 = self._world_to_screen(p1)
+ sp2 = self._world_to_screen(p2)
+
+ dx = sp2.x() - sp1.x()
+ dy = sp2.y() - sp1.y()
+ length = math.sqrt(dx * dx + dy * dy)
+ if length == 0:
+ return
+
+ # Perpendicular direction for offset lines
+ perp_dx = -dy / length
+ perp_dy = dx / length
+ offset = 25.0
+
+ p1a = QPointF(sp1.x() + perp_dx, sp1.y() + perp_dy)
+ p1b = QPointF(sp1.x() + perp_dx * offset, sp1.y() + perp_dy * offset)
+ p2a = QPointF(sp2.x() + perp_dx, sp2.y() + perp_dy)
+ p2b = QPointF(sp2.x() + perp_dx * offset, sp2.y() + perp_dy * offset)
+ mid = QPointF((p1b.x() + p2b.x()) / 2, (p1b.y() + p2b.y()) / 2)
+
+ pen_dim = QPen(QColor("#a6e3a1"), 1.5, Qt.DotLine)
+ painter.setPen(pen_dim)
+ painter.drawLine(p1a.toPoint(), p1b.toPoint())
+ painter.drawLine(p2a.toPoint(), p2b.toPoint())
+ painter.drawLine(p1b.toPoint(), p2b.toPoint())
+
+ # Draw distance text
+ dist = self._point_distance(p1, p2)
+ painter.save()
+ painter.translate(mid)
+ painter.scale(1, -1)
+ painter.setPen(QPen(QColor("#a6e3a1"), 1))
+ painter.drawText(0, 0, f"{dist:.2f}")
+ painter.restore()
+
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.fillRect(self.rect(), QColor("#1e1e2e"))
+
+ # ── Grid (fixed 10mm world-units spacing) ──
+ # Minor grid: 10 world-unit (10mm) spacing.
+ # Major grid: 100 world-unit (100mm) spacing drawn bolder.
+ # Grid lines are drawn in WORLD space so they keep their meaning
+ # regardless of zoom. When zoomed out too far to show 10mm lines
+ # clearly, only the 100mm major grid is drawn.
+ MIN_PX_SPACING = 6.0 # skip a grid level if screen spacing is below this
+ grid_10 = 10 # 10mm minor grid
+ grid_100 = 100 # 100mm major grid
+
+ # Compute screen-space spacing
+ px_10 = grid_10 * self._zoom
+ px_100 = grid_100 * self._zoom
+
+ # Viewport bounds in world coordinates
+ top_left = self._screen_to_world(QPoint(0, 0))
+ bottom_right = self._screen_to_world(QPoint(self.width(), self.height()))
+
+ pen_major = QPen(QColor("#45475a"), 1)
+ pen_minor = QPen(QColor("#3a3a4e"), 0.5)
+
+ # ── Draw 100mm major grid (always if visible) ──
+ if px_100 >= MIN_PX_SPACING:
+ start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_100) * grid_100
+ end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_100) * grid_100
+ start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_100) * grid_100
+ end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_100) * grid_100
+ painter.setPen(pen_major)
+ wx = start_x
+ while wx <= end_x:
+ sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
+ painter.drawLine(sx, 0, sx, self.height())
+ wx += grid_100
+ wy = start_y
+ while wy <= end_y:
+ sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
+ painter.drawLine(0, sy, self.width(), sy)
+ wy += grid_100
+
+ # ── Draw 10mm minor grid (only when wide enough spacing) ──
+ if px_10 >= MIN_PX_SPACING:
+ start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_10) * grid_10
+ end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_10) * grid_10
+ start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_10) * grid_10
+ end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_10) * grid_10
+ painter.setPen(pen_minor)
+ wx = start_x
+ while wx <= end_x:
+ sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
+ painter.drawLine(sx, 0, sx, self.height())
+ wx += grid_10
+ wy = start_y
+ while wy <= end_y:
+ sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
+ painter.drawLine(0, sy, self.width(), sy)
+ wy += grid_10
+
+ # ── Centerlines (X and Y reference axes through origin) ──
+ # X centerline = horizontal (red dashed), Y centerline = vertical (green dashed).
+ # Both span the full viewport so they are always visible as reference guides.
+ origin_sc = self._world_to_screen(QPoint(0, 0))
+ # X centerline (red) — horizontal through origin
+ painter.setPen(QPen(QColor("#f38ba8"), 1.5, Qt.DashLine))
+ painter.drawLine(0, origin_sc.y(), self.width(), origin_sc.y())
+ # Y centerline (green) — vertical through origin
+ painter.setPen(QPen(QColor("#a6e3a1"), 1.5, Qt.DashLine))
+ painter.drawLine(origin_sc.x(), 0, origin_sc.x(), self.height())
+ # Origin intersection point (small ring)
+ painter.setPen(QPen(QColor("#cdd6f4"), 1))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(origin_sc, 3, 3)
+
+ # ── Source-face underlay fill (sketch-on-surface) ──
+ # The underlay is now drawn as real construction-line entities
+ # (rendered below in the Points / Lines sections with an orange
+ # dashed style) so the user can pick them for constraints. We
+ # still draw a faint fill over the *outer* loop of the projected
+ # face here for visual context, but the lines themselves are NOT
+ # drawn from this cache any more — that would double-paint the
+ # underlay on top of the entity-based lines.
+ if self._source_underlay_uv and self._underlay_visible:
+ if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
+ fill_poly = QPolygonF([
+ self._world_to_screen(QPoint(int(round(u)), int(round(v))))
+ for (u, v) in self._source_underlay_uv[0]
+ ])
+ painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
+ painter.setPen(Qt.NoPen)
+ painter.drawPolygon(fill_poly)
+
+ # ── Points ──
+ for entity in self._points:
+ # External / underlay points are rendered in the dedicated
+ # underlay block below (with an orange style) and skipped when
+ # the underlay is hidden. Skip them here to avoid double draw.
+ if self._is_external(entity):
+ continue
+ # Centerline points are rendered as part of the viewport-spanning
+ # axes above; skip them here to avoid double-drawing tiny dots
+ # at the endpoints far out of view.
+ if self._is_centerline(entity):
+ continue
+ if entity.geometry:
+ x, y = entity.geometry
+ screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ if entity.is_construction:
+ painter.setPen(QPen(QColor("#6c7086"), 1))
+ painter.setBrush(QBrush(QColor("#6c7086")))
+ else:
+ painter.setPen(QPen(QColor("#89b4fa"), 2))
+ painter.setBrush(QBrush(QColor("#89b4fa")))
+ size = 4 if entity.is_construction else 6
+ painter.drawEllipse(screen_pos, size, size)
+
+ # ── Underlay (face-projected) construction lines & points ──
+ # Drawn with an orange dashed style so the user can visually tell
+ # them apart from user-drawn construction lines (grey). Gated by
+ # the underlay visibility toggle.
+ if self._underlay_visible:
+ for p1_ent, p2_ent in self._lines:
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if not self._is_external(line_ent):
+ continue
+ if p1_ent.geometry and p2_ent.geometry:
+ x1, y1 = p1_ent.geometry
+ x2, y2 = p2_ent.geometry
+ sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
+ sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
+ painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
+ painter.drawLine(sp1, sp2)
+ for entity in self._points:
+ if not self._is_external(entity):
+ continue
+ if entity.geometry:
+ x, y = entity.geometry
+ screen_pos = self._world_to_screen(
+ QPoint(int(round(x)), int(round(y)))
+ )
+ painter.setPen(QPen(QColor("#fab387"), 1))
+ painter.setBrush(QBrush(QColor("#fab387")))
+ painter.drawEllipse(screen_pos, 4, 4)
+
+ # ── Lines ──
+ for p1_ent, p2_ent in self._lines:
+ # External lines are drawn above; skip here to avoid double draw.
+ line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
+ if self._is_external(line_ent):
+ continue
+ # Centerlines are rendered as viewport-spanning axes above;
+ # skip the finite-endpoint version here.
+ if line_ent is not None and self._is_centerline(line_ent):
+ continue
+ if p1_ent.geometry and p2_ent.geometry:
+ x1, y1 = p1_ent.geometry
+ x2, y2 = p2_ent.geometry
+ sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
+ sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
+
+ is_construction = p1_ent.is_construction or p2_ent.is_construction or (line_ent is not None and line_ent.is_construction)
+ if is_construction:
+ painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine))
+ else:
+ painter.setPen(QPen(QColor("#cdd6f4"), 2))
+ painter.drawLine(sp1, sp2)
+
+ # ── Constraint tags (log-driven; drawn upright in screen space) ──
+ # Tags are recomputed here so paint stays in sync with the latest solve.
+ self._constraint_tags = self._compute_constraint_tags()
+ tag_font = QFont("Monospace", 9)
+ painter.setFont(tag_font)
+ for tag in self._constraint_tags:
+ rect: QRect = tag["rect"]
+ hovered = tag["idx"] == self._hovered_constraint_idx
+ # Background pill so the label is readable over the sketch.
+ painter.setPen(QPen(QColor("#f9e2af"), 1 if not hovered else 2))
+ painter.setBrush(QBrush(QColor(40, 40, 60, 200)))
+ painter.drawRoundedRect(rect, 6, 6)
+ painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
+ painter.drawText(rect, Qt.AlignCenter, tag["label"])
+
+ # ── Circles ──
+ for center_ent, radius in self._circles:
+ if center_ent.geometry:
+ cx, cy = center_ent.geometry
+ sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
+ sr = radius * self._zoom
+ painter.setPen(QPen(QColor("#cdd6f4"), 2))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(sc, int(sr), int(sr))
+
+ # ── Arcs ──
+ for arc_item in self._arcs:
+ center_ent, radius, start_ent, end_ent, sweep = arc_item[:5]
+ if not (center_ent.geometry and start_ent.geometry and end_ent.geometry):
+ continue
+ cx, cy = center_ent.geometry
+ sx, sy = start_ent.geometry
+
+ sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
+ sr = int(radius * self._zoom)
+
+ # Use stored sweep if available; fall back to shortest-path.
+ if sweep is None:
+ ex, ey = end_ent.geometry
+ sa = math.atan2(sy - cy, sx - cx)
+ ea = math.atan2(ey - cy, ex - cx)
+ sweep = ea - sa
+ while sweep > math.pi:
+ sweep -= 2 * math.pi
+ while sweep < -math.pi:
+ sweep += 2 * math.pi
+
+ start_angle = math.atan2(sy - cy, sx - cx)
+
+ # QPainter: 1/16 degree, positive = CCW; 0° = 3 o'clock
+ start_deg_16 = int(math.degrees(start_angle) * 16)
+ span_deg_16 = int(math.degrees(sweep) * 16)
+
+ rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
+ painter.setPen(QPen(QColor("#cdd6f4"), 2))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawArc(rect, start_deg_16, span_deg_16)
+
+ # ── Offset preview (live preview from OffsetDialog) ──
+ if self._offset_preview_active and self._offset_preview_points:
+ preview = self._offset_preview_points
+ painter.setPen(QPen(QColor("#f9e2af"), 2, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+ for i in range(len(preview)):
+ p1 = self._world_to_screen(
+ QPoint(int(round(preview[i][0])), int(round(preview[i][1])))
+ )
+ p2 = self._world_to_screen(
+ QPoint(int(round(preview[(i + 1) % len(preview)][0])),
+ int(round(preview[(i + 1) % len(preview)][1])))
+ )
+ painter.drawLine(p1, p2)
+
+ # ── Dynamic drawing previews ──
+ if self._draw_buffer and self._dynamic_line_end and self._mode == "line":
+ start = self._world_to_screen(self._draw_buffer[0])
+ end = self._world_to_screen(self._dynamic_line_end)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.drawLine(start, end)
+
+ if self._draw_buffer and self._dynamic_line_end and self._mode == "rectangle":
+ p1 = self._world_to_screen(self._draw_buffer[0])
+ p2 = self._world_to_screen(self._dynamic_line_end)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.drawRect(min(p1.x(), p2.x()), min(p1.y(), p2.y()),
+ abs(p2.x() - p1.x()), abs(p2.y() - p1.y()))
+
+ if self._draw_buffer and self._dynamic_line_end and self._mode == "circle":
+ center = self._world_to_screen(self._draw_buffer[0])
+ end = self._world_to_screen(self._dynamic_line_end)
+ r = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(center, int(r), int(r))
+
+ if self._draw_buffer and self._dynamic_line_end and self._mode == "arc":
+ if len(self._draw_buffer) == 1:
+ # Click 1 done: show circle preview (center to cursor)
+ center = self._world_to_screen(self._draw_buffer[0])
+ end = self._world_to_screen(self._dynamic_line_end)
+ r = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(center, int(r), int(r))
+ elif len(self._draw_buffer) == 2:
+ # Click 2 done: show arc from start to cursor
+ cw = self._draw_buffer[0] # center world
+ sw = self._draw_buffer[1] # start world
+ ew = self._dynamic_line_end # end world (mouse)
+
+ cx_f, cy_f = cw.x(), cw.y()
+ sx_f, sy_f = sw.x(), sw.y()
+
+ arc_radius = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2)
+ sr = int(arc_radius * self._zoom)
+
+ sc = self._world_to_screen(QPoint(int(round(cx_f)), int(round(cy_f))))
+
+ # Use accumulated sweep so the arc follows the mouse
+ # smoothly even past 180° (no shortest-path flip).
+ start_angle = math.atan2(sy_f - cy_f, sx_f - cx_f)
+ sweep = self._arc_accum_sweep
+
+ start_deg_16 = int(math.degrees(start_angle) * 16)
+ span_deg_16 = int(math.degrees(sweep) * 16)
+
+ rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawArc(rect, start_deg_16, span_deg_16)
+
+ # Also draw a helper line from center to cursor so the
+ # user can see the sweep angle being defined
+ painter.setPen(QPen(QColor("#a6e3a1"), 1, Qt.DotLine))
+ painter.drawLine(sc, self._world_to_screen(ew))
+
+ # ── Slot preview ──
+ if self._draw_buffer and self._dynamic_line_end and self._mode == "slot":
+ if len(self._draw_buffer) == 1:
+ # Click 1 done: preview centerline
+ c1 = self._world_to_screen(self._draw_buffer[0])
+ c2 = self._world_to_screen(self._dynamic_line_end)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.drawLine(c1, c2)
+ elif len(self._draw_buffer) == 2:
+ # Click 2 done: preview full slot outline
+ c1 = self._draw_buffer[0]
+ c2 = self._draw_buffer[1]
+ cursor = self._dynamic_line_end
+
+ dx_c = c2.x() - c1.x()
+ dy_c = c2.y() - c1.y()
+ L = math.sqrt(dx_c * dx_c + dy_c * dy_c)
+ if L > 0:
+ r = abs((cursor.x() - c1.x()) * dy_c
+ - (cursor.y() - c1.y()) * dx_c) / L
+ if r < 0.5:
+ r = 0.5
+ perp_x = -dy_c / L
+ perp_y = dx_c / L
+
+ def sc(pt):
+ return self._world_to_screen(QPoint(int(round(pt[0])), int(round(pt[1]))))
+
+ c1s = (c1.x(), c1.y())
+ c2s = (c2.x(), c2.y())
+ t1 = (c1.x() + r * perp_x, c1.y() + r * perp_y)
+ b1 = (c1.x() - r * perp_x, c1.y() - r * perp_y)
+ t2 = (c2.x() + r * perp_x, c2.y() + r * perp_y)
+ b2 = (c2.x() - r * perp_x, c2.y() - r * perp_y)
+
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+
+ # Bottom line
+ painter.drawLine(sc(b1), sc(b2))
+ # Top line
+ painter.drawLine(sc(t2), sc(t1))
+ # Arc 1 (center C1, from t1 to b1, CCW exterior)
+ sa1 = math.atan2(t1[1] - c1s[1], t1[0] - c1s[0])
+ sweep1 = math.pi
+ n_seg = 16
+ for i in range(n_seg):
+ a1 = sa1 + (i / n_seg) * sweep1
+ a2 = sa1 + ((i + 1) / n_seg) * sweep1
+ p1 = (c1s[0] + r * math.cos(a1), c1s[1] + r * math.sin(a1))
+ p2 = (c1s[0] + r * math.cos(a2), c1s[1] + r * math.sin(a2))
+ painter.drawLine(sc(p1), sc(p2))
+ # Arc 2 (center C2, from b2 to t2)
+ sa2 = math.atan2(b2[1] - c2s[1], b2[0] - c2s[0])
+ sweep2 = math.pi
+ for i in range(n_seg):
+ a1 = sa2 + (i / n_seg) * sweep2
+ a2 = sa2 + ((i + 1) / n_seg) * sweep2
+ p1 = (c2s[0] + r * math.cos(a1), c2s[1] + r * math.sin(a1))
+ p2 = (c2s[0] + r * math.cos(a2), c2s[1] + r * math.sin(a2))
+ painter.drawLine(sc(p1), sc(p2))
+
+ # ── Hovered point highlight ──
+ if self._hovered_point:
+ screen_pos = self._world_to_screen(self._hovered_point)
+ painter.setPen(QPen(QColor("#f9e2af"), 2))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(screen_pos, 10, 10)
+
+ # ── Hovered line distance measurement ──
+ if self._hovered_line and not self._hovered_point:
+ p1, p2 = self._hovered_line
+ sp1 = self._world_to_screen(p1)
+ sp2 = self._world_to_screen(p2)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2))
+ painter.drawLine(sp1, sp2)
+ self._draw_distance_measurement(painter, p1, p2)
+
+ # ── Moved-element highlight ──
+ if self._move_active and self._moving_points:
+ painter.setPen(QPen(QColor("#f38ba8"), 2))
+ painter.setBrush(Qt.NoBrush)
+ for ent in self._moving_points:
+ if ent.geometry:
+ x, y = ent.geometry
+ sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ painter.drawEllipse(sp, 10, 10)
+ if self._move_anchor is not None and self._move_anchor.geometry:
+ x, y = self._move_anchor.geometry
+ sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ painter.setPen(QPen(QColor("#f9e2af"), 2))
+ painter.drawEllipse(sp, 12, 12)
+
+ # ── Selected face preview (detected regions) ──
+ if self._sketch:
+ faces = self._sketch.detect_faces()
+ for face in faces:
+ is_hovered = self._faces_match(face, self._hovered_face)
+ is_selected = self._faces_match(face, self._selected_face)
+ if not (is_hovered or is_selected):
+ continue
+ path = QPainterPath()
+ outer = face["outer"]
+ if outer["type"] == "polygon":
+ pts = outer["points"]
+ sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1]))))
+ path.moveTo(sp0.x(), sp0.y())
+ for (px, py) in pts[1:]:
+ sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
+ path.lineTo(sp.x(), sp.y())
+ path.closeSubpath()
+ else: # circle
+ cx, cy = outer["center"]
+ spc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
+ sr = outer["radius"] * self._zoom
+ path.addEllipse(spc, sr, sr)
+ # Add holes as sub-paths (odd-even fill punches them out).
+ for hole in face["holes"]:
+ if hole["type"] == "polygon":
+ pts = hole["points"]
+ sp0 = self._world_to_screen(QPoint(int(round(pts[0][0])), int(round(pts[0][1]))))
+ path.moveTo(sp0.x(), sp0.y())
+ for (px, py) in pts[1:]:
+ sp = self._world_to_screen(QPoint(int(round(px)), int(round(py))))
+ path.lineTo(sp.x(), sp.y())
+ path.closeSubpath()
+ else:
+ hcx, hcy = hole["center"]
+ hspc = self._world_to_screen(QPoint(int(round(hcx)), int(round(hcy))))
+ hsr = hole["radius"] * self._zoom
+ path.addEllipse(hspc, hsr, hsr)
+ path.setFillRule(Qt.OddEvenFill)
+
+ # Determine colours
+ if is_selected and not is_hovered:
+ fill = QColor(137, 180, 250, 100) # blue-ish
+ stroke = QPen(QColor("#89b4fa"), 2)
+ elif is_hovered:
+ fill = QColor(249, 226, 175, 120) # gold-ish
+ stroke = QPen(QColor("#f9e2af"), 2)
+ else:
+ fill = QColor(166, 227, 161, 80) # green-ish
+ stroke = QPen(QColor("#a6e3a1"), 1)
+ painter.setPen(stroke)
+ painter.setBrush(fill)
+ painter.drawPath(path)
+
+ # ── Selected entities ──
+ for entity in self._selected_entities:
+ if entity.geometry:
+ x, y = entity.geometry
+ screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ painter.setPen(QPen(QColor("#f9e2af"), 2))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(screen_pos, 12, 12)
+
+ # ── DOF display ──
+ if self._sketch:
+ try:
+ dof = self._sketch.get_solver_dof()
+ painter.save()
+ painter.setPen(QPen(QColor("#a6adc8"), 1))
+ font = QFont("Monospace", 9)
+ painter.setFont(font)
+ painter.drawText(10, 20, f"DOF: {dof}")
+ painter.restore()
+ except Exception:
+ pass
diff --git a/src/fluency/ui/viewer_widget.py b/src/fluency/ui/viewer_widget.py
new file mode 100644
index 0000000..647b647
--- /dev/null
+++ b/src/fluency/ui/viewer_widget.py
@@ -0,0 +1,811 @@
+"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
+
+from __future__ import annotations
+
+import logging
+import sys
+from typing import Any, Dict, List, Optional, Tuple
+
+from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
+from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF
+from PySide6.QtWidgets import QWidget
+
+logger = logging.getLogger(__name__)
+
+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()
+
+ # Emitted when the user picks an entity for a connector point (assembly).
+ # Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
+ connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
+ # Emitted when connector pick mode is cancelled.
+ connectorPickCancelled = Signal()
+ # Emitted on mouse move in connector mode to show snap preview.
+ # Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
+ connectorHover = Signal(object)
+
+ # Emitted when a body is clicked in assembly move mode.
+ # Payload: owner_obj_id.
+ assemblyComponentActivated = Signal(str)
+ # Emitted during a drag move: owner_obj_id, world dx, dy, dz.
+ assemblyComponentDragged = Signal(str, float, float, float)
+ # Emitted when a drag move finishes.
+ assemblyMoveFinished = Signal(str)
+
+ 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)
+ # Enable mouse tracking so ``mouseMoveEvent`` fires even without a
+ # button held — required for the connector-pick hover gizmo (and any
+ # status-bar hover feedback) to show under the cursor as the user
+ # moves the mouse over candidate snap entities before clicking.
+ self.setMouseTracking(True)
+ # 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
+ # When True, a left-click picks an entity for a connector point
+ # (assembly component connection).
+ self._connector_pick_mode: bool = False
+ # Current snap highlight object id (for hover during connector mode).
+ self._connector_snap_id: Optional[str] = None
+ # When True, left-click on a body activates assembly drag-to-move.
+ self._assembly_move_mode: bool = False
+ # State for ongoing assembly drag.
+ self._move_drag_active: bool = False
+ self._move_owner_obj_id: str = ""
+ self._move_click_3d: Optional[Tuple[float, float, float]] = None
+ self._move_click_screen: Optional[Any] = None
+ self._move_plane_normal: Optional[Tuple[float, float, float]] = None
+ self._move_initial_position: Optional[Tuple[float, float, float]] = None
+ # 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()
+
+ # ─── Workplane visualization ───────────────────────────────────────────
+
+ def show_workplane(
+ self,
+ origin: Tuple[float, float, float] = (0, 0, 0),
+ normal: Tuple[float, float, float] = (0, 0, 1),
+ x_dir: Tuple[float, float, float] = (1, 0, 0),
+ size: float = 200.0,
+ name: Optional[str] = None,
+ ) -> Optional[str]:
+ """Display a semi-transparent workplane plane in the 3D view.
+
+ Returns the object ID (for later removal) or None if the renderer
+ doesn't support workplane planes.
+ """
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "show_workplane_plane", None)
+ if fn is None:
+ return None
+ oid = fn(origin, normal, x_dir, size, name)
+ self._renderer.render()
+ return oid
+
+ def remove_workplane(self, obj_id: str) -> bool:
+ """Remove a workplane plane visual by its ID."""
+ self._ensure_initialized()
+ fn = getattr(self._renderer, "remove_workplane_plane", None)
+ if fn is None:
+ return False
+ ok = fn(obj_id)
+ if ok:
+ self._renderer.render()
+ return ok
+
+ 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
+ # Connector pick mode: a left-click selects a face for a connection point.
+ if self._connector_pick_mode and event.button() == Qt.LeftButton:
+ self._handle_connector_pick(event)
+ return
+ # Assembly move mode: start dragging the clicked body.
+ if self._assembly_move_mode and event.button() == Qt.LeftButton:
+ self._handle_assembly_move_press(event)
+ return
+ self._renderer.handle_mouse_press(event)
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event):
+ self._ensure_initialized()
+ # In connector mode, show snap hover.
+ if self._connector_pick_mode:
+ self._handle_connector_hover(event)
+ super().mouseMoveEvent(event)
+ return
+ # In face-pick mode, keep dynamic highlighting.
+ if self._pick_face_mode:
+ if hasattr(self._renderer, "handle_mouse_move"):
+ self._renderer.handle_mouse_move(event)
+ super().mouseMoveEvent(event)
+ return
+ # Active drag in assembly move mode.
+ if self._move_drag_active:
+ self._handle_assembly_move_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()
+ # Finish assembly drag.
+ if self._move_drag_active:
+ self._handle_assembly_move_release(event)
+ return
+ 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()
+
+ def get_camera_position(self):
+ """Return the current camera ``(eye, at, up)`` triple.
+
+ The underlying renderer's ``get_camera_position`` returns three
+ ``np.ndarray``s. We forward the call so callers (notably
+ :meth:`MainWindow._collect_view_state`) can persist the camera.
+ Returns a tuple of ``(np.zeros(3), np.zeros(3), (0,0,1))`` if the
+ renderer hasn't been initialised yet (e.g. when the window is
+ being constructed).
+ """
+ self._ensure_initialized()
+ if hasattr(self._renderer, "get_camera_position"):
+ return self._renderer.get_camera_position()
+ import numpy as np
+ return (
+ np.zeros(3, dtype=float),
+ np.zeros(3, dtype=float),
+ np.array([0.0, 0.0, 1.0], dtype=float),
+ )
+
+ # ─── 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()
+
+ # ─── Connector pick mode (assembly) ────────────────────────────────────
+
+ def set_connector_pick_mode(self, enabled: bool) -> None:
+ """Toggle connector pick mode for placing connection points.
+
+ When enabled, clicking an entity (face, edge, vertex, hole)
+ on a body in the assembly view captures its position and
+ direction as a connection point for the SolveSpace solver.
+ """
+ self._connector_pick_mode = bool(enabled)
+ if enabled:
+ self.setCursor(Qt.CrossCursor)
+ elif not self._pick_face_mode:
+ self.unsetCursor()
+ if not enabled:
+ self._clear_connector_snap()
+
+ def is_connector_pick_mode(self) -> bool:
+ return self._connector_pick_mode
+
+ def _clear_connector_snap(self) -> None:
+ """Remove the hover gizmo."""
+ fn = getattr(self._renderer, "clear_entity_gizmo", None)
+ if fn is not None:
+ fn()
+ # Backwards compat: also try the old method.
+ if self._connector_snap_id is not None:
+ fn2 = getattr(self._renderer, "remove_highlight_snap", None)
+ if fn2 is not None:
+ fn2(self._connector_snap_id)
+ self._connector_snap_id = None
+
+ def _handle_connector_hover(self, event) -> None:
+ """Update the hover snap gizmo during connector pick mode.
+
+ Probes a small neighbourhood around the cursor for ALL nearby snap
+ candidates (vertices, edge midpoints, face centres, hole openings)
+ and renders a dim marker on each plus a bright primary on the nearest
+ one — the general snap indicator. Clicking then selects the
+ primary's position.
+ """
+ self._ensure_initialized()
+ probe = getattr(self._renderer, "probe_snap_candidates", None)
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+
+ if probe is not None:
+ candidates = probe(pos.x(), pos.y())
+ if not candidates:
+ self._clear_connector_snap()
+ self.connectorHover.emit(None)
+ return
+ # Primary = the nearest candidate (probe sorts nearest-first).
+ info = candidates[0]
+ else:
+ # Fall back to single-pixel pick on renderers without the probe.
+ picker = getattr(self._renderer, "pick_entity", None)
+ if picker is None:
+ return
+ info = picker(pos.x(), pos.y())
+ candidates = [info] if info else []
+ if info is None or info.get("owner_obj_id") is None:
+ self._clear_connector_snap()
+ self.connectorHover.emit(None)
+ return
+
+ origin = info["position"]
+ normal = info.get("normal")
+ entity_type = info["type"]
+ owner = info.get("owner_obj_id", "")
+
+ # Show smart entity gizmo — dim candidate markers + bright primary.
+ self._clear_connector_snap()
+ gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
+ if gizmo_fn is not None:
+ gizmo_fn(
+ entity_type=entity_type,
+ position=origin,
+ normal=normal,
+ x_dir=info.get("x_dir"),
+ radius=info.get("radius"),
+ candidates=candidates,
+ )
+ else:
+ # Fallback to old highlight_snap.
+ fn = getattr(self._renderer, "highlight_snap", None)
+ if fn is not None:
+ colors = {
+ "planar_face": (0.0, 0.8, 1.0), # cyan
+ "cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
+ "edge": (0.0, 1.0, 0.4), # green
+ "vertex": (1.0, 1.0, 0.0), # yellow
+ }
+ c = colors.get(entity_type, (1.0, 0.6, 0.0))
+ self._connector_snap_id = fn(origin, color=c, size=3.0)
+
+ self.connectorHover.emit({
+ "origin": origin,
+ "normal": normal,
+ "type": entity_type,
+ "owner_obj_id": owner,
+ })
+
+ def _handle_connector_pick(self, event) -> None:
+ """Detect an entity under the click and emit connectorPicked.
+
+ Uses the multi-pixel ``probe_snap_candidates`` so a click selects the
+ PRIMARY (nearest) snap target — the same one the hover gizmo
+ emphasised. Falls back to single-pixel ``pick_entity`` then to
+ ``pick_planar_face`` on renderers without the probe.
+ """
+ self._ensure_initialized()
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+ info: Optional[Dict[str, Any]] = None
+
+ probe = getattr(self._renderer, "probe_snap_candidates", None)
+ if probe is not None:
+ candidates = probe(pos.x(), pos.y())
+ if candidates:
+ info = candidates[0] # nearest = primary
+
+ if info is None:
+ picker = getattr(self._renderer, "pick_entity", None)
+ if picker is None:
+ # Fallback to planar face only.
+ picker = getattr(self._renderer, "pick_planar_face", None)
+ if picker is None:
+ logger.warning("Renderer has no entity picking support")
+ return
+ pinfo = picker(pos.x(), pos.y())
+ if pinfo is None:
+ logger.info("Connector pick: no planar face under cursor")
+ return
+ owner_obj_id = pinfo.get("owner_obj_id", "")
+ self.connectorPicked.emit(
+ tuple(pinfo["origin"]),
+ tuple(pinfo["normal"]),
+ tuple(pinfo["x_dir"]),
+ "planar_face",
+ pinfo["face"],
+ owner_obj_id,
+ )
+ return
+ info = picker(pos.x(), pos.y())
+
+ if info is None:
+ logger.info("Connector pick: no entity under cursor")
+ return
+ owner_obj_id = info.get("owner_obj_id", "")
+ if not owner_obj_id:
+ return
+
+ entity_type = info["type"]
+ origin = info["position"]
+ normal = info.get("normal") or (0.0, 0.0, 1.0)
+ x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
+
+ # For vertices, pick a sensible normal from the parent face if possible.
+ if entity_type == "vertex" and normal is None:
+ normal = (0.0, 0.0, 1.0)
+
+ # Package the raw shape appropriately.
+ raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
+
+ self.connectorPicked.emit(
+ tuple(origin),
+ tuple(normal),
+ tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
+ entity_type,
+ raw_shape,
+ owner_obj_id,
+ )
+
+ # ─── Assembly move mode (3D drag) ─────────────────────────────────────
+
+ def set_assembly_move_mode(self, enabled: bool) -> None:
+ """Toggle assembly move mode.
+
+ When enabled, clicking on a body and dragging moves its
+ assembly component in the view plane. Shift+drag moves in Z.
+ """
+ self._assembly_move_mode = bool(enabled)
+ if enabled:
+ self.setCursor(Qt.SizeAllCursor)
+ elif not self._pick_face_mode and not self._connector_pick_mode:
+ self.unsetCursor()
+ if not enabled:
+ self._move_drag_active = False
+ self._move_owner_obj_id = ""
+ self._move_click_3d = None
+ self._move_click_screen = None
+ self._move_plane_normal = None
+ self._move_initial_position = None
+
+ def _handle_assembly_move_press(self, event) -> None:
+ """Start a drag-to-move for the body under the cursor."""
+ self._ensure_initialized()
+ picker = getattr(self._renderer, "pick_planar_face", None)
+ if picker is None:
+ return
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+ info = picker(pos.x(), pos.y())
+ if info is None:
+ return
+
+ owner_obj_id = info.get("owner_obj_id", "")
+ if not owner_obj_id or not owner_obj_id.startswith("asm_"):
+ return
+
+ # Store drag state.
+ self._move_drag_active = True
+ self._move_owner_obj_id = owner_obj_id
+ self._move_click_3d = tuple(info["origin"])
+ self._move_click_screen = pos
+ self._move_plane_normal = tuple(info["normal"])
+
+ # Emit activation signal so MainWindow stores initial position.
+ self.assemblyComponentActivated.emit(owner_obj_id)
+
+ def _handle_assembly_move_move(self, event) -> None:
+ """Continue the drag: project mouse delta to world-space and emit."""
+ if not self._move_drag_active or self._move_click_screen is None:
+ return
+
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+
+ # Screen delta (Qt Y is inverted vs OCC).
+ dx = pos.x() - self._move_click_screen.x()
+ dy = -(pos.y() - self._move_click_screen.y()) # invert Y
+
+ # Convert screen delta to world units using the view scale.
+ # view.Scale() returns a scale factor — the smaller the value the
+ # more world distance per pixel. We use an empirical conversion:
+ # at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
+ scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
+ world_per_pixel = 2.0 / max(scale, 0.001)
+
+ # Get camera vectors for proper view-plane projection.
+ import numpy as np
+ from OCP.V3d import V3d_TypeOfOrientation
+ try:
+ # Get camera direction and up from the OCC view.
+ camera = self._renderer._view.Camera()
+ dir_ = camera.Direction()
+ up_ = camera.Up()
+ cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
+ cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
+ cam_right = np.cross(cam_dir, cam_up)
+ cam_right = cam_right / np.linalg.norm(cam_right)
+ cam_up = cam_up / np.linalg.norm(cam_up)
+ except Exception:
+ # Fallback: assume XY plane.
+ cam_right = np.array([1.0, 0.0, 0.0])
+ cam_up = np.array([0.0, 0.0, 1.0])
+
+ # Compute world-space delta.
+ modifiers = event.modifiers()
+ if modifiers & Qt.ShiftModifier:
+ # Shift+drag: move along camera direction (Z-depth).
+ dz_world = dx * world_per_pixel
+ dx_world = 0.0
+ dy_world = 0.0
+ else:
+ # Normal drag: move in view plane.
+ dx_world = float(cam_right[0] * dx * world_per_pixel +
+ cam_up[0] * dy * world_per_pixel)
+ dy_world = float(cam_right[1] * dx * world_per_pixel +
+ cam_up[1] * dy * world_per_pixel)
+ dz_world = float(cam_right[2] * dx * world_per_pixel +
+ cam_up[2] * dy * world_per_pixel)
+
+ self.assemblyComponentDragged.emit(
+ self._move_owner_obj_id, dx_world, dy_world, dz_world
+ )
+
+ def _handle_assembly_move_release(self, event) -> None:
+ """Finish the drag, emit final position."""
+ self.assemblyMoveFinished.emit(self._move_owner_obj_id)
+ self._move_drag_active = False
+ self._move_owner_obj_id = ""
+ self._move_click_3d = None
+ self._move_click_screen = None
+ self._move_plane_normal = None
+ self._move_initial_position = None
+
+ 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
+ # Esc cancels connector pick mode.
+ if self._connector_pick_mode and event.key() == Qt.Key_Escape:
+ self.set_connector_pick_mode(False)
+ self.connectorPickCancelled.emit()
+ return
+ # Esc cancels assembly move mode.
+ if self._assembly_move_mode and event.key() == Qt.Key_Escape:
+ self.set_assembly_move_mode(False)
+ 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)
+
+