- assembly draft

This commit is contained in:
bklronin
2026-07-05 19:36:27 +02:00
parent b595b88e04
commit 3a169007f7
4 changed files with 1357 additions and 134 deletions
+641 -118
View File
@@ -3547,11 +3547,14 @@ class Viewer3DWidget(QWidget):
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal()
# Emitted when the user picks a face for a connector point (assembly).
# Payload: (origin, normal, x_dir, face_shape, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, object, str)
# Emitted when the user picks an entity for a connector point (assembly).
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
# Emitted when connector pick mode is cancelled.
connectorPickCancelled = Signal()
# Emitted on mouse move in connector mode to show snap preview.
# Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
connectorHover = Signal(object)
# Emitted when a body is clicked in assembly move mode.
# Payload: owner_obj_id.
@@ -3569,6 +3572,11 @@ class Viewer3DWidget(QWidget):
self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
self.setFocusPolicy(Qt.StrongFocus)
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
# button held — required for the connector-pick hover gizmo (and any
# status-bar hover feedback) to show under the cursor as the user
# moves the mouse over candidate snap entities before clicking.
self.setMouseTracking(True)
# Try OCC renderer first; fall back to pygfx if unavailable.
self._renderer: Any = None
self._initialized = False
@@ -3579,9 +3587,11 @@ class Viewer3DWidget(QWidget):
# When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False
# When True, a left-click picks a face for a connector point
# When True, a left-click picks an entity for a connector point
# (assembly component connection).
self._connector_pick_mode: bool = False
# Current snap highlight object id (for hover during connector mode).
self._connector_snap_id: Optional[str] = None
# When True, left-click on a body activates assembly drag-to-move.
self._assembly_move_mode: bool = False
# State for ongoing assembly drag.
@@ -3830,8 +3840,13 @@ class Viewer3DWidget(QWidget):
def mouseMoveEvent(self, event):
self._ensure_initialized()
# In face-pick/connector mode, keep dynamic highlighting.
if self._pick_face_mode or self._connector_pick_mode:
# In connector mode, show snap hover.
if self._connector_pick_mode:
self._handle_connector_hover(event)
super().mouseMoveEvent(event)
return
# In face-pick mode, keep dynamic highlighting.
if self._pick_face_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
@@ -3914,37 +3929,170 @@ class Viewer3DWidget(QWidget):
def set_connector_pick_mode(self, enabled: bool) -> None:
"""Toggle connector pick mode for placing connection points.
When enabled, clicking a face on a body in the assembly view
captures its origin and normal as a connection point for the
SolveSpace solver.
When enabled, clicking an entity (face, edge, vertex, hole)
on a body in the assembly view captures its position and
direction as a connection point for the SolveSpace solver.
"""
self._connector_pick_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
elif not self._pick_face_mode:
self.unsetCursor()
if not enabled:
self._clear_connector_snap()
def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode
def _handle_connector_pick(self, event) -> None:
"""Detect a planar face under the click and emit connectorPicked."""
def _clear_connector_snap(self) -> None:
"""Remove the hover gizmo."""
fn = getattr(self._renderer, "clear_entity_gizmo", None)
if fn is not None:
fn()
# Backwards compat: also try the old method.
if self._connector_snap_id is not None:
fn2 = getattr(self._renderer, "remove_highlight_snap", None)
if fn2 is not None:
fn2(self._connector_snap_id)
self._connector_snap_id = None
def _handle_connector_hover(self, event) -> None:
"""Update the hover snap gizmo during connector pick mode.
Probes a small neighbourhood around the cursor for ALL nearby snap
candidates (vertices, edge midpoints, face centres, hole openings)
and renders a dim marker on each plus a bright primary on the nearest
one the general snap indicator. Clicking then selects the
primary's position.
"""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
logger.warning("Renderer has no pick_planar_face support")
return
probe = getattr(self._renderer, "probe_snap_candidates", None)
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if probe is not None:
candidates = probe(pos.x(), pos.y())
if not candidates:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
# Primary = the nearest candidate (probe sorts nearest-first).
info = candidates[0]
else:
# Fall back to single-pixel pick on renderers without the probe.
picker = getattr(self._renderer, "pick_entity", None)
if picker is None:
return
info = picker(pos.x(), pos.y())
candidates = [info] if info else []
if info is None or info.get("owner_obj_id") is None:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
origin = info["position"]
normal = info.get("normal")
entity_type = info["type"]
owner = info.get("owner_obj_id", "")
# Show smart entity gizmo — dim candidate markers + bright primary.
self._clear_connector_snap()
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
if gizmo_fn is not None:
gizmo_fn(
entity_type=entity_type,
position=origin,
normal=normal,
x_dir=info.get("x_dir"),
radius=info.get("radius"),
candidates=candidates,
)
else:
# Fallback to old highlight_snap.
fn = getattr(self._renderer, "highlight_snap", None)
if fn is not None:
colors = {
"planar_face": (0.0, 0.8, 1.0), # cyan
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
"edge": (0.0, 1.0, 0.4), # green
"vertex": (1.0, 1.0, 0.0), # yellow
}
c = colors.get(entity_type, (1.0, 0.6, 0.0))
self._connector_snap_id = fn(origin, color=c, size=3.0)
self.connectorHover.emit({
"origin": origin,
"normal": normal,
"type": entity_type,
"owner_obj_id": owner,
})
def _handle_connector_pick(self, event) -> None:
"""Detect an entity under the click and emit connectorPicked.
Uses the multi-pixel ``probe_snap_candidates`` so a click selects the
PRIMARY (nearest) snap target the same one the hover gizmo
emphasised. Falls back to single-pixel ``pick_entity`` then to
``pick_planar_face`` on renderers without the probe.
"""
self._ensure_initialized()
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info: Optional[Dict[str, Any]] = None
probe = getattr(self._renderer, "probe_snap_candidates", None)
if probe is not None:
candidates = probe(pos.x(), pos.y())
if candidates:
info = candidates[0] # nearest = primary
if info is None:
logger.info("Connector pick: no planar face under cursor")
picker = getattr(self._renderer, "pick_entity", None)
if picker is None:
# Fallback to planar face only.
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
logger.warning("Renderer has no entity picking support")
return
pinfo = picker(pos.x(), pos.y())
if pinfo is None:
logger.info("Connector pick: no planar face under cursor")
return
owner_obj_id = pinfo.get("owner_obj_id", "")
self.connectorPicked.emit(
tuple(pinfo["origin"]),
tuple(pinfo["normal"]),
tuple(pinfo["x_dir"]),
"planar_face",
pinfo["face"],
owner_obj_id,
)
return
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Connector pick: no entity under cursor")
return
owner_obj_id = info.get("owner_obj_id", "")
if not owner_obj_id:
return
entity_type = info["type"]
origin = info["position"]
normal = info.get("normal") or (0.0, 0.0, 1.0)
x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
# For vertices, pick a sensible normal from the parent face if possible.
if entity_type == "vertex" and normal is None:
normal = (0.0, 0.0, 1.0)
# Package the raw shape appropriately.
raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
self.connectorPicked.emit(
tuple(info["origin"]),
tuple(info["normal"]),
tuple(info["x_dir"]),
info["face"],
tuple(origin),
tuple(normal),
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
entity_type,
raw_shape,
owner_obj_id,
)
@@ -4199,6 +4347,14 @@ class MainWindow(QMainWindow):
# Drag-move state for assembly components
self._asm_move_ac_id: Optional[str] = None
self._asm_move_start_pos: Any = None
# Rigid-group drag state: maps every component id in the dragged
# rigid group to its start position, so the whole group translates
# together and connected partners keep their solved relative
# transforms. Keyed by AssemblyComponent.id.
self._asm_move_group_start: Dict[str, Any] = {}
# Cached rigid-group membership for the current drag (avoids recomputing
# the BFS graph on every mouse-move event).
self._asm_move_group_ids: List[str] = []
# Cache of render object IDs per assembly component, so drag updates
# can replace only the moved component's shapes without clearing the
# entire scene (avoids camera flicker).
@@ -4520,6 +4676,7 @@ class MainWindow(QMainWindow):
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
self._btn_del_connector.clicked.connect(self._on_delete_connector)
self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
self._viewer_3d.connectorPickCancelled.connect(
lambda: self._btn_add_connector.setChecked(False)
)
@@ -5068,7 +5225,12 @@ class MainWindow(QMainWindow):
def _on_assembly_move_activated(self, owner_obj_id: str):
"""Called when the user clicks a body in move mode.
Parse the assembly component id and store its starting position.
Parse the assembly component id, compute the rigid group it belongs
to (transitively via mated connectors), and snapshot EVERY member's
start position so the whole group can translate together during the
drag. The first-picked component of each mated pair stays as the
grounded reference frame for the solver; for a pure-translation
drag that just means we preserve all current relative transforms.
"""
import numpy as np
@@ -5082,14 +5244,26 @@ class MainWindow(QMainWindow):
return
self._asm_move_ac_id = ac_id
# Rigid group membership (BFS over mated-connector connections).
group_ids = assembly.get_rigid_group(ac_id)
self._asm_move_group_ids = group_ids
self._asm_move_group_start = {}
for gid in group_ids:
g_ac = assembly.components.get(gid)
if g_ac is not None:
self._asm_move_group_start[gid] = np.array(g_ac.position, dtype=float)
# Keep the legacy single-component start for backwards compatibility.
self._asm_move_start_pos = np.array(ac.position, dtype=float)
def _on_assembly_move_dragged(self, owner_obj_id: str, dx: float, dy: float, dz: float):
"""Called during a drag move. Update only the dragged component in-place.
"""Propagate a drag move across the entire rigid group, in-place.
Uses the targeted ``_update_assembly_component_in_viewer`` instead of
a full ``clear_scene`` + rebuild, so other shapes keep their render
objects and the camera stays perfectly still.
Every component in the dragged rigid group receives the SAME world
translation delta (relative to its own start position), so the mated
relative transforms are preserved exactly and SolveSpace's solved
alignment stays valid throughout the drag. Each member is updated
in-place via ``_update_assembly_component_in_viewer`` so the camera
never flickers.
"""
if self._asm_move_ac_id is None or self._asm_move_start_pos is None:
return
@@ -5101,17 +5275,32 @@ class MainWindow(QMainWindow):
return
import numpy as np
# Apply delta relative to the start position.
ac.position = self._asm_move_start_pos + np.array([dx, dy, dz])
# Only update the dragged component's shapes — no scene clear.
self._update_assembly_component_in_viewer(ac_id)
delta = np.array([dx, dy, dz], dtype=float)
# Propagate the same delta to every rigid-group member.
group_ids = self._asm_move_group_ids or [ac_id]
for gid in group_ids:
start = self._asm_move_group_start.get(gid)
if start is None:
continue
g_ac = assembly.components.get(gid)
if g_ac is None:
continue
g_ac.position = start + delta
# Update only this component's shapes — no scene clear.
self._update_assembly_component_in_viewer(gid)
def _on_assembly_move_finished(self, owner_obj_id: str):
"""Finalize the drag move."""
if self._asm_move_ac_id is not None:
logger.info(f"Moved assembly component {self._asm_move_ac_id} to final position")
members = len(self._asm_move_group_ids) if self._asm_move_group_ids else 1
logger.info(
f"Moved assembly rigid group led by {self._asm_move_ac_id} "
f"({members} member(s)) to final position"
)
self._asm_move_ac_id = None
self._asm_move_start_pos = None
self._asm_move_group_start = {}
self._asm_move_group_ids = []
# ────────────────────────────────────────────────────────────────────
# Connector methods — two-click selection + preview dialog
@@ -5133,8 +5322,8 @@ class MainWindow(QMainWindow):
def _on_start_connector_placement(self, checked: bool):
"""Toggle connector pick mode.
First click selects the first component's connection face.
Second click selects the second component and triggers alignment.
First click selects the first component's connection entity.
Second click selects the second component and triggers SolveSpace alignment.
"""
if not self._assembly_view_active:
self._btn_add_connector.setChecked(False)
@@ -5145,17 +5334,49 @@ class MainWindow(QMainWindow):
# Reset any in-progress two-click state.
self._connector_first_pick = None
self._connector_second_ac_id = None
self._connector_align_pos = None
self._viewer_3d.set_connector_pick_mode(checked)
if checked:
self._viewer_3d.setFocus()
self._viewer_3d.activateWindow()
self.setStatusTip("Click on the first component's connection face")
self.setStatusTip("Click on the first component's connection point/face/edge/hole")
else:
self.setStatusTip("")
def _on_connector_picked(self, origin, normal, x_dir, face_shape, owner_obj_id):
"""Handle a connector face pick — first or second click."""
def _on_connector_hover(self, info) -> None:
"""Show entity-type feedback in the status bar during connector pick.
The gizmo itself is drawn by the viewer; this just reports what
entity is under the cursor so the user knows what they will snap to.
"""
if info is None:
self.statusBar().showMessage("Move over a face / edge / hole / vertex to snap")
return
entity_type = info.get("type", "")
names = {
"planar_face": "Face",
"cylindrical_face": "Hole",
"edge": "Edge",
"vertex": "Vertex",
}
name = names.get(entity_type, "Entity")
ac_id = self._parse_ac_id(info.get("owner_obj_id", ""))
comp_name = ""
if ac_id is not None:
assembly = self._get_assembly()
ac = assembly.components.get(ac_id) if assembly else None
if ac is not None:
comp_name = f" on {ac.name}"
self.statusBar().showMessage(f"Snap target: {name}{comp_name} — click to pick")
def _on_connector_picked(self, origin, normal, x_dir, entity_type, raw_shape, owner_obj_id):
"""Handle a connector entity pick — first or second click.
Snaps to faces, cylindrical holes, edges, or vertices.
Stores connector in component-local coordinates so it stays
valid when the component is moved by the solver.
"""
import numpy as np
ac_id = self._parse_ac_id(owner_obj_id)
@@ -5171,19 +5392,37 @@ class MainWindow(QMainWindow):
"The clicked component was not found in the assembly.")
return
# Convert world-space connector to component-local coordinates.
# p_local = R^T @ (p_world - P)
pos_world = np.array(origin, dtype=float)
rot = ac.rotation
pos_local = rot.T @ (pos_world - ac.position)
n_world = np.array(normal, dtype=float)
n_local = rot.T @ n_world
n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
x_world = np.array(x_dir, dtype=float) if x_dir else np.array([1.0, 0.0, 0.0])
x_local = rot.T @ x_world
x_local = x_local / max(np.linalg.norm(x_local), 1e-12)
# ── First pick ──
if self._connector_first_pick is None:
self._connector_first_pick = {
"ac_id": ac_id,
"origin": tuple(origin),
"normal": tuple(normal),
"x_dir": tuple(x_dir),
"origin_local": tuple(pos_local),
"normal_local": tuple(n_local),
"x_dir_local": tuple(x_local),
"origin_world": tuple(origin),
"normal_world": tuple(normal),
"entity_type": entity_type,
"owner_obj_id": owner_obj_id,
}
# Highlight the first face.
self._viewer_3d.highlight_face(face_shape)
self.setStatusTip("Now click on the second component's connection face")
logger.info(f"Connector first pick: {ac.name} at {origin}")
# Highlight the first face if planar.
if entity_type in ("planar_face", "cylindrical_face"):
self._viewer_3d.highlight_face(raw_shape)
self.setStatusTip("Now click on the second component's connection point/face/edge/hole")
logger.info(f"Connector first pick: {ac.name} at {origin} ({entity_type})")
return
# ── Second pick ──
@@ -5201,99 +5440,350 @@ class MainWindow(QMainWindow):
self._btn_add_connector.setChecked(False)
self.setStatusTip("")
logger.info(f"Connector second pick: {ac.name} at {origin}")
logger.info(f"Connector second pick: {ac.name} at {origin} ({entity_type})")
# Compute rough alignment transform (simulating SolveSpace).
# Move the second component so its connector face aligns with the
# first: translate so origin2 → origin1.
o1 = np.array(first["origin"])
o2 = np.array(origin)
align_delta = o1 - o2
# Build connector records (local coords).
second_pick = {
"ac_id": ac_id,
"origin_local": tuple(pos_local),
"normal_local": tuple(n_local),
"x_dir_local": tuple(x_local),
"origin_world": tuple(origin),
"normal_world": tuple(normal),
"entity_type": entity_type,
"owner_obj_id": owner_obj_id,
}
# Store the alignment position on the second component temporarily.
ac2 = assembly.components.get(ac_id)
if ac2 is not None:
self._connector_align_pos = np.array(ac2.position, dtype=float) + align_delta
else:
self._connector_align_pos = align_delta
# SolveSpace alignment: move second component so its connector
# aligns with the first. First component is fixed.
first_ac = assembly.components.get(first["ac_id"])
second_ac = ac
# Show the connector dialog with live preview callback.
rotation, offset = self._show_connector_dialog_with_preview(
first_ac=assembly.components.get(first["ac_id"]),
second_ac=ac2,
first_origin=first["origin"],
second_origin=o2,
first_normal=first["normal"],
second_normal=normal,
first_x_dir=first["x_dir"],
second_x_dir=x_dir,
align_delta=align_delta,
# Compute the world target for the second connector.
# It's at the first connector world position.
target_pos = np.array(first["origin_world"], dtype=float)
target_normal = np.array(first["normal_world"], dtype=float)
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
# SolveSpace solver call.
solved = self._solve_assembly_alignment(
first_ac=first_ac,
second_ac=second_ac,
first_pick=first,
second_pick=second_pick,
)
if solved is None:
QMessageBox.warning(self, "Solver Error",
"SolveSpace could not align the components.")
self._connector_first_pick = None
self._connector_second_ac_id = None
self._show_assembly_in_viewer(fit=True)
return
# Apply solved transform to second component.
second_ac.position = solved["position"]
second_ac.rotation = solved["rotation"]
# Show dialog with live preview (rotation offset along normal).
rotation, offset, flip = self._show_connector_dialog_with_preview(
first_ac=first_ac,
second_ac=second_ac,
first_pick=first,
second_pick=second_pick,
solved=solved,
)
if rotation is None:
# User cancelled — restore original position.
if ac2 is not None:
self._show_assembly_in_viewer()
second_ac.position = np.array(solved["original_position"], dtype=float)
second_ac.rotation = np.array(solved["original_rotation"], dtype=float)
self._connector_first_pick = None
self._connector_second_ac_id = None
self._show_assembly_in_viewer(fit=True)
return
# Create connectors on both components.
first_ac = assembly.components.get(first["ac_id"])
# Apply dialog adjustments (rotation + offset + flip).
import numpy as np
# Build rotation matrix: rotate second connector normal around
# the target normal axis by rotation degrees.
angle_rad = np.radians(rotation)
# Rodrigues' rotation formula around target_normal.
k = target_normal
K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
# Apply axis rotation to the solved rotation.
second_ac.rotation = R_axis @ second_ac.rotation
# Offset along the (possibly flipped) target normal.
flip_sign = -1.0 if flip else 1.0
second_ac.position = second_ac.position + flip_sign * target_normal * offset
# Create connectors on both components and link them as a mated pair.
conn1 = None
conn2 = None
if first_ac:
conn1 = first_ac.add_connector(
position=first["origin"],
normal=first["normal"],
x_dir=first["x_dir"],
position=first["origin_world"],
normal=first["normal_world"],
x_dir=first["x_dir_local"],
source_obj_id=first["owner_obj_id"],
name="Connector A",
name=f"Conn {entity_type} A",
)
conn1.axis_rotation = rotation
conn1.offset = offset
# The first-picked connector is the grounded reference of the pair.
conn1.is_grounded = True
if ac2:
conn2 = ac2.add_connector(
position=tuple(o2),
normal=tuple(normal),
x_dir=tuple(x_dir),
if second_ac:
conn2 = second_ac.add_connector(
position=tuple(second_ac.position + second_ac.rotation @ np.array(second_pick["origin_local"])),
normal=tuple(second_ac.rotation @ np.array(second_pick["normal_local"])),
x_dir=tuple(second_ac.rotation @ np.array(second_pick["x_dir_local"])),
source_obj_id=owner_obj_id,
name="Connector B",
name=f"Conn {entity_type} B",
)
conn2.axis_rotation = rotation
conn2.offset = offset
# Apply the aligned position (with dialog adjustments).
n2 = np.array(normal) / max(np.linalg.norm(normal), 1e-12)
ac2.position = self._connector_align_pos + n2 * offset
logger.info(f"Aligned '{ac2.name}' to connector position, offset={offset}mm")
# Cross-link the partners so the rigid-group move handler can follow
# the edge, and register the pair on the assembly graph.
if conn1 is not None and conn2 is not None:
conn1.partner_ac_id = second_ac.id
conn1.partner_connector_id = conn2.id
conn2.partner_ac_id = first_ac.id
conn2.partner_connector_id = conn1.id
assembly.add_connection(first_ac.id, second_ac.id)
logger.info(f"Connected component pair: {first['ac_id']}{ac_id}, rotation={rotation}°")
logger.info(f"Connected component pair: {first['ac_id']}{ac_id}, rotation={rotation}°, offset={offset}mm, flip={flip}")
self._connector_first_pick = None
self._connector_second_ac_id = None
self._show_assembly_in_viewer(fit=True)
@staticmethod
def _rotation_between_vectors(a, b):
"""Return a 3×3 rotation that maps vector *a* onto vector *b*.
Handles the two degenerate cases that plain Rodrigues' formula gets
wrong when the cross-product axis collapses to zero:
* ``a b`` identity (no rotation needed).
* ``a -b`` a 180° rotation about any axis orthogonal to *a*
(picked by a stable reference-vector projection).
Vectors are internally normalized so callers may pass non-unit input.
"""
import numpy as _np
import math as _math
a = _np.asarray(a, dtype=float)
b = _np.asarray(b, dtype=float)
an = _np.linalg.norm(a); bn = _np.linalg.norm(b)
if an < 1e-12 or bn < 1e-12:
return _np.eye(3)
a = a / an; b = b / bn
dot = float(_np.dot(a, b))
cross = _np.cross(a, b)
cross_norm = _np.linalg.norm(cross)
if cross_norm < 1e-9:
if dot > 0.0:
# Already aligned.
return _np.eye(3)
# Anti-parallel: 180° about an axis orthogonal to *a*.
ref = _np.array([1.0, 0.0, 0.0]) if abs(a[0]) < 0.9 else _np.array([0.0, 1.0, 0.0])
axis = ref - a * _np.dot(ref, a)
axis = axis / max(_np.linalg.norm(axis), 1e-12)
K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
# sin(180°)=0, 1-cos(180°)=2 → R = I + 2 (K @ K)
return _np.eye(3) + 2.0 * (K @ K)
axis = cross / cross_norm
angle = _math.acos(max(-1.0, min(1.0, dot)))
K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
return _np.eye(3) + _np.sin(angle) * K + (1.0 - _np.cos(angle)) * (K @ K)
def _solve_assembly_alignment(
self,
first_ac: Any,
second_ac: Any,
first_pick: Dict[str, Any],
second_pick: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Use SolveSpace to align the second component to the first.
The first component is treated as fixed (grounded). The second
component is moved so that its connector coincides with the first
connector (position + normal alignment).
Returns a dict with:
* ``position`` new world position for second component.
* ``rotation`` new 3×3 rotation matrix for second component.
* ``original_position`` / ``original_rotation`` for cancellation.
"""
import numpy as np
try:
from python_solvespace import SolverSystem, ResultFlag, Entity
except ImportError:
logger.warning("python_solvespace not available, falling back to direct alignment")
return self._align_direct(first_ac, second_ac, first_pick, second_pick)
# Save original transform for cancellation.
orig_pos = np.array(second_ac.position, dtype=float)
orig_rot = np.array(second_ac.rotation, dtype=float)
# World positions of connectors.
p1_world = np.array(first_pick["origin_world"], dtype=float)
n1_world = np.array(first_pick["normal_world"], dtype=float)
n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12)
p2_local = np.array(second_pick["origin_local"], dtype=float)
n2_local = np.array(second_pick["normal_local"], dtype=float)
n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12)
# Build solver.
#
# IMPORTANT: SolveSpace's SLVS_C_PARALLEL and SLVS_C_SAME_ORIENTATION
# both generate multi-equation residuals that trigger a hard C-level
# assertion in this python_solvespace build's Newton iterator
# ("Expected constraint to generate a single equation"), aborting the
# whole process. We therefore avoid line-parallel / orientation
# constraints entirely and instead drive BOTH translation AND axis
# alignment with a pair of coincident point constraints:
#
# * coincident(pt1, pt2) — forces the connector points together
# (3 translational DOF)
# * coincident(pt1b, tip2) — pins the *axis tip* of component 2
# onto a fixed point on component 1's
# connector axis, which forces the
# rotated axis direction to align
# with n1 (2 rotational DOF)
#
# That's 6 single-equation-coincident residuals against 6 free point
# parameters — a well-posed 0-DOF system — so it converges cleanly.
# The remaining free rotation around the axis is left for the
# rotation_spinner in the dialog.
sys = SolverSystem()
# Component 1 reference frame — fully grounded (dragged). pt1 is the
# connector pivot, pt1b is one unit along the connector normal.
pt1 = sys.add_point_3d(float(p1_world[0]), float(p1_world[1]), float(p1_world[2]))
sys.dragged(pt1, Entity.FREE_IN_3D)
pt1b = sys.add_point_3d(
float(p1_world[0] + n1_world[0]),
float(p1_world[1] + n1_world[1]),
float(p1_world[2] + n1_world[2]),
)
sys.dragged(pt1b, Entity.FREE_IN_3D)
# Component 2 — free points, seeded near the current world connector.
p2_world_current = orig_pos + orig_rot @ p2_local
pt2 = sys.add_point_3d(float(p2_world_current[0]), float(p2_world_current[1]), float(p2_world_current[2]))
n2_world_current = orig_rot @ n2_local
tip2 = sys.add_point_3d(
float(p2_world_current[0] + n2_world_current[0]),
float(p2_world_current[1] + n2_world_current[1]),
float(p2_world_current[2] + n2_world_current[2]),
)
# Constraints: pivot coincidence + axis-tip coincidence.
sys.coincident(pt1, pt2, Entity.FREE_IN_3D)
sys.coincident(pt1b, tip2, Entity.FREE_IN_3D)
# Solve.
result = sys.solve()
if result != ResultFlag.OKAY:
logger.warning(f"SolveSpace solve failed: {result}")
return self._align_direct(first_ac, second_ac, first_pick, second_pick)
# Extract solved positions from the point entities' parameter tables.
# ``Entity`` does not expose .x/.y/.z — read them via SolverSystem.params.
p2_solved = np.array(sys.params(pt2.params), dtype=float)
tip2_solved = np.array(sys.params(tip2.params), dtype=float)
n2_solved = tip2_solved - p2_solved
n2_solved = n2_solved / max(np.linalg.norm(n2_solved), 1e-12)
# Compute the new component transform.
# The second connector in local coords is at p2_local with normal n2_local.
# In world space: P + R @ p2_local = p2_solved
# R @ n2_local = n2_solved
# We need to find R and P.
# R must map n2_local → n2_solved.
# Use a rotation that aligns the two vectors.
from OCP.gp import gp_Vec, gp_Dir, gp_Ax1, gp_Trsf
# Compute the rotation mapping the connector's local axis to its
# solved world direction. Use the robust helper so the degenerate
# anti-parallel case (cross → 0 but angle = 180°) is handled properly.
R_align = self._rotation_between_vectors(n2_local, n2_solved)
# The full rotation for the component.
new_rot = R_align @ orig_rot
# New position: P = p2_solved - R @ p2_local
new_pos = p2_solved - new_rot @ p2_local
return {
"position": new_pos,
"rotation": new_rot,
"original_position": orig_pos,
"original_rotation": orig_rot,
}
def _align_direct(
self,
first_ac: Any,
second_ac: Any,
first_pick: Dict[str, Any],
second_pick: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Direct geometric alignment (fallback when SolveSpace unavailable).
Moves the second component so its connector matches the first.
"""
import numpy as np
orig_pos = np.array(second_ac.position, dtype=float)
orig_rot = np.array(second_ac.rotation, dtype=float)
p1_world = np.array(first_pick["origin_world"], dtype=float)
n1_world = np.array(first_pick["normal_world"], dtype=float)
n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12)
p2_local = np.array(second_pick["origin_local"], dtype=float)
n2_local = np.array(second_pick["normal_local"], dtype=float)
n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12)
# Align normals through the robust rotation helper so the
# anti-parallel case is handled correctly (see _rotation_between_vectors).
R_align = self._rotation_between_vectors(n2_local, n1_world)
new_rot = R_align @ orig_rot
p2_world_target = p1_world
new_pos = p2_world_target - new_rot @ p2_local
return {
"position": new_pos,
"rotation": new_rot,
"original_position": orig_pos,
"original_rotation": orig_rot,
}
def _show_connector_dialog_with_preview(
self,
first_ac: Any,
second_ac: Any,
first_origin: Tuple[float, float, float],
second_origin: Tuple[float, float, float],
first_normal: Tuple[float, float, float],
second_normal: Tuple[float, float, float],
first_x_dir: Tuple[float, float, float],
second_x_dir: Tuple[float, float, float],
align_delta: Any,
) -> Tuple[Optional[float], Optional[float]]:
first_pick: Dict[str, Any],
second_pick: Dict[str, Any],
solved: Dict[str, Any],
) -> Tuple[Optional[float], Optional[float], bool]:
"""Show connector dialog with live 3D preview of the alignment.
Returns (rotation_degrees, offset_mm) or (None, None) if cancelled.
Returns (rotation_degrees, offset_mm, flip) or (None, None, False) if cancelled.
"""
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
QLabel, QDoubleSpinBox, QPushButton,
QFrame, QCheckBox)
if second_ac is None:
return (None, None)
return (None, None, False)
dialog = QDialog(self)
dialog.setWindowTitle("Connector — Connection Properties")
@@ -5301,10 +5791,18 @@ class MainWindow(QMainWindow):
layout = QVBoxLayout(dialog)
# Info label.
layout.addWidget(QLabel("Adjust the connection between the two components:"))
entity_names = {
"planar_face": "Face",
"cylindrical_face": "Hole",
"edge": "Edge",
"vertex": "Vertex",
}
t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity")
t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity")
layout.addWidget(QLabel(f"<b>{t1}</b> on {first_ac.name} → <b>{t2}</b> on {second_ac.name}"))
layout.addWidget(QLabel("Adjust the connection:"))
# Rotation around normal axis of the FIRST connector.
# Rotation around normal axis.
rot_layout = QHBoxLayout()
rot_layout.addWidget(QLabel("Rotation around axis (°):"))
rotation_spin = QDoubleSpinBox()
@@ -5341,24 +5839,34 @@ class MainWindow(QMainWindow):
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
import numpy as np
target_normal = np.array(first_pick["normal_world"], dtype=float)
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
# ── Live preview callback ──
def _update_preview(*args):
import numpy as np
rot_deg = rotation_spin.value()
off = offset_spin.value()
flip = flip_check.isChecked()
# Compute the aligned position for second component.
base_pos = self._connector_align_pos if hasattr(self, '_connector_align_pos') else (
np.array(second_ac.position, dtype=float) + align_delta
)
# Apply offset along the second normal.
n2 = np.array(second_normal) / max(np.linalg.norm(second_normal), 1e-12)
new_pos = np.array(base_pos, dtype=float) + n2 * off
# Start from solved transform.
base_pos = np.array(solved["position"], dtype=float)
base_rot = np.array(solved["rotation"], dtype=float)
# Store preview position.
second_ac.position = new_pos
self._show_assembly_in_viewer() # no fit — keep camera steady during live preview
# Apply axis rotation around target_normal.
angle_rad = np.radians(rot_deg)
k = target_normal
K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
preview_rot = R_axis @ base_rot
# Apply offset (with flip).
flip_sign = -1.0 if flip else 1.0
preview_pos = base_pos + flip_sign * target_normal * off
second_ac.position = preview_pos
second_ac.rotation = preview_rot
self._show_assembly_in_viewer() # no fit — keep camera steady
rotation_spin.valueChanged.connect(_update_preview)
offset_spin.valueChanged.connect(_update_preview)
@@ -5370,15 +5878,9 @@ class MainWindow(QMainWindow):
ok_btn.clicked.connect(dialog.accept)
cancel_btn.clicked.connect(dialog.reject)
def _on_dialog_close():
nonlocal dialog
if dialog.result() != QDialog.DialogCode.Accepted:
# User cancelled — no changes applied, caller will restore.
pass
if dialog.exec():
return (rotation_spin.value(), offset_spin.value())
return (None, None)
return (rotation_spin.value(), offset_spin.value(), flip_check.isChecked())
return (None, None, False)
def _on_delete_connector(self):
"""Delete the connector nearest to the selected assembly component."""
@@ -5406,6 +5908,27 @@ class MainWindow(QMainWindow):
if ok and label:
idx = conn_labels.index(label)
conn_id = conn_names[idx]
conn = ac.connectors.get(conn_id)
# Un-partner the mate and drop the rigid-group edge so stale
# connections don't linger in the BFS graph.
if conn is not None:
partner_ac_id = conn.partner_ac_id
partner_conn_id = conn.partner_connector_id
if partner_ac_id is not None and partner_conn_id is not None:
partner_ac = assembly.components.get(partner_ac_id)
if partner_ac is not None and partner_conn_id in partner_ac.connectors:
pc = partner_ac.connectors[partner_conn_id]
pc.partner_ac_id = None
pc.partner_connector_id = None
pc.is_grounded = False
# Remove the connection edge either side references this pair.
assembly.connections = [
c for c in assembly.connections
if not (
(c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
)
] if partner_ac_id is not None else assembly.connections
ac.remove_connector(conn_id)
logger.info(f"Removed connector {conn_id}")
self._show_assembly_in_viewer(fit=True)