- assembly draft
This commit is contained in:
Generated
+13
-5
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user