- Operation highlighting, body highlighting

This commit is contained in:
bklronin
2026-08-08 11:15:49 +02:00
parent 79ad85594e
commit b29bc11b42
4 changed files with 301 additions and 49 deletions
+59 -47
View File
@@ -6,13 +6,9 @@
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -49,47 +45,47 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Python.2dtest.executor": "Run", &quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
"Python.3d_windows.executor": "Run", &quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
"Python.Unnamed.executor": "Run", &quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
"Python.base.executor": "Run", &quot;Python.base.executor&quot;: &quot;Run&quot;,
"Python.data_model.executor": "Run", &quot;Python.data_model.executor&quot;: &quot;Run&quot;,
"Python.debug_dragging.executor": "Run", &quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;,
"Python.draw_widget2d.executor": "Run", &quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
"Python.draw_widget_solve.executor": "Run", &quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
"Python.fluency.executor": "Run", &quot;Python.fluency.executor&quot;: &quot;Run&quot;,
"Python.fluencyb.executor": "Run", &quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
"Python.gl_widget.executor": "Run", &quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
"Python.gui_ui.executor": "Run", &quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
"Python.kernel.executor": "Run", &quot;Python.kernel.executor&quot;: &quot;Run&quot;,
"Python.main.executor": "Run", &quot;Python.main.executor&quot;: &quot;Run&quot;,
"Python.main_window.executor": "Run", &quot;Python.main_window.executor&quot;: &quot;Run&quot;,
"Python.meshtest.executor": "Run", &quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
"Python.occ_renderer.executor": "Run", &quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
"Python.occ_to_mesh.executor": "Run", &quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
"Python.render_backend.executor": "Run", &quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
"Python.side_fluency.executor": "Run", &quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
"Python.simple_mesh.executor": "Run", &quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
"Python.sketch.executor": "Run", &quot;Python.sketch.executor&quot;: &quot;Run&quot;,
"Python.vtk_widget.executor": "Run", &quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
"Python.vulkan.executor": "Run", &quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"git-widget-placeholder": "feature/occ-migration", &quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles", &quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings" &quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
} }
}]]></component> }</component>
<component name="QodanaReportsService"> <component name="QodanaReportsService">
<option name="descriptions"> <option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" /> <ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -491,7 +487,23 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1785948828454</updated> <updated>1785948828454</updated>
</task> </task>
<option name="localTasksCounter" value="47" /> <task id="LOCAL-00047" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1786110295218</created>
<option name="number" value="00047" />
<option name="presentableId" value="LOCAL-00047" />
<option name="project" value="LOCAL" />
<updated>1786110295218</updated>
</task>
<task id="LOCAL-00048" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1786125317688</created>
<option name="number" value="00048" />
<option name="presentableId" value="LOCAL-00048" />
<option name="project" value="LOCAL" />
<updated>1786125317688</updated>
</task>
<option name="localTasksCounter" value="49" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
+63
View File
@@ -157,6 +157,8 @@ class OCCRenderer(Renderer):
self._highlight_ais: Any = None self._highlight_ais: Any = None
# Overlays for the fillet tool's two picked faces (one AIS per face). # Overlays for the fillet tool's two picked faces (one AIS per face).
self._faces_highlight_ais: List[Any] = [] 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. # Temporary transparent preview AIS for the live extrude/cut dialog.
self._preview_ais: Any = None self._preview_ais: Any = None
# Smart entity picker gizmo objects (snap markers, axis lines, rings). # Smart entity picker gizmo objects (snap markers, axis lines, rings).
@@ -543,6 +545,16 @@ class OCCRenderer(Renderer):
return False return False
return True 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) ────────────────────────────── # ─── Live preview (extrude/cut preview) ──────────────────────────────
_PREVIEW_ID = "__extrude_preview__" _PREVIEW_ID = "__extrude_preview__"
@@ -608,6 +620,7 @@ class OCCRenderer(Renderer):
return return
self.clear_preview() self.clear_preview()
self.clear_face_highlight() self.clear_face_highlight()
self.clear_operation_highlight()
self.clear_entity_gizmo() self.clear_entity_gizmo()
# The sketch reference gizmo is scene-anchored — drop it with the rest. # The sketch reference gizmo is scene-anchored — drop it with the rest.
self.remove_sketch_gizmo() self.remove_sketch_gizmo()
@@ -1385,6 +1398,56 @@ class OCCRenderer(Renderer):
logger.debug("clear_faces_highlight remove failed", exc_info=True) logger.debug("clear_faces_highlight remove failed", exc_info=True)
self._faces_highlight_ais = [] 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) ─────────── # ─── General entity picking (for assembly connectors / snaps) ───────────
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]: def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
+150 -2
View File
@@ -1423,6 +1423,13 @@ class MainWindow(QMainWindow):
# selectable axis / centre / plane from the 3D viewport. # selectable axis / centre / plane from the 3D viewport.
self._sketch_gizmo_selection: Optional[dict] = None self._sketch_gizmo_selection: Optional[dict] = None
self._selected_body: Optional[Body] = 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). # Fillet tool: two-face pick flow (face 1 → face 2 → options dialog).
self._fillet_pick_active: bool = False self._fillet_pick_active: bool = False
@@ -2345,19 +2352,154 @@ class MainWindow(QMainWindow):
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
self._operations_list.addItem(item) self._operations_list.addItem(item)
self._operations_list.setCurrentRow(min(1, len(features) - 1)) 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: 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() 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: if item is None:
self._btn_del_op.setEnabled(False) self._btn_del_op.setEnabled(False)
self._btn_mirror_op.setEnabled(False) self._btn_mirror_op.setEnabled(False)
self._highlight_selected_body_light_blue()
return return
index = item.data(Qt.ItemDataRole.UserRole) index = item.data(Qt.ItemDataRole.UserRole)
deletable = index is not None and index > 0 deletable = index is not None and index > 0
self._btn_del_op.setEnabled(deletable) self._btn_del_op.setEnabled(deletable)
self._btn_mirror_op.setEnabled(index is not None and index >= 0) 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: def _on_mirror_operation(self) -> None:
"""Mirror the body at the selected operation's point in the feature history. """Mirror the body at the selected operation's point in the feature history.
@@ -6747,6 +6889,9 @@ class MainWindow(QMainWindow):
self._project = Project() self._project = Project()
self._current_component = None self._current_component = None
self._current_sketch = 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_body = None
self._selected_assembly_component_id = None self._selected_assembly_component_id = None
self._assembly_view_active = False self._assembly_view_active = False
@@ -6994,6 +7139,9 @@ class MainWindow(QMainWindow):
self._current_sketch = None self._current_sketch = None
self._selected_body = None self._selected_body = None
self._selected_assembly_component_id = 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._assembly_view_active = False
self._sketch_widget.clear_source_face() self._sketch_widget.clear_source_face()
self._sketch_widget.set_sketch(None) self._sketch_widget.set_sketch(None)
+29
View File
@@ -279,6 +279,19 @@ class Viewer3DWidget(QWidget):
self._renderer.render() self._renderer.render()
return ok 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: def set_transparency(self, mesh_id: str, transparency: float) -> bool:
"""Set a previously-added mesh's transparency (0..1). """Set a previously-added mesh's transparency (0..1).
@@ -862,6 +875,22 @@ class Viewer3DWidget(QWidget):
fn() fn()
self._renderer.render() 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) ──────────────────────────────────── # ─── Connector pick mode (assembly) ────────────────────────────────────
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None: def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None: