- 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
+12 -1
View File
@@ -6,7 +6,10 @@
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Added save file foramt&#10;- Split main.py refactor"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Added save file foramt&#10;- Split main.py refactor">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -367,7 +370,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1783755278516</updated> <updated>1783755278516</updated>
</task> </task>
<option name="localTasksCounter" value="33" /> <task id="LOCAL-00033" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783777171864</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1783777171864</updated>
</task>
<option name="localTasksCounter" value="34" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
+17 -5
View File
@@ -351,11 +351,14 @@ class Connector:
id: str = field(default_factory=lambda: str(uuid.uuid4())) id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Connector" name: str = "Untitled Connector"
# 3D position of the connection point (world coords). # 3D position of the connection point (component-local coords).
# Transformed to world coords at render time using the parent
# AssemblyComponent's position/rotation, so connectors move with
# their component automatically.
position: Tuple[float, float, float] = (0.0, 0.0, 0.0) position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
# Normal direction of the connection (e.g. hole axis). # Normal direction of the connection (e.g. hole axis) in local coords.
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0) normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
# In-plane X direction for defining the reference frame. # In-plane X direction for defining the reference frame (local coords).
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0) x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
# Rotation around the normal axis (degrees). # Rotation around the normal axis (degrees).
@@ -486,9 +489,14 @@ class Assembly:
"""Record a mated connector pair between two component instances. """Record a mated connector pair between two component instances.
The first-picked component (``first_ac_id``) is treated as the The first-picked component (``first_ac_id``) is treated as the
grounded reference of the pair. Returns the AssemblyConnection for grounded reference of the pair. Guards against duplicate entries.
further bookkeeping (e.g. attaching partner connector ids). Returns the AssemblyConnection for further bookkeeping.
""" """
# Guard: deduplicate — same pair in either order
for c in self.connections:
if (c.first_ac_id == first_ac_id and c.second_ac_id == second_ac_id) or \
(c.first_ac_id == second_ac_id and c.second_ac_id == first_ac_id):
return c
conn = AssemblyConnection( conn = AssemblyConnection(
first_ac_id=first_ac_id, first_ac_id=first_ac_id,
second_ac_id=second_ac_id, second_ac_id=second_ac_id,
@@ -534,6 +542,10 @@ class Assembly:
"""True if *ac_id* is the grounded (first-picked) side of any pair.""" """True if *ac_id* is the grounded (first-picked) side of any pair."""
return any(c.first_ac_id == ac_id for c in self.connections) return any(c.first_ac_id == ac_id for c in self.connections)
def get_group_size(self, ac_id: str) -> int:
"""Number of components rigidly linked to *ac_id* (including itself)."""
return len(self.get_rigid_group(ac_id))
def add_component_instance( def add_component_instance(
self, component_id: str, name: Optional[str] = None self, component_id: str, name: Optional[str] = None
) -> AssemblyComponent: ) -> AssemblyComponent:
+334 -25
View File
@@ -1334,25 +1334,6 @@ class OCCRenderer(Renderer):
return [] return []
def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]:
"""Project a 3D world point to (x, y) screen pixel.
Uses OCC's ``V3d_View.Convert`` (world → view coords). Returns None
if the projection fails (e.g. behind the camera).
"""
if self._view is None:
return None
try:
# OCC's Convert returns the window pixel coordinates.
xpix = self._view.Convert(float(p3d[0]), float(p3d[1]), float(p3d[2]))
# Some OCP builds return a tuple (x, y); others return two values.
if isinstance(xpix, (tuple, list)) and len(xpix) == 2:
return (int(xpix[0]), int(xpix[1]))
return None
except Exception:
# Fall back to ConvertWithProj or ProjTexte if Convert is unavailable.
return None
def probe_snap_candidates( def probe_snap_candidates(
self, x: int, y: int, radius: int = 30, self, x: int, y: int, radius: int = 30,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
@@ -1429,8 +1410,8 @@ class OCCRenderer(Renderer):
results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2) results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2)
return results return results
def highlight_snap(self, position, color=None, size=3.0) -> Optional[str]: def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]:
"""Show a small marker sphere at *position* as a snap indicator. """Show a marker sphere at *position* as a snap indicator.
Returns an object id that can be removed later. Returns an object id that can be removed later.
The *size* is auto-scaled by camera distance so the marker stays The *size* is auto-scaled by camera distance so the marker stays
@@ -1454,7 +1435,7 @@ class OCCRenderer(Renderer):
self._view.Update() self._view.Update()
# Track this as a temporary object; use a synthetic id. # Track this as a temporary object; use a synthetic id.
oid = f"__snap_{id(ais)}" oid = f"__snap_{id(ais)}"
self._objects[oid] = _RenderObject(oid, ais, None, None) self._objects[oid] = OCCRenderObject(obj_id=oid, ais_shape=ais, ais_type="snap")
return oid return oid
except Exception as exc: except Exception as exc:
logger.debug(f"highlight_snap failed: {exc}") logger.debug(f"highlight_snap failed: {exc}")
@@ -1596,13 +1577,13 @@ class OCCRenderer(Renderer):
): ):
continue continue
cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7)) cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7))
_make_sphere(cpos, cc, 1.4 * gizmo_scale) # dim, small _make_sphere(cpos, cc, 2.8 * gizmo_scale) # dim, small
# ── 1. Bright primary marker (sphere) ── # ── 1. Bright primary marker (sphere) ──
_make_sphere(position, gizmo_color, 2.8 * gizmo_scale) _make_sphere(position, gizmo_color, 5.6 * gizmo_scale)
# ── 2. Axis indicator lines (primary only) ── # ── 2. Axis indicator lines (primary only) ──
axis_length = 15.0 * gizmo_scale axis_length = 30.0 * gizmo_scale
def _make_axis_line( def _make_axis_line(
origin: Tuple[float, float, float], origin: Tuple[float, float, float],
@@ -1690,6 +1671,334 @@ class OCCRenderer(Renderer):
if self._view is not None: if self._view is not None:
self._view.Update() self._view.Update()
# ─── Selection mode control ───────────────────────────────────────────
#
# When connector gizmo mode is active, standard OCC face/edge/vertex
# selection is deactivated so dynamic highlighting does not interfere
# with the gizmo visuals. The geometric probing method below replaces
# the selection-system-based probe.
def deactivate_selection_modes(self) -> None:
"""Deactivate OCC face/edge/vertex selection on every tracked AIS shape.
Used when entering connector gizmo mode so that standard dynamic
highlighting (MoveTo) does not interfere with the gizmo visuals.
Call :meth:`activate_selection_modes` to restore.
"""
if self._context is None:
return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape
for robj in self._objects.values():
if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
mode = AIS_Shape.SelectionMode_s(topo)
try:
self._context.Deactivate(robj.ais_shape, mode)
except Exception:
pass
logger.debug("Selection modes deactivated for all AIS shapes")
def activate_selection_modes(self) -> None:
"""Re-activate OCC face/edge/vertex selection on every tracked AIS shape.
Called when exiting connector gizmo mode to restore normal
dynamic highlighting and face-pick behaviour.
"""
if self._context is None:
return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape
for robj in self._objects.values():
if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
mode = AIS_Shape.SelectionMode_s(topo)
try:
self._context.Activate(robj.ais_shape, mode)
except Exception:
pass
logger.debug("Selection modes re-activated for all AIS shapes")
# ─── Geometric snap probing (selection-system-independent) ────────────
#
# Walks every AIS shape's topology directly, projects each feature to
# screen, and returns candidates within *radius* pixels of the cursor.
# This replaces the MoveTo-based probe when selection modes are
# deactivated (connector gizmo mode).
def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]:
"""Project a 3D world point to (x, y) screen pixel.
Uses OCC's ``V3d_View.Convert`` (world → view coords). Returns None
if the projection fails (e.g. behind the camera).
"""
if self._view is None:
return None
try:
# OCC's Convert returns the window pixel coordinates.
xpix = self._view.Convert(float(p3d[0]), float(p3d[1]), float(p3d[2]))
# Some OCP builds return a tuple (x, y); others return two values.
if isinstance(xpix, (tuple, list)) and len(xpix) == 2:
return (int(xpix[0]), int(xpix[1]))
return None
except Exception:
# Fall back to ConvertWithProj or ProjTexte if Convert is unavailable.
return None
def probe_snap_candidates_geometric(
self, x: int, y: int, radius: int = 30,
) -> List[Dict[str, Any]]:
"""Probe snap candidates by iterating geometry directly (no selection system).
Uses a two-pass approach for performance:
1. **Bounding-box pre-filter**: projects each shape's 3D bbox to screen;
skips shapes whose screen bbox is far from the cursor.
2. **Feature iteration**: for nearby shapes only, walks faces/edges/vertices,
projects each feature to screen, and collects candidates within
*radius* pixels.
This replaces ``probe_snap_candidates`` when selection modes are
deactivated (connector gizmo mode).
"""
if self._view is None or self._context is None:
return []
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.TopoDS import TopoDS
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
import numpy as np
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
# Expand the search radius for the bbox pre-filter so features near
# the screen edge of a shape are not missed.
margin = radius + 40
for robj in self._objects.values():
if robj.ais_shape is None:
continue
try:
shape = robj.ais_shape.Shape()
except Exception:
continue
if shape is None:
continue
# ── Pass 0: bounding-box pre-filter ──
# Project the shape's 3D AABB to screen. If the cursor is
# outside the screen bbox (with margin), skip this shape entirely.
try:
bbox = Bnd_Box()
BRepBndLib.Add_s(shape, bbox)
if bbox.IsVoid():
continue
bx0, by0, bz0, bx1, by1, bz1 = bbox.Get()
# Project the 8 AABB corners to screen.
corners = [
(bx0, by0, bz0), (bx1, by0, bz0),
(bx0, by1, bz0), (bx1, by1, bz0),
(bx0, by0, bz1), (bx1, by0, bz1),
(bx0, by1, bz1), (bx1, by1, bz1),
]
sx_min, sy_min = 99999, 99999
sx_max, sy_max = -99999, -99999
all_behind = True
for c in corners:
sp = self._project_to_screen(c)
if sp is not None:
all_behind = False
sx_min = min(sx_min, sp[0])
sy_min = min(sy_min, sp[1])
sx_max = max(sx_max, sp[0])
sy_max = max(sy_max, sp[1])
if all_behind:
continue
# Check if cursor is within margin of the screen bbox.
if (x < sx_min - margin or x > sx_max + margin or
y < sy_min - margin or y > sy_max + margin):
continue
except Exception:
pass # If bbox fails, fall through and try features.
# ── Pass 1: iterate only nearby shapes ──
# --- Faces ---
face_expl = TopExp_Explorer(shape, TopAbs_FACE)
while face_expl.More():
face = TopoDS.Face_s(face_expl.Current())
infos = self._classify_detected_shape(face, robj.obj_id)
for info in infos:
pos = info.get("position") or (0.0, 0.0, 0.0)
sp = self._project_to_screen(pos)
if sp is None:
continue
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
if dist2 <= radius * radius:
key = (
info.get("owner_obj_id", ""),
info.get("type", ""),
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
)
if key not in candidates:
info["screen"] = sp
candidates[key] = info
face_expl.Next()
# --- Edges ---
edge_expl = TopExp_Explorer(shape, TopAbs_EDGE)
while edge_expl.More():
edge = TopoDS.Edge_s(edge_expl.Current())
infos = self._classify_detected_shape(edge, robj.obj_id)
for info in infos:
pos = info.get("position") or (0.0, 0.0, 0.0)
sp = self._project_to_screen(pos)
if sp is None:
continue
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
if dist2 <= radius * radius:
key = (
info.get("owner_obj_id", ""),
info.get("type", ""),
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
)
if key not in candidates:
info["screen"] = sp
candidates[key] = info
edge_expl.Next()
# --- Vertices ---
vert_expl = TopExp_Explorer(shape, TopAbs_VERTEX)
while vert_expl.More():
vertex = TopoDS.Vertex_s(vert_expl.Current())
infos = self._classify_detected_shape(vertex, robj.obj_id)
for info in infos:
pos = info.get("position") or (0.0, 0.0, 0.0)
sp = self._project_to_screen(pos)
if sp is None:
continue
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
if dist2 <= radius * radius:
key = (
info.get("owner_obj_id", ""),
info.get("type", ""),
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
)
if key not in candidates:
info["screen"] = sp
candidates[key] = info
vert_expl.Next()
# Sort by screen-space distance to cursor, nearest first.
results = list(candidates.values())
results.sort(
key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2
+ (c.get("screen", (x, y))[1] - y) ** 2
)
return results
def recognize_composite_features(
self, candidates: List[Dict[str, Any]], x: int, y: int, radius: int = 30
) -> List[Dict[str, Any]]:
"""Enhance raw entity candidates with composite feature recognition.
Groups nearby entities and recognizes composite features like:
* **hole** cylindrical face (bolt/shaft insertion point)
* **edge_loop** circular edge loop (alignment target)
* **meeting_edges** vertex shared by two edges (corner constraint)
* **mating_surface** large planar face (assembly plane)
Each candidate gets additional fields:
* ``feature_type`` composite feature name (e.g. "hole", "edge_loop")
* ``suggestion`` human-readable snap suggestion
* ``feature_data`` dict with feature-specific info (radius, axis, etc.)
"""
import numpy as np
from collections import defaultdict
# Group candidates by owner_obj_id.
by_owner: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for c in candidates:
owner = c.get("owner_obj_id", "")
if owner:
by_owner[owner].append(c)
enhanced: List[Dict[str, Any]] = []
for c in candidates:
ec = dict(c) # copy
etype = c.get("type", "")
pos = c.get("position", (0, 0, 0))
owner = c.get("owner_obj_id", "")
# ── Cylindrical face → hole / bolt insertion ──
if etype == "cylindrical_face":
ec["feature_type"] = "hole"
ec["suggestion"] = "Bolt / shaft insertion point"
ec["feature_data"] = {
"axis": c.get("normal"),
"radius": c.get("radius"),
"center": pos,
}
enhanced.append(ec)
continue
# ── Planar face → mating surface ──
if etype == "planar_face":
ec["feature_type"] = "mating_surface"
ec["suggestion"] = "Assembly mating plane"
ec["feature_data"] = {
"normal": c.get("normal"),
"center": pos,
}
enhanced.append(ec)
continue
# ── Edge → check for circular edge loop ──
if etype == "edge":
# Look for other edges nearby that might form a loop.
nearby_edges = [
n for n in candidates
if n.get("type") == "edge"
and n.get("owner_obj_id") == owner
and n is not c
]
# For now, mark as edge — loop detection is complex.
ec["feature_type"] = "edge"
ec["suggestion"] = "Edge midpoint snap"
ec["feature_data"] = {
"tangent": c.get("normal"),
"midpoint": pos,
}
enhanced.append(ec)
continue
# ── Vertex → check for meeting edges ──
if etype == "vertex":
# Look for edges that share this vertex (nearby edges).
nearby_edges = [
n for n in candidates
if n.get("type") == "edge"
and n.get("owner_obj_id") == owner
]
if len(nearby_edges) >= 2:
ec["feature_type"] = "meeting_edges"
ec["suggestion"] = "Corner constraint (vertex)"
ec["feature_data"] = {
"vertex": pos,
"edge_count": len(nearby_edges),
}
else:
ec["feature_type"] = "vertex"
ec["suggestion"] = "Vertex snap"
ec["feature_data"] = {"vertex": pos}
enhanced.append(ec)
continue
# Fallback: pass through unchanged.
enhanced.append(ec)
return enhanced
# ─── Mouse / keyboard event forwarding ────────────────────────────── # ─── Mouse / keyboard event forwarding ──────────────────────────────
# #
# CAD-style navigation: # CAD-style navigation:
+350 -138
View File
@@ -11,7 +11,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple 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 ( from PySide6.QtGui import (
QAction, QAction,
QColor, QColor,
@@ -22,6 +22,8 @@ from PySide6.QtGui import (
QPainterPath, QPainterPath,
QPen, QPen,
) )
MAX_RECENT_PROJECTS = 10
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QButtonGroup, QButtonGroup,
@@ -286,10 +288,14 @@ class MainWindow(QMainWindow):
# doesn't immediately show as "modified" in the title bar. # doesn't immediately show as "modified" in the title bar.
self._suspend_dirty: bool = True self._suspend_dirty: bool = True
# ── Settings (persistent preferences) ──
self._settings = QSettings("FluencyCAD", "FluencyCAD")
self._setup_ui() self._setup_ui()
self._setup_connections() self._setup_connections()
self._create_initial_component() self._create_initial_component()
self._create_initial_assembly() self._create_initial_assembly()
self._setup_recent_projects()
self._suspend_dirty = False self._suspend_dirty = False
self._update_window_title() self._update_window_title()
logger.info("MainWindow initialization complete") logger.info("MainWindow initialization complete")
@@ -327,6 +333,20 @@ class MainWindow(QMainWindow):
self._ui.actionExport_Stl.triggered.connect(self._export_stl) self._ui.actionExport_Stl.triggered.connect(self._export_stl)
self._ui.actionExit.triggered.connect(self.close) 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 (runtime-only, not in the .ui) ──
view_menu = self.menuBar().addMenu("&View") view_menu = self.menuBar().addMenu("&View")
view_menu.addAction("Fit All", self._fit_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], def _make_connector_marker(self, position: Tuple[float, float, float],
color: Tuple[float, float, float] = (1.0, 0.3, 0.0)) -> Optional[Any]: 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: try:
from OCP.gp import gp_Pnt from OCP.gp import gp_Pnt
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere 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 return sphere
except Exception as exc: except Exception as exc:
logger.debug(f"Failed to create connector marker: {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 self._asm_render_objects[ac_id] = render_ids
# Show connector markers for this instance. # 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(): for conn_id, conn in ac.connectors.items():
try: 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: if sphere_shape is not None:
self._viewer_3d.show_shape( self._viewer_3d.show_shape(
sphere_shape, sphere_shape,
@@ -1072,8 +1096,9 @@ class MainWindow(QMainWindow):
Removes the existing render objects for *ac_id* from the viewer Removes the existing render objects for *ac_id* from the viewer
and recreates them at the component's current position/rotation. and recreates them at the component's current position/rotation.
Other components and connector markers are left untouched no Connector markers are also updated to follow the component.
scene clear, so the camera stays perfectly still. Other components are left untouched no scene clear, so the
camera stays perfectly still.
""" """
assembly = self._get_assembly() assembly = self._get_assembly()
ac = assembly.components.get(ac_id) if assembly else None ac = assembly.components.get(ac_id) if assembly else None
@@ -1092,6 +1117,13 @@ class MainWindow(QMainWindow):
except Exception: except Exception:
pass 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) 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) 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: except Exception as exc:
logger.debug(f"Failed to update body {body_id}: {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 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") self.statusBar().showMessage("Move over a face / edge / hole / vertex to snap")
return return
entity_type = info.get("type", "") entity_type = info.get("type", "")
feature_type = info.get("feature_type", "")
suggestion = info.get("suggestion", "")
names = { names = {
"planar_face": "Face", "planar_face": "Face",
"cylindrical_face": "Hole", "cylindrical_face": "Hole",
"edge": "Edge", "edge": "Edge",
"vertex": "Vertex", "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", "")) ac_id = self._parse_ac_id(info.get("owner_obj_id", ""))
comp_name = "" comp_name = ""
if ac_id is not None: if ac_id is not None:
@@ -1285,6 +1336,10 @@ class MainWindow(QMainWindow):
ac = assembly.components.get(ac_id) if assembly else None ac = assembly.components.get(ac_id) if assembly else None
if ac is not None: if ac is not None:
comp_name = f" on {ac.name}" comp_name = f" on {ac.name}"
# 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") 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): def _on_connector_picked(self, origin, normal, x_dir, entity_type, raw_shape, owner_obj_id):
@@ -1353,7 +1408,8 @@ class MainWindow(QMainWindow):
self._connector_second_ac_id = ac_id self._connector_second_ac_id = ac_id
self._viewer_3d.clear_face_highlight() 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._btn_add_connector.setChecked(False)
self.setStatusTip("") self.setStatusTip("")
@@ -1371,23 +1427,26 @@ class MainWindow(QMainWindow):
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
} }
# SolveSpace alignment: move second component so its connector # SolveSpace alignment: move appropriate component so its connector
# aligns with the first. First component is fixed. # 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"]) first_ac = assembly.components.get(first["ac_id"])
second_ac = ac second_ac = ac
anchor_ac_id = next(iter(assembly.components.keys()))
# Compute the world target for the second connector. # Compute the world target normal (from the anchor's connector).
# It's at the first connector world position. anchor_pick_source = first if anchor_ac_id == first["ac_id"] else second_pick
target_pos = np.array(first["origin_world"], dtype=float) target_pos = np.array(anchor_pick_source["origin_world"], dtype=float)
target_normal = np.array(first["normal_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) target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
# SolveSpace solver call.
solved = self._solve_assembly_alignment( solved = self._solve_assembly_alignment(
first_ac=first_ac, first_ac=first_ac,
second_ac=second_ac, second_ac=second_ac,
first_pick=first, first_pick=first,
second_pick=second_pick, second_pick=second_pick,
anchor_component_id=anchor_ac_id,
) )
if solved is None: if solved is None:
@@ -1398,23 +1457,36 @@ class MainWindow(QMainWindow):
self._show_assembly_in_viewer(fit=True) self._show_assembly_in_viewer(fit=True)
return return
# Apply solved transform to second component. # Apply solved transform to the component the solver actually moved.
second_ac.position = solved["position"] moved_ac_id = solved["moved_ac_id"]
second_ac.rotation = solved["rotation"] 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). # 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( rotation, offset, flip = self._show_connector_dialog_with_preview(
first_ac=first_ac, first_ac=first_ac,
second_ac=second_ac, second_ac=second_ac,
first_pick=first, first_pick=first,
second_pick=second_pick, second_pick=second_pick,
solved=solved, solved=solved,
mover_ac=moved_ac,
) )
if rotation is None: if rotation is None:
# User cancelled — restore original position. # User cancelled — restore original position.
second_ac.position = np.array(solved["original_position"], dtype=float) if moved_comp_before_dialog is not None:
second_ac.rotation = np.array(solved["original_rotation"], dtype=float) 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_first_pick = None
self._connector_second_ac_id = None self._connector_second_ac_id = None
self._show_assembly_in_viewer(fit=True) 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]]) 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) R_axis = np.eye(3) + np.sin(angle_rad) * K + (1 - np.cos(angle_rad)) * (K @ K)
# Apply axis rotation to the solved rotation. # Apply dialog adjustments to the MOVED component.
second_ac.rotation = R_axis @ second_ac.rotation if moved_ac is not None:
moved_ac.rotation = R_axis @ moved_ac.rotation
# Offset along the (possibly flipped) target normal.
flip_sign = -1.0 if flip else 1.0 flip_sign = -1.0 if flip else 1.0
second_ac.position = second_ac.position + flip_sign * target_normal * offset moved_ac.position = moved_ac.position + flip_sign * target_normal * offset
# Create connectors on both components and link them as a mated pair. # Determine which pick is the anchor and which is the mover.
conn1 = None anchor_pick = first if anchor_ac_id == first["ac_id"] else second_pick
conn2 = None mover_pick = second_pick if anchor_ac_id == first["ac_id"] else first
if first_ac: anchor_comp = assembly.components.get(anchor_ac_id)
conn1 = first_ac.add_connector( mover_comp = assembly.components.get(mover_pick["ac_id"])
position=first["origin_world"],
normal=first["normal_world"], # Create connectors on both sides and link them as a mated pair.
x_dir=first["x_dir_local"], conn_a = None
source_obj_id=first["owner_obj_id"], conn_m = None
name=f"Conn {entity_type} A", 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 conn_a.axis_rotation = rotation
conn1.offset = offset conn_a.offset = offset
# The first-picked connector is the grounded reference of the pair. conn_a.is_grounded = True
conn1.is_grounded = True
if second_ac: if mover_comp:
conn2 = second_ac.add_connector( conn_m = mover_comp.add_connector(
position=tuple(second_ac.position + second_ac.rotation @ np.array(second_pick["origin_local"])), position=mover_pick["origin_local"],
normal=tuple(second_ac.rotation @ np.array(second_pick["normal_local"])), normal=mover_pick["normal_local"],
x_dir=tuple(second_ac.rotation @ np.array(second_pick["x_dir_local"])), x_dir=mover_pick["x_dir_local"],
source_obj_id=owner_obj_id, source_obj_id=mover_pick["owner_obj_id"],
name=f"Conn {entity_type} B", name=f"Conn {mover_pick['entity_type']} mover",
) )
conn2.axis_rotation = rotation conn_m.axis_rotation = rotation
conn2.offset = offset conn_m.offset = offset
# Cross-link the partners so the rigid-group move handler can follow # Cross-link the partners and register the pair on the assembly graph.
# the edge, and register the pair on the assembly graph. if conn_a is not None and conn_m is not None:
if conn1 is not None and conn2 is not None: conn_a.partner_ac_id = mover_comp.id if mover_comp else ""
conn1.partner_ac_id = second_ac.id conn_a.partner_connector_id = conn_m.id
conn1.partner_connector_id = conn2.id conn_m.partner_ac_id = anchor_comp.id if anchor_comp else ""
conn2.partner_ac_id = first_ac.id conn_m.partner_connector_id = conn_a.id
conn2.partner_connector_id = conn1.id assembly.add_connection(anchor_ac_id, moved_ac_id)
assembly.add_connection(first_ac.id, second_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_first_pick = None
self._connector_second_ac_id = None self._connector_second_ac_id = None
self._mark_dirty() self._mark_dirty()
@@ -1525,16 +1601,19 @@ class MainWindow(QMainWindow):
second_ac: Any, second_ac: Any,
first_pick: Dict[str, Any], first_pick: Dict[str, Any],
second_pick: Dict[str, Any], second_pick: Dict[str, Any],
anchor_component_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Use SolveSpace to align the second component to the first. """Use SolveSpace to align the second component to the first.
The first component is treated as fixed (grounded). The second The anchor component (either ``anchor_component_id`` or, failing that,
component is moved so that its connector coincides with the first 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). connector (position + normal alignment).
Returns a dict with: Returns a dict with:
* ``position`` new world position for second component. * ``position`` new world position for the moved component.
* ``rotation`` new 3×3 rotation matrix for second component. * ``rotation`` new 3×3 rotation matrix for the moved component.
* ``moved_ac_id`` which component was moved.
* ``original_position`` / ``original_rotation`` for cancellation. * ``original_position`` / ``original_rotation`` for cancellation.
""" """
import numpy as np import numpy as np
@@ -1542,20 +1621,43 @@ class MainWindow(QMainWindow):
from python_solvespace import SolverSystem, ResultFlag, Entity from python_solvespace import SolverSystem, ResultFlag, Entity
except ImportError: except ImportError:
logger.warning("python_solvespace not available, falling back to direct alignment") 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. # Save original transform for cancellation.
orig_pos = np.array(second_ac.position, dtype=float) orig_pos = np.array(mover_ac.position, dtype=float)
orig_rot = np.array(second_ac.rotation, dtype=float) orig_rot = np.array(mover_ac.rotation, dtype=float)
# World positions of connectors. # World positions of anchor connector (grounded).
p1_world = np.array(first_pick["origin_world"], dtype=float) a_world = np.array(anchor_pick["origin_world"], dtype=float)
n1_world = np.array(first_pick["normal_world"], dtype=float) n_anchor = np.array(anchor_pick["normal_world"], dtype=float)
n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12) n_anchor = n_anchor / max(np.linalg.norm(n_anchor), 1e-12)
p2_local = np.array(second_pick["origin_local"], dtype=float) # Local positions of mover connector (solved).
n2_local = np.array(second_pick["normal_local"], dtype=float) m_local = np.array(mover_pick["origin_local"], dtype=float)
n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12) n_local = np.array(mover_pick["normal_local"], dtype=float)
n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
# Build solver. # Build solver.
# #
@@ -1567,13 +1669,11 @@ class MainWindow(QMainWindow):
# constraints entirely and instead drive BOTH translation AND axis # constraints entirely and instead drive BOTH translation AND axis
# alignment with a pair of coincident point constraints: # alignment with a pair of coincident point constraints:
# #
# * coincident(pt1, pt2) — forces the connector points together # * coincident(pt_anchor, pt_mover) — forces the connector points
# (3 translational DOF) # together (3 trans DOF)
# * coincident(pt1b, tip2) — pins the *axis tip* of component 2 # * coincident(pt_anchor_tip, tip_mover) — pins the mover's axis
# onto a fixed point on component 1's # tip onto the anchor's
# connector axis, which forces the # normal line (2 rot DOF)
# rotated axis direction to align
# with n1 (2 rotational DOF)
# #
# That's 6 single-equation-coincident residuals against 6 free point # That's 6 single-equation-coincident residuals against 6 free point
# parameters — a well-posed 0-DOF system — so it converges cleanly. # parameters — a well-posed 0-DOF system — so it converges cleanly.
@@ -1581,67 +1681,54 @@ class MainWindow(QMainWindow):
# rotation_spinner in the dialog. # rotation_spinner in the dialog.
sys = SolverSystem() sys = SolverSystem()
# Component 1 reference frame — fully grounded (dragged). pt1 is the # Anchor (grounded) reference frame.
# connector pivot, pt1b is one unit along the connector normal. pt_anchor = sys.add_point_3d(float(a_world[0]), float(a_world[1]), float(a_world[2]))
pt1 = sys.add_point_3d(float(p1_world[0]), float(p1_world[1]), float(p1_world[2])) sys.dragged(pt_anchor, Entity.FREE_IN_3D)
sys.dragged(pt1, Entity.FREE_IN_3D) pt_anchor_tip = sys.add_point_3d(
pt1b = sys.add_point_3d( float(a_world[0] + n_anchor[0]),
float(p1_world[0] + n1_world[0]), float(a_world[1] + n_anchor[1]),
float(p1_world[1] + n1_world[1]), float(a_world[2] + n_anchor[2]),
float(p1_world[2] + n1_world[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. # Mover (free) points, seeded near its current world connector.
p2_world_current = orig_pos + orig_rot @ p2_local m_world_current = orig_pos + orig_rot @ m_local
pt2 = sys.add_point_3d(float(p2_world_current[0]), float(p2_world_current[1]), float(p2_world_current[2])) pt_mover = sys.add_point_3d(
n2_world_current = orig_rot @ n2_local float(m_world_current[0]), float(m_world_current[1]), float(m_world_current[2])
tip2 = sys.add_point_3d( )
float(p2_world_current[0] + n2_world_current[0]), n_world_current = orig_rot @ n_local
float(p2_world_current[1] + n2_world_current[1]), tip_mover = sys.add_point_3d(
float(p2_world_current[2] + n2_world_current[2]), 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. # Constraints: pivot coincidence + axis-tip coincidence.
sys.coincident(pt1, pt2, Entity.FREE_IN_3D) sys.coincident(pt_anchor, pt_mover, Entity.FREE_IN_3D)
sys.coincident(pt1b, tip2, Entity.FREE_IN_3D) sys.coincident(pt_anchor_tip, tip_mover, Entity.FREE_IN_3D)
# Solve. # Solve.
result = sys.solve() result = sys.solve()
if result != ResultFlag.OKAY: if result != ResultFlag.OKAY:
logger.warning(f"SolveSpace solve failed: {result}") 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. # Extract solved positions.
# ``Entity`` does not expose .x/.y/.z — read them via SolverSystem.params. p_solved = np.array(sys.params(pt_mover.params), dtype=float)
p2_solved = np.array(sys.params(pt2.params), dtype=float) tip_solved = np.array(sys.params(tip_mover.params), dtype=float)
tip2_solved = np.array(sys.params(tip2.params), dtype=float) n_solved = tip_solved - p_solved
n2_solved = tip2_solved - p2_solved n_solved = n_solved / max(np.linalg.norm(n_solved), 1e-12)
n2_solved = n2_solved / max(np.linalg.norm(n2_solved), 1e-12)
# Compute the new component transform. # Compute the new component transform.
# The second connector in local coords is at p2_local with normal n2_local. R_align = self._rotation_between_vectors(n_local, n_solved)
# 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_rot = R_align @ orig_rot
new_pos = p_solved - new_rot @ m_local
# New position: P = p2_solved - R @ p2_local
new_pos = p2_solved - new_rot @ p2_local
return { return {
"position": new_pos, "position": new_pos,
"rotation": new_rot, "rotation": new_rot,
"moved_ac_id": mover_ac.id,
"original_position": orig_pos, "original_position": orig_pos,
"original_rotation": orig_rot, "original_rotation": orig_rot,
} }
@@ -1652,34 +1739,55 @@ class MainWindow(QMainWindow):
second_ac: Any, second_ac: Any,
first_pick: Dict[str, Any], first_pick: Dict[str, Any],
second_pick: Dict[str, Any], second_pick: Dict[str, Any],
anchor_component_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Direct geometric alignment (fallback when SolveSpace unavailable). """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 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) # ── Determine anchor and mover ──
n1_world = np.array(first_pick["normal_world"], dtype=float) assembly = self._get_assembly()
n1_world = n1_world / max(np.linalg.norm(n1_world), 1e-12) 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) if second_ac.id == anchor_ac.id:
n2_local = np.array(second_pick["normal_local"], dtype=float) mover_ac = first_ac
n2_local = n2_local / max(np.linalg.norm(n2_local), 1e-12) 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 orig_pos = np.array(mover_ac.position, dtype=float)
# anti-parallel case is handled correctly (see _rotation_between_vectors). orig_rot = np.array(mover_ac.rotation, dtype=float)
R_align = self._rotation_between_vectors(n2_local, n1_world)
# 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 new_rot = R_align @ orig_rot
p2_world_target = p1_world new_pos = a_world - new_rot @ m_local
new_pos = p2_world_target - new_rot @ p2_local
return { return {
"position": new_pos, "position": new_pos,
"rotation": new_rot, "rotation": new_rot,
"moved_ac_id": mover_ac.id,
"original_position": orig_pos, "original_position": orig_pos,
"original_rotation": orig_rot, "original_rotation": orig_rot,
} }
@@ -1691,6 +1799,7 @@ class MainWindow(QMainWindow):
first_pick: Dict[str, Any], first_pick: Dict[str, Any],
second_pick: Dict[str, Any], second_pick: Dict[str, Any],
solved: Dict[str, Any], solved: Dict[str, Any],
mover_ac: Any = None,
) -> Tuple[Optional[float], Optional[float], bool]: ) -> Tuple[Optional[float], Optional[float], bool]:
"""Show connector dialog with live 3D preview of the alignment. """Show connector dialog with live 3D preview of the alignment.
@@ -1702,6 +1811,9 @@ class MainWindow(QMainWindow):
if second_ac is None: if second_ac is None:
return (None, None, False) 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 = QDialog(self)
dialog.setWindowTitle("Connector — Connection Properties") dialog.setWindowTitle("Connector — Connection Properties")
@@ -1782,8 +1894,8 @@ class MainWindow(QMainWindow):
flip_sign = -1.0 if flip else 1.0 flip_sign = -1.0 if flip else 1.0
preview_pos = base_pos + flip_sign * target_normal * off preview_pos = base_pos + flip_sign * target_normal * off
second_ac.position = preview_pos preview_target.position = preview_pos
second_ac.rotation = preview_rot preview_target.rotation = preview_rot
self._show_assembly_in_viewer() # no fit — keep camera steady self._show_assembly_in_viewer() # no fit — keep camera steady
rotation_spin.valueChanged.connect(_update_preview) rotation_spin.valueChanged.connect(_update_preview)
@@ -3166,6 +3278,83 @@ class MainWindow(QMainWindow):
self._refresh_lists() self._refresh_lists()
logger.info(f"Deleted body: {name}") 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): def _new_project(self):
if not self._confirm_discard_changes(): if not self._confirm_discard_changes():
return return
@@ -3362,6 +3551,7 @@ class MainWindow(QMainWindow):
self._project.file_path = path self._project.file_path = path
self._dirty = False self._dirty = False
self._update_window_title() self._update_window_title()
self._add_recent_project(path)
self.statusBar().showMessage(f"Saved: {os.path.basename(path)}", 5000) self.statusBar().showMessage(f"Saved: {os.path.basename(path)}", 5000)
logger.info("Saved project: %s", path) logger.info("Saved project: %s", path)
return True return True
@@ -3456,6 +3646,27 @@ class MainWindow(QMainWindow):
b.setChecked(False) b.setChecked(False)
self._component_buttons[idx].setChecked(True) 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 the saved view says we're in assembly view, switch over.
if view_state.get("assembly_view_active") and self._project.assemblies: if view_state.get("assembly_view_active") and self._project.assemblies:
self._assembly_view_active = True self._assembly_view_active = True
@@ -3486,6 +3697,7 @@ class MainWindow(QMainWindow):
self._project_path = path self._project_path = path
self._dirty = False self._dirty = False
self._update_window_title() self._update_window_title()
self._add_recent_project(path)
self.statusBar().showMessage(f"Opened: {os.path.basename(path)}", 5000) self.statusBar().showMessage(f"Opened: {os.path.basename(path)}", 5000)
logger.info("Opened project: %s", path) logger.info("Opened project: %s", path)
return True return True
+88 -17
View File
@@ -66,6 +66,9 @@ class Viewer3DWidget(QWidget):
self._connector_pick_mode: bool = False self._connector_pick_mode: bool = False
# Current snap highlight object id (for hover during connector mode). # Current snap highlight object id (for hover during connector mode).
self._connector_snap_id: Optional[str] = None self._connector_snap_id: Optional[str] = None
# Throttle connector hover probes to avoid UI lag on fast mouse moves.
self._connector_last_hover_time: float = 0.0
self._connector_hover_interval: float = 0.05 # 50 ms between probes
# When True, left-click on a body activates assembly drag-to-move. # When True, left-click on a body activates assembly drag-to-move.
self._assembly_move_mode: bool = False self._assembly_move_mode: bool = False
# State for ongoing assembly drag. # State for ongoing assembly drag.
@@ -315,10 +318,17 @@ class Viewer3DWidget(QWidget):
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
self._ensure_initialized() self._ensure_initialized()
# In connector mode, show snap hover. # In connector mode, show snap hover.
# Selection modes are deactivated so we skip the idle MoveTo
# (dynamic highlighting) — only the gizmo hover handler runs.
if self._connector_pick_mode: if self._connector_pick_mode:
self._handle_connector_hover(event) self._handle_connector_hover(event)
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
return return
# If connector mode was just exited (gizmo persists after pick),
# clear any lingering gizmo on first mouse move.
gizmo_objs = getattr(self._renderer, '_gizmo_objects', None)
if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
self._clear_connector_snap()
# In face-pick mode, keep dynamic highlighting. # In face-pick mode, keep dynamic highlighting.
if self._pick_face_mode: if self._pick_face_mode:
if hasattr(self._renderer, "handle_mouse_move"): if hasattr(self._renderer, "handle_mouse_move"):
@@ -420,20 +430,38 @@ class Viewer3DWidget(QWidget):
# ─── Connector pick mode (assembly) ──────────────────────────────────── # ─── Connector pick mode (assembly) ────────────────────────────────────
def set_connector_pick_mode(self, enabled: bool) -> None: def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
"""Toggle connector pick mode for placing connection points. """Toggle connector pick mode for placing connection points.
When enabled, clicking an entity (face, edge, vertex, hole) When enabled, clicking an entity (face, edge, vertex, hole)
on a body in the assembly view captures its position and on a body in the assembly view captures its position and
direction as a connection point for the SolveSpace solver. direction as a connection point for the SolveSpace solver.
Entering connector mode deactivates standard OCC face/edge/vertex
selection so dynamic highlighting does not clash with the gizmo
visuals. Selection is re-activated on exit.
*clear_gizmo*: if False the gizmo marker is not cleared on exit,
allowing it to persist until the next hover event (used after a
successful pick so the user sees what was selected).
""" """
self._connector_pick_mode = bool(enabled) self._connector_pick_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.CrossCursor) self.setCursor(Qt.CrossCursor)
elif not self._pick_face_mode: # Disable standard OCC selection so gizmo visuals are not
self.unsetCursor() # interfered with by dynamic face highlighting.
if not enabled: fn = getattr(self._renderer, "deactivate_selection_modes", None)
if fn is not None:
fn()
else:
if clear_gizmo:
self._clear_connector_snap() self._clear_connector_snap()
# Restore standard OCC selection for face-pick / normal modes.
fn = getattr(self._renderer, "activate_selection_modes", None)
if fn is not None:
fn()
if not self._pick_face_mode:
self.unsetCursor()
def is_connector_pick_mode(self) -> bool: def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode return self._connector_pick_mode
@@ -453,14 +481,20 @@ class Viewer3DWidget(QWidget):
def _handle_connector_hover(self, event) -> None: def _handle_connector_hover(self, event) -> None:
"""Update the hover snap gizmo during connector pick mode. """Update the hover snap gizmo during connector pick mode.
Probes a small neighbourhood around the cursor for ALL nearby snap Uses geometric probing (direct topology walk) which does not depend
candidates (vertices, edge midpoints, face centres, hole openings) on OCC's selection system — this avoids clashing with the gizmo
and renders a dim marker on each plus a bright primary on the nearest visuals since selection modes are deactivated in connector mode.
one the general snap indicator. Clicking then selects the Probes are throttled to at most once every 50 ms to avoid UI lag
primary's position. on fast mouse moves.
""" """
import time
now = time.monotonic()
if now - self._connector_last_hover_time < self._connector_hover_interval:
return # throttled — skip this mouse move
self._connector_last_hover_time = now
self._ensure_initialized() self._ensure_initialized()
probe = getattr(self._renderer, "probe_snap_candidates", None) probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
pos = event.position().toPoint() if hasattr(event, "position") else event.pos() pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
if probe is not None: if probe is not None:
@@ -472,7 +506,17 @@ class Viewer3DWidget(QWidget):
# Primary = the nearest candidate (probe sorts nearest-first). # Primary = the nearest candidate (probe sorts nearest-first).
info = candidates[0] info = candidates[0]
else: else:
# Fall back to single-pixel pick on renderers without the probe. # Fall back to the selection-system-based probe.
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
if probe2 is not None:
candidates = probe2(pos.x(), pos.y())
if not candidates:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
info = candidates[0]
else:
# Last resort: single-pixel pick.
picker = getattr(self._renderer, "pick_entity", None) picker = getattr(self._renderer, "pick_entity", None)
if picker is None: if picker is None:
return return
@@ -488,6 +532,16 @@ class Viewer3DWidget(QWidget):
entity_type = info["type"] entity_type = info["type"]
owner = info.get("owner_obj_id", "") owner = info.get("owner_obj_id", "")
# ── Feature recognition ──
# Enhance candidates with composite feature info (holes, edge loops, etc.)
recognize = getattr(self._renderer, "recognize_composite_features", None)
if recognize is not None and candidates:
candidates = recognize(candidates, pos.x(), pos.y())
info = candidates[0] # re-read primary after enhancement
origin = info["position"]
normal = info.get("normal")
entity_type = info.get("type", info.get("feature_type", entity_type))
# Show smart entity gizmo — dim candidate markers + bright primary. # Show smart entity gizmo — dim candidate markers + bright primary.
self._clear_connector_snap() self._clear_connector_snap()
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None) gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
@@ -513,31 +567,48 @@ class Viewer3DWidget(QWidget):
c = colors.get(entity_type, (1.0, 0.6, 0.0)) c = colors.get(entity_type, (1.0, 0.6, 0.0))
self._connector_snap_id = fn(origin, color=c, size=3.0) self._connector_snap_id = fn(origin, color=c, size=3.0)
self.connectorHover.emit({ # Build payload with feature recognition info.
payload = {
"origin": origin, "origin": origin,
"normal": normal, "normal": normal,
"type": entity_type, "type": entity_type,
"owner_obj_id": owner, "owner_obj_id": owner,
}) }
# Attach feature info if available.
if "feature_type" in info:
payload["feature_type"] = info["feature_type"]
if "suggestion" in info:
payload["suggestion"] = info["suggestion"]
if "feature_data" in info:
payload["feature_data"] = info["feature_data"]
self.connectorHover.emit(payload)
def _handle_connector_pick(self, event) -> None: def _handle_connector_pick(self, event) -> None:
"""Detect an entity under the click and emit connectorPicked. """Detect an entity under the click and emit connectorPicked.
Uses the multi-pixel ``probe_snap_candidates`` so a click selects the Uses geometric probing (direct topology walk) so a click selects the
PRIMARY (nearest) snap target the same one the hover gizmo PRIMARY (nearest) snap target the same one the hover gizmo
emphasised. Falls back to single-pixel ``pick_entity`` then to emphasised. Falls back to selection-system probe, then single-pixel
``pick_planar_face`` on renderers without the probe. ``pick_entity``, then ``pick_planar_face``.
""" """
self._ensure_initialized() self._ensure_initialized()
pos = event.position().toPoint() if hasattr(event, "position") else event.pos() pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info: Optional[Dict[str, Any]] = None info: Optional[Dict[str, Any]] = None
probe = getattr(self._renderer, "probe_snap_candidates", None) probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
if probe is not None: if probe is not None:
candidates = probe(pos.x(), pos.y()) candidates = probe(pos.x(), pos.y())
if candidates: if candidates:
info = candidates[0] # nearest = primary info = candidates[0] # nearest = primary
if info is None:
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
if probe2 is not None:
candidates = probe2(pos.x(), pos.y())
if candidates:
info = candidates[0]
if info is None: if info is None:
picker = getattr(self._renderer, "pick_entity", None) picker = getattr(self._renderer, "pick_entity", None)
if picker is None: if picker is None: