diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index dcee416..a3407d6 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -6,7 +6,10 @@
+
+
+
@@ -367,7 +370,15 @@
1783755278516
-
+
+
+ 1783777171864
+
+
+
+ 1783777171864
+
+
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 1a0a418..fa6beec 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -351,11 +351,14 @@ class Connector:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
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)
- # 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)
- # 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)
# Rotation around the normal axis (degrees).
@@ -486,9 +489,14 @@ class Assembly:
"""Record a mated connector pair between two component instances.
The first-picked component (``first_ac_id``) is treated as the
- grounded reference of the pair. Returns the AssemblyConnection for
- further bookkeeping (e.g. attaching partner connector ids).
+ grounded reference of the pair. Guards against duplicate entries.
+ 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(
first_ac_id=first_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."""
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(
self, component_id: str, name: Optional[str] = None
) -> AssemblyComponent:
diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py
index 8e53afd..b64ff91 100644
--- a/src/fluency/rendering/occ_renderer.py
+++ b/src/fluency/rendering/occ_renderer.py
@@ -1334,25 +1334,6 @@ class OCCRenderer(Renderer):
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(
self, x: int, y: int, radius: int = 30,
) -> 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)
return results
- def highlight_snap(self, position, color=None, size=3.0) -> Optional[str]:
- """Show a small marker sphere at *position* as a snap indicator.
+ def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]:
+ """Show a marker sphere at *position* as a snap indicator.
Returns an object id that can be removed later.
The *size* is auto-scaled by camera distance so the marker stays
@@ -1454,7 +1435,7 @@ class OCCRenderer(Renderer):
self._view.Update()
# Track this as a temporary object; use a synthetic id.
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
except Exception as exc:
logger.debug(f"highlight_snap failed: {exc}")
@@ -1596,13 +1577,13 @@ class OCCRenderer(Renderer):
):
continue
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) ──
- _make_sphere(position, gizmo_color, 2.8 * gizmo_scale)
+ _make_sphere(position, gizmo_color, 5.6 * gizmo_scale)
# ── 2. Axis indicator lines (primary only) ──
- axis_length = 15.0 * gizmo_scale
+ axis_length = 30.0 * gizmo_scale
def _make_axis_line(
origin: Tuple[float, float, float],
@@ -1690,6 +1671,334 @@ class OCCRenderer(Renderer):
if self._view is not None:
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 ──────────────────────────────
#
# CAD-style navigation:
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index 53b289c..17d76c8 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -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
diff --git a/src/fluency/ui/viewer_widget.py b/src/fluency/ui/viewer_widget.py
index 647b647..cb08f33 100644
--- a/src/fluency/ui/viewer_widget.py
+++ b/src/fluency/ui/viewer_widget.py
@@ -66,6 +66,9 @@ class Viewer3DWidget(QWidget):
self._connector_pick_mode: bool = False
# Current snap highlight object id (for hover during connector mode).
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.
self._assembly_move_mode: bool = False
# State for ongoing assembly drag.
@@ -315,10 +318,17 @@ class Viewer3DWidget(QWidget):
def mouseMoveEvent(self, event):
self._ensure_initialized()
# 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:
self._handle_connector_hover(event)
super().mouseMoveEvent(event)
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.
if self._pick_face_mode:
if hasattr(self._renderer, "handle_mouse_move"):
@@ -420,20 +430,38 @@ class Viewer3DWidget(QWidget):
# ─── 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.
When enabled, clicking an entity (face, edge, vertex, hole)
on a body in the assembly view captures its position and
direction as a connection point for the SolveSpace solver.
+
+ 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)
if enabled:
self.setCursor(Qt.CrossCursor)
- elif not self._pick_face_mode:
- self.unsetCursor()
- if not enabled:
- self._clear_connector_snap()
+ # Disable standard OCC selection so gizmo visuals are not
+ # interfered with by dynamic face highlighting.
+ fn = getattr(self._renderer, "deactivate_selection_modes", None)
+ if fn is not None:
+ fn()
+ else:
+ if clear_gizmo:
+ 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:
return self._connector_pick_mode
@@ -453,14 +481,20 @@ class Viewer3DWidget(QWidget):
def _handle_connector_hover(self, event) -> None:
"""Update the hover snap gizmo during connector pick mode.
- Probes a small neighbourhood around the cursor for ALL nearby snap
- candidates (vertices, edge midpoints, face centres, hole openings)
- and renders a dim marker on each plus a bright primary on the nearest
- one — the general snap indicator. Clicking then selects the
- primary's position.
+ Uses geometric probing (direct topology walk) which does not depend
+ on OCC's selection system — this avoids clashing with the gizmo
+ visuals since selection modes are deactivated in connector mode.
+ Probes are throttled to at most once every 50 ms to avoid UI lag
+ 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()
- 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()
if probe is not None:
@@ -472,22 +506,42 @@ class Viewer3DWidget(QWidget):
# Primary = the nearest candidate (probe sorts nearest-first).
info = candidates[0]
else:
- # Fall back to single-pixel pick on renderers without the probe.
- picker = getattr(self._renderer, "pick_entity", None)
- if picker is None:
- return
- info = picker(pos.x(), pos.y())
- candidates = [info] if info else []
- if info is None or info.get("owner_obj_id") is None:
- self._clear_connector_snap()
- self.connectorHover.emit(None)
- return
+ # 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)
+ if picker is None:
+ return
+ info = picker(pos.x(), pos.y())
+ candidates = [info] if info else []
+ if info is None or info.get("owner_obj_id") is None:
+ self._clear_connector_snap()
+ self.connectorHover.emit(None)
+ return
origin = info["position"]
normal = info.get("normal")
entity_type = info["type"]
owner = info.get("owner_obj_id", "")
+ # ── 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.
self._clear_connector_snap()
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))
self._connector_snap_id = fn(origin, color=c, size=3.0)
- self.connectorHover.emit({
+ # Build payload with feature recognition info.
+ payload = {
"origin": origin,
"normal": normal,
"type": entity_type,
"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:
"""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
- emphasised. Falls back to single-pixel ``pick_entity`` then to
- ``pick_planar_face`` on renderers without the probe.
+ emphasised. Falls back to selection-system probe, then single-pixel
+ ``pick_entity``, then ``pick_planar_face``.
"""
self._ensure_initialized()
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info: Optional[Dict[str, Any]] = None
- probe = getattr(self._renderer, "probe_snap_candidates", None)
+ probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
if probe is not None:
candidates = probe(pos.x(), pos.y())
if candidates:
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:
picker = getattr(self._renderer, "pick_entity", None)
if picker is None: