- Working assembly multi :)

This commit is contained in:
bklronin
2026-07-11 21:29:58 +02:00
parent 2b2afbc479
commit d7e5929a13
5 changed files with 812 additions and 197 deletions
+351 -139
View File
@@ -11,7 +11,7 @@ import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect, QSettings
from PySide6.QtGui import (
QAction,
QColor,
@@ -22,6 +22,8 @@ from PySide6.QtGui import (
QPainterPath,
QPen,
)
MAX_RECENT_PROJECTS = 10
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
@@ -286,10 +288,14 @@ class MainWindow(QMainWindow):
# doesn't immediately show as "modified" in the title bar.
self._suspend_dirty: bool = True
# ── Settings (persistent preferences) ──
self._settings = QSettings("FluencyCAD", "FluencyCAD")
self._setup_ui()
self._setup_connections()
self._create_initial_component()
self._create_initial_assembly()
self._setup_recent_projects()
self._suspend_dirty = False
self._update_window_title()
logger.info("MainWindow initialization complete")
@@ -327,6 +333,20 @@ class MainWindow(QMainWindow):
self._ui.actionExport_Stl.triggered.connect(self._export_stl)
self._ui.actionExit.triggered.connect(self.close)
# ── Recent Projects submenu (runtime-only) ──
file_menu = self._ui.menuFile
self._recent_projects_menu = QMenu("Recent Projects", self)
file_menu.addMenu(self._recent_projects_menu)
self._update_recent_menu()
# ── Load Last Project on Startup toggle ──
file_menu.addSeparator()
self._action_load_last = QAction("Load Last Project on Startup", self)
self._action_load_last.setCheckable(True)
self._action_load_last.setChecked(self._settings.value("load_last_on_startup", False, type=bool))
self._action_load_last.toggled.connect(self._toggle_load_last_project)
file_menu.addAction(self._action_load_last)
# ── View menu (runtime-only, not in the .ui) ──
view_menu = self.menuBar().addMenu("&View")
view_menu.addAction("Fit All", self._fit_view)
@@ -981,14 +1001,14 @@ class MainWindow(QMainWindow):
def _make_connector_marker(self, position: Tuple[float, float, float],
color: Tuple[float, float, float] = (1.0, 0.3, 0.0)) -> Optional[Any]:
"""Create a small sphere marker for a connector at *position*.
"""Create a sphere marker for a connector at *position*.
Returns the TopoDS_Shape of a tiny sphere, or None on failure.
Returns the TopoDS_Shape of a sphere, or None on failure.
"""
try:
from OCP.gp import gp_Pnt
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), 2.0).Shape()
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), 4.0).Shape()
return sphere
except Exception as exc:
logger.debug(f"Failed to create connector marker: {exc}")
@@ -1052,9 +1072,13 @@ class MainWindow(QMainWindow):
self._asm_render_objects[ac_id] = render_ids
# Show connector markers for this instance.
# Connector positions are stored in component-local coords;
# transform to world coords for rendering.
for conn_id, conn in ac.connectors.items():
try:
sphere_shape = self._make_connector_marker(conn.position)
local_pos = np.array(conn.position, dtype=float)
world_pos = ac.position + ac.rotation @ local_pos
sphere_shape = self._make_connector_marker(tuple(world_pos))
if sphere_shape is not None:
self._viewer_3d.show_shape(
sphere_shape,
@@ -1072,8 +1096,9 @@ class MainWindow(QMainWindow):
Removes the existing render objects for *ac_id* from the viewer
and recreates them at the component's current position/rotation.
Other components and connector markers are left untouched — no
scene clear, so the camera stays perfectly still.
Connector markers are also updated to follow the component.
Other components are left untouched — no scene clear, so the
camera stays perfectly still.
"""
assembly = self._get_assembly()
ac = assembly.components.get(ac_id) if assembly else None
@@ -1092,6 +1117,13 @@ class MainWindow(QMainWindow):
except Exception:
pass
# Remove old connector markers for this component.
for conn_id in list(ac.connectors.keys()):
try:
self._viewer_3d.remove_mesh(f"conn_{ac_id}_{conn_id}")
except Exception:
pass
is_selected = (ac_id == self._selected_assembly_component_id)
color = (0.2, 0.6, 1.0) if is_selected else (0.5, 0.5, 0.5)
@@ -1113,6 +1145,23 @@ class MainWindow(QMainWindow):
except Exception as exc:
logger.debug(f"Failed to update body {body_id}: {exc}")
# Re-add connector markers at updated world positions.
import numpy as np
for conn_id, conn in ac.connectors.items():
try:
local_pos = np.array(conn.position, dtype=float)
world_pos = ac.position + ac.rotation @ local_pos
sphere_shape = self._make_connector_marker(tuple(world_pos))
if sphere_shape is not None:
self._viewer_3d.show_shape(
sphere_shape,
color=(1.0, 0.3, 0.0), # Orange
name=f"conn_{ac_id}_{conn_id}",
)
new_ids.append(f"conn_{ac_id}_{conn_id}")
except Exception as exc:
logger.debug(f"Failed to update connector {conn_id}: {exc}")
self._asm_render_objects[ac_id] = new_ids
# ────────────────────────────────────────────────────────────────────
@@ -1271,13 +1320,15 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage("Move over a face / edge / hole / vertex to snap")
return
entity_type = info.get("type", "")
feature_type = info.get("feature_type", "")
suggestion = info.get("suggestion", "")
names = {
"planar_face": "Face",
"cylindrical_face": "Hole",
"edge": "Edge",
"vertex": "Vertex",
}
name = names.get(entity_type, "Entity")
name = names.get(entity_type, names.get(feature_type, "Entity"))
ac_id = self._parse_ac_id(info.get("owner_obj_id", ""))
comp_name = ""
if ac_id is not None:
@@ -1285,7 +1336,11 @@ class MainWindow(QMainWindow):
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")
# Show suggestion if available, otherwise generic message.
if suggestion:
self.statusBar().showMessage(f"{name}{comp_name}: {suggestion} — click to pick")
else:
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.
@@ -1353,7 +1408,8 @@ class MainWindow(QMainWindow):
self._connector_second_ac_id = ac_id
self._viewer_3d.clear_face_highlight()
self._viewer_3d.set_connector_pick_mode(False)
# Keep gizmo visible until next hover so user sees what was picked.
self._viewer_3d.set_connector_pick_mode(False, clear_gizmo=False)
self._btn_add_connector.setChecked(False)
self.setStatusTip("")
@@ -1371,23 +1427,26 @@ class MainWindow(QMainWindow):
"owner_obj_id": owner_obj_id,
}
# SolveSpace alignment: move second component so its connector
# aligns with the first. First component is fixed.
# SolveSpace alignment: move appropriate component so its connector
# coincides with the anchor's connector. The chronologically first
# component added to the assembly is the global anchor — it stays
# locked in world space. All solving keeps it fixed.
first_ac = assembly.components.get(first["ac_id"])
second_ac = ac
anchor_ac_id = next(iter(assembly.components.keys()))
# 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)
# Compute the world target normal (from the anchor's connector).
anchor_pick_source = first if anchor_ac_id == first["ac_id"] else second_pick
target_pos = np.array(anchor_pick_source["origin_world"], dtype=float)
target_normal = np.array(anchor_pick_source["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,
anchor_component_id=anchor_ac_id,
)
if solved is None:
@@ -1398,23 +1457,36 @@ class MainWindow(QMainWindow):
self._show_assembly_in_viewer(fit=True)
return
# Apply solved transform to second component.
second_ac.position = solved["position"]
second_ac.rotation = solved["rotation"]
# Apply solved transform to the component the solver actually moved.
moved_ac_id = solved["moved_ac_id"]
moved_ac = assembly.components.get(moved_ac_id)
if moved_ac is not None:
moved_ac.position = solved["position"]
moved_ac.rotation = solved["rotation"]
# Chain auto-offset: if the anchor already has a rigid group (>1
# member), auto-offset the moved component along the connector
# normal so it doesn't stack at the same point.
if assembly.get_group_size(anchor_ac_id) > 1 and moved_ac is not None:
auto_offset = 50.0
moved_ac.position = moved_ac.position + target_normal * auto_offset
# Show dialog with live preview (rotation offset along normal).
moved_comp_before_dialog = assembly.components.get(moved_ac_id)
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,
mover_ac=moved_ac,
)
if rotation is None:
# User cancelled — restore original position.
second_ac.position = np.array(solved["original_position"], dtype=float)
second_ac.rotation = np.array(solved["original_rotation"], dtype=float)
if moved_comp_before_dialog is not None:
moved_comp_before_dialog.position = np.array(solved["original_position"], dtype=float)
moved_comp_before_dialog.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)
@@ -1430,50 +1502,54 @@ class MainWindow(QMainWindow):
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
# Apply dialog adjustments to the MOVED component.
if moved_ac is not None:
moved_ac.rotation = R_axis @ moved_ac.rotation
flip_sign = -1.0 if flip else 1.0
moved_ac.position = moved_ac.position + flip_sign * target_normal * offset
# 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
# Determine which pick is the anchor and which is the mover.
anchor_pick = first if anchor_ac_id == first["ac_id"] else second_pick
mover_pick = second_pick if anchor_ac_id == first["ac_id"] else first
anchor_comp = assembly.components.get(anchor_ac_id)
mover_comp = assembly.components.get(mover_pick["ac_id"])
# 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_world"],
normal=first["normal_world"],
x_dir=first["x_dir_local"],
source_obj_id=first["owner_obj_id"],
name=f"Conn {entity_type} A",
# Create connectors on both sides and link them as a mated pair.
conn_a = None
conn_m = None
if anchor_comp:
conn_a = anchor_comp.add_connector(
position=anchor_pick["origin_local"],
normal=anchor_pick["normal_local"],
x_dir=anchor_pick["x_dir_local"],
source_obj_id=anchor_pick["owner_obj_id"],
name=f"Conn {anchor_pick['entity_type']} anchor",
)
conn1.axis_rotation = rotation
conn1.offset = offset
# The first-picked connector is the grounded reference of the pair.
conn1.is_grounded = True
conn_a.axis_rotation = rotation
conn_a.offset = offset
conn_a.is_grounded = True
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=f"Conn {entity_type} B",
if mover_comp:
conn_m = mover_comp.add_connector(
position=mover_pick["origin_local"],
normal=mover_pick["normal_local"],
x_dir=mover_pick["x_dir_local"],
source_obj_id=mover_pick["owner_obj_id"],
name=f"Conn {mover_pick['entity_type']} mover",
)
conn2.axis_rotation = rotation
conn2.offset = offset
conn_m.axis_rotation = rotation
conn_m.offset = offset
# 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)
# Cross-link the partners and register the pair on the assembly graph.
if conn_a is not None and conn_m is not None:
conn_a.partner_ac_id = mover_comp.id if mover_comp else ""
conn_a.partner_connector_id = conn_m.id
conn_m.partner_ac_id = anchor_comp.id if anchor_comp else ""
conn_m.partner_connector_id = conn_a.id
assembly.add_connection(anchor_ac_id, moved_ac_id)
logger.info(f"Connected component pair: {first['ac_id']}{ac_id}, rotation={rotation}°, offset={offset}mm, flip={flip}")
logger.info(f"Connected: anchor={anchor_ac_id} ↔ moved={moved_ac_id}, "
f"rotation={rotation}°, offset={offset}mm, flip={flip}")
self._connector_first_pick = None
self._connector_second_ac_id = None
self._mark_dirty()
@@ -1525,16 +1601,19 @@ class MainWindow(QMainWindow):
second_ac: Any,
first_pick: Dict[str, Any],
second_pick: Dict[str, Any],
anchor_component_id: Optional[str] = None,
) -> 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
The anchor component (either ``anchor_component_id`` or, failing that,
the ``first_ac``) is treated as fixed (grounded). The solver moves
the *other* component so its connector coincides with the anchor's
connector (position + normal alignment).
Returns a dict with:
* ``position`` — new world position for second component.
* ``rotation`` — new 3×3 rotation matrix for second component.
* ``position`` — new world position for the moved component.
* ``rotation`` — new 3×3 rotation matrix for the moved component.
* ``moved_ac_id`` — which component was moved.
* ``original_position`` / ``original_rotation`` — for cancellation.
"""
import numpy as np
@@ -1542,20 +1621,43 @@ class MainWindow(QMainWindow):
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)
return self._align_direct(first_ac, second_ac, first_pick, second_pick,
anchor_component_id=anchor_component_id)
# ── Determine anchor and mover ──
# The anchor component stays locked. Prefer anchor_component_id
# (the first-added component); fall back to first_ac (the first click).
assembly = self._get_assembly()
if anchor_component_id:
anchor_ac = assembly.components.get(anchor_component_id) if assembly else None
else:
anchor_ac = first_ac
if anchor_ac is None:
anchor_ac = first_ac
# The mover is whichever of first_ac / second_ac is NOT the anchor.
if second_ac.id == anchor_ac.id:
mover_ac = first_ac
mover_pick = first_pick
anchor_pick = second_pick
else:
mover_ac = second_ac
mover_pick = second_pick
anchor_pick = first_pick
# Save original transform for cancellation.
orig_pos = np.array(second_ac.position, dtype=float)
orig_rot = np.array(second_ac.rotation, dtype=float)
orig_pos = np.array(mover_ac.position, dtype=float)
orig_rot = np.array(mover_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)
# World positions of anchor connector (grounded).
a_world = np.array(anchor_pick["origin_world"], dtype=float)
n_anchor = np.array(anchor_pick["normal_world"], dtype=float)
n_anchor = n_anchor / max(np.linalg.norm(n_anchor), 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)
# Local positions of mover connector (solved).
m_local = np.array(mover_pick["origin_local"], dtype=float)
n_local = np.array(mover_pick["normal_local"], dtype=float)
n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
# Build solver.
#
@@ -1567,13 +1669,11 @@ class MainWindow(QMainWindow):
# 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)
# * coincident(pt_anchor, pt_mover) — forces the connector points
# together (3 trans DOF)
# * coincident(pt_anchor_tip, tip_mover) — pins the mover's axis
# tip onto the anchor's
# normal line (2 rot DOF)
#
# That's 6 single-equation-coincident residuals against 6 free point
# parameters — a well-posed 0-DOF system — so it converges cleanly.
@@ -1581,67 +1681,54 @@ class MainWindow(QMainWindow):
# 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]),
# Anchor (grounded) reference frame.
pt_anchor = sys.add_point_3d(float(a_world[0]), float(a_world[1]), float(a_world[2]))
sys.dragged(pt_anchor, Entity.FREE_IN_3D)
pt_anchor_tip = sys.add_point_3d(
float(a_world[0] + n_anchor[0]),
float(a_world[1] + n_anchor[1]),
float(a_world[2] + n_anchor[2]),
)
sys.dragged(pt1b, Entity.FREE_IN_3D)
sys.dragged(pt_anchor_tip, 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]),
# Mover (free) points, seeded near its current world connector.
m_world_current = orig_pos + orig_rot @ m_local
pt_mover = sys.add_point_3d(
float(m_world_current[0]), float(m_world_current[1]), float(m_world_current[2])
)
n_world_current = orig_rot @ n_local
tip_mover = sys.add_point_3d(
float(m_world_current[0] + n_world_current[0]),
float(m_world_current[1] + n_world_current[1]),
float(m_world_current[2] + n_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)
sys.coincident(pt_anchor, pt_mover, Entity.FREE_IN_3D)
sys.coincident(pt_anchor_tip, tip_mover, 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)
return self._align_direct(first_ac, second_ac, first_pick, second_pick,
anchor_component_id=anchor_component_id)
# 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)
# Extract solved positions.
p_solved = np.array(sys.params(pt_mover.params), dtype=float)
tip_solved = np.array(sys.params(tip_mover.params), dtype=float)
n_solved = tip_solved - p_solved
n_solved = n_solved / max(np.linalg.norm(n_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.
R_align = self._rotation_between_vectors(n_local, n_solved)
new_rot = R_align @ orig_rot
# New position: P = p2_solved - R @ p2_local
new_pos = p2_solved - new_rot @ p2_local
new_pos = p_solved - new_rot @ m_local
return {
"position": new_pos,
"rotation": new_rot,
"moved_ac_id": mover_ac.id,
"original_position": orig_pos,
"original_rotation": orig_rot,
}
@@ -1652,34 +1739,55 @@ class MainWindow(QMainWindow):
second_ac: Any,
first_pick: Dict[str, Any],
second_pick: Dict[str, Any],
anchor_component_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Direct geometric alignment (fallback when SolveSpace unavailable).
Moves the second component so its connector matches the first.
Moves the non-anchor component so its connector coincides with the
anchor's connector.
"""
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)
# ── Determine anchor and mover ──
assembly = self._get_assembly()
if anchor_component_id:
anchor_ac = assembly.components.get(anchor_component_id) if assembly else None
else:
anchor_ac = first_ac
if anchor_ac is None:
anchor_ac = first_ac
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)
if second_ac.id == anchor_ac.id:
mover_ac = first_ac
mover_pick = first_pick
anchor_pick = second_pick
else:
mover_ac = second_ac
mover_pick = second_pick
anchor_pick = first_pick
# 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)
orig_pos = np.array(mover_ac.position, dtype=float)
orig_rot = np.array(mover_ac.rotation, dtype=float)
# World position of the anchor connector (locked target).
a_world = np.array(anchor_pick["origin_world"], dtype=float)
n_anchor = np.array(anchor_pick["normal_world"], dtype=float)
n_anchor = n_anchor / max(np.linalg.norm(n_anchor), 1e-12)
# Mover's connector in local coords.
m_local = np.array(mover_pick["origin_local"], dtype=float)
n_local = np.array(mover_pick["normal_local"], dtype=float)
n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
# Align mover's normal to anchor's normal.
R_align = self._rotation_between_vectors(n_local, n_anchor)
new_rot = R_align @ orig_rot
p2_world_target = p1_world
new_pos = p2_world_target - new_rot @ p2_local
new_pos = a_world - new_rot @ m_local
return {
"position": new_pos,
"rotation": new_rot,
"moved_ac_id": mover_ac.id,
"original_position": orig_pos,
"original_rotation": orig_rot,
}
@@ -1691,6 +1799,7 @@ class MainWindow(QMainWindow):
first_pick: Dict[str, Any],
second_pick: Dict[str, Any],
solved: Dict[str, Any],
mover_ac: Any = None,
) -> Tuple[Optional[float], Optional[float], bool]:
"""Show connector dialog with live 3D preview of the alignment.
@@ -1702,6 +1811,9 @@ class MainWindow(QMainWindow):
if second_ac is None:
return (None, None, False)
# The component to preview adjustments on — defaults to second_ac
# but can be overridden via mover_ac (for anchor-aware solving).
preview_target = mover_ac if mover_ac is not None else second_ac
dialog = QDialog(self)
dialog.setWindowTitle("Connector — Connection Properties")
@@ -1782,8 +1894,8 @@ class MainWindow(QMainWindow):
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
preview_target.position = preview_pos
preview_target.rotation = preview_rot
self._show_assembly_in_viewer() # no fit — keep camera steady
rotation_spin.valueChanged.connect(_update_preview)
@@ -3166,6 +3278,83 @@ class MainWindow(QMainWindow):
self._refresh_lists()
logger.info(f"Deleted body: {name}")
# ── Recent Projects ──────────────────────────────────────────────
def _setup_recent_projects(self) -> None:
"""Restore the recent projects menu from settings on startup."""
self._update_recent_menu()
# Auto-load last project if the preference is enabled.
if self._settings.value("load_last_on_startup", False, type=bool):
recent = self._settings.value("recent_projects", [], type=list)
if recent and os.path.isfile(recent[0]):
self._suspend_dirty = True
try:
self._open_project_file(recent[0])
except Exception as exc:
logger.warning("Failed to auto-load last project: %s", exc)
finally:
self._suspend_dirty = False
def _get_recent_projects(self) -> List[str]:
"""Return the list of recent project paths from QSettings."""
return self._settings.value("recent_projects", [], type=list)
def _add_recent_project(self, path: str) -> None:
"""Add *path* to the top of the recent-projects list."""
recent = self._get_recent_projects()
# Normalize and deduplicate.
path = os.path.abspath(path)
if path in recent:
recent.remove(path)
recent.insert(0, path)
# Trim to max.
recent = recent[:MAX_RECENT_PROJECTS]
self._settings.setValue("recent_projects", recent)
self._update_recent_menu()
def _update_recent_menu(self) -> None:
"""Rebuild the Recent Projects submenu from the stored list."""
self._recent_projects_menu.clear()
recent = self._get_recent_projects()
if not recent:
action = self._recent_projects_menu.addAction("(Empty)")
action.setEnabled(False)
return
for path in recent:
name = os.path.basename(path)
action = self._recent_projects_menu.addAction(f"{name}{path}")
# Use the full path as data so we can open it.
action.setData(path)
action.triggered.connect(self._open_recent_project)
self._recent_projects_menu.addSeparator()
clear_action = self._recent_projects_menu.addAction("Clear Recent Projects")
clear_action.triggered.connect(self._clear_recent_projects)
@Slot()
def _open_recent_project(self) -> None:
"""Open the project whose action was clicked."""
action = self.sender()
if action is None:
return
path = action.data()
if path and os.path.isfile(path):
self._open_project_file(path)
else:
QMessageBox.warning(self, "File Not Found", f"Project not found:\n{path}")
@Slot(bool)
def _toggle_load_last_project(self, checked: bool) -> None:
"""Persist the "Load last project on startup" preference."""
self._settings.setValue("load_last_on_startup", checked)
@Slot()
def _clear_recent_projects(self) -> None:
"""Empty the recent projects list."""
self._settings.setValue("recent_projects", [])
self._update_recent_menu()
# ── Project save / load ─────────────────────────────────────────
def _new_project(self):
if not self._confirm_discard_changes():
return
@@ -3362,6 +3551,7 @@ class MainWindow(QMainWindow):
self._project.file_path = path
self._dirty = False
self._update_window_title()
self._add_recent_project(path)
self.statusBar().showMessage(f"Saved: {os.path.basename(path)}", 5000)
logger.info("Saved project: %s", path)
return True
@@ -3456,6 +3646,27 @@ class MainWindow(QMainWindow):
b.setChecked(False)
self._component_buttons[idx].setChecked(True)
# Rebuild assembly component buttons (one per assembly instance).
for assembly in self._project.assemblies.values():
for ac_id, ac in assembly.components.items():
instance_num = len(self._assembly_component_buttons) + 1
btn = QPushButton(str(instance_num))
btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40))
btn.setToolTip(f"{ac.name} (instance {instance_num})")
btn._assembly_component_id = ac.id
btn.clicked.connect(self._on_assembly_component_clicked)
self._assembly_component_buttons.append(btn)
self._assembly_component_group.addButton(btn)
self._assembly_box_layout.addWidget(btn)
# Restore the active assembly component selection.
if assembly.active_assembly_component and assembly.active_assembly_component in assembly.components:
for b in self._assembly_component_buttons:
if getattr(b, '_assembly_component_id', None) == assembly.active_assembly_component:
b.setChecked(True)
self._selected_assembly_component_id = assembly.active_assembly_component
break
# If the saved view says we're in assembly view, switch over.
if view_state.get("assembly_view_active") and self._project.assemblies:
self._assembly_view_active = True
@@ -3486,6 +3697,7 @@ class MainWindow(QMainWindow):
self._project_path = path
self._dirty = False
self._update_window_title()
self._add_recent_project(path)
self.statusBar().showMessage(f"Opened: {os.path.basename(path)}", 5000)
logger.info("Opened project: %s", path)
return True