- assembly draft
This commit is contained in:
Generated
+13
-5
@@ -4,12 +4,11 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- UI refinement, button position ui file as source no dirty drafting anymore">
|
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- assembly draft">
|
||||||
<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$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" 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/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" />
|
||||||
</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" />
|
||||||
@@ -322,7 +321,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1783174566362</updated>
|
<updated>1783174566362</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="27" />
|
<task id="LOCAL-00027" summary="- assembly draft">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783239410744</created>
|
||||||
|
<option name="number" value="00027" />
|
||||||
|
<option name="presentableId" value="LOCAL-00027" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783239410744</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="28" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -365,6 +372,7 @@
|
|||||||
<MESSAGE value="- removed cadquery deoendency" />
|
<MESSAGE value="- removed cadquery deoendency" />
|
||||||
<MESSAGE value="- sketch enhacements" />
|
<MESSAGE value="- sketch enhacements" />
|
||||||
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
|
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="- UI refinement, button position ui file as source no dirty drafting anymore" />
|
<MESSAGE value="- assembly draft" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="- assembly draft" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
+641
-118
@@ -3547,11 +3547,14 @@ class Viewer3DWidget(QWidget):
|
|||||||
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
||||||
pickFaceCancelled = Signal()
|
pickFaceCancelled = Signal()
|
||||||
|
|
||||||
# Emitted when the user picks a face for a connector point (assembly).
|
# Emitted when the user picks an entity for a connector point (assembly).
|
||||||
# Payload: (origin, normal, x_dir, face_shape, owner_obj_id).
|
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
|
||||||
connectorPicked = Signal(tuple, tuple, tuple, object, str)
|
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
|
||||||
# Emitted when connector pick mode is cancelled.
|
# Emitted when connector pick mode is cancelled.
|
||||||
connectorPickCancelled = Signal()
|
connectorPickCancelled = Signal()
|
||||||
|
# Emitted on mouse move in connector mode to show snap preview.
|
||||||
|
# Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
|
||||||
|
connectorHover = Signal(object)
|
||||||
|
|
||||||
# Emitted when a body is clicked in assembly move mode.
|
# Emitted when a body is clicked in assembly move mode.
|
||||||
# Payload: owner_obj_id.
|
# Payload: owner_obj_id.
|
||||||
@@ -3569,6 +3572,11 @@ class Viewer3DWidget(QWidget):
|
|||||||
self.setAutoFillBackground(False)
|
self.setAutoFillBackground(False)
|
||||||
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
||||||
self.setFocusPolicy(Qt.StrongFocus)
|
self.setFocusPolicy(Qt.StrongFocus)
|
||||||
|
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
|
||||||
|
# button held — required for the connector-pick hover gizmo (and any
|
||||||
|
# status-bar hover feedback) to show under the cursor as the user
|
||||||
|
# moves the mouse over candidate snap entities before clicking.
|
||||||
|
self.setMouseTracking(True)
|
||||||
# Try OCC renderer first; fall back to pygfx if unavailable.
|
# Try OCC renderer first; fall back to pygfx if unavailable.
|
||||||
self._renderer: Any = None
|
self._renderer: Any = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
@@ -3579,9 +3587,11 @@ class Viewer3DWidget(QWidget):
|
|||||||
# When True, a left-click picks a planar face (for sketch-on-surface)
|
# When True, a left-click picks a planar face (for sketch-on-surface)
|
||||||
# instead of orbiting the camera. Set via set_pick_face_mode().
|
# instead of orbiting the camera. Set via set_pick_face_mode().
|
||||||
self._pick_face_mode: bool = False
|
self._pick_face_mode: bool = False
|
||||||
# When True, a left-click picks a face for a connector point
|
# When True, a left-click picks an entity for a connector point
|
||||||
# (assembly component connection).
|
# (assembly component connection).
|
||||||
self._connector_pick_mode: bool = False
|
self._connector_pick_mode: bool = False
|
||||||
|
# Current snap highlight object id (for hover during connector mode).
|
||||||
|
self._connector_snap_id: Optional[str] = None
|
||||||
# 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.
|
||||||
@@ -3830,8 +3840,13 @@ class Viewer3DWidget(QWidget):
|
|||||||
|
|
||||||
def mouseMoveEvent(self, event):
|
def mouseMoveEvent(self, event):
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
# In face-pick/connector mode, keep dynamic highlighting.
|
# In connector mode, show snap hover.
|
||||||
if self._pick_face_mode or self._connector_pick_mode:
|
if self._connector_pick_mode:
|
||||||
|
self._handle_connector_hover(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
return
|
||||||
|
# In face-pick mode, keep dynamic highlighting.
|
||||||
|
if self._pick_face_mode:
|
||||||
if hasattr(self._renderer, "handle_mouse_move"):
|
if hasattr(self._renderer, "handle_mouse_move"):
|
||||||
self._renderer.handle_mouse_move(event)
|
self._renderer.handle_mouse_move(event)
|
||||||
super().mouseMoveEvent(event)
|
super().mouseMoveEvent(event)
|
||||||
@@ -3914,37 +3929,170 @@ class Viewer3DWidget(QWidget):
|
|||||||
def set_connector_pick_mode(self, enabled: bool) -> None:
|
def set_connector_pick_mode(self, enabled: bool) -> None:
|
||||||
"""Toggle connector pick mode for placing connection points.
|
"""Toggle connector pick mode for placing connection points.
|
||||||
|
|
||||||
When enabled, clicking a face on a body in the assembly view
|
When enabled, clicking an entity (face, edge, vertex, hole)
|
||||||
captures its origin and normal as a connection point for the
|
on a body in the assembly view captures its position and
|
||||||
SolveSpace solver.
|
direction as a connection point for the SolveSpace solver.
|
||||||
"""
|
"""
|
||||||
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:
|
elif not self._pick_face_mode:
|
||||||
self.unsetCursor()
|
self.unsetCursor()
|
||||||
|
if not enabled:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
|
||||||
def is_connector_pick_mode(self) -> bool:
|
def is_connector_pick_mode(self) -> bool:
|
||||||
return self._connector_pick_mode
|
return self._connector_pick_mode
|
||||||
|
|
||||||
def _handle_connector_pick(self, event) -> None:
|
def _clear_connector_snap(self) -> None:
|
||||||
"""Detect a planar face under the click and emit connectorPicked."""
|
"""Remove the hover gizmo."""
|
||||||
|
fn = getattr(self._renderer, "clear_entity_gizmo", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
# Backwards compat: also try the old method.
|
||||||
|
if self._connector_snap_id is not None:
|
||||||
|
fn2 = getattr(self._renderer, "remove_highlight_snap", None)
|
||||||
|
if fn2 is not None:
|
||||||
|
fn2(self._connector_snap_id)
|
||||||
|
self._connector_snap_id = None
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
probe = getattr(self._renderer, "probe_snap_candidates", None)
|
||||||
if picker is None:
|
|
||||||
logger.warning("Renderer has no pick_planar_face support")
|
|
||||||
return
|
|
||||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
info = picker(pos.x(), pos.y())
|
|
||||||
|
if probe is not None:
|
||||||
|
candidates = probe(pos.x(), pos.y())
|
||||||
|
if not candidates:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
self.connectorHover.emit(None)
|
||||||
|
return
|
||||||
|
# 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
|
||||||
|
|
||||||
|
origin = info["position"]
|
||||||
|
normal = info.get("normal")
|
||||||
|
entity_type = info["type"]
|
||||||
|
owner = info.get("owner_obj_id", "")
|
||||||
|
|
||||||
|
# Show smart entity gizmo — dim candidate markers + bright primary.
|
||||||
|
self._clear_connector_snap()
|
||||||
|
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
|
||||||
|
if gizmo_fn is not None:
|
||||||
|
gizmo_fn(
|
||||||
|
entity_type=entity_type,
|
||||||
|
position=origin,
|
||||||
|
normal=normal,
|
||||||
|
x_dir=info.get("x_dir"),
|
||||||
|
radius=info.get("radius"),
|
||||||
|
candidates=candidates,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to old highlight_snap.
|
||||||
|
fn = getattr(self._renderer, "highlight_snap", None)
|
||||||
|
if fn is not None:
|
||||||
|
colors = {
|
||||||
|
"planar_face": (0.0, 0.8, 1.0), # cyan
|
||||||
|
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
|
||||||
|
"edge": (0.0, 1.0, 0.4), # green
|
||||||
|
"vertex": (1.0, 1.0, 0.0), # yellow
|
||||||
|
}
|
||||||
|
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({
|
||||||
|
"origin": origin,
|
||||||
|
"normal": normal,
|
||||||
|
"type": entity_type,
|
||||||
|
"owner_obj_id": owner,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
if probe is not None:
|
||||||
|
candidates = probe(pos.x(), pos.y())
|
||||||
|
if candidates:
|
||||||
|
info = candidates[0] # nearest = primary
|
||||||
|
|
||||||
if info is None:
|
if info is None:
|
||||||
logger.info("Connector pick: no planar face under cursor")
|
picker = getattr(self._renderer, "pick_entity", None)
|
||||||
|
if picker is None:
|
||||||
|
# Fallback to planar face only.
|
||||||
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||||
|
if picker is None:
|
||||||
|
logger.warning("Renderer has no entity picking support")
|
||||||
|
return
|
||||||
|
pinfo = picker(pos.x(), pos.y())
|
||||||
|
if pinfo is None:
|
||||||
|
logger.info("Connector pick: no planar face under cursor")
|
||||||
|
return
|
||||||
|
owner_obj_id = pinfo.get("owner_obj_id", "")
|
||||||
|
self.connectorPicked.emit(
|
||||||
|
tuple(pinfo["origin"]),
|
||||||
|
tuple(pinfo["normal"]),
|
||||||
|
tuple(pinfo["x_dir"]),
|
||||||
|
"planar_face",
|
||||||
|
pinfo["face"],
|
||||||
|
owner_obj_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
logger.info("Connector pick: no entity under cursor")
|
||||||
return
|
return
|
||||||
owner_obj_id = info.get("owner_obj_id", "")
|
owner_obj_id = info.get("owner_obj_id", "")
|
||||||
|
if not owner_obj_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
entity_type = info["type"]
|
||||||
|
origin = info["position"]
|
||||||
|
normal = info.get("normal") or (0.0, 0.0, 1.0)
|
||||||
|
x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# For vertices, pick a sensible normal from the parent face if possible.
|
||||||
|
if entity_type == "vertex" and normal is None:
|
||||||
|
normal = (0.0, 0.0, 1.0)
|
||||||
|
|
||||||
|
# Package the raw shape appropriately.
|
||||||
|
raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
|
||||||
|
|
||||||
self.connectorPicked.emit(
|
self.connectorPicked.emit(
|
||||||
tuple(info["origin"]),
|
tuple(origin),
|
||||||
tuple(info["normal"]),
|
tuple(normal),
|
||||||
tuple(info["x_dir"]),
|
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
|
||||||
info["face"],
|
entity_type,
|
||||||
|
raw_shape,
|
||||||
owner_obj_id,
|
owner_obj_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4199,6 +4347,14 @@ class MainWindow(QMainWindow):
|
|||||||
# Drag-move state for assembly components
|
# Drag-move state for assembly components
|
||||||
self._asm_move_ac_id: Optional[str] = None
|
self._asm_move_ac_id: Optional[str] = None
|
||||||
self._asm_move_start_pos: Any = None
|
self._asm_move_start_pos: Any = None
|
||||||
|
# Rigid-group drag state: maps every component id in the dragged
|
||||||
|
# rigid group to its start position, so the whole group translates
|
||||||
|
# together and connected partners keep their solved relative
|
||||||
|
# transforms. Keyed by AssemblyComponent.id.
|
||||||
|
self._asm_move_group_start: Dict[str, Any] = {}
|
||||||
|
# Cached rigid-group membership for the current drag (avoids recomputing
|
||||||
|
# the BFS graph on every mouse-move event).
|
||||||
|
self._asm_move_group_ids: List[str] = []
|
||||||
# Cache of render object IDs per assembly component, so drag updates
|
# Cache of render object IDs per assembly component, so drag updates
|
||||||
# can replace only the moved component's shapes without clearing the
|
# can replace only the moved component's shapes without clearing the
|
||||||
# entire scene (avoids camera flicker).
|
# entire scene (avoids camera flicker).
|
||||||
@@ -4520,6 +4676,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
|
self._btn_add_connector.clicked.connect(self._on_start_connector_placement)
|
||||||
self._btn_del_connector.clicked.connect(self._on_delete_connector)
|
self._btn_del_connector.clicked.connect(self._on_delete_connector)
|
||||||
self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
|
self._viewer_3d.connectorPicked.connect(self._on_connector_picked)
|
||||||
|
self._viewer_3d.connectorHover.connect(self._on_connector_hover)
|
||||||
self._viewer_3d.connectorPickCancelled.connect(
|
self._viewer_3d.connectorPickCancelled.connect(
|
||||||
lambda: self._btn_add_connector.setChecked(False)
|
lambda: self._btn_add_connector.setChecked(False)
|
||||||
)
|
)
|
||||||
@@ -5068,7 +5225,12 @@ class MainWindow(QMainWindow):
|
|||||||
def _on_assembly_move_activated(self, owner_obj_id: str):
|
def _on_assembly_move_activated(self, owner_obj_id: str):
|
||||||
"""Called when the user clicks a body in move mode.
|
"""Called when the user clicks a body in move mode.
|
||||||
|
|
||||||
Parse the assembly component id and store its starting position.
|
Parse the assembly component id, compute the rigid group it belongs
|
||||||
|
to (transitively via mated connectors), and snapshot EVERY member's
|
||||||
|
start position so the whole group can translate together during the
|
||||||
|
drag. The first-picked component of each mated pair stays as the
|
||||||
|
grounded reference frame for the solver; for a pure-translation
|
||||||
|
drag that just means we preserve all current relative transforms.
|
||||||
"""
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -5082,14 +5244,26 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._asm_move_ac_id = ac_id
|
self._asm_move_ac_id = ac_id
|
||||||
|
# Rigid group membership (BFS over mated-connector connections).
|
||||||
|
group_ids = assembly.get_rigid_group(ac_id)
|
||||||
|
self._asm_move_group_ids = group_ids
|
||||||
|
self._asm_move_group_start = {}
|
||||||
|
for gid in group_ids:
|
||||||
|
g_ac = assembly.components.get(gid)
|
||||||
|
if g_ac is not None:
|
||||||
|
self._asm_move_group_start[gid] = np.array(g_ac.position, dtype=float)
|
||||||
|
# Keep the legacy single-component start for backwards compatibility.
|
||||||
self._asm_move_start_pos = np.array(ac.position, dtype=float)
|
self._asm_move_start_pos = np.array(ac.position, dtype=float)
|
||||||
|
|
||||||
def _on_assembly_move_dragged(self, owner_obj_id: str, dx: float, dy: float, dz: float):
|
def _on_assembly_move_dragged(self, owner_obj_id: str, dx: float, dy: float, dz: float):
|
||||||
"""Called during a drag move. Update only the dragged component in-place.
|
"""Propagate a drag move across the entire rigid group, in-place.
|
||||||
|
|
||||||
Uses the targeted ``_update_assembly_component_in_viewer`` instead of
|
Every component in the dragged rigid group receives the SAME world
|
||||||
a full ``clear_scene`` + rebuild, so other shapes keep their render
|
translation delta (relative to its own start position), so the mated
|
||||||
objects and the camera stays perfectly still.
|
relative transforms are preserved exactly and SolveSpace's solved
|
||||||
|
alignment stays valid throughout the drag. Each member is updated
|
||||||
|
in-place via ``_update_assembly_component_in_viewer`` so the camera
|
||||||
|
never flickers.
|
||||||
"""
|
"""
|
||||||
if self._asm_move_ac_id is None or self._asm_move_start_pos is None:
|
if self._asm_move_ac_id is None or self._asm_move_start_pos is None:
|
||||||
return
|
return
|
||||||
@@ -5101,17 +5275,32 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
# Apply delta relative to the start position.
|
delta = np.array([dx, dy, dz], dtype=float)
|
||||||
ac.position = self._asm_move_start_pos + np.array([dx, dy, dz])
|
# Propagate the same delta to every rigid-group member.
|
||||||
# Only update the dragged component's shapes — no scene clear.
|
group_ids = self._asm_move_group_ids or [ac_id]
|
||||||
self._update_assembly_component_in_viewer(ac_id)
|
for gid in group_ids:
|
||||||
|
start = self._asm_move_group_start.get(gid)
|
||||||
|
if start is None:
|
||||||
|
continue
|
||||||
|
g_ac = assembly.components.get(gid)
|
||||||
|
if g_ac is None:
|
||||||
|
continue
|
||||||
|
g_ac.position = start + delta
|
||||||
|
# Update only this component's shapes — no scene clear.
|
||||||
|
self._update_assembly_component_in_viewer(gid)
|
||||||
|
|
||||||
def _on_assembly_move_finished(self, owner_obj_id: str):
|
def _on_assembly_move_finished(self, owner_obj_id: str):
|
||||||
"""Finalize the drag move."""
|
"""Finalize the drag move."""
|
||||||
if self._asm_move_ac_id is not None:
|
if self._asm_move_ac_id is not None:
|
||||||
logger.info(f"Moved assembly component {self._asm_move_ac_id} to final position")
|
members = len(self._asm_move_group_ids) if self._asm_move_group_ids else 1
|
||||||
|
logger.info(
|
||||||
|
f"Moved assembly rigid group led by {self._asm_move_ac_id} "
|
||||||
|
f"({members} member(s)) to final position"
|
||||||
|
)
|
||||||
self._asm_move_ac_id = None
|
self._asm_move_ac_id = None
|
||||||
self._asm_move_start_pos = None
|
self._asm_move_start_pos = None
|
||||||
|
self._asm_move_group_start = {}
|
||||||
|
self._asm_move_group_ids = []
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
# Connector methods — two-click selection + preview dialog
|
# Connector methods — two-click selection + preview dialog
|
||||||
@@ -5133,8 +5322,8 @@ class MainWindow(QMainWindow):
|
|||||||
def _on_start_connector_placement(self, checked: bool):
|
def _on_start_connector_placement(self, checked: bool):
|
||||||
"""Toggle connector pick mode.
|
"""Toggle connector pick mode.
|
||||||
|
|
||||||
First click selects the first component's connection face.
|
First click selects the first component's connection entity.
|
||||||
Second click selects the second component and triggers alignment.
|
Second click selects the second component and triggers SolveSpace alignment.
|
||||||
"""
|
"""
|
||||||
if not self._assembly_view_active:
|
if not self._assembly_view_active:
|
||||||
self._btn_add_connector.setChecked(False)
|
self._btn_add_connector.setChecked(False)
|
||||||
@@ -5145,17 +5334,49 @@ class MainWindow(QMainWindow):
|
|||||||
# Reset any in-progress two-click state.
|
# Reset any in-progress two-click state.
|
||||||
self._connector_first_pick = None
|
self._connector_first_pick = None
|
||||||
self._connector_second_ac_id = None
|
self._connector_second_ac_id = None
|
||||||
|
self._connector_align_pos = None
|
||||||
|
|
||||||
self._viewer_3d.set_connector_pick_mode(checked)
|
self._viewer_3d.set_connector_pick_mode(checked)
|
||||||
if checked:
|
if checked:
|
||||||
self._viewer_3d.setFocus()
|
self._viewer_3d.setFocus()
|
||||||
self._viewer_3d.activateWindow()
|
self._viewer_3d.activateWindow()
|
||||||
self.setStatusTip("Click on the first component's connection face")
|
self.setStatusTip("Click on the first component's connection point/face/edge/hole")
|
||||||
else:
|
else:
|
||||||
self.setStatusTip("")
|
self.setStatusTip("")
|
||||||
|
|
||||||
def _on_connector_picked(self, origin, normal, x_dir, face_shape, owner_obj_id):
|
def _on_connector_hover(self, info) -> None:
|
||||||
"""Handle a connector face pick — first or second click."""
|
"""Show entity-type feedback in the status bar during connector pick.
|
||||||
|
|
||||||
|
The gizmo itself is drawn by the viewer; this just reports what
|
||||||
|
entity is under the cursor so the user knows what they will snap to.
|
||||||
|
"""
|
||||||
|
if info is None:
|
||||||
|
self.statusBar().showMessage("Move over a face / edge / hole / vertex to snap")
|
||||||
|
return
|
||||||
|
entity_type = info.get("type", "")
|
||||||
|
names = {
|
||||||
|
"planar_face": "Face",
|
||||||
|
"cylindrical_face": "Hole",
|
||||||
|
"edge": "Edge",
|
||||||
|
"vertex": "Vertex",
|
||||||
|
}
|
||||||
|
name = names.get(entity_type, "Entity")
|
||||||
|
ac_id = self._parse_ac_id(info.get("owner_obj_id", ""))
|
||||||
|
comp_name = ""
|
||||||
|
if ac_id is not None:
|
||||||
|
assembly = self._get_assembly()
|
||||||
|
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")
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Snaps to faces, cylindrical holes, edges, or vertices.
|
||||||
|
Stores connector in component-local coordinates so it stays
|
||||||
|
valid when the component is moved by the solver.
|
||||||
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
ac_id = self._parse_ac_id(owner_obj_id)
|
ac_id = self._parse_ac_id(owner_obj_id)
|
||||||
@@ -5171,19 +5392,37 @@ class MainWindow(QMainWindow):
|
|||||||
"The clicked component was not found in the assembly.")
|
"The clicked component was not found in the assembly.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Convert world-space connector to component-local coordinates.
|
||||||
|
# p_local = R^T @ (p_world - P)
|
||||||
|
pos_world = np.array(origin, dtype=float)
|
||||||
|
rot = ac.rotation
|
||||||
|
pos_local = rot.T @ (pos_world - ac.position)
|
||||||
|
|
||||||
|
n_world = np.array(normal, dtype=float)
|
||||||
|
n_local = rot.T @ n_world
|
||||||
|
n_local = n_local / max(np.linalg.norm(n_local), 1e-12)
|
||||||
|
|
||||||
|
x_world = np.array(x_dir, dtype=float) if x_dir else np.array([1.0, 0.0, 0.0])
|
||||||
|
x_local = rot.T @ x_world
|
||||||
|
x_local = x_local / max(np.linalg.norm(x_local), 1e-12)
|
||||||
|
|
||||||
# ── First pick ──
|
# ── First pick ──
|
||||||
if self._connector_first_pick is None:
|
if self._connector_first_pick is None:
|
||||||
self._connector_first_pick = {
|
self._connector_first_pick = {
|
||||||
"ac_id": ac_id,
|
"ac_id": ac_id,
|
||||||
"origin": tuple(origin),
|
"origin_local": tuple(pos_local),
|
||||||
"normal": tuple(normal),
|
"normal_local": tuple(n_local),
|
||||||
"x_dir": tuple(x_dir),
|
"x_dir_local": tuple(x_local),
|
||||||
|
"origin_world": tuple(origin),
|
||||||
|
"normal_world": tuple(normal),
|
||||||
|
"entity_type": entity_type,
|
||||||
"owner_obj_id": owner_obj_id,
|
"owner_obj_id": owner_obj_id,
|
||||||
}
|
}
|
||||||
# Highlight the first face.
|
# Highlight the first face if planar.
|
||||||
self._viewer_3d.highlight_face(face_shape)
|
if entity_type in ("planar_face", "cylindrical_face"):
|
||||||
self.setStatusTip("Now click on the second component's connection face")
|
self._viewer_3d.highlight_face(raw_shape)
|
||||||
logger.info(f"Connector first pick: {ac.name} at {origin}")
|
self.setStatusTip("Now click on the second component's connection point/face/edge/hole")
|
||||||
|
logger.info(f"Connector first pick: {ac.name} at {origin} ({entity_type})")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── Second pick ──
|
# ── Second pick ──
|
||||||
@@ -5201,99 +5440,350 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_add_connector.setChecked(False)
|
self._btn_add_connector.setChecked(False)
|
||||||
self.setStatusTip("")
|
self.setStatusTip("")
|
||||||
|
|
||||||
logger.info(f"Connector second pick: {ac.name} at {origin}")
|
logger.info(f"Connector second pick: {ac.name} at {origin} ({entity_type})")
|
||||||
|
|
||||||
# Compute rough alignment transform (simulating SolveSpace).
|
# Build connector records (local coords).
|
||||||
# Move the second component so its connector face aligns with the
|
second_pick = {
|
||||||
# first: translate so origin2 → origin1.
|
"ac_id": ac_id,
|
||||||
o1 = np.array(first["origin"])
|
"origin_local": tuple(pos_local),
|
||||||
o2 = np.array(origin)
|
"normal_local": tuple(n_local),
|
||||||
align_delta = o1 - o2
|
"x_dir_local": tuple(x_local),
|
||||||
|
"origin_world": tuple(origin),
|
||||||
|
"normal_world": tuple(normal),
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"owner_obj_id": owner_obj_id,
|
||||||
|
}
|
||||||
|
|
||||||
# Store the alignment position on the second component temporarily.
|
# SolveSpace alignment: move second component so its connector
|
||||||
ac2 = assembly.components.get(ac_id)
|
# aligns with the first. First component is fixed.
|
||||||
if ac2 is not None:
|
first_ac = assembly.components.get(first["ac_id"])
|
||||||
self._connector_align_pos = np.array(ac2.position, dtype=float) + align_delta
|
second_ac = ac
|
||||||
else:
|
|
||||||
self._connector_align_pos = align_delta
|
|
||||||
|
|
||||||
# Show the connector dialog with live preview callback.
|
# Compute the world target for the second connector.
|
||||||
rotation, offset = self._show_connector_dialog_with_preview(
|
# It's at the first connector world position.
|
||||||
first_ac=assembly.components.get(first["ac_id"]),
|
target_pos = np.array(first["origin_world"], dtype=float)
|
||||||
second_ac=ac2,
|
target_normal = np.array(first["normal_world"], dtype=float)
|
||||||
first_origin=first["origin"],
|
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
|
||||||
second_origin=o2,
|
|
||||||
first_normal=first["normal"],
|
# SolveSpace solver call.
|
||||||
second_normal=normal,
|
solved = self._solve_assembly_alignment(
|
||||||
first_x_dir=first["x_dir"],
|
first_ac=first_ac,
|
||||||
second_x_dir=x_dir,
|
second_ac=second_ac,
|
||||||
align_delta=align_delta,
|
first_pick=first,
|
||||||
|
second_pick=second_pick,
|
||||||
|
)
|
||||||
|
|
||||||
|
if solved is None:
|
||||||
|
QMessageBox.warning(self, "Solver Error",
|
||||||
|
"SolveSpace could not align the components.")
|
||||||
|
self._connector_first_pick = None
|
||||||
|
self._connector_second_ac_id = None
|
||||||
|
self._show_assembly_in_viewer(fit=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply solved transform to second component.
|
||||||
|
second_ac.position = solved["position"]
|
||||||
|
second_ac.rotation = solved["rotation"]
|
||||||
|
|
||||||
|
# Show dialog with live preview (rotation offset along normal).
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
if rotation is None:
|
if rotation is None:
|
||||||
# User cancelled — restore original position.
|
# User cancelled — restore original position.
|
||||||
if ac2 is not None:
|
second_ac.position = np.array(solved["original_position"], dtype=float)
|
||||||
self._show_assembly_in_viewer()
|
second_ac.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)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create connectors on both components.
|
# Apply dialog adjustments (rotation + offset + flip).
|
||||||
first_ac = assembly.components.get(first["ac_id"])
|
import numpy as np
|
||||||
|
# Build rotation matrix: rotate second connector normal around
|
||||||
|
# the target normal axis by rotation degrees.
|
||||||
|
angle_rad = np.radians(rotation)
|
||||||
|
# Rodrigues' rotation formula around target_normal.
|
||||||
|
k = target_normal
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Create connectors on both components and link them as a mated pair.
|
||||||
|
conn1 = None
|
||||||
|
conn2 = None
|
||||||
if first_ac:
|
if first_ac:
|
||||||
conn1 = first_ac.add_connector(
|
conn1 = first_ac.add_connector(
|
||||||
position=first["origin"],
|
position=first["origin_world"],
|
||||||
normal=first["normal"],
|
normal=first["normal_world"],
|
||||||
x_dir=first["x_dir"],
|
x_dir=first["x_dir_local"],
|
||||||
source_obj_id=first["owner_obj_id"],
|
source_obj_id=first["owner_obj_id"],
|
||||||
name="Connector A",
|
name=f"Conn {entity_type} A",
|
||||||
)
|
)
|
||||||
conn1.axis_rotation = rotation
|
conn1.axis_rotation = rotation
|
||||||
conn1.offset = offset
|
conn1.offset = offset
|
||||||
|
# The first-picked connector is the grounded reference of the pair.
|
||||||
|
conn1.is_grounded = True
|
||||||
|
|
||||||
if ac2:
|
if second_ac:
|
||||||
conn2 = ac2.add_connector(
|
conn2 = second_ac.add_connector(
|
||||||
position=tuple(o2),
|
position=tuple(second_ac.position + second_ac.rotation @ np.array(second_pick["origin_local"])),
|
||||||
normal=tuple(normal),
|
normal=tuple(second_ac.rotation @ np.array(second_pick["normal_local"])),
|
||||||
x_dir=tuple(x_dir),
|
x_dir=tuple(second_ac.rotation @ np.array(second_pick["x_dir_local"])),
|
||||||
source_obj_id=owner_obj_id,
|
source_obj_id=owner_obj_id,
|
||||||
name="Connector B",
|
name=f"Conn {entity_type} B",
|
||||||
)
|
)
|
||||||
conn2.axis_rotation = rotation
|
conn2.axis_rotation = rotation
|
||||||
conn2.offset = offset
|
conn2.offset = offset
|
||||||
|
|
||||||
# Apply the aligned position (with dialog adjustments).
|
# Cross-link the partners so the rigid-group move handler can follow
|
||||||
n2 = np.array(normal) / max(np.linalg.norm(normal), 1e-12)
|
# the edge, and register the pair on the assembly graph.
|
||||||
ac2.position = self._connector_align_pos + n2 * offset
|
if conn1 is not None and conn2 is not None:
|
||||||
logger.info(f"Aligned '{ac2.name}' to connector position, offset={offset}mm")
|
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)
|
||||||
|
|
||||||
logger.info(f"Connected component pair: {first['ac_id']} ↔ {ac_id}, rotation={rotation}°")
|
logger.info(f"Connected component pair: {first['ac_id']} ↔ {ac_id}, 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._show_assembly_in_viewer(fit=True)
|
self._show_assembly_in_viewer(fit=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rotation_between_vectors(a, b):
|
||||||
|
"""Return a 3×3 rotation that maps vector *a* onto vector *b*.
|
||||||
|
|
||||||
|
Handles the two degenerate cases that plain Rodrigues' formula gets
|
||||||
|
wrong when the cross-product axis collapses to zero:
|
||||||
|
|
||||||
|
* ``a ≈ b`` → identity (no rotation needed).
|
||||||
|
* ``a ≈ -b`` → a 180° rotation about any axis orthogonal to *a*
|
||||||
|
(picked by a stable reference-vector projection).
|
||||||
|
|
||||||
|
Vectors are internally normalized so callers may pass non-unit input.
|
||||||
|
"""
|
||||||
|
import numpy as _np
|
||||||
|
import math as _math
|
||||||
|
a = _np.asarray(a, dtype=float)
|
||||||
|
b = _np.asarray(b, dtype=float)
|
||||||
|
an = _np.linalg.norm(a); bn = _np.linalg.norm(b)
|
||||||
|
if an < 1e-12 or bn < 1e-12:
|
||||||
|
return _np.eye(3)
|
||||||
|
a = a / an; b = b / bn
|
||||||
|
dot = float(_np.dot(a, b))
|
||||||
|
cross = _np.cross(a, b)
|
||||||
|
cross_norm = _np.linalg.norm(cross)
|
||||||
|
if cross_norm < 1e-9:
|
||||||
|
if dot > 0.0:
|
||||||
|
# Already aligned.
|
||||||
|
return _np.eye(3)
|
||||||
|
# Anti-parallel: 180° about an axis orthogonal to *a*.
|
||||||
|
ref = _np.array([1.0, 0.0, 0.0]) if abs(a[0]) < 0.9 else _np.array([0.0, 1.0, 0.0])
|
||||||
|
axis = ref - a * _np.dot(ref, a)
|
||||||
|
axis = axis / max(_np.linalg.norm(axis), 1e-12)
|
||||||
|
K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
|
||||||
|
# sin(180°)=0, 1-cos(180°)=2 → R = I + 2 (K @ K)
|
||||||
|
return _np.eye(3) + 2.0 * (K @ K)
|
||||||
|
axis = cross / cross_norm
|
||||||
|
angle = _math.acos(max(-1.0, min(1.0, dot)))
|
||||||
|
K = _np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
|
||||||
|
return _np.eye(3) + _np.sin(angle) * K + (1.0 - _np.cos(angle)) * (K @ K)
|
||||||
|
|
||||||
|
def _solve_assembly_alignment(
|
||||||
|
self,
|
||||||
|
first_ac: Any,
|
||||||
|
second_ac: Any,
|
||||||
|
first_pick: Dict[str, Any],
|
||||||
|
second_pick: Dict[str, Any],
|
||||||
|
) -> 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
|
||||||
|
connector (position + normal alignment).
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
* ``position`` — new world position for second component.
|
||||||
|
* ``rotation`` — new 3×3 rotation matrix for second component.
|
||||||
|
* ``original_position`` / ``original_rotation`` — for cancellation.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Save original transform for cancellation.
|
||||||
|
orig_pos = np.array(second_ac.position, dtype=float)
|
||||||
|
orig_rot = np.array(second_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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Build solver.
|
||||||
|
#
|
||||||
|
# IMPORTANT: SolveSpace's SLVS_C_PARALLEL and SLVS_C_SAME_ORIENTATION
|
||||||
|
# both generate multi-equation residuals that trigger a hard C-level
|
||||||
|
# assertion in this python_solvespace build's Newton iterator
|
||||||
|
# ("Expected constraint to generate a single equation"), aborting the
|
||||||
|
# whole process. We therefore avoid line-parallel / orientation
|
||||||
|
# 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)
|
||||||
|
#
|
||||||
|
# That's 6 single-equation-coincident residuals against 6 free point
|
||||||
|
# parameters — a well-posed 0-DOF system — so it converges cleanly.
|
||||||
|
# The remaining free rotation around the axis is left for the
|
||||||
|
# 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]),
|
||||||
|
)
|
||||||
|
sys.dragged(pt1b, 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]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints: pivot coincidence + axis-tip coincidence.
|
||||||
|
sys.coincident(pt1, pt2, Entity.FREE_IN_3D)
|
||||||
|
sys.coincident(pt1b, tip2, 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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
new_rot = R_align @ orig_rot
|
||||||
|
|
||||||
|
# New position: P = p2_solved - R @ p2_local
|
||||||
|
new_pos = p2_solved - new_rot @ p2_local
|
||||||
|
|
||||||
|
return {
|
||||||
|
"position": new_pos,
|
||||||
|
"rotation": new_rot,
|
||||||
|
"original_position": orig_pos,
|
||||||
|
"original_rotation": orig_rot,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _align_direct(
|
||||||
|
self,
|
||||||
|
first_ac: Any,
|
||||||
|
second_ac: Any,
|
||||||
|
first_pick: Dict[str, Any],
|
||||||
|
second_pick: Dict[str, Any],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Direct geometric alignment (fallback when SolveSpace unavailable).
|
||||||
|
|
||||||
|
Moves the second component so its connector matches the first.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
new_rot = R_align @ orig_rot
|
||||||
|
p2_world_target = p1_world
|
||||||
|
new_pos = p2_world_target - new_rot @ p2_local
|
||||||
|
|
||||||
|
return {
|
||||||
|
"position": new_pos,
|
||||||
|
"rotation": new_rot,
|
||||||
|
"original_position": orig_pos,
|
||||||
|
"original_rotation": orig_rot,
|
||||||
|
}
|
||||||
|
|
||||||
def _show_connector_dialog_with_preview(
|
def _show_connector_dialog_with_preview(
|
||||||
self,
|
self,
|
||||||
first_ac: Any,
|
first_ac: Any,
|
||||||
second_ac: Any,
|
second_ac: Any,
|
||||||
first_origin: Tuple[float, float, float],
|
first_pick: Dict[str, Any],
|
||||||
second_origin: Tuple[float, float, float],
|
second_pick: Dict[str, Any],
|
||||||
first_normal: Tuple[float, float, float],
|
solved: Dict[str, Any],
|
||||||
second_normal: Tuple[float, float, float],
|
) -> Tuple[Optional[float], Optional[float], bool]:
|
||||||
first_x_dir: Tuple[float, float, float],
|
|
||||||
second_x_dir: Tuple[float, float, float],
|
|
||||||
align_delta: Any,
|
|
||||||
) -> Tuple[Optional[float], Optional[float]]:
|
|
||||||
"""Show connector dialog with live 3D preview of the alignment.
|
"""Show connector dialog with live 3D preview of the alignment.
|
||||||
|
|
||||||
Returns (rotation_degrees, offset_mm) or (None, None) if cancelled.
|
Returns (rotation_degrees, offset_mm, flip) or (None, None, False) if cancelled.
|
||||||
"""
|
"""
|
||||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
|
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
|
||||||
QLabel, QDoubleSpinBox, QPushButton,
|
QLabel, QDoubleSpinBox, QPushButton,
|
||||||
QFrame, QCheckBox)
|
QFrame, QCheckBox)
|
||||||
|
|
||||||
if second_ac is None:
|
if second_ac is None:
|
||||||
return (None, None)
|
return (None, None, False)
|
||||||
|
|
||||||
dialog = QDialog(self)
|
dialog = QDialog(self)
|
||||||
dialog.setWindowTitle("Connector — Connection Properties")
|
dialog.setWindowTitle("Connector — Connection Properties")
|
||||||
@@ -5301,10 +5791,18 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
layout = QVBoxLayout(dialog)
|
layout = QVBoxLayout(dialog)
|
||||||
|
|
||||||
# Info label.
|
entity_names = {
|
||||||
layout.addWidget(QLabel("Adjust the connection between the two components:"))
|
"planar_face": "Face",
|
||||||
|
"cylindrical_face": "Hole",
|
||||||
|
"edge": "Edge",
|
||||||
|
"vertex": "Vertex",
|
||||||
|
}
|
||||||
|
t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity")
|
||||||
|
t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity")
|
||||||
|
layout.addWidget(QLabel(f"<b>{t1}</b> on {first_ac.name} → <b>{t2}</b> on {second_ac.name}"))
|
||||||
|
layout.addWidget(QLabel("Adjust the connection:"))
|
||||||
|
|
||||||
# Rotation around normal axis of the FIRST connector.
|
# Rotation around normal axis.
|
||||||
rot_layout = QHBoxLayout()
|
rot_layout = QHBoxLayout()
|
||||||
rot_layout.addWidget(QLabel("Rotation around axis (°):"))
|
rot_layout.addWidget(QLabel("Rotation around axis (°):"))
|
||||||
rotation_spin = QDoubleSpinBox()
|
rotation_spin = QDoubleSpinBox()
|
||||||
@@ -5341,24 +5839,34 @@ class MainWindow(QMainWindow):
|
|||||||
btn_layout.addWidget(cancel_btn)
|
btn_layout.addWidget(cancel_btn)
|
||||||
layout.addLayout(btn_layout)
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
target_normal = np.array(first_pick["normal_world"], dtype=float)
|
||||||
|
target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12)
|
||||||
|
|
||||||
# ── Live preview callback ──
|
# ── Live preview callback ──
|
||||||
def _update_preview(*args):
|
def _update_preview(*args):
|
||||||
import numpy as np
|
|
||||||
rot_deg = rotation_spin.value()
|
rot_deg = rotation_spin.value()
|
||||||
off = offset_spin.value()
|
off = offset_spin.value()
|
||||||
flip = flip_check.isChecked()
|
flip = flip_check.isChecked()
|
||||||
|
|
||||||
# Compute the aligned position for second component.
|
# Start from solved transform.
|
||||||
base_pos = self._connector_align_pos if hasattr(self, '_connector_align_pos') else (
|
base_pos = np.array(solved["position"], dtype=float)
|
||||||
np.array(second_ac.position, dtype=float) + align_delta
|
base_rot = np.array(solved["rotation"], dtype=float)
|
||||||
)
|
|
||||||
# Apply offset along the second normal.
|
|
||||||
n2 = np.array(second_normal) / max(np.linalg.norm(second_normal), 1e-12)
|
|
||||||
new_pos = np.array(base_pos, dtype=float) + n2 * off
|
|
||||||
|
|
||||||
# Store preview position.
|
# Apply axis rotation around target_normal.
|
||||||
second_ac.position = new_pos
|
angle_rad = np.radians(rot_deg)
|
||||||
self._show_assembly_in_viewer() # no fit — keep camera steady during live preview
|
k = target_normal
|
||||||
|
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)
|
||||||
|
preview_rot = R_axis @ base_rot
|
||||||
|
|
||||||
|
# Apply offset (with flip).
|
||||||
|
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
|
||||||
|
self._show_assembly_in_viewer() # no fit — keep camera steady
|
||||||
|
|
||||||
rotation_spin.valueChanged.connect(_update_preview)
|
rotation_spin.valueChanged.connect(_update_preview)
|
||||||
offset_spin.valueChanged.connect(_update_preview)
|
offset_spin.valueChanged.connect(_update_preview)
|
||||||
@@ -5370,15 +5878,9 @@ class MainWindow(QMainWindow):
|
|||||||
ok_btn.clicked.connect(dialog.accept)
|
ok_btn.clicked.connect(dialog.accept)
|
||||||
cancel_btn.clicked.connect(dialog.reject)
|
cancel_btn.clicked.connect(dialog.reject)
|
||||||
|
|
||||||
def _on_dialog_close():
|
|
||||||
nonlocal dialog
|
|
||||||
if dialog.result() != QDialog.DialogCode.Accepted:
|
|
||||||
# User cancelled — no changes applied, caller will restore.
|
|
||||||
pass
|
|
||||||
|
|
||||||
if dialog.exec():
|
if dialog.exec():
|
||||||
return (rotation_spin.value(), offset_spin.value())
|
return (rotation_spin.value(), offset_spin.value(), flip_check.isChecked())
|
||||||
return (None, None)
|
return (None, None, False)
|
||||||
|
|
||||||
def _on_delete_connector(self):
|
def _on_delete_connector(self):
|
||||||
"""Delete the connector nearest to the selected assembly component."""
|
"""Delete the connector nearest to the selected assembly component."""
|
||||||
@@ -5406,6 +5908,27 @@ class MainWindow(QMainWindow):
|
|||||||
if ok and label:
|
if ok and label:
|
||||||
idx = conn_labels.index(label)
|
idx = conn_labels.index(label)
|
||||||
conn_id = conn_names[idx]
|
conn_id = conn_names[idx]
|
||||||
|
conn = ac.connectors.get(conn_id)
|
||||||
|
# Un-partner the mate and drop the rigid-group edge so stale
|
||||||
|
# connections don't linger in the BFS graph.
|
||||||
|
if conn is not None:
|
||||||
|
partner_ac_id = conn.partner_ac_id
|
||||||
|
partner_conn_id = conn.partner_connector_id
|
||||||
|
if partner_ac_id is not None and partner_conn_id is not None:
|
||||||
|
partner_ac = assembly.components.get(partner_ac_id)
|
||||||
|
if partner_ac is not None and partner_conn_id in partner_ac.connectors:
|
||||||
|
pc = partner_ac.connectors[partner_conn_id]
|
||||||
|
pc.partner_ac_id = None
|
||||||
|
pc.partner_connector_id = None
|
||||||
|
pc.is_grounded = False
|
||||||
|
# Remove the connection edge either side references this pair.
|
||||||
|
assembly.connections = [
|
||||||
|
c for c in assembly.connections
|
||||||
|
if not (
|
||||||
|
(c.first_ac_id == active_id and c.second_ac_id == partner_ac_id)
|
||||||
|
or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id)
|
||||||
|
)
|
||||||
|
] if partner_ac_id is not None else assembly.connections
|
||||||
ac.remove_connector(conn_id)
|
ac.remove_connector(conn_id)
|
||||||
logger.info(f"Removed connector {conn_id}")
|
logger.info(f"Removed connector {conn_id}")
|
||||||
self._show_assembly_in_viewer(fit=True)
|
self._show_assembly_in_viewer(fit=True)
|
||||||
|
|||||||
@@ -368,8 +368,16 @@ class Connector:
|
|||||||
# Which body/face this connector was placed on (renderer obj_id).
|
# Which body/face this connector was placed on (renderer obj_id).
|
||||||
source_obj_id: str = ""
|
source_obj_id: str = ""
|
||||||
|
|
||||||
# Future: connected to another Connector's id.
|
# --- Rigid-group pairing (set when two connectors are mated) ---
|
||||||
# connected_to: Optional[str] = None
|
# The id of the partner AssemblyComponent this connector is mated to.
|
||||||
|
# The FIRST-picked component is the grounded reference of the pair;
|
||||||
|
# 'is_grounded' marks that side so the move handler knows which half
|
||||||
|
# is the fixed frame of the rigid group.
|
||||||
|
partner_ac_id: Optional[str] = None
|
||||||
|
# The id of the partner Connector on the partner component.
|
||||||
|
partner_connector_id: Optional[str] = None
|
||||||
|
# True on the first-picked (grounded) connector of a mated pair.
|
||||||
|
is_grounded: bool = False
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_at: datetime = field(default_factory=datetime.now)
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
@@ -430,6 +438,25 @@ class AssemblyComponent:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssemblyConnection:
|
||||||
|
"""A mated connector pair linking two AssemblyComponents.
|
||||||
|
|
||||||
|
Records which component is the grounded reference (``first_ac_id``) and
|
||||||
|
which was solved against it (``second_ac_id``), plus the partner
|
||||||
|
connector ids so the linkage can be followed / removed symmetrically.
|
||||||
|
Used by the assembly-move handler to propagate translations across the
|
||||||
|
rigid group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
first_ac_id: str = "" # grounded reference side
|
||||||
|
second_ac_id: str = "" # solved side
|
||||||
|
first_connector_id: Optional[str] = None
|
||||||
|
second_connector_id: Optional[str] = None
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Assembly:
|
class Assembly:
|
||||||
"""
|
"""
|
||||||
@@ -447,9 +474,66 @@ class Assembly:
|
|||||||
components: Dict[str, AssemblyComponent] = field(default_factory=dict)
|
components: Dict[str, AssemblyComponent] = field(default_factory=dict)
|
||||||
active_assembly_component: Optional[str] = None
|
active_assembly_component: Optional[str] = None
|
||||||
|
|
||||||
|
# Mated connector pairs — each entry links two AssemblyComponents so the
|
||||||
|
# assembly-move handler can propagate rigid-group translations. The
|
||||||
|
# 'first_ac_id' side is the grounded reference of the pair.
|
||||||
|
connections: List["AssemblyConnection"] = field(default_factory=list)
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_at: datetime = field(default_factory=datetime.now)
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def add_connection(self, first_ac_id: str, second_ac_id: str) -> "AssemblyConnection":
|
||||||
|
"""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).
|
||||||
|
"""
|
||||||
|
conn = AssemblyConnection(
|
||||||
|
first_ac_id=first_ac_id,
|
||||||
|
second_ac_id=second_ac_id,
|
||||||
|
)
|
||||||
|
self.connections.append(conn)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def remove_connections_for(self, ac_id: str) -> None:
|
||||||
|
"""Drop every connection that involves *ac_id* (e.g. on removal)."""
|
||||||
|
self.connections = [
|
||||||
|
c for c in self.connections
|
||||||
|
if c.first_ac_id != ac_id and c.second_ac_id != ac_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_rigid_group(self, ac_id: str) -> List[str]:
|
||||||
|
"""Return ids of all components rigidly linked to *ac_id* (BFS).
|
||||||
|
|
||||||
|
Includes *ac_id* itself. Two components are linked when a mated
|
||||||
|
connector pair (in ``connections``) joins them; linkage is
|
||||||
|
transitive, so the whole connected subgraph forms one rigid group.
|
||||||
|
"""
|
||||||
|
if ac_id not in self.components:
|
||||||
|
return []
|
||||||
|
# Build adjacency from the connection list.
|
||||||
|
adj: Dict[str, List[str]] = {}
|
||||||
|
for c in self.connections:
|
||||||
|
adj.setdefault(c.first_ac_id, []).append(c.second_ac_id)
|
||||||
|
adj.setdefault(c.second_ac_id, []).append(c.first_ac_id)
|
||||||
|
seen: List[str] = []
|
||||||
|
queue: List[str] = [ac_id]
|
||||||
|
while queue:
|
||||||
|
cur = queue.pop(0)
|
||||||
|
if cur in seen:
|
||||||
|
continue
|
||||||
|
seen.append(cur)
|
||||||
|
for nb in adj.get(cur, []):
|
||||||
|
if nb not in seen:
|
||||||
|
queue.append(nb)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def is_grounded_reference(self, ac_id: str) -> bool:
|
||||||
|
"""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 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:
|
||||||
@@ -472,6 +556,10 @@ class Assembly:
|
|||||||
"""Remove a component instance from the assembly."""
|
"""Remove a component instance from the assembly."""
|
||||||
if assembly_component_id in self.components:
|
if assembly_component_id in self.components:
|
||||||
del self.components[assembly_component_id]
|
del self.components[assembly_component_id]
|
||||||
|
# Also drop any mated-connector links that referenced this
|
||||||
|
# instance — otherwise stale connection edges would remain in
|
||||||
|
# the rigid-group graph and point at a missing component.
|
||||||
|
self.remove_connections_for(assembly_component_id)
|
||||||
if self.active_assembly_component == assembly_component_id:
|
if self.active_assembly_component == assembly_component_id:
|
||||||
self.active_assembly_component = next(
|
self.active_assembly_component = next(
|
||||||
iter(self.components.keys()), None
|
iter(self.components.keys()), None
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class OCCRenderer(Renderer):
|
|||||||
self._highlight_ais: Any = None
|
self._highlight_ais: Any = None
|
||||||
# Temporary transparent preview AIS for the live extrude/cut dialog.
|
# Temporary transparent preview AIS for the live extrude/cut dialog.
|
||||||
self._preview_ais: Any = None
|
self._preview_ais: Any = None
|
||||||
|
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
|
||||||
|
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
|
||||||
|
self._gizmo_objects: Dict[str, Any] = {}
|
||||||
|
|
||||||
def initialize(self, parent_widget: Any) -> bool:
|
def initialize(self, parent_widget: Any) -> bool:
|
||||||
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
||||||
@@ -251,16 +254,22 @@ class OCCRenderer(Renderer):
|
|||||||
logger.debug("polygon offset unavailable", exc_info=True)
|
logger.debug("polygon offset unavailable", exc_info=True)
|
||||||
|
|
||||||
self._context.Display(ais, True)
|
self._context.Display(ais, True)
|
||||||
# Activate selection modes so the viewer can detect/pick the whole
|
# Activate selection modes so the viewer can detect/pick individual
|
||||||
# shape (mode 0) and individual faces (mode for TopAbs_FACE) — needed
|
# faces, edges and vertices. Required for the smart entity picker
|
||||||
# for sketch-on-surface face picking. Left-click orbits the camera
|
# gizmo (assembly connector picks) AND for sketch-on-surface face
|
||||||
# (see handle_mouse_press), so active selection doesn't interfere.
|
# picking. OCC assigns higher detection priority to vertices, then
|
||||||
|
# edges, then faces — so hovering near an edge/vertex picks the
|
||||||
|
# edge/vertex rather than the underlying face, which is what we want
|
||||||
|
# for snapping. Left-click still orbits the camera (see
|
||||||
|
# handle_mouse_press); active selection is only consumed by the
|
||||||
|
# explicit pick methods (pick_entity / pick_planar_face).
|
||||||
try:
|
try:
|
||||||
from OCP.TopAbs import TopAbs_FACE
|
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||||
face_mode = AIS_Shape.SelectionMode_s(TopAbs_FACE)
|
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
|
||||||
self._context.Activate(ais, face_mode)
|
mode = AIS_Shape.SelectionMode_s(topo)
|
||||||
|
self._context.Activate(ais, mode)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("face selection mode activation failed", exc_info=True)
|
logger.debug("selection mode activation failed", exc_info=True)
|
||||||
|
|
||||||
defcol = color or (0.5, 0.5, 0.5)
|
defcol = color or (0.5, 0.5, 0.5)
|
||||||
robj = OCCRenderObject(
|
robj = OCCRenderObject(
|
||||||
@@ -467,7 +476,9 @@ class OCCRenderer(Renderer):
|
|||||||
self._context.Display(ais, True)
|
self._context.Display(ais, True)
|
||||||
self._preview_ais = ais
|
self._preview_ais = ais
|
||||||
if self._view is not None:
|
if self._view is not None:
|
||||||
self._view.Repaint()
|
# OCC's V3d_View exposes ``Redraw`` (not ``Repaint``); calling
|
||||||
|
# the wrong name crashed the live extrude-cut preview callback.
|
||||||
|
self._view.Redraw()
|
||||||
|
|
||||||
def clear_preview(self) -> None:
|
def clear_preview(self) -> None:
|
||||||
"""Remove the live extrude preview shape."""
|
"""Remove the live extrude preview shape."""
|
||||||
@@ -484,6 +495,7 @@ class OCCRenderer(Renderer):
|
|||||||
return
|
return
|
||||||
self.clear_preview()
|
self.clear_preview()
|
||||||
self.clear_face_highlight()
|
self.clear_face_highlight()
|
||||||
|
self.clear_entity_gizmo()
|
||||||
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
|
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
|
||||||
# path; fall back to iterating the displayed list if unavailable.
|
# path; fall back to iterating the displayed list if unavailable.
|
||||||
try:
|
try:
|
||||||
@@ -1007,6 +1019,598 @@ class OCCRenderer(Renderer):
|
|||||||
logger.debug("clear_face_highlight remove failed", exc_info=True)
|
logger.debug("clear_face_highlight remove failed", exc_info=True)
|
||||||
self._highlight_ais = None
|
self._highlight_ais = None
|
||||||
|
|
||||||
|
# ─── General entity picking (for assembly connectors / snaps) ───────────
|
||||||
|
|
||||||
|
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Pick the entity under screen pixel (x, y).
|
||||||
|
|
||||||
|
Returns a dict describing the detected geometry:
|
||||||
|
|
||||||
|
* ``type`` — one of ``planar_face``, ``cylindrical_face``,
|
||||||
|
``edge``, ``vertex``.
|
||||||
|
* ``position`` — 3D point (face origin / edge midpoint / vertex).
|
||||||
|
* ``normal`` — direction vector (face normal / cylinder axis /
|
||||||
|
edge tangent / ``None`` for vertex).
|
||||||
|
* ``x_dir`` — stable in-plane reference direction.
|
||||||
|
* ``face`` / ``edge`` / ``vertex`` — the raw OCC sub-shape.
|
||||||
|
* ``owner_obj_id`` — the displayed body this belongs to.
|
||||||
|
|
||||||
|
Returns *None* if nothing detectable is under the cursor.
|
||||||
|
"""
|
||||||
|
if self._view is None or self._context is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._context.MoveTo(x, y, self._view, True)
|
||||||
|
if not self._context.HasDetected():
|
||||||
|
return None
|
||||||
|
|
||||||
|
shape = self._context.DetectedShape()
|
||||||
|
if shape is None:
|
||||||
|
return None
|
||||||
|
return self._classify_detected_shape(shape)
|
||||||
|
|
||||||
|
def _classify_detected_shape(
|
||||||
|
self, shape: Any, owner_obj_id: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Classify a detected OCC sub-shape into a snap-candidate dict.
|
||||||
|
|
||||||
|
Shared by ``pick_entity`` (single-pixel) and ``probe_snap_candidates``
|
||||||
|
(multi-pixel grid probing). Determines whether *shape* is a planar
|
||||||
|
face, cylindrical face (hole), edge, or vertex and returns the snap
|
||||||
|
info dict with ``position`` / ``normal`` / ``x_dir`` / ``type`` /
|
||||||
|
``owner_obj_id`` (+ ``radius`` for holes). When *owner_obj_id* is
|
||||||
|
omitted it is looked up from the context's currently-detected AIS.
|
||||||
|
"""
|
||||||
|
if shape is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS
|
||||||
|
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||||
|
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
|
||||||
|
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder
|
||||||
|
from OCP.BRep import BRep_Tool
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE
|
||||||
|
from OCP.gp import gp_Pnt, gp_Dir
|
||||||
|
from OCP.Bnd import Bnd_Box
|
||||||
|
from OCP.BRepBndLib import BRepBndLib
|
||||||
|
from OCP.TopExp import TopExp
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Helper: find owner object id if not supplied.
|
||||||
|
if owner_obj_id is None:
|
||||||
|
owner_obj_id = ""
|
||||||
|
try:
|
||||||
|
owner_ais = self._context.DetectedInteractive()
|
||||||
|
except Exception:
|
||||||
|
owner_ais = None
|
||||||
|
if owner_ais is not None:
|
||||||
|
for oid, robj in self._objects.items():
|
||||||
|
if robj.ais_shape is owner_ais:
|
||||||
|
owner_obj_id = oid
|
||||||
|
break
|
||||||
|
|
||||||
|
# Try face first.
|
||||||
|
face = None
|
||||||
|
try:
|
||||||
|
face = TopoDS.Face_s(shape)
|
||||||
|
_ = BRepAdaptor_Surface(face)
|
||||||
|
except Exception:
|
||||||
|
face = None
|
||||||
|
|
||||||
|
if face is not None:
|
||||||
|
adaptor = BRepAdaptor_Surface(face)
|
||||||
|
stype = adaptor.GetType()
|
||||||
|
|
||||||
|
if stype == GeomAbs_Plane:
|
||||||
|
pln = adaptor.Plane()
|
||||||
|
n = pln.Axis().Direction()
|
||||||
|
from OCP.TopAbs import TopAbs_REVERSED
|
||||||
|
if face.Orientation() == TopAbs_REVERSED:
|
||||||
|
n = n.Reversed()
|
||||||
|
nx, ny, nz = n.X(), n.Y(), n.Z()
|
||||||
|
|
||||||
|
# Center of face bbox projected onto plane.
|
||||||
|
bbox = Bnd_Box()
|
||||||
|
BRepBndLib.Add_s(face, bbox)
|
||||||
|
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||||
|
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
|
||||||
|
pln_origin = pln.Location()
|
||||||
|
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
|
||||||
|
origin = (cx - d * nx, cy - d * ny, cz - d * nz)
|
||||||
|
|
||||||
|
# x_dir from first edge.
|
||||||
|
x_dir = None
|
||||||
|
try:
|
||||||
|
expl = TopExp_Explorer(face, TopAbs_EDGE_TYPE)
|
||||||
|
if expl.More():
|
||||||
|
edge = TopoDS.Edge_s(expl.Current())
|
||||||
|
v1 = TopExp.FirstVertex_s(edge, True)
|
||||||
|
v2 = TopExp.LastVertex_s(edge, True)
|
||||||
|
p1 = BRep_Tool.Pnt_s(v1)
|
||||||
|
p2 = BRep_Tool.Pnt_s(v2)
|
||||||
|
ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
|
||||||
|
elen = (ex * ex + ey * ey + ez * ez) ** 0.5
|
||||||
|
if elen > 1e-9:
|
||||||
|
x_dir = (ex / elen, ey / elen, ez / elen)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if x_dir is None:
|
||||||
|
px = pln.XAxis().Direction()
|
||||||
|
x_dir = (px.X(), px.Y(), px.Z())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "planar_face",
|
||||||
|
"position": origin,
|
||||||
|
"normal": (nx, ny, nz),
|
||||||
|
"x_dir": x_dir,
|
||||||
|
"face": face,
|
||||||
|
"owner_obj_id": owner_obj_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
elif stype == GeomAbs_Cylinder:
|
||||||
|
cyl = adaptor.Cylinder()
|
||||||
|
axis = cyl.Axis()
|
||||||
|
ax_dir = axis.Direction()
|
||||||
|
ax_pos = axis.Location()
|
||||||
|
radius = cyl.Radius()
|
||||||
|
|
||||||
|
# Parameter extents along the cylinder axis (v = height).
|
||||||
|
# BRepAdaptor_Surface exposes these via First/Last V
|
||||||
|
# *Parameter() — NOT a Bounds() method (that quirk crashed
|
||||||
|
# cylindrical-face picking).
|
||||||
|
vmin = adaptor.FirstVParameter()
|
||||||
|
vmax = adaptor.LastVParameter()
|
||||||
|
|
||||||
|
# Two candidate snap points: the centers of the cylinder's
|
||||||
|
# two end circles (the hole openings). A bolt enters a
|
||||||
|
# hole from the camera-facing opening, so pick the END of
|
||||||
|
# the axis closest to the camera as the primary snap point.
|
||||||
|
# The axis location (ax_pos) is already on the cylinder axis
|
||||||
|
# at v=0; the other end is at v=vmax.
|
||||||
|
p0 = np.array([
|
||||||
|
ax_pos.X(), ax_pos.Y(), ax_pos.Z(),
|
||||||
|
], dtype=float)
|
||||||
|
p1 = np.array([
|
||||||
|
ax_pos.X() + ax_dir.X() * (vmax - vmin),
|
||||||
|
ax_pos.Y() + ax_dir.Y() * (vmax - vmin),
|
||||||
|
ax_pos.Z() + ax_dir.Z() * (vmax - vmin),
|
||||||
|
], dtype=float)
|
||||||
|
|
||||||
|
# Choose the camera-facing end. The camera looks FROM its
|
||||||
|
# eye TOWARD its target, so the camera direction is
|
||||||
|
# (target - eye). The end whose vector-from-camera is MOST
|
||||||
|
# OPPOSITE to (i.e. faces) the camera is the near opening.
|
||||||
|
cam_from = None
|
||||||
|
try:
|
||||||
|
if self._view is not None:
|
||||||
|
eye = self._view.Eye()
|
||||||
|
at = self._view.At()
|
||||||
|
cam_from = np.array([eye.X(), eye.Y(), eye.Z()], dtype=float)
|
||||||
|
cam_to = np.array([at.X(), at.Y(), at.Z()], dtype=float)
|
||||||
|
except Exception:
|
||||||
|
cam_from = None
|
||||||
|
|
||||||
|
if cam_from is not None:
|
||||||
|
# End closest to the camera eye is the visible opening.
|
||||||
|
d0 = float(np.linalg.norm(p0 - cam_from))
|
||||||
|
d1 = float(np.linalg.norm(p1 - cam_from))
|
||||||
|
near_end = p0 if d0 <= d1 else p1
|
||||||
|
else:
|
||||||
|
# Fallback: axial midpoint.
|
||||||
|
near_end = 0.5 * (p0 + p1)
|
||||||
|
|
||||||
|
origin = (float(near_end[0]), float(near_end[1]), float(near_end[2]))
|
||||||
|
# Normal = the cylinder axis direction. This is the "bolt
|
||||||
|
# axis": the direction a bolt would travel INTO the hole.
|
||||||
|
# (Sign is the cylinder's own axis direction; a later flip can
|
||||||
|
# reverse it via the connector dialog's Flip checkbox.)
|
||||||
|
normal = (ax_dir.X(), ax_dir.Y(), ax_dir.Z())
|
||||||
|
|
||||||
|
# x_dir: radial direction from the axis center to the hole
|
||||||
|
# wall — gives a stable in-plane reference for the connector
|
||||||
|
# frame. Use the cylinder's own XDirection if available.
|
||||||
|
try:
|
||||||
|
px = cyl.Position().XDirection()
|
||||||
|
x_dir = (px.X(), px.Y(), px.Z())
|
||||||
|
except Exception:
|
||||||
|
x_dir = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "cylindrical_face",
|
||||||
|
"position": origin,
|
||||||
|
"normal": normal,
|
||||||
|
"x_dir": x_dir,
|
||||||
|
"face": face,
|
||||||
|
"owner_obj_id": owner_obj_id,
|
||||||
|
"radius": radius,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try edge.
|
||||||
|
edge = None
|
||||||
|
try:
|
||||||
|
edge = TopoDS.Edge_s(shape)
|
||||||
|
_ = BRepAdaptor_Curve(edge)
|
||||||
|
except Exception:
|
||||||
|
edge = None
|
||||||
|
|
||||||
|
if edge is not None:
|
||||||
|
curve = BRepAdaptor_Curve(edge)
|
||||||
|
# Midpoint parameter.
|
||||||
|
ufirst = curve.FirstParameter()
|
||||||
|
ulast = curve.LastParameter()
|
||||||
|
umid = (ufirst + ulast) / 2.0
|
||||||
|
p_mid = curve.Value(umid)
|
||||||
|
position = (p_mid.X(), p_mid.Y(), p_mid.Z())
|
||||||
|
|
||||||
|
# Tangent at midpoint.
|
||||||
|
try:
|
||||||
|
tangent = curve.DN(umid, 1)
|
||||||
|
tx, ty, tz = tangent.X(), tangent.Y(), tangent.Z()
|
||||||
|
tlen = (tx * tx + ty * ty + tz * tz) ** 0.5
|
||||||
|
if tlen > 1e-9:
|
||||||
|
tangent = (tx / tlen, ty / tlen, tz / tlen)
|
||||||
|
else:
|
||||||
|
tangent = None
|
||||||
|
except Exception:
|
||||||
|
tangent = None
|
||||||
|
|
||||||
|
# x_dir: perpendicular to tangent (arbitrary but stable).
|
||||||
|
x_dir = None
|
||||||
|
if tangent is not None:
|
||||||
|
t = np.array(tangent)
|
||||||
|
# Find a vector not parallel to t.
|
||||||
|
ref = np.array([1.0, 0.0, 0.0]) if abs(t[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
||||||
|
x = np.cross(t, ref)
|
||||||
|
xlen = np.linalg.norm(x)
|
||||||
|
if xlen > 1e-9:
|
||||||
|
x = x / xlen
|
||||||
|
x_dir = (float(x[0]), float(x[1]), float(x[2]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "edge",
|
||||||
|
"position": position,
|
||||||
|
"normal": tangent,
|
||||||
|
"x_dir": x_dir,
|
||||||
|
"edge": edge,
|
||||||
|
"owner_obj_id": owner_obj_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try vertex.
|
||||||
|
vertex = None
|
||||||
|
try:
|
||||||
|
vertex = TopoDS.Vertex_s(shape)
|
||||||
|
p = BRep_Tool.Pnt_s(vertex)
|
||||||
|
position = (p.X(), p.Y(), p.Z())
|
||||||
|
return {
|
||||||
|
"type": "vertex",
|
||||||
|
"position": position,
|
||||||
|
"normal": None,
|
||||||
|
"x_dir": None,
|
||||||
|
"vertex": vertex,
|
||||||
|
"owner_obj_id": owner_obj_id,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
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 = 18,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Probe a pixel grid around (x, y) and return visible snap candidates.
|
||||||
|
|
||||||
|
Samples a small ring + centre around the cursor, runs OCC's
|
||||||
|
``MoveTo`` at each pixel, and classifies every distinct detected
|
||||||
|
sub-shape via :meth:`_classify_detected_shape`. Results are
|
||||||
|
deduplicated by (owner_obj_id, type, rounded position) and sorted by
|
||||||
|
screen-space distance to the cursor, nearest first.
|
||||||
|
|
||||||
|
This is the general hover snap indicator: it surfaces nearby
|
||||||
|
vertices, edge midpoints, hole centres, and face centres so the
|
||||||
|
user can see the snap targets in the cursor neighbourhood — not
|
||||||
|
just the single entity directly under the crosshair.
|
||||||
|
|
||||||
|
Each entry is the same dict shape returned by ``pick_entity`` plus an
|
||||||
|
extra ``screen`` key carrying the (sx, sy) pixel where the entity was
|
||||||
|
first detected.
|
||||||
|
"""
|
||||||
|
if self._view is None or self._context is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sample pattern: the exact cursor pixel plus a small ring of
|
||||||
|
# offsets. The ring catches nearby vertices/edges/holes that sit a
|
||||||
|
# few pixels away from where the user is pointing.
|
||||||
|
ring_offsets = [
|
||||||
|
(0, 0),
|
||||||
|
(-radius, 0), (radius, 0), (0, -radius), (0, radius),
|
||||||
|
(-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius),
|
||||||
|
(-radius // 2, 0), (radius // 2, 0), (0, -radius // 2), (0, radius // 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
|
||||||
|
for dx, dy in ring_offsets:
|
||||||
|
sx, sy = x + dx, y + dy
|
||||||
|
try:
|
||||||
|
self._context.MoveTo(sx, sy, self._view, True)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not self._context.HasDetected():
|
||||||
|
continue
|
||||||
|
shape = self._context.DetectedShape()
|
||||||
|
if shape is None:
|
||||||
|
continue
|
||||||
|
info = self._classify_detected_shape(shape)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
# Skip non-trackable hits (no owner — e.g. the workplane plane).
|
||||||
|
if not info.get("owner_obj_id"):
|
||||||
|
continue
|
||||||
|
pos = info.get("position") or (0.0, 0.0, 0.0)
|
||||||
|
# Dedupe key: owner + type + position rounded to 0.1 mm.
|
||||||
|
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"] = (sx, sy)
|
||||||
|
candidates[key] = info
|
||||||
|
|
||||||
|
# Sort by screen-space distance to the 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 highlight_snap(self, position, color=None, size=3.0) -> Optional[str]:
|
||||||
|
"""Show a small marker sphere at *position* as a snap indicator.
|
||||||
|
|
||||||
|
Returns an object id that can be removed later.
|
||||||
|
"""
|
||||||
|
if self._context is None:
|
||||||
|
return None
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
|
||||||
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.AIS import AIS_Shape
|
||||||
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
|
try:
|
||||||
|
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), size).Shape()
|
||||||
|
ais = AIS_Shape(sphere)
|
||||||
|
c = color or (1.0, 0.6, 0.0) # orange
|
||||||
|
ais.SetColor(Quantity_Color(c[0], c[1], c[2], Quantity_TOC_RGB))
|
||||||
|
ais.SetDisplayMode(1)
|
||||||
|
self._context.Display(ais, True)
|
||||||
|
if self._view is not None:
|
||||||
|
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)
|
||||||
|
return oid
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"highlight_snap failed: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def remove_highlight_snap(self, obj_id: str) -> None:
|
||||||
|
"""Remove a snap marker by its id."""
|
||||||
|
if obj_id in self._objects:
|
||||||
|
robj = self._objects.pop(obj_id)
|
||||||
|
if self._context is not None and robj.ais_shape is not None:
|
||||||
|
try:
|
||||||
|
self._context.Remove(robj.ais_shape, True)
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.Update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ─── Smart Entity Picker Gizmo ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def clear_entity_gizmo(self) -> None:
|
||||||
|
"""Remove all gizmo display objects (markers, axes, highlights)."""
|
||||||
|
had_any = bool(self._gizmo_objects)
|
||||||
|
for obj_id in list(self._gizmo_objects.keys()):
|
||||||
|
robj = self._gizmo_objects.pop(obj_id)
|
||||||
|
if self._context is not None and robj is not None:
|
||||||
|
try:
|
||||||
|
self._context.Remove(robj, True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if had_any and self._view is not None:
|
||||||
|
self._view.Update()
|
||||||
|
|
||||||
|
def show_entity_gizmo(
|
||||||
|
self,
|
||||||
|
entity_type: str,
|
||||||
|
position: Tuple[float, float, float],
|
||||||
|
normal: Optional[Tuple[float, float, float]] = None,
|
||||||
|
x_dir: Optional[Tuple[float, float, float]] = None,
|
||||||
|
color: Optional[Tuple[float, float, float]] = None,
|
||||||
|
radius: Optional[float] = None,
|
||||||
|
candidates: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Display a smart snap gizmo for the given entity type.
|
||||||
|
|
||||||
|
Renders a composite visual appropriate to the entity type:
|
||||||
|
* **planar_face** — sphere at pick point + white normal axis + x_dir axis
|
||||||
|
* **cylindrical_face** — sphere at the camera-facing hole opening +
|
||||||
|
white bolt-axis line through the hole (the snap 'normal' points
|
||||||
|
along the hole like a bolt) + a ring of the hole radius at the
|
||||||
|
opening + a small radial reference line.
|
||||||
|
* **edge** — sphere at edge midpoint + tangent axis line
|
||||||
|
* **vertex** — sphere + RGB crosshair
|
||||||
|
|
||||||
|
When *candidates* is provided, every candidate is ALSO rendered with a
|
||||||
|
small DIM half-size sphere in its own per-type colour, while the
|
||||||
|
primary (this method's *position*/*entity_type*) is emphasised with a
|
||||||
|
larger bright sphere + axis indicators. This is the general hover
|
||||||
|
snap indicator: it shows all snap targets in the cursor neighbourhood
|
||||||
|
(vertices, edge midpoints, face centres, hole openings) so the user
|
||||||
|
can see what they can snap to — click then selects the primary.
|
||||||
|
|
||||||
|
All previously shown gizmo objects are removed first so the gizmo
|
||||||
|
follows the cursor cleanly.
|
||||||
|
"""
|
||||||
|
if self._context is None:
|
||||||
|
return
|
||||||
|
self.clear_entity_gizmo()
|
||||||
|
|
||||||
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
from OCP.AIS import AIS_Shape
|
||||||
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
|
||||||
|
|
||||||
|
# Per-entity-type colours (shared by the dim candidate markers).
|
||||||
|
default_colors = {
|
||||||
|
"planar_face": (0.0, 0.8, 1.0), # cyan
|
||||||
|
"cylindrical_face": (1.0, 0.5, 0.0), # orange (hole / bolt axis)
|
||||||
|
"edge": (0.2, 1.0, 0.4), # green
|
||||||
|
"vertex": (1.0, 1.0, 0.0), # yellow
|
||||||
|
}
|
||||||
|
gizmo_color = color or default_colors.get(entity_type, (1.0, 0.6, 0.0))
|
||||||
|
|
||||||
|
def _make_sphere(p, c, size):
|
||||||
|
try:
|
||||||
|
s = BRepPrimAPI_MakeSphere(gp_Pnt(*p), size).Shape()
|
||||||
|
a = AIS_Shape(s)
|
||||||
|
a.SetColor(Quantity_Color(*c, Quantity_TOC_RGB))
|
||||||
|
a.SetDisplayMode(1)
|
||||||
|
self._context.Display(a, True)
|
||||||
|
self._gizmo_objects[f"__gizmo_sphere_{id(a)}"] = a
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"gizmo sphere failed: {exc}")
|
||||||
|
|
||||||
|
px, py, pz = position
|
||||||
|
|
||||||
|
# ── 0. Dim candidate markers (every nearby snap target) ──
|
||||||
|
#
|
||||||
|
# Drawn FIRST so the bright primary sphere renders on top of them.
|
||||||
|
# Each candidate gets a small half-size sphere tinted by its own
|
||||||
|
# entity-type colour; the primary (this method's position) is drawn
|
||||||
|
# brighter & larger in step 1 below so it reads as the active snap.
|
||||||
|
if candidates:
|
||||||
|
for cand in candidates:
|
||||||
|
cpos = cand.get("position")
|
||||||
|
if cpos is None:
|
||||||
|
continue
|
||||||
|
# Skip the primary itself — it gets its own bright marker.
|
||||||
|
if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == (
|
||||||
|
round(px, 1), round(py, 1), round(pz, 1)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7))
|
||||||
|
_make_sphere(cpos, cc, 1.4) # dim, small
|
||||||
|
|
||||||
|
# ── 1. Bright primary marker (sphere) ──
|
||||||
|
_make_sphere(position, gizmo_color, 2.8)
|
||||||
|
|
||||||
|
# ── 2. Axis indicator lines (primary only) ──
|
||||||
|
axis_length = 15.0
|
||||||
|
|
||||||
|
def _make_axis_line(
|
||||||
|
origin: Tuple[float, float, float],
|
||||||
|
direction: Tuple[float, float, float],
|
||||||
|
length: float,
|
||||||
|
line_color: Tuple[float, float, float],
|
||||||
|
label: str = "axis",
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Create a small line segment along *direction* from *origin*."""
|
||||||
|
try:
|
||||||
|
dx, dy, dz = direction
|
||||||
|
norm = (dx * dx + dy * dy + dz * dz) ** 0.5
|
||||||
|
if norm < 1e-9:
|
||||||
|
return None
|
||||||
|
ux, uy, uz = dx / norm, dy / norm, dz / norm
|
||||||
|
ex = origin[0] + ux * length
|
||||||
|
ey = origin[1] + uy * length
|
||||||
|
ez = origin[2] + uz * length
|
||||||
|
|
||||||
|
edge = BRepBuilderAPI_MakeEdge(
|
||||||
|
gp_Pnt(*origin), gp_Pnt(ex, ey, ez)
|
||||||
|
).Edge()
|
||||||
|
ais = AIS_Shape(edge)
|
||||||
|
ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
|
||||||
|
ais.SetDisplayMode(0) # wireframe
|
||||||
|
self._context.Display(ais, True)
|
||||||
|
gid = f"__gizmo_{label}_{id(ais)}"
|
||||||
|
self._gizmo_objects[gid] = ais
|
||||||
|
return gid
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"gizmo axis line failed: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if entity_type == "planar_face" and normal is not None:
|
||||||
|
# Normal axis (white).
|
||||||
|
_make_axis_line(position, normal, axis_length, (1.0, 1.0, 1.0), "normal")
|
||||||
|
# X direction axis (slightly dimmer).
|
||||||
|
if x_dir is not None:
|
||||||
|
_make_axis_line(position, x_dir, axis_length * 0.6, gizmo_color, "xdir")
|
||||||
|
|
||||||
|
elif entity_type == "cylindrical_face" and normal is not None:
|
||||||
|
# Hole / bolt axis. The snap point is the camera-facing opening,
|
||||||
|
# so draw the axis going INTO the hole (the +normal direction —
|
||||||
|
# the bolt travel direction) plus a short stub the other way for
|
||||||
|
# visual balance. This reads as 'bolt axis through hole'.
|
||||||
|
_make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
|
||||||
|
_make_axis_line(
|
||||||
|
position, (-normal[0], -normal[1], -normal[2]),
|
||||||
|
axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub",
|
||||||
|
)
|
||||||
|
# Radial reference (same colour as the marker).
|
||||||
|
if x_dir is not None:
|
||||||
|
_make_axis_line(position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial")
|
||||||
|
|
||||||
|
elif entity_type == "edge" and normal is not None:
|
||||||
|
# Tangent direction at midpoint.
|
||||||
|
_make_axis_line(position, normal, axis_length, gizmo_color, "tangent")
|
||||||
|
|
||||||
|
elif entity_type == "vertex":
|
||||||
|
# Small crosshair (three short axes) so a vertex snap reads as
|
||||||
|
# a coordinate point even before clicking.
|
||||||
|
_make_axis_line(position, (1, 0, 0), axis_length * 0.5, (1.0, 0.3, 0.3), "cross_x")
|
||||||
|
_make_axis_line(position, (0, 1, 0), axis_length * 0.5, (0.3, 1.0, 0.3), "cross_y")
|
||||||
|
_make_axis_line(position, (0, 0, 1), axis_length * 0.5, (0.3, 0.3, 1.0), "cross_z")
|
||||||
|
|
||||||
|
# ── 3. For cylindrical faces, ring of the hole radius at the opening ──
|
||||||
|
#
|
||||||
|
# Drawn at the camera-facing opening (the snap point), so the user
|
||||||
|
# sees the actual hole diameter they're snapping a bolt to.
|
||||||
|
if entity_type == "cylindrical_face" and normal is not None and radius is not None:
|
||||||
|
try:
|
||||||
|
center = gp_Pnt(px, py, pz)
|
||||||
|
ax2 = gp_Ax2(center, gp_Dir(*normal))
|
||||||
|
circ = gp_Circ(ax2, radius)
|
||||||
|
ring_edge = BRepBuilderAPI_MakeEdge(circ).Edge()
|
||||||
|
ring_ais = AIS_Shape(ring_edge)
|
||||||
|
ring_ais.SetColor(Quantity_Color(*gizmo_color, Quantity_TOC_RGB))
|
||||||
|
ring_ais.SetDisplayMode(0) # wireframe
|
||||||
|
self._context.Display(ring_ais, True)
|
||||||
|
gid = f"__gizmo_ring_{id(ring_ais)}"
|
||||||
|
self._gizmo_objects[gid] = ring_ais
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"gizmo ring failed: {exc}")
|
||||||
|
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.Update()
|
||||||
|
|
||||||
# ─── Mouse / keyboard event forwarding ──────────────────────────────
|
# ─── Mouse / keyboard event forwarding ──────────────────────────────
|
||||||
#
|
#
|
||||||
# CAD-style navigation:
|
# CAD-style navigation:
|
||||||
|
|||||||
Reference in New Issue
Block a user