From b29bc11b42577cd879f7be3b8b8d8a976962cb0a Mon Sep 17 00:00:00 2001 From: bklronin Date: Sat, 8 Aug 2026 11:15:49 +0200 Subject: [PATCH] - Operation highlighting, body highlighting --- .idea/workspace.xml | 106 ++++++++++-------- src/fluency/rendering/occ_renderer.py | 63 +++++++++++ src/fluency/ui/main_window.py | 152 +++++++++++++++++++++++++- src/fluency/ui/viewer_widget.py | 29 +++++ 4 files changed, 301 insertions(+), 49 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index fd0e8d9..5ac5f2a 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -6,13 +6,9 @@ - - - - - + - + - { + "keyToString": { + "Python.2dtest.executor": "Run", + "Python.3d_windows.executor": "Run", + "Python.Unnamed.executor": "Run", + "Python.base.executor": "Run", + "Python.data_model.executor": "Run", + "Python.debug_dragging.executor": "Run", + "Python.draw_widget2d.executor": "Run", + "Python.draw_widget_solve.executor": "Run", + "Python.fluency.executor": "Run", + "Python.fluencyb.executor": "Run", + "Python.gl_widget.executor": "Run", + "Python.gui_ui.executor": "Run", + "Python.kernel.executor": "Run", + "Python.main.executor": "Run", + "Python.main_window.executor": "Run", + "Python.meshtest.executor": "Run", + "Python.occ_renderer.executor": "Run", + "Python.occ_to_mesh.executor": "Run", + "Python.render_backend.executor": "Run", + "Python.side_fluency.executor": "Run", + "Python.simple_mesh.executor": "Run", + "Python.sketch.executor": "Run", + "Python.vtk_widget.executor": "Run", + "Python.vulkan.executor": "Run", + "RunOnceActivity.OpenProjectViewOnStart": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.typescript.service.memoryLimit.init": "true", + "codeWithMe.voiceChat.enabledByDefault": "false", + "git-widget-placeholder": "feature/occ-migration", + "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles", + "node.js.detected.package.eslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings" } -}]]> +} diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py index 335a17a..6b5f052 100644 --- a/src/fluency/rendering/occ_renderer.py +++ b/src/fluency/rendering/occ_renderer.py @@ -157,6 +157,8 @@ class OCCRenderer(Renderer): self._highlight_ais: Any = None # Overlays for the fillet tool's two picked faces (one AIS per face). self._faces_highlight_ais: List[Any] = [] + # Hot-pink translucent overlay for operation-history highlighting. + self._op_highlight_ais: Any = None # 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). @@ -543,6 +545,16 @@ class OCCRenderer(Renderer): return False return True + def set_color( + self, obj_id: str, color: Tuple[float, float, float] + ) -> bool: + """Set the colour of an object by ID. Returns True on success.""" + obj = self._objects.get(obj_id) + if obj is None: + return False + self.set_object_color(obj, color) + return True + # ─── Live preview (extrude/cut preview) ────────────────────────────── _PREVIEW_ID = "__extrude_preview__" @@ -608,6 +620,7 @@ class OCCRenderer(Renderer): return self.clear_preview() self.clear_face_highlight() + self.clear_operation_highlight() self.clear_entity_gizmo() # The sketch reference gizmo is scene-anchored — drop it with the rest. self.remove_sketch_gizmo() @@ -1385,6 +1398,56 @@ class OCCRenderer(Renderer): logger.debug("clear_faces_highlight remove failed", exc_info=True) self._faces_highlight_ais = [] + + # ─── Operation history highlight ───────────────────────────────────── + + def highlight_operation_shape( + self, shape: Any, color: Tuple[float, float, float] = (1.0, 0.08, 0.58) + ) -> None: + """Overlay a translucent hot-pink *shape* on the 3D view. + + Used to show the intermediate geometry at a selected operation in + the feature-history list. The overlay is an independent + ``AIS_Shape`` with polygon offset so it draws on top of the + coincident body surface without z-fighting. Replaces any previous + operation highlight. + """ + if self._context is None: + return + self.clear_operation_highlight() + from OCP.AIS import AIS_Shape + from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB + + ais = AIS_Shape(shape) + try: + ais.SetMaterial(self._default_material()) + except Exception: + logger.debug("op highlight material set failed", exc_info=True) + ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB)) + ais.SetDisplayMode(1) # shaded + try: + ais.SetTransparency(0.65) + except Exception: + logger.debug("op highlight transparency set failed", exc_info=True) + try: + ais.SetPolygonOffsets(3, 1.0, -0.5) + except Exception: + logger.debug("op highlight polygon offset failed", exc_info=True) + self._context.Display(ais, True) + self._op_highlight_ais = ais + if self._view is not None: + self._view.Redraw() + + def clear_operation_highlight(self) -> None: + """Remove the operation-history overlay, if any.""" + if self._context is None or self._op_highlight_ais is None: + return + try: + self._context.Remove(self._op_highlight_ais, True) + except Exception: + logger.debug("clear_operation_highlight remove failed", exc_info=True) + self._op_highlight_ais = None + # ─── General entity picking (for assembly connectors / snaps) ─────────── def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]: diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py index 0796c50..2643d19 100644 --- a/src/fluency/ui/main_window.py +++ b/src/fluency/ui/main_window.py @@ -1423,6 +1423,13 @@ class MainWindow(QMainWindow): # selectable axis / centre / plane from the 3D viewport. self._sketch_gizmo_selection: Optional[dict] = None self._selected_body: Optional[Body] = None + # Track which body we're hiding while an operation-history highlight + # is active, so we can restore it when the selection changes. + self._op_highlight_body_id: Optional[str] = None + # Body-highlight state: when no operation is selected we tint the + # selected body light-blue; save its original colour to restore later. + self._body_highlight_id: Optional[str] = None + self._body_highlight_original_color: Optional[Tuple[float, float, float]] = None # Fillet tool: two-face pick flow (face 1 → face 2 → options dialog). self._fillet_pick_active: bool = False @@ -2345,19 +2352,154 @@ class MainWindow(QMainWindow): 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() + # setCurrentRow doesn't fire for non-selectable items (the base); + # ensure buttons reflect the empty selection in that edge case. + if self._operations_list.currentItem() is None: + self._on_operations_selection_changed() + + def _compute_operation_highlight_shape( + self, body: Body, features: List[Feature], index: int, feat: Feature + ) -> Optional[Any]: + """Return the shape to highlight for *feat* at *index* in the history. + + For subtractive operations (cut), returns the tool shape — the + volume that was removed — so the user sees what was cut away. + For additive operations (union), returns the tool shape that was + added. For everything else, returns the intermediate body shape + after replaying features up to and including *index*. + """ + if feat.operation in ("cut", "union"): + return self._compute_tool_shape(body, features, index, feat) + # Default: show the intermediate body after this operation. + geom = _replay_body_features( + self._kernel, body, features[: index + 1], + self._through_all_length_for_geometry, + ) + if geom is None: + return None + return self._kernel._get_shape(geom) + + def _compute_tool_shape( + self, body: Body, features: List[Feature], index: int, feat: Feature + ) -> Optional[Any]: + """Compute the extruded tool shape for a cut or union feature. + + Replays the body up to (but not including) *index* so the + pre-operation geometry is available for through-all length + calculation, then extrudes the feature's sketch profile to + produce the tool volume. + """ + sketch = feat.sketch + if sketch is None or sketch.occ_sketch is None: + return None + + sketch.apply_workplane() + sketch.solve() + + face_geom = _feature_face_geometry(body, feat, sketch.occ_sketch) + if face_geom is None: + return None + + # For through-all cuts we need the pre-op body to size the tool. + if feat.through_all: + pre_geom = _replay_body_features( + self._kernel, body, features[:index], + self._through_all_length_for_geometry, + ) + if pre_geom is not None: + length = self._through_all_length_for_geometry(pre_geom, sketch) + symmetric = True + invert = False + else: + length = feat.length if feat.length is not None else 10.0 + symmetric = feat.symmetric + invert = True # cut: go inward + elif feat.operation == "cut": + # Cut tool must go INTO the solid — the face normal points away, + # so force the extrude direction inward. + length = feat.length if feat.length is not None else 10.0 + symmetric = feat.symmetric + invert = True + else: + length = feat.length if feat.length is not None else 10.0 + symmetric = feat.symmetric + invert = feat.invert + + tool_geom = self._kernel.extrude( + face_geom, -length if invert else length, symmetric=symmetric + ) + if tool_geom is None: + return None + return self._kernel._get_shape(tool_geom) + + + # ── Body highlight helpers ───────────────────────────────────────── + + def _restore_body_highlight_color(self) -> None: + """Restore the original colour of a previously highlighted body.""" + if self._body_highlight_id is not None: + if self._body_highlight_original_color is not None: + self._viewer_3d.set_body_color( + self._body_highlight_id, self._body_highlight_original_color + ) + self._body_highlight_id = None + self._body_highlight_original_color = None + + def _highlight_selected_body_light_blue(self) -> None: + """Tint the currently selected body light blue in the 3D view.""" + body = self._selected_body + if body is None or body.render_object is None or not body.visible: + return + # Save the original colour so we can restore it later. + self._body_highlight_id = body.render_object + self._body_highlight_original_color = body.color + self._viewer_3d.set_body_color( + body.render_object, (0.45, 0.75, 1.0) + ) def _on_operations_selection_changed(self) -> None: - """Enable 'Del Op' / 'Mirror Op' when a non-base op is selected.""" + """Enable 'Del Op' / 'Mirror Op', highlight body or selected op in 3D.""" item = self._operations_list.currentItem() + + # Restore the body we hid for the previous operation highlight. + if self._op_highlight_body_id is not None: + self._viewer_3d.set_visibility(self._op_highlight_body_id, True) + self._op_highlight_body_id = None + self._viewer_3d.clear_operation_highlight() + + # Restore the previous body colour highlight. + self._restore_body_highlight_color() + if item is None: self._btn_del_op.setEnabled(False) self._btn_mirror_op.setEnabled(False) + self._highlight_selected_body_light_blue() return index = item.data(Qt.ItemDataRole.UserRole) deletable = index is not None and index > 0 self._btn_del_op.setEnabled(deletable) self._btn_mirror_op.setEnabled(index is not None and index >= 0) + if index is not None and index > 0 and self._selected_body is not None: + body = self._selected_body + features = _ensure_feature_history(body) + if 0 <= index < len(features): + feat = features[index] + try: + shape = self._compute_operation_highlight_shape( + body, features, index, feat + ) + if shape is not None: + if body.render_object is not None and body.visible: + self._viewer_3d.set_visibility( + body.render_object, False + ) + self._op_highlight_body_id = body.render_object + self._viewer_3d.highlight_operation(shape) + except Exception: + logger.debug("op highlight replay failed", exc_info=True) + else: + # Base operation (or non-selectable) — highlight body light blue. + self._highlight_selected_body_light_blue() def _on_mirror_operation(self) -> None: """Mirror the body at the selected operation's point in the feature history. @@ -6747,6 +6889,9 @@ class MainWindow(QMainWindow): self._project = Project() self._current_component = None self._current_sketch = None + self._op_highlight_body_id = None + self._body_highlight_id = None + self._body_highlight_original_color = None self._selected_body = None self._selected_assembly_component_id = None self._assembly_view_active = False @@ -6994,6 +7139,9 @@ class MainWindow(QMainWindow): self._current_sketch = None self._selected_body = None self._selected_assembly_component_id = None + self._op_highlight_body_id = None + self._body_highlight_id = None + self._body_highlight_original_color = None self._assembly_view_active = False self._sketch_widget.clear_source_face() self._sketch_widget.set_sketch(None) diff --git a/src/fluency/ui/viewer_widget.py b/src/fluency/ui/viewer_widget.py index 20d2628..b876115 100644 --- a/src/fluency/ui/viewer_widget.py +++ b/src/fluency/ui/viewer_widget.py @@ -279,6 +279,19 @@ class Viewer3DWidget(QWidget): self._renderer.render() return ok + def set_body_color( + self, mesh_id: str, color: Tuple[float, float, float] + ) -> bool: + """Change the colour of a body in the 3D view. Returns True on success.""" + self._ensure_initialized() + fn = getattr(self._renderer, "set_color", None) + if fn is None: + return False + ok = fn(mesh_id, color) + if ok: + self._renderer.render() + return ok + def set_transparency(self, mesh_id: str, transparency: float) -> bool: """Set a previously-added mesh's transparency (0..1). @@ -862,6 +875,22 @@ class Viewer3DWidget(QWidget): fn() self._renderer.render() + def highlight_operation(self, shape: Any) -> None: + """Overlay a hot-pink translucent *shape* to show operation history.""" + self._ensure_initialized() + fn = getattr(self._renderer, "highlight_operation_shape", None) + if fn is not None: + fn(shape) + self._renderer.render() + + def clear_operation_highlight(self) -> None: + """Remove the operation-history overlay, if any.""" + self._ensure_initialized() + fn = getattr(self._renderer, "clear_operation_highlight", None) + if fn is not None: + fn() + self._renderer.render() + # ─── Connector pick mode (assembly) ──────────────────────────────────── def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None: