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