2 Commits

Author SHA1 Message Date
bklronin 3d63f033f2 - Operation highlighting, body highlighting 2026-08-08 13:46:12 +02:00
bklronin b29bc11b42 - Operation highlighting, body highlighting 2026-08-08 11:15:49 +02:00
10 changed files with 782 additions and 85 deletions
+69 -47
View File
@@ -4,13 +4,11 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<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="- Operation highlighting, body highlighting">
<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$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.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/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
</list>
@@ -49,47 +47,47 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"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"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.main_window.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
}
}]]></component>
}</component>
<component name="QodanaReportsService">
<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" />
@@ -491,7 +489,31 @@
<option name="project" value="LOCAL" />
<updated>1785948828454</updated>
</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>
<task id="LOCAL-00049" summary="- Operation highlighting, body highlighting">
<option name="closed" value="true" />
<created>1786180549405</created>
<option name="number" value="00049" />
<option name="presentableId" value="LOCAL-00049" />
<option name="project" value="LOCAL" />
<updated>1786180549405</updated>
</task>
<option name="localTasksCounter" value="50" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -512,7 +534,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
<MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" />
<MESSAGE value="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance" />
@@ -537,6 +558,7 @@
<MESSAGE value="- Render improvements, camera plane, update" />
<MESSAGE value="- added &quot;measurement lines&quot;" />
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
<option name="LAST_COMMIT_MESSAGE" value="- arc improvements, fillets, operations, bodys" />
<MESSAGE value="- Operation highlighting, body highlighting" />
<option name="LAST_COMMIT_MESSAGE" value="- Operation highlighting, body highlighting" />
</component>
</project>
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

