diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index ea9e0b1..3ee8ff9 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,12 +4,11 @@
-
+
-
-
+
@@ -322,7 +321,15 @@
1783174566362
-
+
+
+ 1783239410744
+
+
+
+ 1783239410744
+
+
@@ -365,6 +372,7 @@
-
+
+
\ No newline at end of file
diff --git a/src/fluency/main.py b/src/fluency/main.py
index 1b76261..22d2ba7 100644
--- a/src/fluency/main.py
+++ b/src/fluency/main.py
@@ -3547,11 +3547,14 @@ class Viewer3DWidget(QWidget):
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal()
- # Emitted when the user picks a face for a connector point (assembly).
- # Payload: (origin, normal, x_dir, face_shape, owner_obj_id).
- connectorPicked = Signal(tuple, tuple, tuple, object, str)
+ # Emitted when the user picks an entity for a connector point (assembly).
+ # Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
+ connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
# Emitted when connector pick mode is cancelled.
connectorPickCancelled = Signal()
+ # Emitted on mouse move in connector mode to show snap preview.
+ # Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
+ connectorHover = Signal(object)
# Emitted when a body is clicked in assembly move mode.
# Payload: owner_obj_id.
@@ -3569,6 +3572,11 @@ class Viewer3DWidget(QWidget):
self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
self.setFocusPolicy(Qt.StrongFocus)
+ # Enable mouse tracking so ``mouseMoveEvent`` fires even without a
+ # button held — required for the connector-pick hover gizmo (and any
+ # status-bar hover feedback) to show under the cursor as the user
+ # moves the mouse over candidate snap entities before clicking.
+ self.setMouseTracking(True)
# Try OCC renderer first; fall back to pygfx if unavailable.
self._renderer: Any = None
self._initialized = False
@@ -3579,9 +3587,11 @@ class Viewer3DWidget(QWidget):
# When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False
- # When True, a left-click picks a face for a connector point
+ # When True, a left-click picks an entity for a connector point
# (assembly component connection).
self._connector_pick_mode: bool = False
+ # Current snap highlight object id (for hover during connector mode).
+ self._connector_snap_id: Optional[str] = None
# When True, left-click on a body activates assembly drag-to-move.
self._assembly_move_mode: bool = False
# State for ongoing assembly drag.
@@ -3830,8 +3840,13 @@ class Viewer3DWidget(QWidget):
def mouseMoveEvent(self, event):
self._ensure_initialized()
- # In face-pick/connector mode, keep dynamic highlighting.
- if self._pick_face_mode or self._connector_pick_mode:
+ # In connector mode, show snap hover.
+ if self._connector_pick_mode:
+ self._handle_connector_hover(event)
+ super().mouseMoveEvent(event)
+ return
+ # In face-pick mode, keep dynamic highlighting.
+ if self._pick_face_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
@@ -3914,37 +3929,170 @@ class Viewer3DWidget(QWidget):
def set_connector_pick_mode(self, enabled: bool) -> None:
"""Toggle connector pick mode for placing connection points.
- When enabled, clicking a face on a body in the assembly view
- captures its origin and normal as a connection point for the
- SolveSpace solver.
+ When enabled, clicking an entity (face, edge, vertex, hole)
+ on a body in the assembly view captures its position and
+ direction as a connection point for the SolveSpace solver.
"""
self._connector_pick_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
elif not self._pick_face_mode:
self.unsetCursor()
+ if not enabled:
+ self._clear_connector_snap()
def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode
- def _handle_connector_pick(self, event) -> None:
- """Detect a planar face under the click and emit connectorPicked."""
+ def _clear_connector_snap(self) -> None:
+ """Remove the hover gizmo."""
+ fn = getattr(self._renderer, "clear_entity_gizmo", None)
+ if fn is not None:
+ fn()
+ # Backwards compat: also try the old method.
+ if self._connector_snap_id is not None:
+ fn2 = getattr(self._renderer, "remove_highlight_snap", None)
+ if fn2 is not None:
+ fn2(self._connector_snap_id)
+ self._connector_snap_id = None
+
+ def _handle_connector_hover(self, event) -> None:
+ """Update the hover snap gizmo during connector pick mode.
+
+ Probes a small neighbourhood around the cursor for ALL nearby snap
+ candidates (vertices, edge midpoints, face centres, hole openings)
+ and renders a dim marker on each plus a bright primary on the nearest
+ one — the general snap indicator. Clicking then selects the
+ primary's position.
+ """
self._ensure_initialized()
- picker = getattr(self._renderer, "pick_planar_face", None)
- if picker is None:
- logger.warning("Renderer has no pick_planar_face support")
- return
+ probe = getattr(self._renderer, "probe_snap_candidates", None)
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
- info = picker(pos.x(), pos.y())
+
+ if probe is not None:
+ candidates = probe(pos.x(), pos.y())
+ if not candidates:
+ self._clear_connector_snap()
+ self.connectorHover.emit(None)
+ return
+ # Primary = the nearest candidate (probe sorts nearest-first).
+ info = candidates[0]
+ else:
+ # Fall back to single-pixel pick on renderers without the probe.
+ picker = getattr(self._renderer, "pick_entity", None)
+ if picker is None:
+ return
+ info = picker(pos.x(), pos.y())
+ candidates = [info] if info else []
+ if info is None or info.get("owner_obj_id") is None:
+ self._clear_connector_snap()
+ self.connectorHover.emit(None)
+ return
+
+ origin = info["position"]
+ normal = info.get("normal")
+ entity_type = info["type"]
+ owner = info.get("owner_obj_id", "")
+
+ # Show smart entity gizmo — dim candidate markers + bright primary.
+ self._clear_connector_snap()
+ gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
+ if gizmo_fn is not None:
+ gizmo_fn(
+ entity_type=entity_type,
+ position=origin,
+ normal=normal,
+ x_dir=info.get("x_dir"),
+ radius=info.get("radius"),
+ candidates=candidates,
+ )
+ else:
+ # Fallback to old highlight_snap.
+ fn = getattr(self._renderer, "highlight_snap", None)
+ if fn is not None:
+ colors = {
+ "planar_face": (0.0, 0.8, 1.0), # cyan
+ "cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
+ "edge": (0.0, 1.0, 0.4), # green
+ "vertex": (1.0, 1.0, 0.0), # yellow
+ }
+ c = colors.get(entity_type, (1.0, 0.6, 0.0))
+ self._connector_snap_id = fn(origin, color=c, size=3.0)
+
+ self.connectorHover.emit({
+ "origin": origin,
+ "normal": normal,
+ "type": entity_type,
+ "owner_obj_id": owner,
+ })
+
+ def _handle_connector_pick(self, event) -> None:
+ """Detect an entity under the click and emit connectorPicked.
+
+ Uses the multi-pixel ``probe_snap_candidates`` so a click selects the
+ PRIMARY (nearest) snap target — the same one the hover gizmo
+ emphasised. Falls back to single-pixel ``pick_entity`` then to
+ ``pick_planar_face`` on renderers without the probe.
+ """
+ self._ensure_initialized()
+ pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
+ info: Optional[Dict[str, Any]] = None
+
+ probe = getattr(self._renderer, "probe_snap_candidates", None)
+ if probe is not None:
+ candidates = probe(pos.x(), pos.y())
+ if candidates:
+ info = candidates[0] # nearest = primary
+
if info is None:
- logger.info("Connector pick: no planar face under cursor")
+ picker = getattr(self._renderer, "pick_entity", None)
+ if picker is None:
+ # Fallback to planar face only.
+ picker = getattr(self._renderer, "pick_planar_face", None)
+ if picker is None:
+ logger.warning("Renderer has no entity picking support")
+ return
+ pinfo = picker(pos.x(), pos.y())
+ if pinfo is None:
+ logger.info("Connector pick: no planar face under cursor")
+ return
+ owner_obj_id = pinfo.get("owner_obj_id", "")
+ self.connectorPicked.emit(
+ tuple(pinfo["origin"]),
+ tuple(pinfo["normal"]),
+ tuple(pinfo["x_dir"]),
+ "planar_face",
+ pinfo["face"],
+ owner_obj_id,
+ )
+ return
+ info = picker(pos.x(), pos.y())
+
+ if info is None:
+ logger.info("Connector pick: no entity under cursor")
return
owner_obj_id = info.get("owner_obj_id", "")
+ if not owner_obj_id:
+ return
+
+ entity_type = info["type"]
+ origin = info["position"]
+ normal = info.get("normal") or (0.0, 0.0, 1.0)
+ x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
+
+ # For vertices, pick a sensible normal from the parent face if possible.
+ if entity_type == "vertex" and normal is None:
+ normal = (0.0, 0.0, 1.0)
+
+ # Package the raw shape appropriately.
+ raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
+
self.connectorPicked.emit(
- tuple(info["origin"]),
- tuple(info["normal"]),
- tuple(info["x_dir"]),
- info["face"],
+ tuple(origin),
+ tuple(normal),
+ tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
+ entity_type,
+ raw_shape,
owner_obj_id,
)
@@ -4199,6 +4347,14 @@ class MainWindow(QMainWindow):
# Drag-move state for assembly components
self._asm_move_ac_id: Optional[str] = None
self._asm_move_start_pos: Any = None
+ # Rigid-group drag state: maps every component id in the dragged
+ # rigid group to its start position, so the whole group translates
+ # together and connected partners keep their solved relative
+ # transforms. Keyed by AssemblyComponent.id.
+ self._asm_move_group_start: Dict[str, Any] = {}
+ # Cached rigid-group membership for the current drag (avoids recomputing
+ # the BFS graph on every mouse-move event).
+ self._asm_move_group_ids: List[str] = []
# Cache of render object IDs per assembly component, so drag updates
# can replace only the moved component's shapes without clearing the
# entire scene (avoids camera flicker).
@@ -4520,6 +4676,7 @@ class MainWindow(QMainWindow):
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
self._btn_del_connector.clicked.connect(self._on_delete_connector)
self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
+ self._viewer_3d.connectorHover.connect(self._on_connector_hover)
self._viewer_3d.connectorPickCancelled.connect(
lambda: self._btn_add_connector.setChecked(False)
)
@@ -5068,7 +5225,12 @@ class MainWindow(QMainWindow):
def _on_assembly_move_activated(self, owner_obj_id: str):
"""Called when the user clicks a body in move mode.
- Parse the assembly component id and store its starting position.
+ Parse the assembly component id, compute the rigid group it belongs
+ to (transitively via mated connectors), and snapshot EVERY member's
+ start position so the whole group can translate together during the
+ drag. The first-picked component of each mated pair stays as the
+ grounded reference frame for the solver; for a pure-translation
+ drag that just means we preserve all current relative transforms.
"""
import numpy as np
@@ -5082,14 +5244,26 @@ class MainWindow(QMainWindow):
return
self._asm_move_ac_id = ac_id
+ # Rigid group membership (BFS over mated-connector connections).
+ group_ids = assembly.get_rigid_group(ac_id)
+ self._asm_move_group_ids = group_ids
+ self._asm_move_group_start = {}
+ for gid in group_ids:
+ g_ac = assembly.components.get(gid)
+ if g_ac is not None:
+ self._asm_move_group_start[gid] = np.array(g_ac.position, dtype=float)
+ # Keep the legacy single-component start for backwards compatibility.
self._asm_move_start_pos = np.array(ac.position, dtype=float)
def _on_assembly_move_dragged(self, owner_obj_id: str, dx: float, dy: float, dz: float):
- """Called during a drag move. Update only the dragged component in-place.
+ """Propagate a drag move across the entire rigid group, in-place.
- Uses the targeted ``_update_assembly_component_in_viewer`` instead of
- a full ``clear_scene`` + rebuild, so other shapes keep their render
- objects and the camera stays perfectly still.
+ Every component in the dragged rigid group receives the SAME world
+ translation delta (relative to its own start position), so the mated
+ relative transforms are preserved exactly and SolveSpace's solved
+ alignment stays valid throughout the drag. Each member is updated
+ in-place via ``_update_assembly_component_in_viewer`` so the camera
+ never flickers.
"""
if self._asm_move_ac_id is None or self._asm_move_start_pos is None:
return
@@ -5101,17 +5275,32 @@ class MainWindow(QMainWindow):
return
import numpy as np
- # Apply delta relative to the start position.
- ac.position = self._asm_move_start_pos + np.array([dx, dy, dz])
- # Only update the dragged component's shapes — no scene clear.
- self._update_assembly_component_in_viewer(ac_id)
+ delta = np.array([dx, dy, dz], dtype=float)
+ # Propagate the same delta to every rigid-group member.
+ group_ids = self._asm_move_group_ids or [ac_id]
+ for gid in group_ids:
+ start = self._asm_move_group_start.get(gid)
+ if start is None:
+ continue
+ g_ac = assembly.components.get(gid)
+ if g_ac is None:
+ continue
+ g_ac.position = start + delta
+ # Update only this component's shapes — no scene clear.
+ self._update_assembly_component_in_viewer(gid)
def _on_assembly_move_finished(self, owner_obj_id: str):
"""Finalize the drag move."""
if self._asm_move_ac_id is not None:
- logger.info(f"Moved assembly component {self._asm_move_ac_id} to final position")
+ members = len(self._asm_move_group_ids) if self._asm_move_group_ids else 1
+ logger.info(
+ f"Moved assembly rigid group led by {self._asm_move_ac_id} "
+ f"({members} member(s)) to final position"
+ )
self._asm_move_ac_id = None
self._asm_move_start_pos = None
+ self._asm_move_group_start = {}
+ self._asm_move_group_ids = []
# ────────────────────────────────────────────────────────────────────
# Connector methods — two-click selection + preview dialog
@@ -5133,8 +5322,8 @@ class MainWindow(QMainWindow):
def _on_start_connector_placement(self, checked: bool):
"""Toggle connector pick mode.
- First click selects the first component's connection face.
- Second click selects the second component and triggers alignment.
+ First click selects the first component's connection entity.
+ Second click selects the second component and triggers SolveSpace alignment.
"""
if not self._assembly_view_active:
self._btn_add_connector.setChecked(False)
@@ -5145,17 +5334,49 @@ class MainWindow(QMainWindow):
# Reset any in-progress two-click state.
self._connector_first_pick = None
self._connector_second_ac_id = None
+ self._connector_align_pos = None
self._viewer_3d.set_connector_pick_mode(checked)
if checked:
self._viewer_3d.setFocus()
self._viewer_3d.activateWindow()
- self.setStatusTip("Click on the first component's connection face")
+ self.setStatusTip("Click on the first component's connection point/face/edge/hole")
else:
self.setStatusTip("")
- def _on_connector_picked(self, origin, normal, x_dir, face_shape, owner_obj_id):
- """Handle a connector face pick — first or second click."""
+ def _on_connector_hover(self, info) -> None:
+ """Show entity-type feedback in the status bar during connector pick.
+
+ The gizmo itself is drawn by the viewer; this just reports what
+ entity is under the cursor so the user knows what they will snap to.
+ """
+ if info is None:
+ self.statusBar().showMessage("Move over a face / edge / hole / vertex to snap")
+ return
+ entity_type = info.get("type", "")
+ names = {
+ "planar_face": "Face",
+ "cylindrical_face": "Hole",
+ "edge": "Edge",
+ "vertex": "Vertex",
+ }
+ name = names.get(entity_type, "Entity")
+ ac_id = self._parse_ac_id(info.get("owner_obj_id", ""))
+ comp_name = ""
+ if ac_id is not None:
+ assembly = self._get_assembly()
+ ac = assembly.components.get(ac_id) if assembly else None
+ if ac is not None:
+ comp_name = f" on {ac.name}"
+ self.statusBar().showMessage(f"Snap target: {name}{comp_name} — click to pick")
+
+ def _on_connector_picked(self, origin, normal, x_dir, entity_type, raw_shape, owner_obj_id):
+ """Handle a connector entity pick — first or second click.
+
+ Snaps to faces, cylindrical holes, edges, or vertices.
+ Stores connector in component-local coordinates so it stays
+ valid when the component is moved by the solver.
+ """
import numpy as np
ac_id = self._parse_ac_id(owner_obj_id)
@@ -5171,19 +5392,37 @@ class MainWindow(QMainWindow):
"The clicked component was not found in the assembly.")
return
+ # Convert world-space connector to component-local coordinates.
+ # p_local = R^T @ (p_world - P)
+ pos_world = np.array(origin, dtype=float)
+ rot = ac.rotation
+ pos_local = rot.T @ (pos_world - ac.position)
+
+ n_world = np.array(normal, dtype=float)
+ n_local = rot.T @ n_world
+ n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
+
+ x_world = np.array(x_dir, dtype=float) if x_dir else np.array([1.0, 0.0, 0.0])
+ x_local = rot.T @ x_world
+ x_local = x_local / max(np.linalg.norm(x_local), 1e-12)
+
# ── First pick ──
if self._connector_first_pick is None:
self._connector_first_pick = {
"ac_id": ac_id,
- "origin": tuple(origin),
- "normal": tuple(normal),
- "x_dir": tuple(x_dir),
+ "origin_local": tuple(pos_local),
+ "normal_local": tuple(n_local),
+ "x_dir_local": tuple(x_local),
+ "origin_world": tuple(origin),
+ "normal_world": tuple(normal),
+ "entity_type": entity_type,
"owner_obj_id": owner_obj_id,
}
- # Highlight the first face.
- self._viewer_3d.highlight_face(face_shape)
- self.setStatusTip("Now click on the second component's connection face")
- logger.info(f"Connector first pick: {ac.name} at {origin}")
+ # Highlight the first face if planar.
+ if entity_type in ("planar_face", "cylindrical_face"):
+ self._viewer_3d.highlight_face(raw_shape)
+ self.setStatusTip("Now click on the second component's connection point/face/edge/hole")
+ logger.info(f"Connector first pick: {ac.name} at {origin} ({entity_type})")
return
# ── Second pick ──
@@ -5201,99 +5440,350 @@ class MainWindow(QMainWindow):
self._btn_add_connector.setChecked(False)
self.setStatusTip("")
- logger.info(f"Connector second pick: {ac.name} at {origin}")
+ logger.info(f"Connector second pick: {ac.name} at {origin} ({entity_type})")
- # Compute rough alignment transform (simulating SolveSpace).
- # Move the second component so its connector face aligns with the
- # first: translate so origin2 → origin1.
- o1 = np.array(first["origin"])
- o2 = np.array(origin)
- align_delta = o1 - o2
+ # Build connector records (local coords).
+ second_pick = {
+ "ac_id": ac_id,
+ "origin_local": tuple(pos_local),
+ "normal_local": tuple(n_local),
+ "x_dir_local": tuple(x_local),
+ "origin_world": tuple(origin),
+ "normal_world": tuple(normal),
+ "entity_type": entity_type,
+ "owner_obj_id": owner_obj_id,
+ }
- # Store the alignment position on the second component temporarily.
- ac2 = assembly.components.get(ac_id)
- if ac2 is not None:
- self._connector_align_pos = np.array(ac2.position, dtype=float) + align_delta
- else:
- self._connector_align_pos = align_delta
+ # SolveSpace alignment: move second component so its connector
+ # aligns with the first. First component is fixed.
+ first_ac = assembly.components.get(first["ac_id"])
+ second_ac = ac
- # Show the connector dialog with live preview callback.
- rotation, offset = self._show_connector_dialog_with_preview(
- first_ac=assembly.components.get(first["ac_id"]),
- second_ac=ac2,
- first_origin=first["origin"],
- second_origin=o2,
- first_normal=first["normal"],
- second_normal=normal,
- first_x_dir=first["x_dir"],
- second_x_dir=x_dir,
- align_delta=align_delta,
+ # Compute the world target for the second connector.
+ # It's at the first connector world position.
+ target_pos = np.array(first["origin_world"], dtype=float)
+ target_normal = np.array(first["normal_world"], dtype=float)
+ target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
+
+ # SolveSpace solver call.
+ solved = self._solve_assembly_alignment(
+ first_ac=first_ac,
+ second_ac=second_ac,
+ first_pick=first,
+ second_pick=second_pick,
+ )
+
+ if solved is None:
+ QMessageBox.warning(self, "Solver Error",
+ "SolveSpace could not align the components.")
+ self._connector_first_pick = None
+ self._connector_second_ac_id = None
+ self._show_assembly_in_viewer(fit=True)
+ return
+
+ # Apply solved transform to second component.
+ second_ac.position = solved["position"]
+ second_ac.rotation = solved["rotation"]
+
+ # Show dialog with live preview (rotation offset along normal).
+ rotation, offset, flip = self._show_connector_dialog_with_preview(
+ first_ac=first_ac,
+ second_ac=second_ac,
+ first_pick=first,
+ second_pick=second_pick,
+ solved=solved,
)
if rotation is None:
# User cancelled — restore original position.
- if ac2 is not None:
- self._show_assembly_in_viewer()
+ second_ac.position = np.array(solved["original_position"], dtype=float)
+ second_ac.rotation = np.array(solved["original_rotation"], dtype=float)
self._connector_first_pick = None
self._connector_second_ac_id = None
+ self._show_assembly_in_viewer(fit=True)
return
- # Create connectors on both components.
- first_ac = assembly.components.get(first["ac_id"])
+ # Apply dialog adjustments (rotation + offset + flip).
+ import numpy as np
+ # Build rotation matrix: rotate second connector normal around
+ # the target normal axis by rotation degrees.
+ angle_rad = np.radians(rotation)
+ # Rodrigues' rotation formula around target_normal.
+ k = target_normal
+ K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
+ R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
+
+ # Apply axis rotation to the solved rotation.
+ second_ac.rotation = R_axis @ second_ac.rotation
+
+ # Offset along the (possibly flipped) target normal.
+ flip_sign = -1.0 if flip else 1.0
+ second_ac.position = second_ac.position + flip_sign * target_normal * offset
+
+ # Create connectors on both components and link them as a mated pair.
+ conn1 = None
+ conn2 = None
if first_ac:
conn1 = first_ac.add_connector(
- position=first["origin"],
- normal=first["normal"],
- x_dir=first["x_dir"],
+ position=first["origin_world"],
+ normal=first["normal_world"],
+ x_dir=first["x_dir_local"],
source_obj_id=first["owner_obj_id"],
- name="Connector A",
+ name=f"Conn {entity_type} A",
)
conn1.axis_rotation = rotation
conn1.offset = offset
+ # The first-picked connector is the grounded reference of the pair.
+ conn1.is_grounded = True
- if ac2:
- conn2 = ac2.add_connector(
- position=tuple(o2),
- normal=tuple(normal),
- x_dir=tuple(x_dir),
+ if second_ac:
+ conn2 = second_ac.add_connector(
+ position=tuple(second_ac.position + second_ac.rotation @ np.array(second_pick["origin_local"])),
+ normal=tuple(second_ac.rotation @ np.array(second_pick["normal_local"])),
+ x_dir=tuple(second_ac.rotation @ np.array(second_pick["x_dir_local"])),
source_obj_id=owner_obj_id,
- name="Connector B",
+ name=f"Conn {entity_type} B",
)
conn2.axis_rotation = rotation
conn2.offset = offset
- # Apply the aligned position (with dialog adjustments).
- n2 = np.array(normal) / max(np.linalg.norm(normal), 1e-12)
- ac2.position = self._connector_align_pos + n2 * offset
- logger.info(f"Aligned '{ac2.name}' to connector position, offset={offset}mm")
+ # Cross-link the partners so the rigid-group move handler can follow
+ # the edge, and register the pair on the assembly graph.
+ if conn1 is not None and conn2 is not None:
+ conn1.partner_ac_id = second_ac.id
+ conn1.partner_connector_id = conn2.id
+ conn2.partner_ac_id = first_ac.id
+ conn2.partner_connector_id = conn1.id
+ assembly.add_connection(first_ac.id, second_ac.id)
- logger.info(f"Connected component pair: {first['ac_id']} ↔ {ac_id}, rotation={rotation}°")
+ logger.info(f"Connected component pair: {first['ac_id']} ↔ {ac_id}, rotation={rotation}°, offset={offset}mm, flip={flip}")
self._connector_first_pick = None
self._connector_second_ac_id = None
self._show_assembly_in_viewer(fit=True)
+ @staticmethod
+ def _rotation_between_vectors(a, b):
+ """Return a 3×3 rotation that maps vector *a* onto vector *b*.
+
+ Handles the two degenerate cases that plain Rodrigues' formula gets
+ wrong when the cross-product axis collapses to zero:
+
+ * ``a ≈ b`` → identity (no rotation needed).
+ * ``a ≈ -b`` → a 180° rotation about any axis orthogonal to *a*
+ (picked by a stable reference-vector projection).
+
+ Vectors are internally normalized so callers may pass non-unit input.
+ """
+ import numpy as _np
+ import math as _math
+ a = _np.asarray(a, dtype=float)
+ b = _np.asarray(b, dtype=float)
+ an = _np.linalg.norm(a); bn = _np.linalg.norm(b)
+ if an < 1e-12 or bn < 1e-12:
+ return _np.eye(3)
+ a = a / an; b = b / bn
+ dot = float(_np.dot(a, b))
+ cross = _np.cross(a, b)
+ cross_norm = _np.linalg.norm(cross)
+ if cross_norm < 1e-9:
+ if dot > 0.0:
+ # Already aligned.
+ return _np.eye(3)
+ # Anti-parallel: 180° about an axis orthogonal to *a*.
+ ref = _np.array([1.0, 0.0, 0.0]) if abs(a[0]) < 0.9 else _np.array([0.0, 1.0, 0.0])
+ axis = ref - a * _np.dot(ref, a)
+ axis = axis / max(_np.linalg.norm(axis), 1e-12)
+ K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
+ # sin(180°)=0, 1-cos(180°)=2 → R = I + 2 (K @ K)
+ return _np.eye(3) + 2.0 * (K @ K)
+ axis = cross / cross_norm
+ angle = _math.acos(max(-1.0, min(1.0, dot)))
+ K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
+ return _np.eye(3) + _np.sin(angle) * K + (1.0 - _np.cos(angle)) * (K @ K)
+
+ def _solve_assembly_alignment(
+ self,
+ first_ac: Any,
+ second_ac: Any,
+ first_pick: Dict[str, Any],
+ second_pick: Dict[str, Any],
+ ) -> Optional[Dict[str, Any]]:
+ """Use SolveSpace to align the second component to the first.
+
+ The first component is treated as fixed (grounded). The second
+ component is moved so that its connector coincides with the first
+ connector (position + normal alignment).
+
+ Returns a dict with:
+ * ``position`` — new world position for second component.
+ * ``rotation`` — new 3×3 rotation matrix for second component.
+ * ``original_position`` / ``original_rotation`` — for cancellation.
+ """
+ import numpy as np
+ try:
+ from python_solvespace import SolverSystem, ResultFlag, Entity
+ except ImportError:
+ logger.warning("python_solvespace not available, falling back to direct alignment")
+ return self._align_direct(first_ac, second_ac, first_pick, second_pick)
+
+ # Save original transform for cancellation.
+ orig_pos = np.array(second_ac.position, dtype=float)
+ orig_rot = np.array(second_ac.rotation, dtype=float)
+
+ # World positions of connectors.
+ p1_world = np.array(first_pick["origin_world"], dtype=float)
+ n1_world = np.array(first_pick["normal_world"], dtype=float)
+ n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12)
+
+ p2_local = np.array(second_pick["origin_local"], dtype=float)
+ n2_local = np.array(second_pick["normal_local"], dtype=float)
+ n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12)
+
+ # Build solver.
+ #
+ # IMPORTANT: SolveSpace's SLVS_C_PARALLEL and SLVS_C_SAME_ORIENTATION
+ # both generate multi-equation residuals that trigger a hard C-level
+ # assertion in this python_solvespace build's Newton iterator
+ # ("Expected constraint to generate a single equation"), aborting the
+ # whole process. We therefore avoid line-parallel / orientation
+ # constraints entirely and instead drive BOTH translation AND axis
+ # alignment with a pair of coincident point constraints:
+ #
+ # * coincident(pt1, pt2) — forces the connector points together
+ # (3 translational DOF)
+ # * coincident(pt1b, tip2) — pins the *axis tip* of component 2
+ # onto a fixed point on component 1's
+ # connector axis, which forces the
+ # rotated axis direction to align
+ # with n1 (2 rotational DOF)
+ #
+ # That's 6 single-equation-coincident residuals against 6 free point
+ # parameters — a well-posed 0-DOF system — so it converges cleanly.
+ # The remaining free rotation around the axis is left for the
+ # rotation_spinner in the dialog.
+ sys = SolverSystem()
+
+ # Component 1 reference frame — fully grounded (dragged). pt1 is the
+ # connector pivot, pt1b is one unit along the connector normal.
+ pt1 = sys.add_point_3d(float(p1_world[0]), float(p1_world[1]), float(p1_world[2]))
+ sys.dragged(pt1, Entity.FREE_IN_3D)
+ pt1b = sys.add_point_3d(
+ float(p1_world[0] + n1_world[0]),
+ float(p1_world[1] + n1_world[1]),
+ float(p1_world[2] + n1_world[2]),
+ )
+ sys.dragged(pt1b, Entity.FREE_IN_3D)
+
+ # Component 2 — free points, seeded near the current world connector.
+ p2_world_current = orig_pos + orig_rot @ p2_local
+ pt2 = sys.add_point_3d(float(p2_world_current[0]), float(p2_world_current[1]), float(p2_world_current[2]))
+ n2_world_current = orig_rot @ n2_local
+ tip2 = sys.add_point_3d(
+ float(p2_world_current[0] + n2_world_current[0]),
+ float(p2_world_current[1] + n2_world_current[1]),
+ float(p2_world_current[2] + n2_world_current[2]),
+ )
+
+ # Constraints: pivot coincidence + axis-tip coincidence.
+ sys.coincident(pt1, pt2, Entity.FREE_IN_3D)
+ sys.coincident(pt1b, tip2, Entity.FREE_IN_3D)
+
+ # Solve.
+ result = sys.solve()
+ if result != ResultFlag.OKAY:
+ logger.warning(f"SolveSpace solve failed: {result}")
+ return self._align_direct(first_ac, second_ac, first_pick, second_pick)
+
+ # Extract solved positions from the point entities' parameter tables.
+ # ``Entity`` does not expose .x/.y/.z — read them via SolverSystem.params.
+ p2_solved = np.array(sys.params(pt2.params), dtype=float)
+ tip2_solved = np.array(sys.params(tip2.params), dtype=float)
+ n2_solved = tip2_solved - p2_solved
+ n2_solved = n2_solved / max(np.linalg.norm(n2_solved), 1e-12)
+
+ # Compute the new component transform.
+ # The second connector in local coords is at p2_local with normal n2_local.
+ # In world space: P + R @ p2_local = p2_solved
+ # R @ n2_local = n2_solved
+ # We need to find R and P.
+
+ # R must map n2_local → n2_solved.
+ # Use a rotation that aligns the two vectors.
+ from OCP.gp import gp_Vec, gp_Dir, gp_Ax1, gp_Trsf
+ # Compute the rotation mapping the connector's local axis to its
+ # solved world direction. Use the robust helper so the degenerate
+ # anti-parallel case (cross → 0 but angle = 180°) is handled properly.
+ R_align = self._rotation_between_vectors(n2_local, n2_solved)
+
+ # The full rotation for the component.
+ new_rot = R_align @ orig_rot
+
+ # New position: P = p2_solved - R @ p2_local
+ new_pos = p2_solved - new_rot @ p2_local
+
+ return {
+ "position": new_pos,
+ "rotation": new_rot,
+ "original_position": orig_pos,
+ "original_rotation": orig_rot,
+ }
+
+ def _align_direct(
+ self,
+ first_ac: Any,
+ second_ac: Any,
+ first_pick: Dict[str, Any],
+ second_pick: Dict[str, Any],
+ ) -> Optional[Dict[str, Any]]:
+ """Direct geometric alignment (fallback when SolveSpace unavailable).
+
+ Moves the second component so its connector matches the first.
+ """
+ import numpy as np
+ orig_pos = np.array(second_ac.position, dtype=float)
+ orig_rot = np.array(second_ac.rotation, dtype=float)
+
+ p1_world = np.array(first_pick["origin_world"], dtype=float)
+ n1_world = np.array(first_pick["normal_world"], dtype=float)
+ n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12)
+
+ p2_local = np.array(second_pick["origin_local"], dtype=float)
+ n2_local = np.array(second_pick["normal_local"], dtype=float)
+ n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12)
+
+ # Align normals through the robust rotation helper so the
+ # anti-parallel case is handled correctly (see _rotation_between_vectors).
+ R_align = self._rotation_between_vectors(n2_local, n1_world)
+
+ new_rot = R_align @ orig_rot
+ p2_world_target = p1_world
+ new_pos = p2_world_target - new_rot @ p2_local
+
+ return {
+ "position": new_pos,
+ "rotation": new_rot,
+ "original_position": orig_pos,
+ "original_rotation": orig_rot,
+ }
+
def _show_connector_dialog_with_preview(
self,
first_ac: Any,
second_ac: Any,
- first_origin: Tuple[float, float, float],
- second_origin: Tuple[float, float, float],
- first_normal: Tuple[float, float, float],
- second_normal: Tuple[float, float, float],
- first_x_dir: Tuple[float, float, float],
- second_x_dir: Tuple[float, float, float],
- align_delta: Any,
- ) -> Tuple[Optional[float], Optional[float]]:
+ first_pick: Dict[str, Any],
+ second_pick: Dict[str, Any],
+ solved: Dict[str, Any],
+ ) -> Tuple[Optional[float], Optional[float], bool]:
"""Show connector dialog with live 3D preview of the alignment.
- Returns (rotation_degrees, offset_mm) or (None, None) if cancelled.
+ Returns (rotation_degrees, offset_mm, flip) or (None, None, False) if cancelled.
"""
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
QLabel, QDoubleSpinBox, QPushButton,
QFrame, QCheckBox)
if second_ac is None:
- return (None, None)
+ return (None, None, False)
dialog = QDialog(self)
dialog.setWindowTitle("Connector — Connection Properties")
@@ -5301,10 +5791,18 @@ class MainWindow(QMainWindow):
layout = QVBoxLayout(dialog)
- # Info label.
- layout.addWidget(QLabel("Adjust the connection between the two components:"))
+ entity_names = {
+ "planar_face": "Face",
+ "cylindrical_face": "Hole",
+ "edge": "Edge",
+ "vertex": "Vertex",
+ }
+ t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity")
+ t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity")
+ layout.addWidget(QLabel(f"{t1} on {first_ac.name} → {t2} on {second_ac.name}"))
+ layout.addWidget(QLabel("Adjust the connection:"))
- # Rotation around normal axis of the FIRST connector.
+ # Rotation around normal axis.
rot_layout = QHBoxLayout()
rot_layout.addWidget(QLabel("Rotation around axis (°):"))
rotation_spin = QDoubleSpinBox()
@@ -5341,24 +5839,34 @@ class MainWindow(QMainWindow):
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
+ import numpy as np
+ target_normal = np.array(first_pick["normal_world"], dtype=float)
+ target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
+
# ── Live preview callback ──
def _update_preview(*args):
- import numpy as np
rot_deg = rotation_spin.value()
off = offset_spin.value()
flip = flip_check.isChecked()
- # Compute the aligned position for second component.
- base_pos = self._connector_align_pos if hasattr(self, '_connector_align_pos') else (
- np.array(second_ac.position, dtype=float) + align_delta
- )
- # Apply offset along the second normal.
- n2 = np.array(second_normal) / max(np.linalg.norm(second_normal), 1e-12)
- new_pos = np.array(base_pos, dtype=float) + n2 * off
+ # Start from solved transform.
+ base_pos = np.array(solved["position"], dtype=float)
+ base_rot = np.array(solved["rotation"], dtype=float)
- # Store preview position.
- second_ac.position = new_pos
- self._show_assembly_in_viewer() # no fit — keep camera steady during live preview
+ # Apply axis rotation around target_normal.
+ angle_rad = np.radians(rot_deg)
+ k = target_normal
+ K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
+ R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
+ preview_rot = R_axis @ base_rot
+
+ # Apply offset (with flip).
+ flip_sign = -1.0 if flip else 1.0
+ preview_pos = base_pos + flip_sign * target_normal * off
+
+ second_ac.position = preview_pos
+ second_ac.rotation = preview_rot
+ self._show_assembly_in_viewer() # no fit — keep camera steady
rotation_spin.valueChanged.connect(_update_preview)
offset_spin.valueChanged.connect(_update_preview)
@@ -5370,15 +5878,9 @@ class MainWindow(QMainWindow):
ok_btn.clicked.connect(dialog.accept)
cancel_btn.clicked.connect(dialog.reject)
- def _on_dialog_close():
- nonlocal dialog
- if dialog.result() != QDialog.DialogCode.Accepted:
- # User cancelled — no changes applied, caller will restore.
- pass
-
if dialog.exec():
- return (rotation_spin.value(), offset_spin.value())
- return (None, None)
+ return (rotation_spin.value(), offset_spin.value(), flip_check.isChecked())
+ return (None, None, False)
def _on_delete_connector(self):
"""Delete the connector nearest to the selected assembly component."""
@@ -5406,6 +5908,27 @@ class MainWindow(QMainWindow):
if ok and label:
idx = conn_labels.index(label)
conn_id = conn_names[idx]
+ conn = ac.connectors.get(conn_id)
+ # Un-partner the mate and drop the rigid-group edge so stale
+ # connections don't linger in the BFS graph.
+ if conn is not None:
+ partner_ac_id = conn.partner_ac_id
+ partner_conn_id = conn.partner_connector_id
+ if partner_ac_id is not None and partner_conn_id is not None:
+ partner_ac = assembly.components.get(partner_ac_id)
+ if partner_ac is not None and partner_conn_id in partner_ac.connectors:
+ pc = partner_ac.connectors[partner_conn_id]
+ pc.partner_ac_id = None
+ pc.partner_connector_id = None
+ pc.is_grounded = False
+ # Remove the connection edge either side references this pair.
+ assembly.connections = [
+ c for c in assembly.connections
+ if not (
+ (c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
+ or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
+ )
+ ] if partner_ac_id is not None else assembly.connections
ac.remove_connector(conn_id)
logger.info(f"Removed connector {conn_id}")
self._show_assembly_in_viewer(fit=True)
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 60749db..1a0a418 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -368,8 +368,16 @@ class Connector:
# Which body/face this connector was placed on (renderer obj_id).
source_obj_id: str = ""
- # Future: connected to another Connector's id.
- # connected_to: Optional[str] = None
+ # --- Rigid-group pairing (set when two connectors are mated) ---
+ # The id of the partner AssemblyComponent this connector is mated to.
+ # The FIRST-picked component is the grounded reference of the pair;
+ # 'is_grounded' marks that side so the move handler knows which half
+ # is the fixed frame of the rigid group.
+ partner_ac_id: Optional[str] = None
+ # The id of the partner Connector on the partner component.
+ partner_connector_id: Optional[str] = None
+ # True on the first-picked (grounded) connector of a mated pair.
+ is_grounded: bool = False
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
@@ -430,6 +438,25 @@ class AssemblyComponent:
return False
+@dataclass
+class AssemblyConnection:
+ """A mated connector pair linking two AssemblyComponents.
+
+ Records which component is the grounded reference (``first_ac_id``) and
+ which was solved against it (``second_ac_id``), plus the partner
+ connector ids so the linkage can be followed / removed symmetrically.
+ Used by the assembly-move handler to propagate translations across the
+ rigid group.
+ """
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ first_ac_id: str = "" # grounded reference side
+ second_ac_id: str = "" # solved side
+ first_connector_id: Optional[str] = None
+ second_connector_id: Optional[str] = None
+ created_at: datetime = field(default_factory=datetime.now)
+
+
@dataclass
class Assembly:
"""
@@ -447,9 +474,66 @@ class Assembly:
components: Dict[str, AssemblyComponent] = field(default_factory=dict)
active_assembly_component: Optional[str] = None
+ # Mated connector pairs — each entry links two AssemblyComponents so the
+ # assembly-move handler can propagate rigid-group translations. The
+ # 'first_ac_id' side is the grounded reference of the pair.
+ connections: List["AssemblyConnection"] = field(default_factory=list)
+
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
+ def add_connection(self, first_ac_id: str, second_ac_id: str) -> "AssemblyConnection":
+ """Record a mated connector pair between two component instances.
+
+ The first-picked component (``first_ac_id``) is treated as the
+ grounded reference of the pair. Returns the AssemblyConnection for
+ further bookkeeping (e.g. attaching partner connector ids).
+ """
+ conn = AssemblyConnection(
+ first_ac_id=first_ac_id,
+ second_ac_id=second_ac_id,
+ )
+ self.connections.append(conn)
+ self.modified_at = datetime.now()
+ return conn
+
+ def remove_connections_for(self, ac_id: str) -> None:
+ """Drop every connection that involves *ac_id* (e.g. on removal)."""
+ self.connections = [
+ c for c in self.connections
+ if c.first_ac_id != ac_id and c.second_ac_id != ac_id
+ ]
+
+ def get_rigid_group(self, ac_id: str) -> List[str]:
+ """Return ids of all components rigidly linked to *ac_id* (BFS).
+
+ Includes *ac_id* itself. Two components are linked when a mated
+ connector pair (in ``connections``) joins them; linkage is
+ transitive, so the whole connected subgraph forms one rigid group.
+ """
+ if ac_id not in self.components:
+ return []
+ # Build adjacency from the connection list.
+ adj: Dict[str, List[str]] = {}
+ for c in self.connections:
+ adj.setdefault(c.first_ac_id, []).append(c.second_ac_id)
+ adj.setdefault(c.second_ac_id, []).append(c.first_ac_id)
+ seen: List[str] = []
+ queue: List[str] = [ac_id]
+ while queue:
+ cur = queue.pop(0)
+ if cur in seen:
+ continue
+ seen.append(cur)
+ for nb in adj.get(cur, []):
+ if nb not in seen:
+ queue.append(nb)
+ return seen
+
+ def is_grounded_reference(self, ac_id: str) -> bool:
+ """True if *ac_id* is the grounded (first-picked) side of any pair."""
+ return any(c.first_ac_id == ac_id for c in self.connections)
+
def add_component_instance(
self, component_id: str, name: Optional[str] = None
) -> AssemblyComponent:
@@ -472,6 +556,10 @@ class Assembly:
"""Remove a component instance from the assembly."""
if assembly_component_id in self.components:
del self.components[assembly_component_id]
+ # Also drop any mated-connector links that referenced this
+ # instance — otherwise stale connection edges would remain in
+ # the rigid-group graph and point at a missing component.
+ self.remove_connections_for(assembly_component_id)
if self.active_assembly_component == assembly_component_id:
self.active_assembly_component = next(
iter(self.components.keys()), None
diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py
index 84ffbc8..0e48a3e 100644
--- a/src/fluency/rendering/occ_renderer.py
+++ b/src/fluency/rendering/occ_renderer.py
@@ -48,6 +48,9 @@ class OCCRenderer(Renderer):
self._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).
+ # Keyed by a synthetic id; values are raw AIS_InteractiveObject.
+ self._gizmo_objects: Dict[str, Any] = {}
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -251,16 +254,22 @@ class OCCRenderer(Renderer):
logger.debug("polygon offset unavailable", exc_info=True)
self._context.Display(ais, True)
- # Activate selection modes so the viewer can detect/pick the whole
- # shape (mode 0) and individual faces (mode for TopAbs_FACE) — needed
- # for sketch-on-surface face picking. Left-click orbits the camera
- # (see handle_mouse_press), so active selection doesn't interfere.
+ # Activate selection modes so the viewer can detect/pick individual
+ # faces, edges and vertices. Required for the smart entity picker
+ # gizmo (assembly connector picks) AND for sketch-on-surface face
+ # picking. OCC assigns higher detection priority to vertices, then
+ # edges, then faces — so hovering near an edge/vertex picks the
+ # edge/vertex rather than the underlying face, which is what we want
+ # for snapping. Left-click still orbits the camera (see
+ # handle_mouse_press); active selection is only consumed by the
+ # explicit pick methods (pick_entity / pick_planar_face).
try:
- from OCP.TopAbs import TopAbs_FACE
- face_mode = AIS_Shape.SelectionMode_s(TopAbs_FACE)
- self._context.Activate(ais, face_mode)
+ from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
+ for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
+ mode = AIS_Shape.SelectionMode_s(topo)
+ self._context.Activate(ais, mode)
except Exception:
- logger.debug("face selection mode activation failed", exc_info=True)
+ logger.debug("selection mode activation failed", exc_info=True)
defcol = color or (0.5, 0.5, 0.5)
robj = OCCRenderObject(
@@ -467,7 +476,9 @@ class OCCRenderer(Renderer):
self._context.Display(ais, True)
self._preview_ais = ais
if self._view is not None:
- self._view.Repaint()
+ # OCC's V3d_View exposes ``Redraw`` (not ``Repaint``); calling
+ # the wrong name crashed the live extrude-cut preview callback.
+ self._view.Redraw()
def clear_preview(self) -> None:
"""Remove the live extrude preview shape."""
@@ -484,6 +495,7 @@ class OCCRenderer(Renderer):
return
self.clear_preview()
self.clear_face_highlight()
+ self.clear_entity_gizmo()
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
# path; fall back to iterating the displayed list if unavailable.
try:
@@ -1007,6 +1019,598 @@ class OCCRenderer(Renderer):
logger.debug("clear_face_highlight remove failed", exc_info=True)
self._highlight_ais = None
+ # ─── General entity picking (for assembly connectors / snaps) ───────────
+
+ def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
+ """Pick the entity under screen pixel (x, y).
+
+ Returns a dict describing the detected geometry:
+
+ * ``type`` — one of ``planar_face``, ``cylindrical_face``,
+ ``edge``, ``vertex``.
+ * ``position`` — 3D point (face origin / edge midpoint / vertex).
+ * ``normal`` — direction vector (face normal / cylinder axis /
+ edge tangent / ``None`` for vertex).
+ * ``x_dir`` — stable in-plane reference direction.
+ * ``face`` / ``edge`` / ``vertex`` — the raw OCC sub-shape.
+ * ``owner_obj_id`` — the displayed body this belongs to.
+
+ Returns *None* if nothing detectable is under the cursor.
+ """
+ if self._view is None or self._context is None:
+ return None
+
+ self._context.MoveTo(x, y, self._view, True)
+ if not self._context.HasDetected():
+ return None
+
+ shape = self._context.DetectedShape()
+ if shape is None:
+ return None
+ return self._classify_detected_shape(shape)
+
+ def _classify_detected_shape(
+ self, shape: Any, owner_obj_id: Optional[str] = None,
+ ) -> Optional[Dict[str, Any]]:
+ """Classify a detected OCC sub-shape into a snap-candidate dict.
+
+ Shared by ``pick_entity`` (single-pixel) and ``probe_snap_candidates``
+ (multi-pixel grid probing). Determines whether *shape* is a planar
+ face, cylindrical face (hole), edge, or vertex and returns the snap
+ info dict with ``position`` / ``normal`` / ``x_dir`` / ``type`` /
+ ``owner_obj_id`` (+ ``radius`` for holes). When *owner_obj_id* is
+ omitted it is looked up from the context's currently-detected AIS.
+ """
+ if shape is None:
+ return None
+
+ from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS
+ from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
+ from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
+ from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder
+ from OCP.BRep import BRep_Tool
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE
+ from OCP.gp import gp_Pnt, gp_Dir
+ from OCP.Bnd import Bnd_Box
+ from OCP.BRepBndLib import BRepBndLib
+ from OCP.TopExp import TopExp
+ import numpy as np
+
+ # Helper: find owner object id if not supplied.
+ if owner_obj_id is None:
+ owner_obj_id = ""
+ try:
+ owner_ais = self._context.DetectedInteractive()
+ except Exception:
+ owner_ais = None
+ if owner_ais is not None:
+ for oid, robj in self._objects.items():
+ if robj.ais_shape is owner_ais:
+ owner_obj_id = oid
+ break
+
+ # Try face first.
+ face = None
+ try:
+ face = TopoDS.Face_s(shape)
+ _ = BRepAdaptor_Surface(face)
+ except Exception:
+ face = None
+
+ if face is not None:
+ adaptor = BRepAdaptor_Surface(face)
+ stype = adaptor.GetType()
+
+ if stype == GeomAbs_Plane:
+ pln = adaptor.Plane()
+ n = pln.Axis().Direction()
+ from OCP.TopAbs import TopAbs_REVERSED
+ if face.Orientation() == TopAbs_REVERSED:
+ n = n.Reversed()
+ nx, ny, nz = n.X(), n.Y(), n.Z()
+
+ # Center of face bbox projected onto plane.
+ bbox = Bnd_Box()
+ BRepBndLib.Add_s(face, bbox)
+ xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
+ cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
+ pln_origin = pln.Location()
+ d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
+ origin = (cx - d * nx, cy - d * ny, cz - d * nz)
+
+ # x_dir from first edge.
+ x_dir = None
+ try:
+ expl = TopExp_Explorer(face, TopAbs_EDGE_TYPE)
+ if expl.More():
+ edge = TopoDS.Edge_s(expl.Current())
+ v1 = TopExp.FirstVertex_s(edge, True)
+ v2 = TopExp.LastVertex_s(edge, True)
+ p1 = BRep_Tool.Pnt_s(v1)
+ p2 = BRep_Tool.Pnt_s(v2)
+ ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
+ elen = (ex * ex + ey * ey + ez * ez) ** 0.5
+ if elen > 1e-9:
+ x_dir = (ex / elen, ey / elen, ez / elen)
+ except Exception:
+ pass
+ if x_dir is None:
+ px = pln.XAxis().Direction()
+ x_dir = (px.X(), px.Y(), px.Z())
+
+ return {
+ "type": "planar_face",
+ "position": origin,
+ "normal": (nx, ny, nz),
+ "x_dir": x_dir,
+ "face": face,
+ "owner_obj_id": owner_obj_id,
+ }
+
+ elif stype == GeomAbs_Cylinder:
+ cyl = adaptor.Cylinder()
+ axis = cyl.Axis()
+ ax_dir = axis.Direction()
+ ax_pos = axis.Location()
+ radius = cyl.Radius()
+
+ # Parameter extents along the cylinder axis (v = height).
+ # BRepAdaptor_Surface exposes these via First/Last V
+ # *Parameter() — NOT a Bounds() method (that quirk crashed
+ # cylindrical-face picking).
+ vmin = adaptor.FirstVParameter()
+ vmax = adaptor.LastVParameter()
+
+ # Two candidate snap points: the centers of the cylinder's
+ # two end circles (the hole openings). A bolt enters a
+ # hole from the camera-facing opening, so pick the END of
+ # the axis closest to the camera as the primary snap point.
+ # The axis location (ax_pos) is already on the cylinder axis
+ # at v=0; the other end is at v=vmax.
+ p0 = np.array([
+ ax_pos.X(), ax_pos.Y(), ax_pos.Z(),
+ ], dtype=float)
+ p1 = np.array([
+ ax_pos.X() + ax_dir.X() * (vmax - vmin),
+ ax_pos.Y() + ax_dir.Y() * (vmax - vmin),
+ ax_pos.Z() + ax_dir.Z() * (vmax - vmin),
+ ], dtype=float)
+
+ # Choose the camera-facing end. The camera looks FROM its
+ # eye TOWARD its target, so the camera direction is
+ # (target - eye). The end whose vector-from-camera is MOST
+ # OPPOSITE to (i.e. faces) the camera is the near opening.
+ cam_from = None
+ try:
+ if self._view is not None:
+ eye = self._view.Eye()
+ at = self._view.At()
+ cam_from = np.array([eye.X(), eye.Y(), eye.Z()], dtype=float)
+ cam_to = np.array([at.X(), at.Y(), at.Z()], dtype=float)
+ except Exception:
+ cam_from = None
+
+ if cam_from is not None:
+ # End closest to the camera eye is the visible opening.
+ d0 = float(np.linalg.norm(p0 - cam_from))
+ d1 = float(np.linalg.norm(p1 - cam_from))
+ near_end = p0 if d0 <= d1 else p1
+ else:
+ # Fallback: axial midpoint.
+ near_end = 0.5 * (p0 + p1)
+
+ origin = (float(near_end[0]), float(near_end[1]), float(near_end[2]))
+ # Normal = the cylinder axis direction. This is the "bolt
+ # axis": the direction a bolt would travel INTO the hole.
+ # (Sign is the cylinder's own axis direction; a later flip can
+ # reverse it via the connector dialog's Flip checkbox.)
+ normal = (ax_dir.X(), ax_dir.Y(), ax_dir.Z())
+
+ # x_dir: radial direction from the axis center to the hole
+ # wall — gives a stable in-plane reference for the connector
+ # frame. Use the cylinder's own XDirection if available.
+ try:
+ px = cyl.Position().XDirection()
+ x_dir = (px.X(), px.Y(), px.Z())
+ except Exception:
+ x_dir = (1.0, 0.0, 0.0)
+
+ return {
+ "type": "cylindrical_face",
+ "position": origin,
+ "normal": normal,
+ "x_dir": x_dir,
+ "face": face,
+ "owner_obj_id": owner_obj_id,
+ "radius": radius,
+ }
+
+ # Try edge.
+ edge = None
+ try:
+ edge = TopoDS.Edge_s(shape)
+ _ = BRepAdaptor_Curve(edge)
+ except Exception:
+ edge = None
+
+ if edge is not None:
+ curve = BRepAdaptor_Curve(edge)
+ # Midpoint parameter.
+ ufirst = curve.FirstParameter()
+ ulast = curve.LastParameter()
+ umid = (ufirst + ulast) / 2.0
+ p_mid = curve.Value(umid)
+ position = (p_mid.X(), p_mid.Y(), p_mid.Z())
+
+ # Tangent at midpoint.
+ try:
+ tangent = curve.DN(umid, 1)
+ tx, ty, tz = tangent.X(), tangent.Y(), tangent.Z()
+ tlen = (tx * tx + ty * ty + tz * tz) ** 0.5
+ if tlen > 1e-9:
+ tangent = (tx / tlen, ty / tlen, tz / tlen)
+ else:
+ tangent = None
+ except Exception:
+ tangent = None
+
+ # x_dir: perpendicular to tangent (arbitrary but stable).
+ x_dir = None
+ if tangent is not None:
+ t = np.array(tangent)
+ # Find a vector not parallel to t.
+ ref = np.array([1.0, 0.0, 0.0]) if abs(t[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
+ x = np.cross(t, ref)
+ xlen = np.linalg.norm(x)
+ if xlen > 1e-9:
+ x = x / xlen
+ x_dir = (float(x[0]), float(x[1]), float(x[2]))
+
+ return {
+ "type": "edge",
+ "position": position,
+ "normal": tangent,
+ "x_dir": x_dir,
+ "edge": edge,
+ "owner_obj_id": owner_obj_id,
+ }
+
+ # Try vertex.
+ vertex = None
+ try:
+ vertex = TopoDS.Vertex_s(shape)
+ p = BRep_Tool.Pnt_s(vertex)
+ position = (p.X(), p.Y(), p.Z())
+ return {
+ "type": "vertex",
+ "position": position,
+ "normal": None,
+ "x_dir": None,
+ "vertex": vertex,
+ "owner_obj_id": owner_obj_id,
+ }
+ except Exception:
+ pass
+
+ return None
+
+ def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]:
+ """Project a 3D world point to (x, y) screen pixel.
+
+ Uses OCC's ``V3d_View.Convert`` (world → view coords). Returns None
+ if the projection fails (e.g. behind the camera).
+ """
+ if self._view is None:
+ return None
+ try:
+ # OCC's Convert returns the window pixel coordinates.
+ xpix = self._view.Convert(float(p3d[0]), float(p3d[1]), float(p3d[2]))
+ # Some OCP builds return a tuple (x, y); others return two values.
+ if isinstance(xpix, (tuple, list)) and len(xpix) == 2:
+ return (int(xpix[0]), int(xpix[1]))
+ return None
+ except Exception:
+ # Fall back to ConvertWithProj or ProjTexte if Convert is unavailable.
+ return None
+
+ def probe_snap_candidates(
+ self, x: int, y: int, radius: int = 18,
+ ) -> List[Dict[str, Any]]:
+ """Probe a pixel grid around (x, y) and return visible snap candidates.
+
+ Samples a small ring + centre around the cursor, runs OCC's
+ ``MoveTo`` at each pixel, and classifies every distinct detected
+ sub-shape via :meth:`_classify_detected_shape`. Results are
+ deduplicated by (owner_obj_id, type, rounded position) and sorted by
+ screen-space distance to the cursor, nearest first.
+
+ This is the general hover snap indicator: it surfaces nearby
+ vertices, edge midpoints, hole centres, and face centres so the
+ user can see the snap targets in the cursor neighbourhood — not
+ just the single entity directly under the crosshair.
+
+ Each entry is the same dict shape returned by ``pick_entity`` plus an
+ extra ``screen`` key carrying the (sx, sy) pixel where the entity was
+ first detected.
+ """
+ if self._view is None or self._context is None:
+ return []
+
+ # Sample pattern: the exact cursor pixel plus a small ring of
+ # offsets. The ring catches nearby vertices/edges/holes that sit a
+ # few pixels away from where the user is pointing.
+ ring_offsets = [
+ (0, 0),
+ (-radius, 0), (radius, 0), (0, -radius), (0, radius),
+ (-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius),
+ (-radius // 2, 0), (radius // 2, 0), (0, -radius // 2), (0, radius // 2),
+ ]
+
+ candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
+ for dx, dy in ring_offsets:
+ sx, sy = x + dx, y + dy
+ try:
+ self._context.MoveTo(sx, sy, self._view, True)
+ except Exception:
+ continue
+ if not self._context.HasDetected():
+ continue
+ shape = self._context.DetectedShape()
+ if shape is None:
+ continue
+ info = self._classify_detected_shape(shape)
+ if info is None:
+ continue
+ # Skip non-trackable hits (no owner — e.g. the workplane plane).
+ if not info.get("owner_obj_id"):
+ continue
+ pos = info.get("position") or (0.0, 0.0, 0.0)
+ # Dedupe key: owner + type + position rounded to 0.1 mm.
+ key = (
+ info.get("owner_obj_id", ""),
+ info.get("type", ""),
+ (round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
+ )
+ if key not in candidates:
+ info["screen"] = (sx, sy)
+ candidates[key] = info
+
+ # Sort by screen-space distance to the cursor, nearest first.
+ results = list(candidates.values())
+ results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2)
+ return results
+
+ def highlight_snap(self, position, color=None, size=3.0) -> Optional[str]:
+ """Show a small marker sphere at *position* as a snap indicator.
+
+ Returns an object id that can be removed later.
+ """
+ if self._context is None:
+ return None
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
+ from OCP.gp import gp_Pnt
+ from OCP.AIS import AIS_Shape
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+ try:
+ sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), size).Shape()
+ ais = AIS_Shape(sphere)
+ c = color or (1.0, 0.6, 0.0) # orange
+ ais.SetColor(Quantity_Color(c[0], c[1], c[2], Quantity_TOC_RGB))
+ ais.SetDisplayMode(1)
+ self._context.Display(ais, True)
+ if self._view is not None:
+ self._view.Update()
+ # Track this as a temporary object; use a synthetic id.
+ oid = f"__snap_{id(ais)}"
+ self._objects[oid] = _RenderObject(oid, ais, None, None)
+ return oid
+ except Exception as exc:
+ logger.debug(f"highlight_snap failed: {exc}")
+ return None
+
+ def remove_highlight_snap(self, obj_id: str) -> None:
+ """Remove a snap marker by its id."""
+ if obj_id in self._objects:
+ robj = self._objects.pop(obj_id)
+ if self._context is not None and robj.ais_shape is not None:
+ try:
+ self._context.Remove(robj.ais_shape, True)
+ if self._view is not None:
+ self._view.Update()
+ except Exception:
+ pass
+
+ # ─── Smart Entity Picker Gizmo ─────────────────────────────────────────
+
+ def clear_entity_gizmo(self) -> None:
+ """Remove all gizmo display objects (markers, axes, highlights)."""
+ had_any = bool(self._gizmo_objects)
+ for obj_id in list(self._gizmo_objects.keys()):
+ robj = self._gizmo_objects.pop(obj_id)
+ if self._context is not None and robj is not None:
+ try:
+ self._context.Remove(robj, True)
+ except Exception:
+ pass
+ if had_any and self._view is not None:
+ self._view.Update()
+
+ def show_entity_gizmo(
+ self,
+ entity_type: str,
+ position: Tuple[float, float, float],
+ normal: Optional[Tuple[float, float, float]] = None,
+ x_dir: Optional[Tuple[float, float, float]] = None,
+ color: Optional[Tuple[float, float, float]] = None,
+ radius: Optional[float] = None,
+ candidates: Optional[List[Dict[str, Any]]] = None,
+ ) -> None:
+ """Display a smart snap gizmo for the given entity type.
+
+ Renders a composite visual appropriate to the entity type:
+ * **planar_face** — sphere at pick point + white normal axis + x_dir axis
+ * **cylindrical_face** — sphere at the camera-facing hole opening +
+ white bolt-axis line through the hole (the snap 'normal' points
+ along the hole like a bolt) + a ring of the hole radius at the
+ opening + a small radial reference line.
+ * **edge** — sphere at edge midpoint + tangent axis line
+ * **vertex** — sphere + RGB crosshair
+
+ When *candidates* is provided, every candidate is ALSO rendered with a
+ small DIM half-size sphere in its own per-type colour, while the
+ primary (this method's *position*/*entity_type*) is emphasised with a
+ larger bright sphere + axis indicators. This is the general hover
+ snap indicator: it shows all snap targets in the cursor neighbourhood
+ (vertices, edge midpoints, face centres, hole openings) so the user
+ can see what they can snap to — click then selects the primary.
+
+ All previously shown gizmo objects are removed first so the gizmo
+ follows the cursor cleanly.
+ """
+ if self._context is None:
+ return
+ self.clear_entity_gizmo()
+
+ from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
+ from OCP.AIS import AIS_Shape
+ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
+
+ # Per-entity-type colours (shared by the dim candidate markers).
+ default_colors = {
+ "planar_face": (0.0, 0.8, 1.0), # cyan
+ "cylindrical_face": (1.0, 0.5, 0.0), # orange (hole / bolt axis)
+ "edge": (0.2, 1.0, 0.4), # green
+ "vertex": (1.0, 1.0, 0.0), # yellow
+ }
+ gizmo_color = color or default_colors.get(entity_type, (1.0, 0.6, 0.0))
+
+ def _make_sphere(p, c, size):
+ try:
+ s = BRepPrimAPI_MakeSphere(gp_Pnt(*p), size).Shape()
+ a = AIS_Shape(s)
+ a.SetColor(Quantity_Color(*c, Quantity_TOC_RGB))
+ a.SetDisplayMode(1)
+ self._context.Display(a, True)
+ self._gizmo_objects[f"__gizmo_sphere_{id(a)}"] = a
+ except Exception as exc:
+ logger.debug(f"gizmo sphere failed: {exc}")
+
+ px, py, pz = position
+
+ # ── 0. Dim candidate markers (every nearby snap target) ──
+ #
+ # Drawn FIRST so the bright primary sphere renders on top of them.
+ # Each candidate gets a small half-size sphere tinted by its own
+ # entity-type colour; the primary (this method's position) is drawn
+ # brighter & larger in step 1 below so it reads as the active snap.
+ if candidates:
+ for cand in candidates:
+ cpos = cand.get("position")
+ if cpos is None:
+ continue
+ # Skip the primary itself — it gets its own bright marker.
+ if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == (
+ round(px, 1), round(py, 1), round(pz, 1)
+ ):
+ continue
+ cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7))
+ _make_sphere(cpos, cc, 1.4) # dim, small
+
+ # ── 1. Bright primary marker (sphere) ──
+ _make_sphere(position, gizmo_color, 2.8)
+
+ # ── 2. Axis indicator lines (primary only) ──
+ axis_length = 15.0
+
+ def _make_axis_line(
+ origin: Tuple[float, float, float],
+ direction: Tuple[float, float, float],
+ length: float,
+ line_color: Tuple[float, float, float],
+ label: str = "axis",
+ ) -> Optional[str]:
+ """Create a small line segment along *direction* from *origin*."""
+ try:
+ dx, dy, dz = direction
+ norm = (dx * dx + dy * dy + dz * dz) ** 0.5
+ if norm < 1e-9:
+ return None
+ ux, uy, uz = dx / norm, dy / norm, dz / norm
+ ex = origin[0] + ux * length
+ ey = origin[1] + uy * length
+ ez = origin[2] + uz * length
+
+ edge = BRepBuilderAPI_MakeEdge(
+ gp_Pnt(*origin), gp_Pnt(ex, ey, ez)
+ ).Edge()
+ ais = AIS_Shape(edge)
+ ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
+ ais.SetDisplayMode(0) # wireframe
+ self._context.Display(ais, True)
+ gid = f"__gizmo_{label}_{id(ais)}"
+ self._gizmo_objects[gid] = ais
+ return gid
+ except Exception as exc:
+ logger.debug(f"gizmo axis line failed: {exc}")
+ return None
+
+ if entity_type == "planar_face" and normal is not None:
+ # Normal axis (white).
+ _make_axis_line(position, normal, axis_length, (1.0, 1.0, 1.0), "normal")
+ # X direction axis (slightly dimmer).
+ if x_dir is not None:
+ _make_axis_line(position, x_dir, axis_length * 0.6, gizmo_color, "xdir")
+
+ elif entity_type == "cylindrical_face" and normal is not None:
+ # Hole / bolt axis. The snap point is the camera-facing opening,
+ # so draw the axis going INTO the hole (the +normal direction —
+ # the bolt travel direction) plus a short stub the other way for
+ # visual balance. This reads as 'bolt axis through hole'.
+ _make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
+ _make_axis_line(
+ position, (-normal[0], -normal[1], -normal[2]),
+ axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub",
+ )
+ # Radial reference (same colour as the marker).
+ if x_dir is not None:
+ _make_axis_line(position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial")
+
+ elif entity_type == "edge" and normal is not None:
+ # Tangent direction at midpoint.
+ _make_axis_line(position, normal, axis_length, gizmo_color, "tangent")
+
+ elif entity_type == "vertex":
+ # Small crosshair (three short axes) so a vertex snap reads as
+ # a coordinate point even before clicking.
+ _make_axis_line(position, (1, 0, 0), axis_length * 0.5, (1.0, 0.3, 0.3), "cross_x")
+ _make_axis_line(position, (0, 1, 0), axis_length * 0.5, (0.3, 1.0, 0.3), "cross_y")
+ _make_axis_line(position, (0, 0, 1), axis_length * 0.5, (0.3, 0.3, 1.0), "cross_z")
+
+ # ── 3. For cylindrical faces, ring of the hole radius at the opening ──
+ #
+ # Drawn at the camera-facing opening (the snap point), so the user
+ # sees the actual hole diameter they're snapping a bolt to.
+ if entity_type == "cylindrical_face" and normal is not None and radius is not None:
+ try:
+ center = gp_Pnt(px, py, pz)
+ ax2 = gp_Ax2(center, gp_Dir(*normal))
+ circ = gp_Circ(ax2, radius)
+ ring_edge = BRepBuilderAPI_MakeEdge(circ).Edge()
+ ring_ais = AIS_Shape(ring_edge)
+ ring_ais.SetColor(Quantity_Color(*gizmo_color, Quantity_TOC_RGB))
+ ring_ais.SetDisplayMode(0) # wireframe
+ self._context.Display(ring_ais, True)
+ gid = f"__gizmo_ring_{id(ring_ais)}"
+ self._gizmo_objects[gid] = ring_ais
+ except Exception as exc:
+ logger.debug(f"gizmo ring failed: {exc}")
+
+ if self._view is not None:
+ self._view.Update()
+
# ─── Mouse / keyboard event forwarding ──────────────────────────────
#
# CAD-style navigation: