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