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 @@
-
-
-
-
-
+
-
+
@@ -49,47 +45,47 @@
- {
+ "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"
}
-}]]>
+}
1785948828454
-
+
+
+ 1786110295218
+
+
+
+ 1786110295218
+
+
+
+ 1786125317688
+
+
+
+ 1786125317688
+
+
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: