From 48042659fca7d80e0c534484191497dbb1440389 Mon Sep 17 00:00:00 2001 From: bklronin Date: Sun, 2 Aug 2026 22:04:57 +0200 Subject: [PATCH] - arc improvements, fillets, operations, bodys --- .idea/workspace.xml | 18 +- gui.ui | 188 ++++----- gui_ui.py | 96 ++--- src/fluency/geometry_occ/kernel.py | 3 +- src/fluency/io/project_io.py | 151 ++++--- src/fluency/models/data_model.py | 19 +- src/fluency/rendering/occ_renderer.py | 417 +++++++++++++------ src/fluency/ui/dialogs.py | 314 ++++++++++---- src/fluency/ui/main_window.py | 576 ++++++++++++++++++++++++++ src/fluency/ui/viewer_widget.py | 141 ++++++- 10 files changed, 1481 insertions(+), 442 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 41052b3..3bd4b28 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -6,13 +6,13 @@ - - - + + + - + diff --git a/gui.ui b/gui.ui index c3e8c58..5696c90 100644 --- a/gui.ui +++ b/gui.ui @@ -786,100 +786,6 @@ - - - - - 0 - 0 - - - - - 200 - 16777215 - - - - Bodys / Operations - - - - 5 - - - 5 - - - 5 - - - 5 - - - - - true - - - - - - - - 0 - 0 - - - - - 200 - 16777215 - - - - Tools - - - - 2 - - - 2 - - - 2 - - - 2 - - - - - Hide - - - - - - - Upd - - - - - - - Del - - - - - - - - - @@ -1337,6 +1243,100 @@ + + + + + 0 + 0 + + + + + 200 + 16777215 + + + + Bodys / Operations + + + + 5 + + + 5 + + + 5 + + + 5 + + + + + true + + + + + + + + 0 + 0 + + + + + 200 + 16777215 + + + + Tools + + + + 2 + + + 2 + + + 2 + + + 2 + + + + + Hide + + + + + + + Upd + + + + + + + Del + + + + + + + + + diff --git a/gui_ui.py b/gui_ui.py index a01698d..fc0d4d7 100644 --- a/gui_ui.py +++ b/gui_ui.py @@ -438,49 +438,6 @@ class Ui_fluencyCAD(object): self.gridLayout.addWidget(self.groupBox_12, 4, 3, 1, 1) - self.groupBox_10 = QGroupBox(self.centralwidget) - self.groupBox_10.setObjectName(u"groupBox_10") - sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth()) - self.groupBox_10.setSizePolicy(sizePolicy2) - self.groupBox_10.setMaximumSize(QSize(200, 16777215)) - self.verticalLayout_6 = QVBoxLayout(self.groupBox_10) - self.verticalLayout_6.setObjectName(u"verticalLayout_6") - self.verticalLayout_6.setContentsMargins(5, 5, 5, 5) - self.body_list = QListWidget(self.groupBox_10) - self.body_list.setObjectName(u"body_list") - self.body_list.setSelectionRectVisible(True) - - self.verticalLayout_6.addWidget(self.body_list) - - self.groupBox_8 = QGroupBox(self.groupBox_10) - self.groupBox_8.setObjectName(u"groupBox_8") - sizePolicy.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth()) - self.groupBox_8.setSizePolicy(sizePolicy) - self.groupBox_8.setMaximumSize(QSize(200, 16777215)) - self.gridLayout_8 = QGridLayout(self.groupBox_8) - self.gridLayout_8.setObjectName(u"gridLayout_8") - self.gridLayout_8.setContentsMargins(2, 2, 2, 2) - self.pb_body_hide = QPushButton(self.groupBox_8) - self.pb_body_hide.setObjectName(u"pb_body_hide") - - self.gridLayout_8.addWidget(self.pb_body_hide, 0, 1, 1, 1) - - self.pb_update_body = QPushButton(self.groupBox_8) - self.pb_update_body.setObjectName(u"pb_update_body") - - self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1) - - self.pb_del_body = QPushButton(self.groupBox_8) - self.pb_del_body.setObjectName(u"pb_del_body") - - self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1) - - - self.verticalLayout_6.addWidget(self.groupBox_8) - - - self.gridLayout.addWidget(self.groupBox_10, 2, 3, 1, 1) - self.joint_tools = QGroupBox(self.centralwidget) self.joint_tools.setObjectName(u"joint_tools") self.joint_tools.setMinimumSize(QSize(0, 50)) @@ -726,6 +683,49 @@ class Ui_fluencyCAD(object): self.gridLayout.addWidget(self.line_3, 5, 0, 1, 4) + self.groupBox_10 = QGroupBox(self.centralwidget) + self.groupBox_10.setObjectName(u"groupBox_10") + sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth()) + self.groupBox_10.setSizePolicy(sizePolicy2) + self.groupBox_10.setMaximumSize(QSize(200, 16777215)) + self.verticalLayout_6 = QVBoxLayout(self.groupBox_10) + self.verticalLayout_6.setObjectName(u"verticalLayout_6") + self.verticalLayout_6.setContentsMargins(5, 5, 5, 5) + self.body_list = QListWidget(self.groupBox_10) + self.body_list.setObjectName(u"body_list") + self.body_list.setSelectionRectVisible(True) + + self.verticalLayout_6.addWidget(self.body_list) + + self.groupBox_8 = QGroupBox(self.groupBox_10) + self.groupBox_8.setObjectName(u"groupBox_8") + sizePolicy.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth()) + self.groupBox_8.setSizePolicy(sizePolicy) + self.groupBox_8.setMaximumSize(QSize(200, 16777215)) + self.gridLayout_8 = QGridLayout(self.groupBox_8) + self.gridLayout_8.setObjectName(u"gridLayout_8") + self.gridLayout_8.setContentsMargins(2, 2, 2, 2) + self.pb_body_hide = QPushButton(self.groupBox_8) + self.pb_body_hide.setObjectName(u"pb_body_hide") + + self.gridLayout_8.addWidget(self.pb_body_hide, 0, 1, 1, 1) + + self.pb_update_body = QPushButton(self.groupBox_8) + self.pb_update_body.setObjectName(u"pb_update_body") + + self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1) + + self.pb_del_body = QPushButton(self.groupBox_8) + self.pb_del_body.setObjectName(u"pb_del_body") + + self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1) + + + self.verticalLayout_6.addWidget(self.groupBox_8) + + + self.gridLayout.addWidget(self.groupBox_10, 2, 3, 2, 1) + fluencyCAD.setCentralWidget(self.centralwidget) self.menubar = QMenuBar(fluencyCAD) self.menubar.setObjectName(u"menubar") @@ -893,11 +893,6 @@ class Ui_fluencyCAD(object): self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None)) self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None)) self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None)) - self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None)) - self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None)) - self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None)) - self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None)) - self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None)) self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None)) self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None)) self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None)) @@ -936,6 +931,11 @@ class Ui_fluencyCAD(object): self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None)) self.pb_face_op.setText(QCoreApplication.translate("fluencyCAD", u"Phase", None)) self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None)) + self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None)) + self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None)) + self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None)) + self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None)) + self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None)) self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None)) self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None)) # retranslateUi diff --git a/src/fluency/geometry_occ/kernel.py b/src/fluency/geometry_occ/kernel.py index eb8a082..1cb4615 100644 --- a/src/fluency/geometry_occ/kernel.py +++ b/src/fluency/geometry_occ/kernel.py @@ -455,10 +455,11 @@ class OCGeometryKernel(GeometryKernel): else: from OCP.TopExp import TopExp_Explorer from OCP.TopAbs import TopAbs_EDGE + from OCP.TopoDS import TopoDS explorer = TopExp_Explorer(shape, TopAbs_EDGE) while explorer.More(): - fillet.Add(radius, explorer.Current()) + fillet.Add(radius, TopoDS.Edge_s(explorer.Current())) explorer.Next() fillet.Build() diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py index 6e37d1e..4f21ef4 100644 --- a/src/fluency/io/project_io.py +++ b/src/fluency/io/project_io.py @@ -28,6 +28,7 @@ import logging import os import shutil import tempfile +import uuid import zipfile from dataclasses import asdict, is_dataclass from datetime import datetime @@ -66,7 +67,7 @@ def _json_default(obj: Any) -> Any: return sorted(obj) if isinstance(obj, tuple): return list(obj) - if is_dataclass(obj): + if is_dataclass(obj) and not isinstance(obj, type): return asdict(obj) raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") @@ -86,42 +87,77 @@ def _coerce_listlike(value: Any) -> List[Any]: return list(value) +def _to_float(value: Any, default: float = 0.0) -> float: + """Safely coerce a saved value to float, falling back to *default*. + + Corrupt archives may store a string or None where a number is expected; + the loaders must not crash on them. + """ + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _saved_id(data: Dict[str, Any]) -> str: + """Return a saved entity id, or a fresh UUID for corrupt/legacy data. + + Old files always wrote an ``id``; a missing/empty value means the + archive is damaged, and the model's uuid factory only kicks in when the + constructor argument is omitted — so we generate here to keep ids valid + non-empty strings. + """ + v = data.get("id") + if isinstance(v, str) and v: + return v + return str(uuid.uuid4()) + + 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])) + try: + 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])) + except (TypeError, ValueError, IndexError): + return (0.0, 0.0, 0.0) 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: + try: + 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) + except (TypeError, ValueError, IndexError): 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: + try: + 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) + except (TypeError, ValueError, IndexError): 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: @@ -152,11 +188,11 @@ def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]: def _workplane_from_dict(data: Dict[str, Any]) -> Workplane: wp = Workplane( - id=data.get("id") or None, # Workplane generates uuid if None + id=_saved_id(data), 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))), + origin=_to_3tuple(data.get("origin", (0.0, 0.0, 0.0))), + normal=_to_3tuple(data.get("normal", (0.0, 0.0, 1.0))), + x_dir=_to_3tuple(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")) @@ -182,7 +218,11 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]: "through_all": bool(feat.through_all), "cut_all_bodies": bool(feat.cut_all_bodies), "face_index": feat.face_index, - "angle": float(feat.angle), + "angle": _to_float(feat.angle, 360.0), + "radius": feat.radius, + "tangent_propagation": bool(feat.tangent_propagation), + "scope": feat.scope, + "edge_refs": list(feat.edge_refs), } @@ -190,7 +230,7 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea """Deserialize a feature, resolving its sketch reference against the component's already-loaded sketches.""" feat = Feature( - id=data.get("id") or None, + id=_saved_id(data), operation=data.get("operation", "extrude"), length=data.get("length"), symmetric=bool(data.get("symmetric", False)), @@ -198,7 +238,11 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea through_all=bool(data.get("through_all", False)), cut_all_bodies=bool(data.get("cut_all_bodies", False)), face_index=data.get("face_index"), - angle=float(data.get("angle", 360.0)), + angle=_to_float(data.get("angle"), 360.0), + radius=data.get("radius"), + tangent_propagation=bool(data.get("tangent_propagation", False)), + scope=data.get("scope", "selected"), + edge_refs=list(data.get("edge_refs") or []), ) sid = data.get("sketch_id") if sid and sid in sketches: @@ -229,7 +273,7 @@ def _body_to_dict(body: Body) -> Dict[str, Any]: "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), + "opacity": _to_float(body.opacity, 1.0), "visible": bool(body.visible), "has_geometry": body.geometry is not None, "geometry_ref": None, # filled in by save_project @@ -248,7 +292,7 @@ def _body_from_dict( geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None body = Body( - id=data.get("id") or None, + id=_saved_id(data), name=data.get("name", "Untitled Body"), geometry=geometry, source_sketch=source_sketch, @@ -265,7 +309,7 @@ def _body_from_dict( 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)), + opacity=_to_float(data.get("opacity"), 1.0), visible=bool(data.get("visible", True)), ) body.created_at = _parse_iso(data.get("created_at")) @@ -315,9 +359,9 @@ def _sketch_from_dict( # 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))), + _to_3tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))), + _to_3tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))), + _to_3tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))), ) geometry: Optional[OCCGeometryObject] = None @@ -325,7 +369,7 @@ def _sketch_from_dict( geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None sk = Sketch( - id=data.get("id") or None, + id=_saved_id(data), name=data.get("name", "Untitled Sketch"), occ_sketch=occ_sketch, geometry=geometry, @@ -362,7 +406,7 @@ def _component_from_dict( sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None, ) -> Component: comp = Component( - id=data.get("id") or None, + id=_saved_id(data), name=data.get("name", "Untitled Component"), description=data.get("description", ""), active_sketch=data.get("active_sketch"), @@ -410,8 +454,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]: "position": list(conn.position), "normal": list(conn.normal), "x_dir": list(conn.x_dir), - "axis_rotation": float(conn.axis_rotation), - "offset": float(conn.offset), + "axis_rotation": _to_float(conn.axis_rotation, 0.0), + "offset": _to_float(conn.offset, 0.0), "assembly_component_id": conn.assembly_component_id, "source_obj_id": conn.source_obj_id, "partner_ac_id": conn.partner_ac_id, @@ -425,13 +469,13 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]: def _connector_from_dict(data: Dict[str, Any]) -> Connector: conn = Connector( - id=data.get("id") or None, + id=_saved_id(data), 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)), + axis_rotation=_to_float(data.get("axis_rotation"), 0.0), + offset=_to_float(data.get("offset"), 0.0), assembly_component_id=data.get("assembly_component_id", ""), source_obj_id=data.get("source_obj_id", ""), ) @@ -459,7 +503,7 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]: def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent: ac = AssemblyComponent( - id=data.get("id") or None, + id=_saved_id(data), component_id=data.get("component_id", ""), name=data.get("name", "Untitled Instance"), position=_to_3vec(data.get("position")), @@ -485,7 +529,7 @@ def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]: def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection: conn = AssemblyConnection( - id=data.get("id") or None, + id=_saved_id(data), first_ac_id=data.get("first_ac_id", ""), second_ac_id=data.get("second_ac_id", ""), first_connector_id=data.get("first_connector_id"), @@ -509,7 +553,7 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]: def _assembly_from_dict(data: Dict[str, Any]) -> Assembly: asm = Assembly( - id=data.get("id") or None, + id=_saved_id(data), name=data.get("name", "Untitled Assembly"), active_assembly_component=data.get("active_assembly_component"), ) @@ -580,7 +624,9 @@ def _read_step_bytes( with open(tmp_path, "wb") as f: f.write(data) geom = kernel.import_step(tmp_path) - return geom + from typing import cast + + return cast(OCCGeometryObject, geom) except Exception as exc: logger.warning("Failed to read STEP: %s", exc) return None @@ -749,7 +795,10 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]: with zipfile.ZipFile(filepath, "r") as zipf: manifest_raw = zipf.read("project.json") - manifest = json.loads(manifest_raw.decode("utf-8")) + try: + manifest = json.loads(manifest_raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise RuntimeError(f"Corrupt project file (bad JSON): {filepath}") from exc view_state: Dict[str, Any] = manifest.get("view_state") or {} # If a sketch's occ_sketch is referenced as a separate file, read @@ -764,7 +813,11 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]: except KeyError: logger.warning("Sketch meta missing in archive: %s", ref) continue - meta = json.loads(meta_bytes.decode("utf-8")) + try: + meta = json.loads(meta_bytes.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + logger.warning("Sketch meta corrupt in archive: %s (%s)", ref, exc) + continue 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). diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py index 4cb1cbc..1f95403 100644 --- a/src/fluency/models/data_model.py +++ b/src/fluency/models/data_model.py @@ -60,9 +60,9 @@ class Workplane: x = x / x_norm y = np.cross(n, x) y = y / np.linalg.norm(y) - self.normal = tuple(float(v) for v in n) - self.x_dir = tuple(float(v) for v in x) - self._y_dir = tuple(float(v) for v in y) + self.normal = (float(n[0]), float(n[1]), float(n[2])) + self.x_dir = (float(x[0]), float(x[1]), float(x[2])) + self._y_dir = (float(y[0]), float(y[1]), float(y[2])) @property def y_dir(self) -> Tuple[float, float, float]: @@ -223,6 +223,9 @@ class Feature: extruded sketch profile - "union": boolean union of the running geometry with the extruded sketch profile + - "fillet": round a set of edges of the running geometry + (``radius``, ``tangent_propagation``, ``scope``, + ``edge_refs`` — see below) - "base": frozen geometry snapshot (``geometry`` field) — used to migrate legacy bodies whose original base feature is unknown. Never the result of a user operation. @@ -243,6 +246,16 @@ class Feature: # "base" features only: frozen pre-feature geometry snapshot. geometry: Optional[OCCGeometryObject] = None + # "fillet" features only: radius (mm) of the round, whether the fillet + # should extend along edges tangent to the picked ones, the edge scope + # ("selected" = edges between the two picked faces, "all" = every edge + # of the body), and stable fingerprints of the selected edges so the + # replay can re-find them after the base geometry is rebuilt. + radius: Optional[float] = None + tangent_propagation: bool = False + scope: str = "selected" + edge_refs: List[str] = field(default_factory=list) + created_at: datetime = field(default_factory=datetime.now) diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py index 575f263..8df1adb 100644 --- a/src/fluency/rendering/occ_renderer.py +++ b/src/fluency/rendering/occ_renderer.py @@ -126,6 +126,8 @@ class OCCRenderer(Renderer): self._nav_mode: Optional[str] = None # "rotate" | "pan" | None # Persistent light-blue transparent overlay marking the selected face. self._highlight_ais: Any = None + # Overlays for the fillet tool's two picked faces (one AIS per face). + self._faces_highlight_ais: List[Any] = [] # Temporary transparent preview AIS for the live extrude/cut dialog. self._preview_ais: Any = None # Smart entity picker gizmo objects (snap markers, axis lines, rings). @@ -137,6 +139,7 @@ class OCCRenderer(Renderer): self._parent_widget = parent_widget import os as _os + if _os.environ.get("QT_QPA_PLATFORM") == "offscreen": logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)") return False @@ -159,16 +162,14 @@ class OCCRenderer(Renderer): ) from OCP.AIS import AIS_InteractiveContext from OCP.Graphic3d import ( - Graphic3d_Camera, Graphic3d_TypeOfShadingModel, - Graphic3d_MaterialAspect, - Graphic3d_NameOfMaterial, ) from OCP.Quantity import ( Quantity_Color, Quantity_TOC_RGB, Quantity_NameOfColor, ) + logger.info("OCCRenderer imports complete") hwnd = int(parent_widget.winId()) @@ -203,9 +204,7 @@ class OCCRenderer(Renderer): Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB), True, ) - ambient = V3d_AmbientLight( - Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB) - ) + ambient = V3d_AmbientLight(Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB)) for light in (key, fill, rim, ambient): viewer.SetLightOn(light) @@ -249,6 +248,7 @@ class OCCRenderer(Renderer): # pick preview matches the persistent selection overlay below. try: from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB + # Modify the existing dynamic-highlight drawer in place (per # OCC docs this is safer than building a fresh Prs3d_Drawer). hd = context.HighlightStyle() @@ -305,7 +305,6 @@ class OCCRenderer(Renderer): """ from OCP.AIS import AIS_Shape from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB - from OCP.Prs3d import Prs3d_Drawer obj_id = name or f"shape_{uuid.uuid4().hex[:8]}" @@ -345,6 +344,7 @@ class OCCRenderer(Renderer): # explicit pick methods (pick_entity / pick_planar_face). try: from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX + for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): mode = AIS_Shape.SelectionMode_s(topo) self._context.Activate(ais, mode) @@ -377,9 +377,7 @@ class OCCRenderer(Renderer): """ from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial - mat = Graphic3d_MaterialAspect( - Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC - ) + mat = Graphic3d_MaterialAspect(Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC) return mat # ─── Legacy mesh / wireframe (kept for backward compat) ──────────── @@ -582,10 +580,12 @@ class OCCRenderer(Renderer): self._context.RemoveAll(True) except Exception: from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive + lst = AIS_ListOfInteractive() self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst) for ais in lst: self._context.Remove(ais, True) + self._faces_highlight_ais = [] self._objects.clear() def update_mesh( @@ -702,10 +702,9 @@ class OCCRenderer(Renderer): # Check projection type. from OCP.Graphic3d import Graphic3d_Camera + proj_type = cam.ProjectionType() - is_orthographic = ( - proj_type == Graphic3d_Camera.Projection_Orthographic - ) + is_orthographic = proj_type == Graphic3d_Camera.Projection_Orthographic if not is_orthographic: # Perspective mode: use the actual eye position directly. @@ -726,6 +725,7 @@ class OCCRenderer(Renderer): # Compute scene bounding box diagonal from displayed objects. from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib + bbox = Bnd_Box() try: for robj in self._objects.values(): @@ -737,13 +737,7 @@ class OCCRenderer(Renderer): pass xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() - diag = float( - np.sqrt( - (xmax - xmin) ** 2 - + (ymax - ymin) ** 2 - + (zmax - zmin) ** 2 - ) - ) + diag = float(np.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2)) # Fallback: if bbox is empty (no objects or all shapes failed), # use the eye-to-at distance as a reasonable estimate. @@ -753,9 +747,8 @@ class OCCRenderer(Renderer): # Base distance: how far the camera must be for the bbox diagonal # to fill the frame at the given vertical FOV. import math - base_distance = diag / ( - 2.0 * math.tan(math.radians(fov_y / 2.0)) - ) + + base_distance = diag / (2.0 * math.tan(math.radians(fov_y / 2.0))) # Scale factor maps inversely: larger scale (zoomed in) → closer camera. # Dividing by view_scale ensures that when the user zooms in (scale increases) @@ -802,13 +795,13 @@ class OCCRenderer(Renderer): from OCP.V3d import V3d_TypeOfOrientation mapping = { - "front": V3d_TypeOfOrientation.V3d_Yneg, - "back": V3d_TypeOfOrientation.V3d_Ypos, - "top": V3d_TypeOfOrientation.V3d_Zpos, + "front": V3d_TypeOfOrientation.V3d_Yneg, + "back": V3d_TypeOfOrientation.V3d_Ypos, + "top": V3d_TypeOfOrientation.V3d_Zpos, "bottom": V3d_TypeOfOrientation.V3d_Zneg, - "left": V3d_TypeOfOrientation.V3d_Xneg, - "right": V3d_TypeOfOrientation.V3d_Xpos, - "iso": V3d_TypeOfOrientation.V3d_XposYnegZpos, + "left": V3d_TypeOfOrientation.V3d_Xneg, + "right": V3d_TypeOfOrientation.V3d_Xpos, + "iso": V3d_TypeOfOrientation.V3d_XposYnegZpos, } orient = mapping.get(orientation.lower()) if orient is None: @@ -886,9 +879,7 @@ class OCCRenderer(Renderer): def on_pick(self, callback: Any) -> None: pass - def project_to_screen( - self, point: Tuple[float, float, float] - ) -> Tuple[float, float]: + def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[float, float]: return (0.0, 0.0) def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None: @@ -898,6 +889,7 @@ class OCCRenderer(Renderer): if self._view is None: return from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB + qcol = Quantity_Color(*color, Quantity_TOC_RGB) self._view.SetBackgroundColor(qcol) @@ -931,7 +923,6 @@ class OCCRenderer(Renderer): from OCP.gp import gp_Pln from OCP.AIS import AIS_Shape from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB - from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial obj_id = name or f"{self._WORKPLANE_BASE_ID}_{uuid.uuid4().hex[:8]}" @@ -980,6 +971,7 @@ class OCCRenderer(Renderer): ) # Use gp_Pnt for the corners to make a bounded face. from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon + mp = BRepBuilderAPI_MakePolygon() for c in corners_3d: mp.Add(gp_Pnt(*c)) @@ -1039,9 +1031,7 @@ class OCCRenderer(Renderer): def take_screenshot(self) -> bytes: return b"" - def unproject_from_screen( - self, x: float, y: float - ) -> Tuple[float, float, float]: + def unproject_from_screen(self, x: float, y: float) -> Tuple[float, float, float]: return (0.0, 0.0, 0.0) # ─── Face picking (for sketch-on-surface) ──────────────────────────── @@ -1061,12 +1051,8 @@ class OCCRenderer(Renderer): from OCP.BRepAdaptor import BRepAdaptor_Surface from OCP.GeomAbs import GeomAbs_Plane - from OCP.TopoDS import TopoDS_Face, TopoDS - from OCP.TopExp import TopExp_Explorer - from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE - from OCP.BRep import BRep_Tool - from OCP.gp import gp_Pln, gp_Dir, gp_Pnt - import numpy as np + from OCP.TopoDS import TopoDS + from OCP.gp import gp_Pln # Detect what's under the cursor. self._context.MoveTo(x, y, self._view, True) @@ -1107,6 +1093,7 @@ class OCCRenderer(Renderer): # default (non-inverted) extrude would punch back into the body # instead of building outward on top of it. from OCP.TopAbs import TopAbs_REVERSED + n = pln.Axis().Direction() if face.Orientation() == TopAbs_REVERSED: n = n.Reversed() @@ -1115,6 +1102,7 @@ class OCCRenderer(Renderer): # plane, so the UV frame is centred on the face (nicer for sketching). from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib + bbox = Bnd_Box() BRepBndLib.Add_s(face, bbox) xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() @@ -1157,6 +1145,56 @@ class OCCRenderer(Renderer): "owner_obj_id": owner_obj_id, } + def pick_face(self, x: int, y: int) -> Optional[Dict[str, Any]]: + """Pick ANY face under screen pixel (x, y) — planar or curved. + + Returns ``{"face": TopoDS_Face, "owner_obj_id": str}`` or *None*. + Unlike :meth:`pick_planar_face` (which requires a planar face so it + can derive a UV frame for sketching) this accepts cylindrical / + spherical / spline faces too — the fillet tool only needs the face + shape and its owning body. + """ + if self._view is None or self._context is None: + return None + + from OCP.TopoDS import TopoDS + from OCP.BRepAdaptor import BRepAdaptor_Surface + + self._context.MoveTo(x, y, self._view, True) + if not self._context.HasDetected(): + return None + + shape = self._context.DetectedShape() + if shape is None: + return None + + face = None + try: + candidate = TopoDS.Face_s(shape) + # Verify it really is a face by building an adaptor (throws for + # edges / vertices). + _ = BRepAdaptor_Surface(candidate) + face = candidate + except Exception: + face = None + if face is None: + return None + + # Identify the displayed body that owns this face (same match used + # by pick_planar_face). + owner_obj_id: Optional[str] = None + try: + owner_ais = self._context.DetectedInteractive() + except Exception: + owner_ais = None + if owner_ais is not None: + for oid, robj in self._objects.items(): + if robj.ais_shape is owner_ais: + owner_obj_id = oid + break + + return {"face": face, "owner_obj_id": owner_obj_id} + def highlight_face(self, face: Any) -> None: """Overlay a persistent, mostly-transparent light-blue tint on *face*. @@ -1205,6 +1243,58 @@ class OCCRenderer(Renderer): logger.debug("clear_face_highlight remove failed", exc_info=True) self._highlight_ais = None + # ─── Multi-face highlight (fillet face picking) ───────────────────────── + + def highlight_faces(self, faces: List[Any]) -> None: + """Tint every face in *faces* with the selection overlay. + + Unlike :meth:`highlight_face` (single face, used by sketch-on- + surface) this keeps one overlay per face so the fillet tool can + show BOTH picked faces at once. Replaces any previous multi-face + overlay; independent of the single-face highlight. + """ + if self._context is None: + return + self.clear_faces_highlight() + if not faces: + return + from OCP.AIS import AIS_Shape + from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB + + for face in faces: + ais = AIS_Shape(face) + try: + ais.SetMaterial(self._default_material()) + except Exception: + logger.debug("faces highlight material set failed", exc_info=True) + ais.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB)) + ais.SetDisplayMode(1) # shaded + try: + ais.SetTransparency(0.78) + except Exception: + logger.debug("faces highlight transparency set failed", exc_info=True) + try: + # Bias the overlay toward the camera so it draws on top of + # the coincident face surface without z-fighting. + ais.SetPolygonOffsets(3, 1.0, -0.5) + except Exception: + logger.debug("faces highlight polygon offset failed", exc_info=True) + self._context.Display(ais, True) + self._faces_highlight_ais.append(ais) + if self._view is not None: + self._view.Update() + + def clear_faces_highlight(self) -> None: + """Remove the multi-face fillet-pick overlays, if any.""" + if self._context is None or not self._faces_highlight_ais: + return + for ais in self._faces_highlight_ais: + try: + self._context.Remove(ais, True) + except Exception: + logger.debug("clear_faces_highlight remove failed", exc_info=True) + self._faces_highlight_ais = [] + # ─── General entity picking (for assembly connectors / snaps) ─────────── def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]: @@ -1246,12 +1336,13 @@ class OCCRenderer(Renderer): except Exception: pass if eye is not None: - results.sort(key=lambda c: float(np.linalg.norm( - np.array(c["position"]) - eye))) + results.sort(key=lambda c: float(np.linalg.norm(np.array(c["position"]) - eye))) return results[0] def _classify_detected_shape( - self, shape: Any, owner_obj_id: Optional[str] = None, + self, + shape: Any, + owner_obj_id: Optional[str] = None, ) -> List[Dict[str, Any]]: """Classify a detected OCC sub-shape into snap-candidate dicts. @@ -1267,17 +1358,14 @@ class OCCRenderer(Renderer): if shape is None: return [] - from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS - from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX + from OCP.TopoDS import TopoDS from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle from OCP.BRep import BRep_Tool from OCP.TopExp import TopExp_Explorer from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE - from OCP.gp import gp_Pnt, gp_Dir from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib - from OCP.TopExp import TopExp import numpy as np # Helper: find owner object id if not supplied. @@ -1309,6 +1397,7 @@ class OCCRenderer(Renderer): pln = adaptor.Plane() n = pln.Axis().Direction() from OCP.TopAbs import TopAbs_REVERSED + if face.Orientation() == TopAbs_REVERSED: n = n.Reversed() nx, ny, nz = n.X(), n.Y(), n.Z() @@ -1319,20 +1408,26 @@ class OCCRenderer(Renderer): xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0 pln_origin = pln.Location() - d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz + d = ( + (cx - pln_origin.X()) * nx + + (cy - pln_origin.Y()) * ny + + (cz - pln_origin.Z()) * nz + ) origin = (cx - d * nx, cy - d * ny, cz - d * nz) # x_dir: viewport-aligned so connector gizmo matches screen. x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view) - return [{ - "type": "planar_face", - "position": origin, - "normal": (nx, ny, nz), - "x_dir": x_dir, - "face": face, - "owner_obj_id": owner_obj_id, - }] + return [ + { + "type": "planar_face", + "position": origin, + "normal": (nx, ny, nz), + "x_dir": x_dir, + "face": face, + "owner_obj_id": owner_obj_id, + } + ] elif stype == GeomAbs_Cylinder: cyl = adaptor.Cylinder() @@ -1356,9 +1451,11 @@ class OCCRenderer(Renderer): if curve_adaptor.GetType() == GeomAbs_Circle: circ = curve_adaptor.Circle() center_pnt = circ.Location() - circle_centers.append(np.array([ - center_pnt.X(), center_pnt.Y(), center_pnt.Z() - ], dtype=float)) + circle_centers.append( + np.array( + [center_pnt.X(), center_pnt.Y(), center_pnt.Z()], dtype=float + ) + ) except Exception: pass edge_explorer.Next() @@ -1368,9 +1465,7 @@ class OCCRenderer(Renderer): if len(circle_centers) >= 2: # Project each center onto the axis direction to get a # scalar "height" value. Cluster into two groups. - ax_dir_np = np.array([ - ax_dir.X(), ax_dir.Y(), ax_dir.Z() - ], dtype=float) + ax_dir_np = np.array([ax_dir.X(), ax_dir.Y(), ax_dir.Z()], dtype=float) heights = [np.dot(c, ax_dir_np) for c in circle_centers] # Sort by height (scalar) and split roughly in half. indexed = list(enumerate(heights)) @@ -1391,16 +1486,22 @@ class OCCRenderer(Renderer): # No circular edges found — fall back to parameter-based. vmin = adaptor.FirstVParameter() vmax = adaptor.LastVParameter() - c0 = np.array([ - ax_pos.X() + ax_dir.X() * vmin, - ax_pos.Y() + ax_dir.Y() * vmin, - ax_pos.Z() + ax_dir.Z() * vmin, - ], dtype=float) - c1 = np.array([ - ax_pos.X() + ax_dir.X() * vmax, - ax_pos.Y() + ax_dir.Y() * vmax, - ax_pos.Z() + ax_dir.Z() * vmax, - ], dtype=float) + c0 = np.array( + [ + ax_pos.X() + ax_dir.X() * vmin, + ax_pos.Y() + ax_dir.Y() * vmin, + ax_pos.Z() + ax_dir.Z() * vmin, + ], + dtype=float, + ) + c1 = np.array( + [ + ax_pos.X() + ax_dir.X() * vmax, + ax_pos.Y() + ax_dir.Y() * vmax, + ax_pos.Z() + ax_dir.Z() * vmax, + ], + dtype=float, + ) # Normal = the cylinder axis direction. This is the "bolt # axis": the direction a bolt would travel INTO the hole. @@ -1423,15 +1524,17 @@ class OCCRenderer(Renderer): results: List[Dict[str, Any]] = [] for end_center in [c0, c1]: origin = (float(end_center[0]), float(end_center[1]), float(end_center[2])) - results.append({ - "type": "cylindrical_face", - "position": origin, - "normal": normal, - "x_dir": x_dir, - "face": face, - "owner_obj_id": owner_obj_id, - "radius": radius, - }) + results.append( + { + "type": "cylindrical_face", + "position": origin, + "normal": normal, + "x_dir": x_dir, + "face": face, + "owner_obj_id": owner_obj_id, + "radius": radius, + } + ) return results # Try edge. @@ -1475,14 +1578,16 @@ class OCCRenderer(Renderer): x = x / xlen x_dir = (float(x[0]), float(x[1]), float(x[2])) - return [{ - "type": "edge", - "position": position, - "normal": tangent, - "x_dir": x_dir, - "edge": edge, - "owner_obj_id": owner_obj_id, - }] + return [ + { + "type": "edge", + "position": position, + "normal": tangent, + "x_dir": x_dir, + "edge": edge, + "owner_obj_id": owner_obj_id, + } + ] # Try vertex. vertex = None @@ -1490,21 +1595,26 @@ class OCCRenderer(Renderer): vertex = TopoDS.Vertex_s(shape) p = BRep_Tool.Pnt_s(vertex) position = (p.X(), p.Y(), p.Z()) - return [{ - "type": "vertex", - "position": position, - "normal": None, - "x_dir": None, - "vertex": vertex, - "owner_obj_id": owner_obj_id, - }] + return [ + { + "type": "vertex", + "position": position, + "normal": None, + "x_dir": None, + "vertex": vertex, + "owner_obj_id": owner_obj_id, + } + ] except Exception: pass return [] def probe_snap_candidates( - self, x: int, y: int, radius: int = 30, + self, + x: int, + y: int, + radius: int = 30, ) -> List[Dict[str, Any]]: """Probe a pixel grid around (x, y) and return visible snap candidates. @@ -1534,13 +1644,28 @@ class OCCRenderer(Renderer): ring_offsets = [ (0, 0), # Full radius ring (cardinal + diagonal) - (-radius, 0), (radius, 0), (0, -radius), (0, radius), - (-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius), + (-radius, 0), + (radius, 0), + (0, -radius), + (0, radius), + (-radius, -radius), + (radius, radius), + (-radius, radius), + (radius, -radius), # Half-radius ring - (-h, 0), (h, 0), (0, -h), (0, h), - (-h, -h), (h, h), (-h, h), (h, -h), + (-h, 0), + (h, 0), + (0, -h), + (0, h), + (-h, -h), + (h, h), + (-h, h), + (h, -h), # Quarter-radius ring for small features - (-q, 0), (q, 0), (0, -q), (0, q), + (-q, 0), + (q, 0), + (0, -q), + (0, q), ] candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {} @@ -1576,7 +1701,11 @@ class OCCRenderer(Renderer): # Sort by screen-space distance to the cursor, nearest first. results = list(candidates.values()) - results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2) + results.sort( + key=lambda c: ( + (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2 + ) + ) return results def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]: @@ -1592,6 +1721,7 @@ class OCCRenderer(Renderer): from OCP.gp import gp_Pnt from OCP.AIS import AIS_Shape from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB + try: scaled_size = size * self._get_gizmo_scale(position) sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), scaled_size).Shape() @@ -1709,10 +1839,10 @@ class OCCRenderer(Renderer): # Per-entity-type colours (shared by the dim candidate markers). default_colors = { - "planar_face": (0.0, 0.8, 1.0), # cyan - "cylindrical_face": (1.0, 0.5, 0.0), # orange (hole / bolt axis) - "edge": (0.2, 1.0, 0.4), # green - "vertex": (1.0, 1.0, 0.0), # yellow + "planar_face": (0.0, 0.8, 1.0), # cyan + "cylindrical_face": (1.0, 0.5, 0.0), # orange (hole / bolt axis) + "edge": (0.2, 1.0, 0.4), # green + "vertex": (1.0, 1.0, 0.0), # yellow } gizmo_color = color or default_colors.get(entity_type, (1.0, 0.6, 0.0)) @@ -1742,7 +1872,9 @@ class OCCRenderer(Renderer): continue # Skip the primary itself — it gets its own bright marker. if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == ( - round(px, 1), round(py, 1), round(pz, 1) + round(px, 1), + round(py, 1), + round(pz, 1), ): continue cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7)) @@ -1772,9 +1904,7 @@ class OCCRenderer(Renderer): ey = origin[1] + uy * length ez = origin[2] + uz * length - edge = BRepBuilderAPI_MakeEdge( - gp_Pnt(*origin), gp_Pnt(ex, ey, ez) - ).Edge() + edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*origin), gp_Pnt(ex, ey, ez)).Edge() ais = AIS_Shape(edge) ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB)) ais.SetDisplayMode(0) # wireframe @@ -1800,12 +1930,17 @@ class OCCRenderer(Renderer): # visual balance. This reads as 'bolt axis through hole'. _make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in") _make_axis_line( - position, (-normal[0], -normal[1], -normal[2]), - axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub", + position, + (-normal[0], -normal[1], -normal[2]), + axis_length * 0.4, + (0.6, 0.6, 0.6), + "axis_stub", ) # Radial reference (same colour as the marker). if x_dir is not None: - _make_axis_line(position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial") + _make_axis_line( + position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial" + ) elif entity_type == "edge" and normal is not None: # Tangent direction at midpoint. @@ -1858,6 +1993,7 @@ class OCCRenderer(Renderer): return from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.AIS import AIS_Shape + for robj in self._objects.values(): if robj.ais_shape is not None: for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): @@ -1878,6 +2014,7 @@ class OCCRenderer(Renderer): return from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.AIS import AIS_Shape + for robj in self._objects.values(): if robj.ais_shape is not None: for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): @@ -1915,7 +2052,10 @@ class OCCRenderer(Renderer): return None def probe_snap_candidates_geometric( - self, x: int, y: int, radius: int = 30, + self, + x: int, + y: int, + radius: int = 30, ) -> List[Dict[str, Any]]: """Probe snap candidates by iterating geometry directly (no selection system). @@ -1937,7 +2077,6 @@ class OCCRenderer(Renderer): from OCP.TopoDS import TopoDS from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib - import numpy as np candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {} # Expand the search radius for the bbox pre-filter so features near @@ -1965,10 +2104,14 @@ class OCCRenderer(Renderer): bx0, by0, bz0, bx1, by1, bz1 = bbox.Get() # Project the 8 AABB corners to screen. corners = [ - (bx0, by0, bz0), (bx1, by0, bz0), - (bx0, by1, bz0), (bx1, by1, bz0), - (bx0, by0, bz1), (bx1, by0, bz1), - (bx0, by1, bz1), (bx1, by1, bz1), + (bx0, by0, bz0), + (bx1, by0, bz0), + (bx0, by1, bz0), + (bx1, by1, bz0), + (bx0, by0, bz1), + (bx1, by0, bz1), + (bx0, by1, bz1), + (bx1, by1, bz1), ] sx_min, sy_min = 99999, 99999 sx_max, sy_max = -99999, -99999 @@ -1984,8 +2127,12 @@ class OCCRenderer(Renderer): if all_behind: continue # Check if cursor is within margin of the screen bbox. - if (x < sx_min - margin or x > sx_max + margin or - y < sy_min - margin or y > sy_max + margin): + if ( + x < sx_min - margin + or x > sx_max + margin + or y < sy_min - margin + or y > sy_max + margin + ): continue except Exception: pass # If bbox fails, fall through and try features. @@ -2060,8 +2207,9 @@ class OCCRenderer(Renderer): # Sort by screen-space distance to cursor, nearest first. results = list(candidates.values()) results.sort( - key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 - + (c.get("screen", (x, y))[1] - y) ** 2 + key=lambda c: ( + (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2 + ) ) return results @@ -2081,7 +2229,6 @@ class OCCRenderer(Renderer): * ``suggestion`` — human-readable snap suggestion * ``feature_data`` — dict with feature-specific info (radius, axis, etc.) """ - import numpy as np from collections import defaultdict # Group candidates by owner_obj_id. @@ -2126,10 +2273,9 @@ class OCCRenderer(Renderer): if etype == "edge": # Look for other edges nearby that might form a loop. nearby_edges = [ - n for n in candidates - if n.get("type") == "edge" - and n.get("owner_obj_id") == owner - and n is not c + n + for n in candidates + if n.get("type") == "edge" and n.get("owner_obj_id") == owner and n is not c ] # For now, mark as edge — loop detection is complex. ec["feature_type"] = "edge" @@ -2145,9 +2291,9 @@ class OCCRenderer(Renderer): if etype == "vertex": # Look for edges that share this vertex (nearby edges). nearby_edges = [ - n for n in candidates - if n.get("type") == "edge" - and n.get("owner_obj_id") == owner + n + for n in candidates + if n.get("type") == "edge" and n.get("owner_obj_id") == owner ] if len(nearby_edges) >= 2: ec["feature_type"] = "meeting_edges" @@ -2180,6 +2326,7 @@ class OCCRenderer(Renderer): def _qt_buttons(self, event) -> Any: """Return the PySide6 Qt enum module lazily.""" from PySide6.QtCore import Qt + return Qt def handle_mouse_press(self, event) -> None: diff --git a/src/fluency/ui/dialogs.py b/src/fluency/ui/dialogs.py index f3f815c..7a358bd 100644 --- a/src/fluency/ui/dialogs.py +++ b/src/fluency/ui/dialogs.py @@ -4,11 +4,12 @@ from __future__ import annotations import logging import math -from typing import Tuple +from typing import Any, Callable, Dict, Optional, Tuple from PySide6.QtWidgets import ( QButtonGroup, QCheckBox, + QComboBox, QDialog, QDoubleSpinBox, QFrame, @@ -19,11 +20,20 @@ from PySide6.QtWidgets import ( QPushButton, QRadioButton, QVBoxLayout, + QWidget, ) logger = logging.getLogger(__name__) +def _vec3(value: Any) -> Tuple[float, float, float]: + """Coerce a 3-vector to a typed float triple (defensive fallback).""" + try: + return (float(value[0]), float(value[1]), float(value[2])) + except (TypeError, ValueError, IndexError): + return (0.0, 0.0, 0.0) + + class ExtrudeDialog(QDialog): """Dialog for extrude options. @@ -33,7 +43,7 @@ class ExtrudeDialog(QDialog): *None*) to the callback tells the host to clear the preview. """ - def __init__(self, parent=None): + def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("Extrude Options") self.setMinimumWidth(320) @@ -82,8 +92,8 @@ class ExtrudeDialog(QDialog): layout.addWidget(self.rounded_checkbox) line = QFrame() - line.setFrameShape(QFrame.HLine) - line.setFrameShadow(QFrame.Sunken) + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() @@ -95,9 +105,9 @@ class ExtrudeDialog(QDialog): 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. + # Live preview: recompute on every option change. Wire each widget + # to its own signal by type — spinboxes emit ``valueChanged``, + # checkboxes emit ``stateChanged``. for w in ( self.length_input, self.symmetric_checkbox, @@ -108,27 +118,18 @@ class ExtrudeDialog(QDialog): self.cut_all_bodies_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: + if isinstance(w, QDoubleSpinBox): w.valueChanged.connect(self._emit_preview) - except AttributeError: - pass - try: + else: w.stateChanged.connect(self._emit_preview) - except AttributeError: - pass - def set_preview_callback(self, callback) -> None: + def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> 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: + def _emit_preview(self, *args: Any) -> None: if self._preview_callback is None: return try: @@ -136,7 +137,7 @@ class ExtrudeDialog(QDialog): except Exception as exc: # preview must never break the dialog logger.debug("extrude preview callback raised: %s", exc) - def hideEvent(self, event): + def hideEvent(self, event: Any) -> None: # 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. @@ -163,7 +164,7 @@ class ExtrudeDialog(QDialog): class RevolveDialog(QDialog): """Dialog for revolve options.""" - def __init__(self, parent=None): + def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("Revolve Options") self.setMinimumWidth(300) @@ -181,8 +182,8 @@ class RevolveDialog(QDialog): layout.addLayout(angle_layout) line = QFrame() - line.setFrameShape(QFrame.HLine) - line.setFrameShadow(QFrame.Sunken) + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() @@ -203,7 +204,7 @@ class OffsetDialog(QDialog): time. On accept the caller retrieves ``get_values()`` → distance. """ - def __init__(self, parent=None): + def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("Offset Sketch") self.setMinimumWidth(300) @@ -227,8 +228,8 @@ class OffsetDialog(QDialog): layout.addWidget(self.inward_checkbox) line = QFrame() - line.setFrameShape(QFrame.HLine) - line.setFrameShadow(QFrame.Sunken) + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() @@ -244,12 +245,12 @@ class OffsetDialog(QDialog): self.distance_input.valueChanged.connect(self._emit_preview) self.inward_checkbox.stateChanged.connect(self._emit_preview) - def set_preview_callback(self, callback) -> None: + def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install the live-preview callback (or *None* to disable).""" self._preview_callback = callback self._emit_preview() - def _emit_preview(self, *args) -> None: + def _emit_preview(self, *args: Any) -> None: if self._preview_callback is None: return try: @@ -257,7 +258,7 @@ class OffsetDialog(QDialog): except Exception as exc: logger.debug("offset preview callback raised: %s", exc) - def hideEvent(self, event): + def hideEvent(self, event: Any) -> None: if self._preview_callback is not None: try: self._preview_callback(None) @@ -277,7 +278,7 @@ class WorkplaneOrientationDialog(QDialog): returns (normal, x_dir) pair (both as 3-tuples). """ - def __init__(self, parent=None): + def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("New Workplane Orientation") self.setMinimumWidth(320) @@ -297,6 +298,12 @@ class WorkplaneOrientationDialog(QDialog): layout.addWidget(lbl) self._preset_group = QButtonGroup(self) + # normal/x_dir per preset, keyed by the QButtonGroup id — QRadioButton + # has no data slot of its own, so stash the vectors here instead of + # duck-typing extra attributes onto the widget. + self._preset_vectors: Dict[ + int, Tuple[Tuple[float, float, float], Tuple[float, float, float]] + ] = {} preset_layout = QGridLayout() presets = [ ("XY (Top)", (0, 0, 1), (1, 0, 0)), @@ -310,15 +317,14 @@ class WorkplaneOrientationDialog(QDialog): btn = QRadioButton(label) btn.setChecked(idx == 0) self._preset_group.addButton(btn, idx) - btn.normal = normal - btn.x_dir = x_dir + self._preset_vectors[idx] = (_vec3(normal), _vec3(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) + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) self._custom_radio = QRadioButton("Custom (angle from XY):") @@ -351,8 +357,8 @@ class WorkplaneOrientationDialog(QDialog): # ── Buttons ── line2 = QFrame() - line2.setFrameShape(QFrame.HLine) - line2.setFrameShadow(QFrame.Sunken) + line2.setFrameShape(QFrame.Shape.HLine) + line2.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line2) button_layout = QHBoxLayout() @@ -371,7 +377,7 @@ class WorkplaneOrientationDialog(QDialog): self._angle_x.valueChanged.connect(self._emit_preview) self._angle_y.valueChanged.connect(self._emit_preview) - def set_preview_callback(self, callback) -> None: + def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install a callback for live 3D preview of the workplane orientation. *callback* is called with ``(normal, x_dir)`` whenever the user @@ -381,7 +387,7 @@ class WorkplaneOrientationDialog(QDialog): # Emit once so the initial state shows a preview right away. self._emit_preview() - def _emit_preview(self, *args) -> None: + def _emit_preview(self, *args: Any) -> None: """Call the preview callback with the current orientation, if installed.""" if self._preview_callback is None: return @@ -391,7 +397,7 @@ class WorkplaneOrientationDialog(QDialog): except Exception as exc: logger.debug("workplane preview callback raised: %s", exc) - def hideEvent(self, event): + def hideEvent(self, event: Any) -> None: """Clear the live preview when the dialog closes.""" if self._preview_callback is not None: try: @@ -400,17 +406,23 @@ class WorkplaneOrientationDialog(QDialog): pass super().hideEvent(event) - def _on_preset_changed(self, btn): + def _on_preset_changed(self, btn: Any) -> None: """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.""" + def _compute_custom_orientation( + self, + ) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]: + """Compute ``(normal, x_dir)`` from the custom angle spinboxes. + + Starts from the +Z normal and rotates by the two angle values. + Returns *None* if the math fails (defensive — the dialog then falls + back to the default orientation instead of crashing). + """ import numpy as np - if self._custom_radio.isChecked(): - # Custom: start from XY normal and rotate by the two angles. + try: ax = math.radians(self._angle_x.value()) ay = math.radians(self._angle_y.value()) # Start from +Z normal, rotate around X then Y @@ -433,10 +445,13 @@ class WorkplaneOrientationDialog(QDialog): ] ) n = ry @ n - n = n / np.linalg.norm(n) + n_norm = np.linalg.norm(n) + if n_norm < 1e-12: + return None + n = n / n_norm # 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: + if abs(float(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) @@ -444,14 +459,24 @@ class WorkplaneOrientationDialog(QDialog): 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) + return ( + (float(n[0]), float(n[1]), float(n[2])), + (float(x[0]), float(x[1]), float(x[2])), + ) + except Exception as exc: + logger.debug("custom workplane orientation math failed: %s", exc) + return None + def _on_ok(self) -> None: + """Compute the final orientation and accept.""" + if self._custom_radio.isChecked(): + orientation = self._compute_custom_orientation() + if orientation is not None: + self._normal, self._x_dir = orientation else: btn = self._preset_group.checkedButton() if btn is not None: - self._normal = btn.normal - self._x_dir = btn.x_dir + self._normal, self._x_dir = self._preset_vectors[self._preset_group.id(btn)] self.accept() def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]: @@ -460,50 +485,171 @@ class WorkplaneOrientationDialog(QDialog): Computes the current selection from the UI state so it works whether called before or after ``_on_ok``. """ - import numpy as np 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]) + orientation = self._compute_custom_orientation() + if orientation is None: + orientation = ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0)) + normal, x_dir = orientation return ( - tuple(float(v) for v in n), - tuple(float(v) for v in x), + normal, + x_dir, 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") + normal, x_dir = self._preset_vectors[self._preset_group.id(btn)] + return (normal, 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 FilletDialog(QDialog): + """Dialog for fillet options — the common settings from CAD fillet tools. + + Shown AFTER the user has picked the two faces whose shared edges will + be rounded. Offers: + + - size, entered as **diameter** or **radius** (the user asked for a + diameter box; the unit toggle covers the radius crowd), + - **tangent propagation** (extend the round along tangent-connected + edges, like FreeCAD/SolidWorks "tangent chain"), + - edge **scope** (only the edges between the two picked faces vs + every edge of the body), + - a live 3D preview (``set_preview_callback``), so dragging the size + spinner shows the fillet in real time before committing. + + ``get_values()`` returns ``(size, size_is_diameter, tangent_propagation, + scope)`` where *scope* is ``"selected"`` or ``"all"``. The host + converts *size* to a radius (``size / 2`` for diameter). + """ + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + self.setWindowTitle("Fillet Options") + self.setMinimumWidth(360) + + self._preview_callback: Optional[Callable[[Any], None]] = None + + layout = QVBoxLayout(self) + + # ── Size: value + Diameter/Radius unit ── + size_row = QHBoxLayout() + self.size_unit_combo = QComboBox() + self.size_unit_combo.addItems(["Diameter", "Radius"]) + self.size_unit_combo.setToolTip( + "Enter the fillet size as a diameter or a radius (radius = diameter / 2)." + ) + size_row.addWidget(self.size_unit_combo) + + self.size_value_label = QLabel("Diameter (mm):") + size_row.addWidget(self.size_value_label) + + self.size_input = QDoubleSpinBox() + self.size_input.setDecimals(2) + self.size_input.setRange(0.01, 100000.0) + self.size_input.setValue(2.0) + self.size_input.setSingleStep(0.5) + self.size_input.setSuffix(" mm") + self.size_input.setToolTip("Fillet size along the rounded edge.") + size_row.addWidget(self.size_input) + layout.addLayout(size_row) + + # ── Edge scope ── + self.scope_group = QButtonGroup(self) + scope_layout = QGridLayout() + self.scope_selected_radio = QRadioButton("Edges between faces") + self.scope_selected_radio.setChecked(True) + self.scope_selected_radio.setToolTip("Round only the edges shared by the two picked faces.") + self.scope_all_radio = QRadioButton("All edges of body") + self.scope_all_radio.setToolTip( + "Round every edge of the body (the picked faces only choose which body is modified)." + ) + self.scope_group.addButton(self.scope_selected_radio) + self.scope_group.addButton(self.scope_all_radio) + scope_layout.addWidget(self.scope_selected_radio, 0, 0) + scope_layout.addWidget(self.scope_all_radio, 1, 0) + layout.addLayout(scope_layout) + + # ── Tangent propagation ── + self.tangent_checkbox = QCheckBox("Tangent propagation") + self.tangent_checkbox.setChecked(True) + self.tangent_checkbox.setToolTip( + "Extend the fillet along edges that are tangent to the picked " + "ones (e.g. a smooth chain of lines and arcs). Off = only the " + "exact edges between the two faces." + ) + layout.addWidget(self.tangent_checkbox) + + # ── Edge count feedback ── + self.edge_label = QLabel("") + self.edge_label.setStyleSheet("color: #8a8a8a;") + layout.addWidget(self.edge_label) + + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) + layout.addWidget(line) + + button_layout = QHBoxLayout() + ok_button = QPushButton("Apply Fillet") + 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 change ── + self.size_unit_combo.currentIndexChanged.connect(self._on_unit_changed) + self.size_input.valueChanged.connect(self._emit_preview) + self.scope_selected_radio.toggled.connect(self._emit_preview) + self.tangent_checkbox.stateChanged.connect(self._emit_preview) + + def _on_unit_changed(self) -> None: + """Swap the size label between Diameter and Radius.""" + self.size_value_label.setText( + "Radius (mm):" if self.size_unit_combo.currentText() == "Radius" else "Diameter (mm):" + ) + self._emit_preview() + + def set_edge_count(self, count: int) -> None: + """Show how many edges the current scope will round.""" + self.edge_label.setText( + f"Fillets {count} edge{'s' if count != 1 else ''} between the picked faces." + ) + + def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: + """Install a live-preview callback; fires immediately with defaults.""" + self._preview_callback = callback + self._emit_preview() + + def _emit_preview(self, *args: Any) -> 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("fillet preview callback raised: %s", exc) + + def hideEvent(self, event: Any) -> None: + 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, str]: + """Return ``(size, size_is_diameter, tangent_propagation, scope)``.""" + return ( + self.size_input.value(), + self.size_unit_combo.currentText() == "Diameter", + self.tangent_checkbox.isChecked(), + "all" if self.scope_all_radio.isChecked() else "selected", + ) diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py index b65815c..be159d3 100644 --- a/src/fluency/ui/main_window.py +++ b/src/fluency/ui/main_window.py @@ -19,15 +19,18 @@ from PySide6.QtGui import ( MAX_RECENT_PROJECTS = 10 from PySide6.QtWidgets import ( + QAbstractItemView, QButtonGroup, QCheckBox, QDialog, QDoubleSpinBox, QFileDialog, QFrame, + QGridLayout, QHBoxLayout, QInputDialog, QLabel, + QListWidget, QListWidgetItem, QMainWindow, QMenu, @@ -45,6 +48,7 @@ from fluency.models.data_model import Project, Component, Sketch, Body, Workplan from fluency.ui.dialogs import ( ExtrudeDialog, + FilletDialog, OffsetDialog, RevolveDialog, WorkplaneOrientationDialog, @@ -641,6 +645,204 @@ def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) -> return face_geom +# ── Fillet edge helpers ───────────────────────────────────────────────────── + + +def _edge_fingerprint(edge: Any) -> str: + """Stable textual fingerprint of an edge (sorted endpoint coordinates). + + Used to persist fillet edge selections in the feature history so the + replay can re-find the edges after the base geometry is rebuilt. + """ + from OCP.BRep import BRep_Tool + from OCP.TopoDS import TopoDS + from OCP.TopExp import TopExp_Explorer + from OCP.TopAbs import TopAbs_VERTEX + + pts = [] + ex = TopExp_Explorer(edge, TopAbs_VERTEX) + while ex.More(): + p = BRep_Tool.Pnt_s(TopoDS.Vertex_s(ex.Current())) + pts.append((round(p.X(), 4), round(p.Y(), 4), round(p.Z(), 4))) + ex.Next() + return repr(sorted(pts)) + + +def _shared_edges_between_faces(shape: Any, face1: Any, face2: Any) -> List[Any]: + """Edges that belong to BOTH faces — the edges a fillet rounds. + + *shape* is only used for context in the signature (the caller already + resolved it from the body); the actual test is ``IsSame`` on the two + faces' edge explorers. + """ + from OCP.TopoDS import TopoDS + from OCP.TopExp import TopExp_Explorer + from OCP.TopAbs import TopAbs_EDGE + + def _edges(face: Any) -> List[Any]: + out = [] + ex = TopExp_Explorer(face, TopAbs_EDGE) + while ex.More(): + out.append(TopoDS.Edge_s(ex.Current())) + ex.Next() + return out + + e1 = _edges(face1) + e2 = _edges(face2) + return [a for a in e1 for b in e2 if a.IsSame(b)] + + +def _edge_tangent_away(edge: Any, vertex: Any) -> Any: + """Unit tangent of *edge* at *vertex*, pointing AWAY from the vertex. + + Two edges are tangent-connected at a shared vertex when these away- + directions are (anti-)collinear. Falls back to ``(0,0,0)`` semantics + (a zero vector) if the curve evaluation fails. + """ + from OCP.BRep import BRep_Tool + from OCP.BRepAdaptor import BRepAdaptor_Curve + from OCP.TopoDS import TopoDS + from OCP.TopExp import TopExp_Explorer + from OCP.TopAbs import TopAbs_VERTEX + from OCP.GeomLProp import GeomLProp_CLProps + from OCP.gp import gp_Dir + + edge = TopoDS.Edge_s(edge) + vertex = TopoDS.Vertex_s(vertex) + ad = BRepAdaptor_Curve(edge) + first, last = ad.FirstParameter(), ad.LastParameter() + curve = BRep_Tool.Curve_s(edge, edge.Location(), 0.0, 0.0) + + # Which end is the vertex at? + vex = TopExp_Explorer(edge, TopAbs_VERTEX) + v1 = TopoDS.Vertex_s(vex.Current()) + vex.Next() + v2 = TopoDS.Vertex_s(vex.Current()) if vex.More() else None + if v2 is not None and v1.IsSame(vertex): + use_first = True + elif v2 is not None and v2.IsSame(vertex): + use_first = False + else: + # Closed edge (single vertex) or ambiguous — use the start end. + use_first = True + + d = gp_Dir() + try: + props = GeomLProp_CLProps(curve, 2, 1e-6) + if use_first: + props.SetParameter(first) + else: + props.SetParameter(last) + props.Tangent(d) + if not use_first: + d.Reverse() + except Exception as exc: + logger.debug("edge tangent eval failed: %s", exc) + return d + + +def _expand_tangent_chain(shape: Any, seeds: List[Any]) -> List[Any]: + """BFS-expand *seeds* to every edge smoothly connected to them. + + An adjacent edge joins the chain when, at the shared vertex, its + away-tangent is (anti-)collinear with the seed edge's away-tangent — + i.e. the geometry continues without a corner. This is the "tangent + propagation" behaviour of mainstream CAD fillet tools. + """ + from OCP.BRep import BRep_Tool + from OCP.TopoDS import TopoDS + from OCP.TopExp import TopExp_Explorer + from OCP.TopAbs import TopAbs_EDGE, TopAbs_VERTEX + + all_edges = [] + ex = TopExp_Explorer(shape, TopAbs_EDGE) + while ex.More(): + all_edges.append(TopoDS.Edge_s(ex.Current())) + ex.Next() + + # vertex location -> incident edges + adj: Dict[Tuple[float, float, float], List[Any]] = {} + for e in all_edges: + vex = TopExp_Explorer(e, TopAbs_VERTEX) + while vex.More(): + p = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vex.Current())) + key = (round(p.X(), 6), round(p.Y(), 6), round(p.Z(), 6)) + adj.setdefault(key, []).append(e) + vex.Next() + + def _key(edge: Any) -> Tuple[Any, ...]: + pts = [] + vex = TopExp_Explorer(edge, TopAbs_VERTEX) + while vex.More(): + p = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vex.Current())) + pts.append((round(p.X(), 6), round(p.Y(), 6), round(p.Z(), 6))) + vex.Next() + return tuple(sorted(pts)) + + selected = list(seeds) + frontier = list(seeds) + seen = {_key(e) for e in seeds} + while frontier: + cur = frontier.pop() + vex = TopExp_Explorer(cur, TopAbs_VERTEX) + while vex.More(): + v = TopoDS.Vertex_s(vex.Current()) + p = BRep_Tool.Pnt_s(v) + key = (round(p.X(), 6), round(p.Y(), 6), round(p.Z(), 6)) + t_cur = _edge_tangent_away(cur, v) + for cand in adj.get(key, []): + k = _key(cand) + if k in seen: + continue + t_cand = _edge_tangent_away(cand, v) + dot = abs(t_cur.X() * t_cand.X() + t_cur.Y() * t_cand.Y() + t_cur.Z() * t_cand.Z()) + if dot > 0.999: + seen.add(k) + selected.append(cand) + frontier.append(cand) + vex.Next() + return selected + + +def _resolve_fillet_edges( + shape: Any, seed_edges: List[Any], tangent_propagation: bool, scope: str +) -> Optional[List[Any]]: + """Compute the edge list to fillet for the given dialog settings. + + Returns *None* for ``scope == "all"`` (the kernel then rounds every + edge); otherwise the seed edges, optionally expanded along tangent + chains. + """ + if scope == "all": + return None + if not tangent_propagation: + return seed_edges + return _expand_tangent_chain(shape, seed_edges) + + +def _resolve_edges_by_fingerprint(shape: Any, refs: List[str]) -> List[Any]: + """Find the edges of *shape* matching the stored fingerprints. + + Best-effort: edges whose endpoints moved or disappeared are simply + skipped. Returns an empty list when nothing matches. + """ + from OCP.TopoDS import TopoDS + from OCP.TopExp import TopExp_Explorer + from OCP.TopAbs import TopAbs_EDGE + + target = set(refs) + if not target: + return [] + out = [] + ex = TopExp_Explorer(shape, TopAbs_EDGE) + while ex.More(): + e = TopoDS.Edge_s(ex.Current()) + if _edge_fingerprint(e) in target: + out.append(e) + ex.Next() + return out + + def _replay_body_features( kernel: OCGeometryKernel, body: Body, @@ -659,6 +861,32 @@ def _replay_body_features( geom = feat.geometry continue + if feat.operation == "fillet": + # Fillet needs no sketch — it rounds edges of the running solid. + if geom is None: + logger.warning(f"Body '{body.name}': fillet feature has no base, replay aborted") + return None + if feat.radius is None: + logger.warning(f"Body '{body.name}': fillet feature has no radius, replay aborted") + return None + if feat.scope == "all" or not feat.edge_refs: + edges: Optional[List[Any]] = None # round every edge + else: + edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs) + if not edges: + # The referenced edges no longer exist after a topology + # change — abort so the body keeps its previous state + # (marked ⚠) instead of silently rounding nothing. + logger.warning( + f"Body '{body.name}': fillet edge refs unresolved after rebuild, " + "replay aborted" + ) + return None + geom = kernel.fillet(geom, feat.radius, edges=edges) + if geom is None: + return None + continue + sketch = feat.sketch if sketch is None or sketch.occ_sketch is None: logger.warning( @@ -741,6 +969,12 @@ class MainWindow(QMainWindow): self._current_sketch: Optional[Sketch] = None self._selected_body: Optional[Body] = None + # Fillet tool: two-face pick flow (face 1 → face 2 → options dialog). + self._fillet_pick_active: bool = False + self._fillet_face1: Optional[Any] = None + self._fillet_face2: Optional[Any] = None + self._fillet_body: Optional[Body] = None + self._component_buttons: List[QPushButton] = [] self._component_group: Optional[QButtonGroup] = None @@ -971,6 +1205,36 @@ class MainWindow(QMainWindow): # Panel-focus mode (equal | sketch | viewer). self._panel_focus: str = "equal" + # ── Body operations list (feature history of the selected body) ── + # Shown in the "Bodys / Operations" group box, between the body + # list and its Tools row. Each operation can be deleted to revert + # the body to its state before that operation. + self._operations_list = QListWidget() + self._operations_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self._operations_list.setToolTip( + "Operations of the selected body (top = newest).\n" + "Select one and press 'Del Op' to revert the body to before it." + ) + ops_layout = self._ui.groupBox_10.layout() + if ops_layout is None: + ops_layout = QVBoxLayout(self._ui.groupBox_10) + # Index 1 places it between the body list and the Tools group box. + ops_layout.insertWidget(1, self._operations_list) + + self._btn_del_op = QPushButton("Del Op") + self._btn_del_op.setEnabled(False) + self._btn_del_op.setToolTip( + "Delete the selected operation — the body reverts to its state " + "before it (the base operation can't be deleted)." + ) + ops_tools = self._ui.groupBox_8.layout() + if ops_tools is None: + ops_tools = QGridLayout(self._ui.groupBox_8) + ops_tools.addWidget(self._btn_del_op, 1, 0, 1, 2) + + self._operations_list.itemSelectionChanged.connect(self._on_operations_selection_changed) + self._btn_del_op.clicked.connect(self._on_delete_operation) + def _setup_ui_aliases(self): """Create _btn_* aliases pointing to the UI-loaded widgets. @@ -1023,6 +1287,8 @@ class MainWindow(QMainWindow): self._btn_move = ui.pb_moveop self._btn_revolve = ui.pb_revop self._btn_array = ui.pb_arrayop + self._btn_fillet = ui.pb_fillet_op + self._btn_fillet.setCheckable(True) # ── Export ── self._btn_export_stl = ui.pushButton_2 self._btn_export_step = ui.pb_export_step @@ -1138,6 +1404,9 @@ class MainWindow(QMainWindow): 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_fillet.clicked.connect(self._on_fillet_button_clicked) + self._viewer_3d.filletFacePicked.connect(self._on_fillet_face_picked) + self._viewer_3d.filletPickCancelled.connect(self._cancel_fillet_pick) self._btn_add_sketch.clicked.connect(self._add_sketch_to_component) self._btn_edit_sketch.clicked.connect(self._edit_sketch) @@ -1484,6 +1753,109 @@ class MainWindow(QMainWindow): item.setForeground(QColor("#f5a623")) # Orange warning self._body_list.addItem(item) + self._refresh_operations_list() + + # ── Body operations list (per-body feature history) ─────────────────── + + def _describe_feature(self, feat: Feature, index: int) -> str: + """Human-readable one-liner for a feature-history entry.""" + op = feat.operation + if op == "extrude": + length = feat.length if feat.length is not None else 10.0 + return f"{index + 1}. Extrude {length:g} mm" + if op == "cut": + length = feat.length if feat.length is not None else 10.0 + return f"{index + 1}. Cut {length:g} mm" + if op == "union": + return f"{index + 1}. Union" + if op == "revolve": + angle = feat.angle if feat.angle is not None else 360.0 + return f"{index + 1}. Revolve {angle:g}°" + if op == "fillet": + radius = feat.radius if feat.radius is not None else 0.0 + scope = " (all edges)" if feat.scope == "all" else "" + return f"{index + 1}. Fillet r={radius:g} mm{scope}" + if op == "base": + return f"{index + 1}. Base (baked geometry)" + return f"{index + 1}. {op.title()}" + + def _refresh_operations_list(self) -> None: + """Show the selected body's feature history in the ops list. + + The first entry (the body's base) is shown greyed and cannot be + selected, because deleting it would leave the body without an + origin. Every other entry can be deleted to revert the body to + its state before that operation. + """ + self._operations_list.clear() + body = self._selected_body + if body is None: + self._btn_del_op.setEnabled(False) + return + features = _ensure_feature_history(body) + for index, feat in enumerate(features): + item = QListWidgetItem(self._describe_feature(feat, index)) + item.setData(Qt.ItemDataRole.UserRole, index) + if index == 0: + item.setForeground(QColor("#6c7086")) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self._operations_list.addItem(item) + self._operations_list.setCurrentRow(min(1, len(features) - 1)) + self._on_operations_selection_changed() + + def _on_operations_selection_changed(self) -> None: + """Enable 'Del Op' only when a deletable (non-base) op is selected.""" + item = self._operations_list.currentItem() + if item is None: + self._btn_del_op.setEnabled(False) + return + index = item.data(Qt.ItemDataRole.UserRole) + self._btn_del_op.setEnabled(index is not None and index > 0) + + def _on_delete_operation(self) -> None: + """Delete the selected operation; the body reverts to before it. + + The feature is removed from the body's history and the body is + rebuilt from scratch by replaying the remaining features. If the + remaining chain can't be replayed (e.g. a cut sketch depended on + the deleted operation's geometry) the body keeps its current + geometry and is marked ⚠ — Update retries the rebuild. + """ + body = self._selected_body + if body is None: + QMessageBox.information(self, "No Body", "Select a body first.") + return + item = self._operations_list.currentItem() + if item is None: + QMessageBox.information(self, "No Operation", "Select an operation in the list first.") + return + index = item.data(Qt.ItemDataRole.UserRole) + features = _ensure_feature_history(body) + if not isinstance(index, int) or not (0 <= index < len(features)): + return + if index == 0: + QMessageBox.warning( + self, + "Cannot Delete Base", + "The first operation is the body's base and can't be deleted.", + ) + return + feat = features[index] + answer = QMessageBox.question( + self, + "Delete Operation", + f"Delete '{self._describe_feature(feat, index)}'?\n\n" + "The body will revert to its state before this operation.", + ) + if answer != QMessageBox.StandardButton.Yes: + return + del features[index] + body.needs_update = True + self._mark_dirty() + logger.info(f"Body '{body.name}': deleted operation at index {index}") + # Rebuild from the remaining features; refreshes lists + ops list. + self._update_and_redraw() + def _update_and_redraw(self): """Full pipeline: rebuild bodies, redraw, propagate to assembly. @@ -3886,6 +4258,7 @@ class MainWindow(QMainWindow): for body_id, body in self._current_component.bodies.items(): if body.name == name: self._selected_body = body + self._refresh_operations_list() logger.info(f"Selected: {name}") break @@ -4638,6 +5011,207 @@ class MainWindow(QMainWindow): logger.exception(f"Boolean union failed: {e}") QMessageBox.critical(self, "Error", f"Boolean union failed: {e}") + # ── Fillet (two-face pick → options dialog) ──────────────────────────── + + def _on_fillet_button_clicked(self) -> None: + """Toggle the fillet face-pick flow from the toolbar button.""" + if self._fillet_pick_active: + self._cancel_fillet_pick() + else: + self._start_fillet_pick() + + def _start_fillet_pick(self) -> None: + """Enter face-pick mode: the user picks face 1, then face 2.""" + if not self._current_component or not self._current_component.bodies: + QMessageBox.warning( + self, + "No Body", + "Create or import a body first, then pick two of its faces " + "to fillet the edge between them.", + ) + # Undo the checkable toggle: we never entered pick mode. + self._btn_fillet.setChecked(False) + self._fillet_pick_active = False + return + self._fillet_pick_active = True + self._fillet_face1 = None + self._fillet_face2 = None + self._fillet_body = None + self._btn_fillet.setChecked(True) + self._viewer_3d.set_fillet_pick_mode(True) + # Disarm the sketch-on-surface picker if it was active. + self._btn_wp_face.setChecked(False) + self._viewer_3d.set_pick_face_mode(False) + self.statusBar().showMessage("Fillet: pick the FIRST face", 6000) + + def _cancel_fillet_pick(self) -> None: + """Abort the fillet flow (button toggle, Esc, or after applying).""" + self._fillet_pick_active = False + self._fillet_face1 = None + self._fillet_face2 = None + self._fillet_body = None + self._viewer_3d.set_fillet_pick_mode(False) + self._btn_fillet.setChecked(False) + self._viewer_3d.clear_faces_highlight() + self._viewer_3d.clear_preview() + + def _fillet_body_for_owner(self, owner_obj_id: Optional[str]) -> Optional[Body]: + """Resolve the body owning a picked face (by render object id). + + Falls back to the component's only body when the owner can't be + matched (e.g. an imported shape that wasn't registered). + """ + if not self._current_component: + return None + if owner_obj_id: + for bid, body in self._current_component.bodies.items(): + if body.render_object == owner_obj_id: + return body + if len(self._current_component.bodies) == 1: + return next(iter(self._current_component.bodies.values())) + return None + + def _on_fillet_face_picked(self, face: Any) -> None: + """Handle one fillet face pick: first face, then second → dialog.""" + if not self._fillet_pick_active: + return + owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None) + body = self._fillet_body_for_owner(owner_obj_id) + if body is None or body.geometry is None: + QMessageBox.warning( + self, + "Pick a Body Face", + "The picked face doesn't belong to a body in the current " + "component. Pick a face on a body.", + ) + return + + if self._fillet_face1 is None: + self._fillet_face1 = face + self._fillet_body = body + self._viewer_3d.highlight_faces([face]) + self.statusBar().showMessage("Fillet: pick the SECOND face", 6000) + return + + # Second face: same body, and it must share an edge with face 1. + if body is not self._fillet_body: + QMessageBox.warning( + self, + "Different Bodies", + "Both faces must belong to the SAME body. Pick the second face again.", + ) + return + shape = self._kernel._get_shape(body.geometry) + seed_edges = _shared_edges_between_faces(shape, self._fillet_face1, face) + if not seed_edges: + QMessageBox.warning( + self, + "No Shared Edge", + "These faces don't share an edge. Pick the second face again.", + ) + return + + self._fillet_face2 = face + self._viewer_3d.highlight_faces([self._fillet_face1, face]) + self._open_fillet_dialog(seed_edges) + + def _open_fillet_dialog(self, seed_edges: List[Any]) -> None: + """Show the fillet options dialog with a live preview, then apply.""" + body = self._fillet_body + if body is None or body.geometry is None: + self._cancel_fillet_pick() + return + shape = self._kernel._get_shape(body.geometry) + + dialog = FilletDialog(self) + dialog.set_edge_count(len(seed_edges)) + + def _preview(values: Any) -> None: + if values is None: + self._viewer_3d.clear_preview() + return + size, is_diameter, tangent, scope = values + radius = size / 2.0 if is_diameter else size + try: + edges = _resolve_fillet_edges(shape, seed_edges, tangent, scope) + result = self._kernel.fillet(body.geometry, radius, edges=edges) + self._viewer_3d.show_preview(self._kernel._get_shape(result)) + except Exception: + self._viewer_3d.clear_preview() + + dialog.set_preview_callback(_preview) + + if dialog.exec(): + size, is_diameter, tangent, scope = dialog.get_values() + radius = size / 2.0 if is_diameter else size + self._apply_fillet(shape, seed_edges, radius, tangent, scope) + + self._cancel_fillet_pick() + + def _apply_fillet( + self, + shape: Any, + seed_edges: List[Any], + radius: float, + tangent_propagation: bool, + scope: str, + ) -> None: + """Fillet the body in place and record the operation as a feature. + + The picked body's geometry is replaced by the filleted solid, its + render object is refreshed, and a ``fillet`` feature is appended to + its feature history so "Update Body" / save-load replay it. + """ + body = self._fillet_body + if body is None or body.geometry is None: + return + try: + edges = _resolve_fillet_edges(shape, seed_edges, tangent_propagation, scope) + result_geom = self._kernel.fillet(body.geometry, radius, edges=edges) + except Exception as exc: + logger.exception(f"Fillet failed: {exc}") + QMessageBox.critical( + self, + "Fillet Failed", + f"Could not fillet these edges: {exc}\n\n" + "Try a smaller size or a different pair of faces.", + ) + return + + # Record the operation in the feature history so it replays on + # Update Body and survives save/load. + features = _ensure_feature_history(body) + if not features and body.geometry is not None: + # Imported / baked body: freeze current geometry as the base. + features.append(Feature(operation="base", geometry=body.geometry)) + features.append( + Feature( + operation="fillet", + radius=radius, + tangent_propagation=tangent_propagation, + scope=scope, + edge_refs=[_edge_fingerprint(e) for e in (edges or [])], + ) + ) + + body.geometry = result_geom + body.needs_update = False + body.modified_at = datetime.now() + self._mark_dirty() + + # Re-render the body in place (replace its AIS object). + if body.render_object is not None: + self._viewer_3d.remove_mesh(body.render_object) + new_shape = self._kernel._get_shape(body.geometry) + body.render_object = self._viewer_3d.show_shape(new_shape, body.color, body.name) + + self._refresh_lists() + self._update_component_thumbnail(self._get_active_component_index()) + self.statusBar().showMessage( + f"Fillet applied — radius {radius:g} mm on {len(edges or [])} edge(s)", 5000 + ) + logger.info(f"Fillet applied to '{body.name}' (radius {radius})") + def _delete_body(self): selected = self._body_list.currentItem() if not selected or not self._current_component: @@ -4654,6 +5228,8 @@ class MainWindow(QMainWindow): if to_delete: del self._current_component.bodies[to_delete] + if self._selected_body is not None and self._selected_body.name == name: + self._selected_body = None self._mark_dirty() self._refresh_lists() logger.info(f"Deleted body: {name}") diff --git a/src/fluency/ui/viewer_widget.py b/src/fluency/ui/viewer_widget.py index 14ddf3d..e2fe135 100644 --- a/src/fluency/ui/viewer_widget.py +++ b/src/fluency/ui/viewer_widget.py @@ -20,6 +20,13 @@ class Viewer3DWidget(QWidget): # Emitted when face-pick mode is cancelled (Esc) so the host can uncheck. pickFaceCancelled = Signal() + # Emitted when the user picks a face for the fillet tool (ANY face, + # planar or curved). Payload: the raw TopoDS_Face. The owning body is + # read from ``_last_pick_owner_obj_id`` (same stash as facePicked). + filletFacePicked = Signal(object) + # Emitted when fillet pick mode is cancelled (Esc). + filletPickCancelled = 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) @@ -44,11 +51,11 @@ class Viewer3DWidget(QWidget): 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.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen) + self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent) self.setAutoFillBackground(False) # Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work. - self.setFocusPolicy(Qt.StrongFocus) + self.setFocusPolicy(Qt.FocusPolicy.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 @@ -64,6 +71,8 @@ class Viewer3DWidget(QWidget): # 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 ANY face for the fillet tool. + self._fillet_pick_mode: bool = False # When True, a left-click picks an entity for a connector point # (assembly component connection). self._connector_pick_mode: bool = False @@ -312,15 +321,19 @@ class Viewer3DWidget(QWidget): 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: + if self._pick_face_mode and event.button() == Qt.MouseButton.LeftButton: self._handle_face_pick(event) return + # Fillet pick mode: a left-click selects any face (planar or curved). + if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton: + self._handle_fillet_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: + if self._connector_pick_mode and event.button() == Qt.MouseButton.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: + if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton: self._handle_assembly_move_press(event) return self._renderer.handle_mouse_press(event) @@ -346,6 +359,12 @@ class Viewer3DWidget(QWidget): self._renderer.handle_mouse_move(event) super().mouseMoveEvent(event) return + # In fillet pick mode, keep dynamic highlighting too. + if self._fillet_pick_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) @@ -354,7 +373,7 @@ class Viewer3DWidget(QWidget): self._renderer.handle_mouse_move(event) super().mouseMoveEvent(event) - def paintEngine(self): + def paintEngine(self) -> Any: """Return None to prevent Qt from painting over OCC's direct OpenGL.""" return None @@ -447,13 +466,75 @@ class Viewer3DWidget(QWidget): """ self._pick_face_mode = bool(enabled) if enabled: - self.setCursor(Qt.CrossCursor) + self.setCursor(Qt.CursorShape.CrossCursor) else: self.unsetCursor() def is_pick_face_mode(self) -> bool: return self._pick_face_mode + # ─── Fillet pick mode (any-face picking) ──────────────────────────────── + + def set_fillet_pick_mode(self, enabled: bool) -> None: + """Toggle fillet face-pick mode (any face — planar or curved). + + The cursor selects faces for the fillet tool instead of orbiting the + camera. Mutually exclusive with the other pick modes: entering this + mode switches the others off. + """ + self._fillet_pick_mode = bool(enabled) + if enabled: + # Pick modes are mutually exclusive — entering fillet mode + # disables sketch-on-surface / connector / assembly modes. + self._pick_face_mode = False + self._connector_pick_mode = False + self._assembly_move_mode = False + self._move_drag_active = False + self.setCursor(Qt.CursorShape.CrossCursor) + elif not self._pick_face_mode and not self._connector_pick_mode: + self.unsetCursor() + + def is_fillet_pick_mode(self) -> bool: + return self._fillet_pick_mode + + def highlight_faces(self, faces: List[Any]) -> None: + """Tint all faces in *faces* so both fillet picks stay visible.""" + self._ensure_initialized() + fn = getattr(self._renderer, "highlight_faces", None) + if fn is not None: + fn(faces) + self._renderer.render() + return + # Fallback: single-face highlight for the last picked face. + if faces: + self.highlight_face(faces[-1]) + + def clear_faces_highlight(self) -> None: + """Remove the multi-face fillet-pick overlays, if any.""" + if not self._initialized or self._renderer is None: + return + fn = getattr(self._renderer, "clear_faces_highlight", None) + if fn is not None: + fn() + self._renderer.render() + + def _handle_fillet_face_pick(self, event: Any) -> None: + """Detect any face under the click and emit filletFacePicked.""" + self._ensure_initialized() + picker = getattr(self._renderer, "pick_face", None) + if picker is None: + logger.warning("Renderer has no pick_face support") + return + pos = event.position().toPoint() if hasattr(event, "position") else event.pos() + info = picker(pos.x(), pos.y()) + if info is None: + logger.info("Fillet face pick: no face under cursor") + return + # Stash the owner so MainWindow can pair the face with its body + # (same convention as sketch-on-face picking). + self._last_pick_owner_obj_id = info.get("owner_obj_id") + self.filletFacePicked.emit(info["face"]) + def highlight_face(self, face: Any) -> None: """Tint the picked face light-blue/transparent in the 3D viewer.""" self._ensure_initialized() @@ -489,7 +570,7 @@ class Viewer3DWidget(QWidget): """ self._connector_pick_mode = bool(enabled) if enabled: - self.setCursor(Qt.CrossCursor) + self.setCursor(Qt.CursorShape.CrossCursor) # Disable standard OCC selection so gizmo visuals are not # interfered with by dynamic face highlighting. fn = getattr(self._renderer, "deactivate_selection_modes", None) @@ -714,7 +795,7 @@ class Viewer3DWidget(QWidget): """ self._assembly_move_mode = bool(enabled) if enabled: - self.setCursor(Qt.SizeAllCursor) + self.setCursor(Qt.CursorShape.SizeAllCursor) elif not self._pick_face_mode and not self._connector_pick_mode: self.unsetCursor() if not enabled: @@ -788,20 +869,29 @@ class Viewer3DWidget(QWidget): # Compute world-space delta. modifiers = event.modifiers() - if modifiers & Qt.ShiftModifier: + if modifiers & Qt.KeyboardModifier.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) + try: + 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 + ) + except (TypeError, ValueError): + dx_world = dy_world = dz_world = 0.0 self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world) - def _handle_assembly_move_release(self, event) -> None: + def _handle_assembly_move_release(self, event: Any) -> None: """Finish the drag, emit final position.""" self.assemblyMoveFinished.emit(self._move_owner_obj_id) self._move_drag_active = False @@ -811,7 +901,7 @@ class Viewer3DWidget(QWidget): self._move_plane_normal = None self._move_initial_position = None - def _handle_face_pick(self, event) -> None: + def _handle_face_pick(self, event: Any) -> None: """Detect a planar face under the click and emit facePicked.""" self._ensure_initialized() picker = getattr(self._renderer, "pick_planar_face", None) @@ -834,7 +924,7 @@ class Viewer3DWidget(QWidget): info["face"], ) - def set_view(self, view: str): + def set_view(self, view: str) -> None: # 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. @@ -856,26 +946,31 @@ class Viewer3DWidget(QWidget): pos, target = positions[view] self.set_camera_position(pos, target) - def mouseDoubleClickEvent(self, event): + def mouseDoubleClickEvent(self, event: Any) -> None: # Double-click → fit all (common CAD convention). self._ensure_initialized() - if event.button() == Qt.LeftButton: + if event.button() == Qt.MouseButton.LeftButton: self.fit_camera() super().mouseDoubleClickEvent(event) - def keyPressEvent(self, event): + def keyPressEvent(self, event: Any) -> None: # Esc cancels face-pick mode. - if self._pick_face_mode and event.key() == Qt.Key_Escape: + if self._pick_face_mode and event.key() == Qt.Key.Key_Escape: self.set_pick_face_mode(False) self.pickFaceCancelled.emit() return + # Esc cancels fillet pick mode. + if self._fillet_pick_mode and event.key() == Qt.Key.Key_Escape: + self.set_fillet_pick_mode(False) + self.filletPickCancelled.emit() + return # Esc cancels connector pick mode. - if self._connector_pick_mode and event.key() == Qt.Key_Escape: + if self._connector_pick_mode and event.key() == Qt.Key.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: + if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape: self.set_assembly_move_mode(False) return # Navigation shortcuts (lowercase = view presets, F = fit,