- Working assembly multi :)
This commit is contained in:
+351
-139
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user