+37 -16
View File
@@ -1949,7 +1949,7 @@ class OCCSketch(SketchInterface):
_SNAP_TOL: float = 1e-2 # world-unit tolerance for snapping line endpoints in loop detection
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
def _line_segments(self) -> List[Tuple[float, float, float, float, int]]:
"""Current line segments as world-coordinate tuples (uses solved positions).
Returns both straight line segments AND tessellated arc segments so
@@ -1957,10 +1957,13 @@ class OCCSketch(SketchInterface):
and external entities are excluded — they're reference geometry and
must not affect the sketch profile.
Each segment is ``(x1, y1, x2, y2, entity_id)`` — the entity_id lets
callers trace which sketch entity produced each segment.
Tessellation density: roughly 12 segments per π radians of arc sweep,
which gives smooth-looking closed loops for face detection.
"""
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
segs: List[Tuple[float, float, float, float, int]] = []
# ── Straight line segments ──
for line_id, (sid, eid2) in self._lines.items():
@@ -1974,8 +1977,9 @@ class OCCSketch(SketchInterface):
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
segs.append(
(
(float(s_ent.geometry[0]), float(s_ent.geometry[1])),
(float(e_ent.geometry[0]), float(e_ent.geometry[1])),
float(s_ent.geometry[0]), float(s_ent.geometry[1]),
float(e_ent.geometry[0]), float(e_ent.geometry[1]),
line_id,
)
)
@@ -2010,7 +2014,7 @@ class OCCSketch(SketchInterface):
a2 = start_angle + t2 * sweep
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2))
segs.append((p1, p2))
segs.append((p1[0], p1[1], p2[0], p2[1], arc_id))
return segs
@@ -2018,8 +2022,8 @@ class OCCSketch(SketchInterface):
"""Detect closed loops: polygon cycles from connected lines + each circle.
Each loop is one of:
{"type": "polygon", "points": [(x,y), ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r}
{"type": "polygon", "points": [(x,y), ...], "entity_ids": [int, ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r, "entity_ids": [int]}
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
rectangle's four corners join into one cycle even after solver floating
point jitter. Only connected components where every node has degree 2
@@ -2035,11 +2039,13 @@ class OCCSketch(SketchInterface):
reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
edges: List[Tuple[Any, Any]] = []
for p1, p2 in segs:
k1, k2 = key(p1), key(p2)
reprs.setdefault(k1, p1)
reprs.setdefault(k2, p2)
edge_eids: Dict[Tuple[Any, Any], int] = {} # (k1,k2) -> entity_id
for x1, y1, x2, y2, eid in segs:
k1, k2 = key((x1, y1)), key((x2, y2))
reprs.setdefault(k1, (x1, y1))
reprs.setdefault(k2, (x2, y2))
edges.append((k1, k2))
edge_eids[(k1, k2) if k1 < k2 else (k2, k1)] = eid
# Undirected adjacency.
adj: Dict[Any, List[Any]] = {}
@@ -2066,8 +2072,9 @@ class OCCSketch(SketchInterface):
if nb not in comp_seen:
stack.append(nb)
if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3:
# Order the cycle by following each node's neighbor not yet visited.
# Order the cycle by following each node's neighbour not yet visited.
ordered: List[Any] = []
eids: List[int] = []
cur = comp[0]
prev = None
for _ in range(len(comp)):
@@ -2075,16 +2082,26 @@ class OCCSketch(SketchInterface):
nbrs = [nb for nb in adj[cur] if nb != prev]
if not nbrs:
break
ekey = (cur, nbrs[0]) if cur < nbrs[0] else (nbrs[0], cur)
if ekey in edge_eids:
eids.append(edge_eids[ekey])
prev = cur
cur = nbrs[0]
if len(ordered) == len(comp):
pts = [reprs[k] for k in ordered]
pts.append(pts[0])
loops.append({"type": "polygon", "points": pts})
loops.append(
{"type": "polygon", "points": pts, "entity_ids": sorted(set(eids))}
)
seen |= comp_seen
# Circles are closed loops of their own.
for cid, (center_id, r) in self._circles.items():
circle_ent = self._entities.get(cid)
if circle_ent is not None and circle_ent.is_construction:
continue
if cid in self._external_entity_ids:
continue
c_ent = self._entities.get(center_id)
if c_ent and c_ent.geometry and r > 0:
loops.append(
@@ -2092,6 +2109,7 @@ class OCCSketch(SketchInterface):
"type": "circle",
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
"radius": float(r),
"entity_ids": [cid],
}
)
return loops
@@ -2242,8 +2260,7 @@ class OCCSketch(SketchInterface):
that is the rectangle minus the circle — exactly the
"shape within a shape = closed without inner" behavior. A shape nested
inside a hole (depth 2) becomes its own solid face again.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int, "entity_ids": [int, ...]}``.
"""
loops = self.get_closed_loops()
if not loops:
@@ -2267,7 +2284,11 @@ class OCCSketch(SketchInterface):
# directly nested: depth one greater, and outer contains inner.
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
holes.append(inner)
faces.append({"outer": outer, "holes": holes, "depth": depths[i]})
# Face entity_ids = union of outer + hole loop entity_ids.
eids = set(outer.get("entity_ids", []))
for h in holes:
eids.update(h.get("entity_ids", []))
faces.append({"outer": outer, "holes": holes, "depth": depths[i], "entity_ids": sorted(eids)})
return faces
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
+14
View File
@@ -226,6 +226,8 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
"tangent_propagation": bool(feat.tangent_propagation),
"scope": feat.scope,
"edge_refs": list(feat.edge_refs),
"face_keys": json.dumps(feat.face_keys) if feat.face_keys is not None else None,
"face_keys_sketch_id": feat.face_keys_sketch_id,
"pattern_type": feat.pattern_type,
"count": feat.count,
"spacing": feat.spacing,
@@ -256,6 +258,8 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
tangent_propagation=bool(data.get("tangent_propagation", False)),
scope=data.get("scope", "selected"),
edge_refs=list(data.get("edge_refs") or []),
face_keys=None,
face_keys_sketch_id=data.get("face_keys_sketch_id"),
pattern_type=data.get("pattern_type", "linear"),
count=int(data.get("count") or 2),
spacing=_to_float(data.get("spacing"), 10.0),
@@ -264,6 +268,16 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
mirror_plane_normal=tuple(float(v) for v in (data.get("mirror_plane_normal") or (1, 0, 0))),
keep_original=bool(data.get("keep_original", True)),
)
# Deserialize face_keys from JSON if present.
fk_raw = data.get("face_keys")
if fk_raw and isinstance(fk_raw, str):
try:
feat.face_keys = json.loads(fk_raw)
except (json.JSONDecodeError, TypeError):
pass
elif isinstance(fk_raw, list):
feat.face_keys = fk_raw
sid = data.get("sketch_id")
if sid and sid in sketches:
feat.sketch = sketches[sid]
+8
View File
@@ -275,6 +275,14 @@ class Feature:
scope: str = "selected"
edge_refs: List[str] = field(default_factory=list)
# FaceKey references for fillet/chamfer: stable face classification
# that survives sketch dimension changes. ``face_keys`` is a list of
# (FaceKey, FaceKey) pairs — one pair per user-picked face;
# ``face_keys_sketch_id`` tracks which sketch produced the body these
# faces belong to.
face_keys: Optional[List[Tuple[Dict[str, Any], Dict[str, Any]]]] = None
face_keys_sketch_id: Optional[str] = None
# "array" / "pattern" features only: repeat the running solid.
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
# number of items including the original. Linear arrays use
+63
View File
@@ -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]]:
+557 -20
View File
@@ -274,7 +274,7 @@ def _make_component_thumbnail(
py_min, py_max = all_py.min(), all_py.max()
span_x = px_max - px_min
span_y = py_max - py_min
if span_x < 1e-10 or span_y < 1e-10:
if span_x < 1e-6 or span_y < 1e-6:
return None
# Scale to fill ~90% of the image
@@ -820,6 +820,229 @@ def _feature_face_geometry(body: Body, feat: Feature, occ_sketch: OCCSketch) ->
return face_geom
def _classify_extruded_faces(
body_shape: Any,
sketch: OCCSketch,
workplane_origin: Tuple[float, float, float],
workplane_normal: Tuple[float, float, float],
) -> Dict[int, Dict[str, Any]]:
"""Classify every face of *body_shape* relative to the extrusion sketch.
Returns ``{face_index: FaceKey}`` where each FaceKey is:
- ``{"type": "cap", "top": bool, "entity_ids": [int, ...]}``
- ``{"type": "lateral", "entity_ids": [int, ...]}``
*entity_ids* are the sketch entity IDs whose extrusion produced the face.
"""
import numpy as np
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Plane
from OCP.BRepGProp import BRepGProp
from OCP.GProp import GProp_GProps
from OCP.BRep import BRep_Tool
from OCP.gp import gp_Pnt
# Workplane frame.
origin = np.asarray(workplane_origin, dtype=float)
normal = np.asarray(workplane_normal, dtype=float)
normal = normal / (np.linalg.norm(normal) + 1e-30)
# Infer x_dir from sketch workplane (needed for UV projection).
wp = sketch.get_workplane()
x_dir = np.asarray(wp[2], dtype=float) if len(wp) > 2 else np.array([1.0, 0.0, 0.0], dtype=float)
x_dir = x_dir / (np.linalg.norm(x_dir) + 1e-30)
y_dir = np.cross(normal, x_dir)
y_dir = y_dir / (np.linalg.norm(y_dir) + 1e-30)
def _world_to_uv(p3d):
v = np.array([p3d[0] - origin[0], p3d[1] - origin[1], p3d[2] - origin[2]], dtype=float)
return (float(np.dot(v, x_dir)), float(np.dot(v, y_dir)))
def _face_center(face):
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(face, props)
c = props.CentreOfMass()
return (float(c.X()), float(c.Y()), float(c.Z()))
def _point_to_segment_dist_sq(px, py, ax, ay, bx, by):
"""Squared distance from point P to segment AB."""
dx, dy = bx - ax, by - ay
if abs(dx) < 1e-12 and abs(dy) < 1e-12:
return (px - ax) ** 2 + (py - ay) ** 2
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)))
return (px - (ax + t * dx)) ** 2 + (py - (ay + t * dy)) ** 2
# ── 1. Collect all faces ──
all_faces: list = []
ex = TopExp_Explorer(body_shape, TopAbs_FACE)
while ex.More():
all_faces.append(TopoDS.Face_s(ex.Current()))
ex.Next()
# ── 2. Classify each face as cap or lateral ──
cap_faces: list = [] # (face, center_3d, top)
lateral_faces: list = [] # (face, index)
face_idx: Dict[Any, int] = {}
for idx, face in enumerate(all_faces):
face_idx[face] = idx
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
plane = surf.Plane()
fn = np.array(
[plane.Axis().Direction().X(), plane.Axis().Direction().Y(), plane.Axis().Direction().Z()],
dtype=float,
)
cos_angle = abs(float(np.dot(normal, fn)))
center = _face_center(face)
if cos_angle > 0.999:
cap_faces.append((face, center))
else:
lateral_faces.append((face, idx))
except Exception:
continue
# Determine top/bottom: sort cap faces by signed distance along normal,
# top = larger projection.
if cap_faces:
cap_projections = [
float(np.dot(np.asarray(c) - origin, normal)) for _, c in cap_faces
]
mid = (min(cap_projections) + max(cap_projections)) / 2.0
cap_faces = [(f, c, float(np.dot(np.asarray(c) - origin, normal)) > mid) for (f, c) in cap_faces]
# Build set of cap face shapes for lateral edge matching.
cap_face_shapes = {f for f, _, _ in cap_faces}
def _edges_of_face(face):
edges = []
ex2 = TopExp_Explorer(face, TopAbs_EDGE)
while ex2.More():
edges.append(TopoDS.Edge_s(ex2.Current()))
ex2.Next()
return edges
# ── 3. Helper: nearest sketch entity to a UV point ──
def _nearest_entity_id(uv_pt):
"""Find the ID of the sketch entity nearest to *uv_pt* in UV space."""
best_id = None
best_dist = float("inf")
ux, uy = uv_pt
# Check lines.
for line_id, (sid, eid2) in sketch._lines.items():
if line_id in sketch._external_entity_ids:
continue
ent = sketch._entities.get(line_id)
if ent is not None and ent.is_construction:
continue
s_ent = sketch._entities.get(sid)
e_ent = sketch._entities.get(eid2)
if not (s_ent and e_ent and s_ent.geometry and e_ent.geometry):
continue
d2 = _point_to_segment_dist_sq(
ux, uy,
float(s_ent.geometry[0]), float(s_ent.geometry[1]),
float(e_ent.geometry[0]), float(e_ent.geometry[1]),
)
if d2 < best_dist:
best_dist = d2
best_id = line_id
# Check circles.
for cid, (center_id, r) in sketch._circles.items():
if cid in sketch._external_entity_ids:
continue
ent = sketch._entities.get(cid)
if ent is not None and ent.is_construction:
continue
c_ent = sketch._entities.get(center_id)
if not (c_ent and c_ent.geometry):
continue
cx, cy = float(c_ent.geometry[0]), float(c_ent.geometry[1])
d2 = (math.sqrt((ux - cx) ** 2 + (uy - cy) ** 2) - float(r)) ** 2
if d2 < best_dist:
best_dist = d2
best_id = cid
# Check arcs.
for arc_id, arc_data in sketch._arcs.items():
ent = sketch._entities.get(arc_id)
if ent is not None and ent.is_construction:
continue
center_id = arc_data.get("center")
radius = arc_data.get("radius", 0.0)
c_ent = sketch._entities.get(center_id)
if not (c_ent and c_ent.geometry and radius > 0):
continue
cx, cy = float(c_ent.geometry[0]), float(c_ent.geometry[1])
d2 = (math.sqrt((ux - cx) ** 2 + (uy - cy) ** 2) - float(radius)) ** 2
if d2 < best_dist:
best_dist = d2
best_id = arc_id
return best_id
# ── 4. Build result ──
result: Dict[int, Dict[str, Any]] = {}
for face, center, top in cap_faces:
uv = _world_to_uv(center)
sketch_face = sketch.find_face_at(uv[0], uv[1])
eids = sketch_face.get("entity_ids", []) if sketch_face else []
result[face_idx[face]] = {"type": "cap", "top": top, "entity_ids": sorted(eids)}
for face, idx in lateral_faces:
# Find edges this lateral face shares with any cap face.
lat_edges = _edges_of_face(face)
cap_adj_edges = []
for le in lat_edges:
for cf in cap_face_shapes:
for ce in _edges_of_face(cf):
if le.IsSame(ce):
cap_adj_edges.append(le)
break
# Project midpoints of cap-adjacent edges to UV and match sketch entities.
eids: set = set()
for edge in cap_adj_edges:
try:
ac = BRepAdaptor_Curve(edge)
mid_param = (ac.FirstParameter() + ac.LastParameter()) / 2.0
mp = ac.Value(mid_param)
uv = _world_to_uv((mp.X(), mp.Y(), mp.Z()))
eid = _nearest_entity_id(uv)
if eid is not None:
eids.add(eid)
except Exception:
continue
# Fallback: if no cap-adjacent edges found (e.g., non-prismatic body),
# project the face's own boundary edges.
if not eids:
for edge in lat_edges:
try:
ac = BRepAdaptor_Curve(edge)
mid_param = (ac.FirstParameter() + ac.LastParameter()) / 2.0
mp = ac.Value(mid_param)
uv = _world_to_uv((mp.X(), mp.Y(), mp.Z()))
eid = _nearest_entity_id(uv)
if eid is not None:
eids.add(eid)
except Exception:
continue
result[idx] = {"type": "lateral", "entity_ids": sorted(eids)}
return result
# ── Fillet edge helpers ─────────────────────────────────────────────────────
@@ -1018,11 +1241,69 @@ def _resolve_edges_by_fingerprint(shape: Any, refs: List[str]) -> List[Any]:
return out
def _resolve_edges_by_face_keys(
shape: Any, feat: Feature, component: Optional[Any]
) -> Optional[List[Any]]:
"""Try to resolve fillet/chamfer edges using FaceKey classification.
Returns a list of seed edges if resolution succeeds, *None* if it
doesn't (so the caller can fall back to fingerprint matching).
"""
if feat.face_keys is None or len(feat.face_keys) != 1:
return None
if component is None or feat.face_keys_sketch_id is None:
return None
sk = component.sketches.get(feat.face_keys_sketch_id)
if sk is None or sk.occ_sketch is None:
return None
try:
face_map = _classify_extruded_faces(
shape,
sk.occ_sketch,
tuple(sk.workplane_origin.tolist()),
tuple(sk.workplane_normal.tolist()),
)
except Exception:
return None
key_a, key_b = feat.face_keys[0]
# Find matching faces by FaceKey content.
from OCP.TopoDS import TopoDS as _TopoDS
from OCP.TopExp import TopExp_Explorer as _TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE as _TopAbs_FACE
face_a = face_b = None
ex2 = _TopExp_Explorer(shape, _TopAbs_FACE)
idx = 0
while ex2.More():
face_obj = _TopoDS.Face_s(ex2.Current())
fk = face_map.get(idx)
if fk is not None:
if fk == key_a:
face_a = face_obj
if fk == key_b:
face_b = face_obj
idx += 1
ex2.Next()
if face_a is None or face_b is None:
return None
seed_edges = _shared_edges_between_faces(shape, face_a, face_b)
if not seed_edges:
return None
return _resolve_fillet_edges(shape, seed_edges, feat.tangent_propagation, feat.scope)
def _replay_body_features(
kernel: OCGeometryKernel,
body: Body,
features: List[Feature],
through_all_length_fn: Callable[[Any, Sketch], float],
component: Optional[Any] = None, # Component for FaceKey sketch lookup
) -> Optional[Any]:
"""Replay *features* in order and return the resulting geometry.
@@ -1047,19 +1328,28 @@ def _replay_body_features(
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:
if feat.scope == "all":
edges: Optional[List[Any]] = None # round every edge
elif feat.face_keys is not None:
# Try FaceKey resolution first (survives dimensional changes),
# fall back to edge fingerprints.
edges = _resolve_edges_by_face_keys(geom.shape, feat, component)
if edges is None:
edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
if not edges:
logger.warning(
f"Body '{body.name}': fillet edge refs unresolved after rebuild, "
"skipping fillet"
)
continue
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"
"skipping fillet"
)
return None
continue
geom = kernel.fillet(geom, feat.radius, edges=edges)
if geom is None:
return None
@@ -1073,20 +1363,26 @@ def _replay_body_features(
if feat.radius is None:
logger.warning(f"Body '{body.name}': chamfer feature has no size, replay aborted")
return None
if feat.scope == "all" or not feat.edge_refs:
edges = None # bevel every edge
if feat.scope == "all":
edges: Optional[List[Any]] = None # bevel every edge
elif feat.face_keys is not None:
edges = _resolve_edges_by_face_keys(geom.shape, feat, component)
if edges is None:
edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
if not edges:
logger.warning(
f"Body '{body.name}': chamfer edge refs unresolved after rebuild, "
"skipping chamfer"
)
continue
else:
edges = _resolve_edges_by_fingerprint(geom.shape, feat.edge_refs)
if not edges:
logger.warning(
f"Body '{body.name}': chamfer edge refs unresolved after rebuild, "
"replay aborted"
"skipping chamfer"
)
return None
geom = kernel.chamfer(geom, feat.radius, edges=edges)
if geom is None:
return None
continue
continue
if feat.operation in ("array", "pattern"):
# Pattern needs no sketch — it repeats the running solid.
@@ -1423,6 +1719,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 +2648,155 @@ 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,
component=self._current_component,
)
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
if feat.through_all:
pre_geom = _replay_body_features(
self._kernel, body, features[:index],
self._through_all_length_for_geometry,
component=self._current_component,
)
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.
@@ -2402,9 +2841,9 @@ class MainWindow(QMainWindow):
self._viewer_3d.clear_preview()
return
try:
# Replay features up to and including the selected one.
geom = _replay_body_features(
self._kernel, body, features[: index + 1], self._through_all_length_for_geometry
self._kernel, body, features[: index + 1], self._through_all_length_for_geometry,
component=self._current_component,
)
if geom is None:
self._viewer_3d.clear_preview()
@@ -2547,7 +2986,8 @@ class MainWindow(QMainWindow):
try:
new_geom = _replay_body_features(
self._kernel, body, features, self._through_all_length_for_geometry
self._kernel, body, features, self._through_all_length_for_geometry,
component=self._current_component,
)
except Exception as exc:
logger.exception(f"Body '{body.name}': feature replay failed: {exc}")
@@ -6431,6 +6871,51 @@ class MainWindow(QMainWindow):
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))
# ── Compute FaceKeys for stable replay across dimension changes ──
face_keys = None
face_keys_sketch_id = None
if scope != "all" and self._fillet_face1 is not None and self._fillet_face2 is not None:
# Find the last sketch-producing feature before this fillet.
sketch_feat: Optional[Feature] = None
for f in reversed(features):
if f.operation in ("extrude", "revolve", "cut", "union") and f.sketch is not None:
sketch_feat = f
break
if sketch_feat is not None and sketch_feat.sketch is not None:
sk = sketch_feat.sketch
if sk.occ_sketch is not None:
try:
face_map = _classify_extruded_faces(
shape,
sk.occ_sketch,
tuple(sk.workplane_origin.tolist()),
tuple(sk.workplane_normal.tolist()),
)
# Re-iterate faces in the same TopExp order to get
# actual face objects for IsSame comparison.
from OCP.TopoDS import TopoDS as _TopoDS
from OCP.TopExp import TopExp_Explorer as _TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE as _TopAbs_FACE
key_a = key_b = None
ex2 = _TopExp_Explorer(shape, _TopAbs_FACE)
idx = 0
while ex2.More():
face_obj = _TopoDS.Face_s(ex2.Current())
fk = face_map.get(idx)
if fk is not None:
if face_obj.IsSame(self._fillet_face1):
key_a = fk
if face_obj.IsSame(self._fillet_face2):
key_b = fk
idx += 1
ex2.Next()
if key_a is not None and key_b is not None:
face_keys = [(key_a, key_b)]
face_keys_sketch_id = sk.id
except Exception:
logger.debug("FaceKey classification failed for fillet", exc_info=True)
features.append(
Feature(
operation="fillet",
@@ -6438,6 +6923,8 @@ class MainWindow(QMainWindow):
tangent_propagation=tangent_propagation,
scope=scope,
edge_refs=[_edge_fingerprint(e) for e in (edges or [])],
face_keys=face_keys,
face_keys_sketch_id=face_keys_sketch_id,
)
)
@@ -6610,6 +7097,48 @@ class MainWindow(QMainWindow):
features = _ensure_feature_history(body)
if not features and body.geometry is not None:
features.append(Feature(operation="base", geometry=body.geometry))
# ── Compute FaceKeys for stable replay across dimension changes ──
face_keys = None
face_keys_sketch_id = None
if scope != "all" and self._chamfer_face1 is not None and self._chamfer_face2 is not None:
sketch_feat: Optional[Feature] = None
for f in reversed(features):
if f.operation in ("extrude", "revolve", "cut", "union") and f.sketch is not None:
sketch_feat = f
break
if sketch_feat is not None and sketch_feat.sketch is not None:
sk = sketch_feat.sketch
if sk.occ_sketch is not None:
try:
face_map = _classify_extruded_faces(
shape,
sk.occ_sketch,
tuple(sk.workplane_origin.tolist()),
tuple(sk.workplane_normal.tolist()),
)
from OCP.TopoDS import TopoDS as _TopoDS
from OCP.TopExp import TopExp_Explorer as _TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE as _TopAbs_FACE
key_a = key_b = None
ex2 = _TopExp_Explorer(shape, _TopAbs_FACE)
idx = 0
while ex2.More():
face_obj = _TopoDS.Face_s(ex2.Current())
fk = face_map.get(idx)
if fk is not None:
if face_obj.IsSame(self._chamfer_face1):
key_a = fk
if face_obj.IsSame(self._chamfer_face2):
key_b = fk
idx += 1
ex2.Next()
if key_a is not None and key_b is not None:
face_keys = [(key_a, key_b)]
face_keys_sketch_id = sk.id
except Exception:
logger.debug("FaceKey classification failed for chamfer", exc_info=True)
features.append(
Feature(
operation="chamfer",
@@ -6617,6 +7146,8 @@ class MainWindow(QMainWindow):
tangent_propagation=tangent_propagation,
scope=scope,
edge_refs=[_edge_fingerprint(e) for e in (edges or [])],
face_keys=face_keys,
face_keys_sketch_id=face_keys_sketch_id,
)
)
@@ -6747,6 +7278,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 +7528,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)
+5 -2
View File
@@ -260,6 +260,7 @@ class Sketch2DWidget(QWidget):
self._move_anchor_orig: Optional[QPoint] = None
self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
self._move_active: bool = False
self._move_did_move: bool = False
# Auto-constraint tracking on snap
self._snap_point_target: Optional[OCCSketchEntity] = None
@@ -811,6 +812,7 @@ class Sketch2DWidget(QWidget):
self._move_anchor_orig = None
self._move_orig_positions = {}
self._move_active = False
self._move_did_move = False
self._hovered_face = None
self._snap_point_target = None
self._snap_line_target = None
@@ -2317,6 +2319,8 @@ class Sketch2DWidget(QWidget):
target_world = self._screen_to_world(snapped_screen)
dx = target_world.x() - self._move_anchor_orig.x()
dy = target_world.y() - self._move_anchor_orig.y()
if dx != 0 or dy != 0:
self._move_did_move = True
for ent in self._moving_points:
if ent.id in self._move_orig_positions and ent.geometry is not None:
ox, oy = self._move_orig_positions[ent.id]
@@ -2514,8 +2518,7 @@ class Sketch2DWidget(QWidget):
# the user expects from dragging a single corner.
if self._move_anchor is not None and self._move_anchor.geometry is not None:
ax, ay = self._move_anchor.geometry
if not self._sketch.is_entity_dragged(self._move_anchor.id):
self._sketch.constrain_fixed(self._move_anchor)
if self._move_did_move and not self._sketch.is_entity_dragged(self._move_anchor.id):
# constrain_fixed reads the current params via
# the dragged() call, so re-sync to be safe.
self._solve_and_sync()
+29
View File
@@ -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: