- Working assembly multi :)

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