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 @@ + + + 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: