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 @@ - - - diff --git a/gui.ui b/gui.ui index 6fb5370..58c51fb 100644 --- a/gui.ui +++ b/gui.ui @@ -1190,9 +1190,17 @@ File - - + + + + + + + + + + @@ -1205,17 +1213,62 @@ - New + New Project + + + Ctrl+N - + - Load + Open Project... + + + Ctrl+O - + - Recent + Save Project + + + Ctrl+S + + + + + Save Project As... + + + Ctrl+Shift+S + + + + + Import STEP/IGES... + + + + + Export STEP... + + + + + Export IGES... + + + + + Export STL... + + + + + Exit + + + Ctrl+Q 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) + +