- Working assembly multi :)
This commit is contained in:
Generated
+12
-1
@@ -6,7 +6,10 @@
|
||||
<component name="ChangeListManager">
|
||||
<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$/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 - 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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user