- assembly draft

This commit is contained in:
bklronin
2026-07-05 19:36:27 +02:00
parent b595b88e04
commit 3a169007f7
4 changed files with 1357 additions and 134 deletions
+13 -5
View File
@@ -4,12 +4,11 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- UI refinement, button position ui file as source no dirty drafting anymore">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- assembly draft">
<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/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.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/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -322,7 +321,15 @@
<option name="project" value="LOCAL" />
<updated>1783174566362</updated>
</task>
<option name="localTasksCounter" value="27" />
<task id="LOCAL-00027" summary="- assembly draft">
<option name="closed" value="true" />
<created>1783239410744</created>
<option name="number" value="00027" />
<option name="presentableId" value="LOCAL-00027" />
<option name="project" value="LOCAL" />
<updated>1783239410744</updated>
</task>
<option name="localTasksCounter" value="28" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -365,6 +372,7 @@
<MESSAGE value="- removed cadquery deoendency" />
<MESSAGE value="- sketch enhacements" />
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
<option name="LAST_COMMIT_MESSAGE" value="- UI refinement, button position ui file as source no dirty drafting anymore" />
<MESSAGE value="- assembly draft" />
<option name="LAST_COMMIT_MESSAGE" value="- assembly draft" />
</component>
</project>
+641 -118
View File
@@ -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"<b>{t1}</b> on {first_ac.name} → <b>{t2}</b> 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)
+90 -2
View File
@@ -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
+613 -9
View File
@@ -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: