893 lines
36 KiB
Python
893 lines
36 KiB
Python
"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
|
|
from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF
|
|
from PySide6.QtWidgets import QWidget
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class Viewer3DWidget(QWidget):
|
|
"""3D viewer widget using OCC's native AIS display."""
|
|
|
|
# Emitted when the user picks a planar face to sketch on.
|
|
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
|
|
facePicked = Signal(tuple, tuple, tuple, object)
|
|
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
|
pickFaceCancelled = Signal()
|
|
|
|
# Emitted when the user picks an entity for a connector point (assembly).
|
|
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
|
|
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
|
|
# Emitted when connector pick mode is cancelled.
|
|
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.
|
|
# Payload: owner_obj_id.
|
|
assemblyComponentActivated = Signal(str)
|
|
# Emitted during a drag move: owner_obj_id, world dx, dy, dz.
|
|
assemblyComponentDragged = Signal(str, float, float, float)
|
|
# Emitted when a drag move finishes.
|
|
assemblyMoveFinished = Signal(str)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
|
self.setAttribute(Qt.WA_PaintOnScreen)
|
|
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
|
self.setAutoFillBackground(False)
|
|
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
|
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.
|
|
self._renderer: Any = None
|
|
self._initialized = False
|
|
self._meshes: Dict[str, Any] = {}
|
|
self._selected_normal: Optional[Tuple[float, float, float]] = None
|
|
self._centroid: Optional[Tuple[float, float, float]] = None
|
|
self._pending_meshes: List[Tuple] = []
|
|
# When True, a left-click picks a planar face (for sketch-on-surface)
|
|
# instead of orbiting the camera. Set via set_pick_face_mode().
|
|
self._pick_face_mode: bool = False
|
|
# When True, a left-click picks an entity for a connector point
|
|
# (assembly component connection).
|
|
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.
|
|
self._move_drag_active: bool = False
|
|
self._move_owner_obj_id: str = ""
|
|
self._move_click_3d: Optional[Tuple[float, float, float]] = None
|
|
self._move_click_screen: Optional[Any] = None
|
|
self._move_plane_normal: Optional[Tuple[float, float, float]] = None
|
|
self._move_initial_position: Optional[Tuple[float, float, float]] = None
|
|
# Most recently recorded owning obj_id for the face returned by
|
|
# ``pick_planar_face``. Stashed on each pick pass so the host can
|
|
# pair the picked face with the body it belongs to (used to auto-
|
|
# target a cut/union extrude against the body the sketch was
|
|
# projected onto).
|
|
self._last_pick_owner_obj_id: Optional[str] = None
|
|
|
|
def _init_renderer(self) -> None:
|
|
"""Create the best available renderer."""
|
|
if self._renderer is not None:
|
|
return
|
|
import sys as _sys
|
|
_sys.stdout.flush()
|
|
logger.info("Renderer: starting import...")
|
|
from fluency.rendering.occ_renderer import OCCRenderer
|
|
from fluency.rendering.pygfx_renderer import PygfxRenderer
|
|
logger.info("Renderer: imports done, creating OCCRenderer...")
|
|
occ = OCCRenderer()
|
|
logger.info("Renderer: calling occ.initialize...")
|
|
try:
|
|
ok = occ.initialize(self)
|
|
except Exception as exc:
|
|
logger.warning(f"OCCRenderer init raised: {exc}")
|
|
ok = False
|
|
logger.info(f"Renderer: OCC result={ok}")
|
|
if ok:
|
|
self._renderer = occ
|
|
logger.info("Using OCCRenderer (native BRep display)")
|
|
else:
|
|
logger.info("Falling back to PygfxRenderer")
|
|
self._renderer = PygfxRenderer()
|
|
logger.info("Renderer: calling pygfx initialize...")
|
|
self._renderer.initialize(self)
|
|
logger.info("Renderer: pygfx init done")
|
|
self._initialized = True
|
|
logger.info("Renderer: initialization complete")
|
|
|
|
def showEvent(self, event):
|
|
logger.info("Viewer3DWidget showEvent - initializing renderer")
|
|
if not self._initialized:
|
|
self._init_renderer()
|
|
logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}")
|
|
for args in self._pending_meshes:
|
|
self.add_mesh(*args)
|
|
self._pending_meshes.clear()
|
|
self._renderer.render()
|
|
|
|
def _ensure_initialized(self):
|
|
if not self._initialized:
|
|
logger.debug("Ensuring renderer is initialized")
|
|
self._init_renderer()
|
|
|
|
def get_renderer(self):
|
|
self._ensure_initialized()
|
|
return self._renderer
|
|
|
|
def show_shape(self, shape: Any, color=None, name=None) -> str:
|
|
"""Display an OCC TopoDS_Shape.
|
|
|
|
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
|
triangulation + add_mesh for the PygfxRenderer.
|
|
"""
|
|
self._ensure_initialized()
|
|
from fluency.rendering.occ_renderer import OCCRenderer
|
|
if isinstance(self._renderer, OCCRenderer):
|
|
oid = self._renderer.add_shape(shape, color, name)
|
|
self._renderer.render()
|
|
return oid
|
|
# Fallback: tessellate and use the mesh pipeline.
|
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
|
k = OCGeometryKernel()
|
|
from fluency.geometry_occ.sketch import OCCSketch
|
|
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
|
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
|
obj = OCCGeometryObject(shape)
|
|
verts, faces = k.get_mesh(obj)
|
|
oid = self._renderer.add_mesh(verts, faces, color, name)
|
|
# Edges
|
|
try:
|
|
e_verts, e_edges = k.get_edges(obj)
|
|
if len(e_verts) > 0:
|
|
self._renderer.add_wireframe(e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges")
|
|
except Exception:
|
|
pass
|
|
self._renderer.render()
|
|
return oid
|
|
|
|
def add_mesh(self, vertices, faces, color=None, name=None) -> str:
|
|
logger.debug(
|
|
f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}"
|
|
)
|
|
if not self._initialized:
|
|
self._pending_meshes.append((vertices, faces, color, name))
|
|
logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}")
|
|
return f"pending_{len(self._pending_meshes)}"
|
|
|
|
self._ensure_initialized()
|
|
mesh_id = self._renderer.add_mesh(vertices, faces, color, name)
|
|
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name}
|
|
self._renderer.render()
|
|
logger.info(f"Added mesh: {mesh_id}, name={name}")
|
|
return mesh_id
|
|
|
|
def update_mesh(self, mesh_id: str, vertices, faces):
|
|
self._ensure_initialized()
|
|
self._renderer.update_mesh(mesh_id, vertices, faces)
|
|
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
|
|
self._renderer.render()
|
|
|
|
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
|
|
self._ensure_initialized()
|
|
wid = self._renderer.add_wireframe(vertices, edges, color or (0.9, 0.9, 0.9), line_width, name)
|
|
self._renderer.render()
|
|
return wid
|
|
|
|
def remove_mesh(self, mesh_id: str):
|
|
self._ensure_initialized()
|
|
self._renderer.remove_mesh(mesh_id)
|
|
if mesh_id in self._meshes:
|
|
del self._meshes[mesh_id]
|
|
self._renderer.render()
|
|
|
|
def set_visibility(self, mesh_id: str, visible: bool) -> bool:
|
|
"""Show or hide a previously-added mesh without removing it.
|
|
|
|
Used by the per-body visibility toggle on the body list so the
|
|
user can quickly hide a body (e.g. to verify a cut worked on
|
|
another body). Returns True on success, False if the mesh is
|
|
unknown to the renderer or the renderer doesn't support it
|
|
(e.g. the Pygfx fallback ABI).
|
|
"""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "set_visibility", None)
|
|
if fn is None:
|
|
return False
|
|
ok = fn(mesh_id, visible)
|
|
if ok:
|
|
self._renderer.render()
|
|
return ok
|
|
|
|
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
|
|
"""Set a previously-added mesh's transparency (0..1).
|
|
|
|
Used by the live extrude preview to dim the target body so the
|
|
previewed result reads on top of it.
|
|
"""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "set_object_transparency", None)
|
|
if fn is None:
|
|
return False
|
|
return fn(mesh_id, transparency)
|
|
|
|
def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
|
|
"""Display a temporary transparent preview of *shape* in the 3D view.
|
|
|
|
Used by the ExtrudeDialog live preview: as the user drags the
|
|
length spinner or toggles Cut/Through-All, the host recomputes
|
|
the operation result and shows it here. Call clear_preview()
|
|
when the dialog closes.
|
|
"""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "preview_shape", None)
|
|
if fn is None:
|
|
return
|
|
fn(shape, color, transparency)
|
|
|
|
def clear_preview(self) -> None:
|
|
"""Remove the live extrude preview shape, if any."""
|
|
if not self._initialized or self._renderer is None:
|
|
return
|
|
fn = getattr(self._renderer, "clear_preview", None)
|
|
if fn is None:
|
|
return
|
|
fn()
|
|
|
|
def clear_scene(self):
|
|
self._ensure_initialized()
|
|
self._renderer.clear_scene()
|
|
self._meshes.clear()
|
|
self._renderer.render()
|
|
|
|
def fit_camera(self):
|
|
self._ensure_initialized()
|
|
self._renderer.fit_camera()
|
|
self._renderer.render()
|
|
|
|
# ─── Workplane visualization ───────────────────────────────────────────
|
|
|
|
def show_workplane(
|
|
self,
|
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
|
normal: Tuple[float, float, float] = (0, 0, 1),
|
|
x_dir: Tuple[float, float, float] = (1, 0, 0),
|
|
size: float = 200.0,
|
|
name: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""Display a semi-transparent workplane plane in the 3D view.
|
|
|
|
Returns the object ID (for later removal) or None if the renderer
|
|
doesn't support workplane planes.
|
|
"""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "show_workplane_plane", None)
|
|
if fn is None:
|
|
return None
|
|
oid = fn(origin, normal, x_dir, size, name)
|
|
self._renderer.render()
|
|
return oid
|
|
|
|
def remove_workplane(self, obj_id: str) -> bool:
|
|
"""Remove a workplane plane visual by its ID."""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "remove_workplane_plane", None)
|
|
if fn is None:
|
|
return False
|
|
ok = fn(obj_id)
|
|
if ok:
|
|
self._renderer.render()
|
|
return ok
|
|
|
|
def mousePressEvent(self, event):
|
|
self._ensure_initialized()
|
|
# Face-pick mode: a left-click selects a planar face to sketch on.
|
|
if self._pick_face_mode and event.button() == Qt.LeftButton:
|
|
self._handle_face_pick(event)
|
|
return
|
|
# Connector pick mode: a left-click selects a face for a connection point.
|
|
if self._connector_pick_mode and event.button() == Qt.LeftButton:
|
|
self._handle_connector_pick(event)
|
|
return
|
|
# Assembly move mode: start dragging the clicked body.
|
|
if self._assembly_move_mode and event.button() == Qt.LeftButton:
|
|
self._handle_assembly_move_press(event)
|
|
return
|
|
self._renderer.handle_mouse_press(event)
|
|
super().mousePressEvent(event)
|
|
|
|
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"):
|
|
self._renderer.handle_mouse_move(event)
|
|
super().mouseMoveEvent(event)
|
|
return
|
|
# Active drag in assembly move mode.
|
|
if self._move_drag_active:
|
|
self._handle_assembly_move_move(event)
|
|
super().mouseMoveEvent(event)
|
|
return
|
|
self._renderer.handle_mouse_move(event)
|
|
super().mouseMoveEvent(event)
|
|
|
|
def paintEngine(self):
|
|
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
|
return None
|
|
|
|
def paintEvent(self, event):
|
|
"""Empty paintEvent — OCC draws directly via OpenGL."""
|
|
pass
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
self._ensure_initialized()
|
|
# Finish assembly drag.
|
|
if self._move_drag_active:
|
|
self._handle_assembly_move_release(event)
|
|
return
|
|
self._renderer.handle_mouse_release(event)
|
|
super().mouseReleaseEvent(event)
|
|
|
|
def wheelEvent(self, event):
|
|
self._ensure_initialized()
|
|
self._renderer.handle_wheel(event)
|
|
super().wheelEvent(event)
|
|
|
|
def resizeEvent(self, event):
|
|
super().resizeEvent(event)
|
|
self._ensure_initialized()
|
|
self._renderer.handle_resize(event.size().width(), event.size().height())
|
|
|
|
def set_camera_position(self, position, target):
|
|
self._ensure_initialized()
|
|
self._renderer.set_camera_position(position, target)
|
|
self._renderer.render()
|
|
|
|
def get_camera_position(self):
|
|
"""Return the current camera ``(eye, at, up)`` triple.
|
|
|
|
The underlying renderer's ``get_camera_position`` returns three
|
|
``np.ndarray``s. We forward the call so callers (notably
|
|
:meth:`MainWindow._collect_view_state`) can persist the camera.
|
|
Returns a tuple of ``(np.zeros(3), np.zeros(3), (0,0,1))`` if the
|
|
renderer hasn't been initialised yet (e.g. when the window is
|
|
being constructed).
|
|
"""
|
|
self._ensure_initialized()
|
|
if hasattr(self._renderer, "get_camera_position"):
|
|
return self._renderer.get_camera_position()
|
|
import numpy as np
|
|
return (
|
|
np.zeros(3, dtype=float),
|
|
np.zeros(3, dtype=float),
|
|
np.array([0.0, 0.0, 1.0], dtype=float),
|
|
)
|
|
|
|
def get_camera_fov(self) -> float:
|
|
"""Return the current vertical FOV in degrees from the renderer.
|
|
|
|
Falls back to 45.0 if the renderer doesn't support it.
|
|
"""
|
|
self._ensure_initialized()
|
|
if hasattr(self._renderer, "get_camera_fov"):
|
|
return self._renderer.get_camera_fov()
|
|
return 45.0
|
|
|
|
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
|
|
|
|
def set_pick_face_mode(self, enabled: bool) -> None:
|
|
"""Toggle face-pick mode.
|
|
|
|
When enabled, the cursor selects planar faces for sketch placement
|
|
instead of orbiting the camera. Middle button still pans; wheel zooms.
|
|
"""
|
|
self._pick_face_mode = bool(enabled)
|
|
if enabled:
|
|
self.setCursor(Qt.CrossCursor)
|
|
else:
|
|
self.unsetCursor()
|
|
|
|
def is_pick_face_mode(self) -> bool:
|
|
return self._pick_face_mode
|
|
|
|
def highlight_face(self, face: Any) -> None:
|
|
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "highlight_face", None)
|
|
if fn is not None:
|
|
fn(face)
|
|
self._renderer.render()
|
|
|
|
def clear_face_highlight(self) -> None:
|
|
"""Remove the persistent face-selection tint."""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "clear_face_highlight", None)
|
|
if fn is not None:
|
|
fn()
|
|
self._renderer.render()
|
|
|
|
# ─── Connector pick mode (assembly) ────────────────────────────────────
|
|
|
|
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)
|
|
# 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
|
|
|
|
def _clear_connector_snap(self) -> None:
|
|
"""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.
|
|
|
|
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_geometric", None)
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
|
|
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 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)
|
|
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)
|
|
|
|
# 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 geometric probing (direct topology walk) so a click selects the
|
|
PRIMARY (nearest) snap target — the same one the hover gizmo
|
|
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_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:
|
|
# 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
|
|
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(
|
|
tuple(origin),
|
|
tuple(normal),
|
|
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
|
|
entity_type,
|
|
raw_shape,
|
|
owner_obj_id,
|
|
)
|
|
|
|
# ─── Assembly move mode (3D drag) ─────────────────────────────────────
|
|
|
|
def set_assembly_move_mode(self, enabled: bool) -> None:
|
|
"""Toggle assembly move mode.
|
|
|
|
When enabled, clicking on a body and dragging moves its
|
|
assembly component in the view plane. Shift+drag moves in Z.
|
|
"""
|
|
self._assembly_move_mode = bool(enabled)
|
|
if enabled:
|
|
self.setCursor(Qt.SizeAllCursor)
|
|
elif not self._pick_face_mode and not self._connector_pick_mode:
|
|
self.unsetCursor()
|
|
if not enabled:
|
|
self._move_drag_active = False
|
|
self._move_owner_obj_id = ""
|
|
self._move_click_3d = None
|
|
self._move_click_screen = None
|
|
self._move_plane_normal = None
|
|
self._move_initial_position = None
|
|
|
|
def _handle_assembly_move_press(self, event) -> None:
|
|
"""Start a drag-to-move for the body under the cursor."""
|
|
self._ensure_initialized()
|
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
|
if picker is None:
|
|
return
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
info = picker(pos.x(), pos.y())
|
|
if info is None:
|
|
return
|
|
|
|
owner_obj_id = info.get("owner_obj_id", "")
|
|
if not owner_obj_id or not owner_obj_id.startswith("asm_"):
|
|
return
|
|
|
|
# Store drag state.
|
|
self._move_drag_active = True
|
|
self._move_owner_obj_id = owner_obj_id
|
|
self._move_click_3d = tuple(info["origin"])
|
|
self._move_click_screen = pos
|
|
self._move_plane_normal = tuple(info["normal"])
|
|
|
|
# Emit activation signal so MainWindow stores initial position.
|
|
self.assemblyComponentActivated.emit(owner_obj_id)
|
|
|
|
def _handle_assembly_move_move(self, event) -> None:
|
|
"""Continue the drag: project mouse delta to world-space and emit."""
|
|
if not self._move_drag_active or self._move_click_screen is None:
|
|
return
|
|
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
|
|
# Screen delta (Qt Y is inverted vs OCC).
|
|
dx = pos.x() - self._move_click_screen.x()
|
|
dy = -(pos.y() - self._move_click_screen.y()) # invert Y
|
|
|
|
# Convert screen delta to world units using the view scale.
|
|
# view.Scale() returns a scale factor — the smaller the value the
|
|
# more world distance per pixel. We use an empirical conversion:
|
|
# at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
|
|
scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
|
|
world_per_pixel = 2.0 / max(scale, 0.001)
|
|
|
|
# Get camera vectors for proper view-plane projection.
|
|
import numpy as np
|
|
from OCP.V3d import V3d_TypeOfOrientation
|
|
try:
|
|
# Get camera direction and up from the OCC view.
|
|
camera = self._renderer._view.Camera()
|
|
dir_ = camera.Direction()
|
|
up_ = camera.Up()
|
|
cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
|
|
cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
|
|
cam_right = np.cross(cam_dir, cam_up)
|
|
cam_right = cam_right / np.linalg.norm(cam_right)
|
|
cam_up = cam_up / np.linalg.norm(cam_up)
|
|
except Exception:
|
|
# Fallback: assume XY plane.
|
|
cam_right = np.array([1.0, 0.0, 0.0])
|
|
cam_up = np.array([0.0, 0.0, 1.0])
|
|
|
|
# Compute world-space delta.
|
|
modifiers = event.modifiers()
|
|
if modifiers & Qt.ShiftModifier:
|
|
# Shift+drag: move along camera direction (Z-depth).
|
|
dz_world = dx * world_per_pixel
|
|
dx_world = 0.0
|
|
dy_world = 0.0
|
|
else:
|
|
# Normal drag: move in view plane.
|
|
dx_world = float(cam_right[0] * dx * world_per_pixel +
|
|
cam_up[0] * dy * world_per_pixel)
|
|
dy_world = float(cam_right[1] * dx * world_per_pixel +
|
|
cam_up[1] * dy * world_per_pixel)
|
|
dz_world = float(cam_right[2] * dx * world_per_pixel +
|
|
cam_up[2] * dy * world_per_pixel)
|
|
|
|
self.assemblyComponentDragged.emit(
|
|
self._move_owner_obj_id, dx_world, dy_world, dz_world
|
|
)
|
|
|
|
def _handle_assembly_move_release(self, event) -> None:
|
|
"""Finish the drag, emit final position."""
|
|
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
|
|
self._move_drag_active = False
|
|
self._move_owner_obj_id = ""
|
|
self._move_click_3d = None
|
|
self._move_click_screen = None
|
|
self._move_plane_normal = None
|
|
self._move_initial_position = None
|
|
|
|
def _handle_face_pick(self, event) -> None:
|
|
"""Detect a planar face under the click and emit facePicked."""
|
|
self._ensure_initialized()
|
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
|
if picker is None:
|
|
logger.warning("Renderer has no pick_planar_face support")
|
|
return
|
|
# Qt6: prefer position().toPoint() over deprecated pos().
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
info = picker(pos.x(), pos.y())
|
|
if info is None:
|
|
logger.info("Face pick: no planar face under cursor")
|
|
return
|
|
# Stash the owning obj_id so MainWindow._on_face_picked can pair the
|
|
# picked face with the body it belongs to (for auto-targeted cut).
|
|
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
|
self.facePicked.emit(
|
|
tuple(info["origin"]),
|
|
tuple(info["normal"]),
|
|
tuple(info["x_dir"]),
|
|
info["face"],
|
|
)
|
|
|
|
def set_view(self, view: str):
|
|
# Prefer the renderer's native orientation snap (preserves target,
|
|
# refits the scene). Falls back to absolute eye positions for
|
|
# renderers that don't implement set_view_orientation.
|
|
self._ensure_initialized()
|
|
if hasattr(self._renderer, "set_view_orientation"):
|
|
self._renderer.set_view_orientation(view)
|
|
self._renderer.render()
|
|
return
|
|
positions = {
|
|
"iso": ((100, 100, 100), (0, 0, 0)),
|
|
"top": ((0, 0, 200), (0, 0, 0)),
|
|
"front": ((0, -200, 0), (0, 0, 0)),
|
|
"right": ((200, 0, 0), (0, 0, 0)),
|
|
"back": ((0, 200, 0), (0, 0, 0)),
|
|
"left": ((-200, 0, 0), (0, 0, 0)),
|
|
"bottom": ((0, 0, -200), (0, 0, 0)),
|
|
}
|
|
if view in positions:
|
|
pos, target = positions[view]
|
|
self.set_camera_position(pos, target)
|
|
|
|
def mouseDoubleClickEvent(self, event):
|
|
# Double-click → fit all (common CAD convention).
|
|
self._ensure_initialized()
|
|
if event.button() == Qt.LeftButton:
|
|
self.fit_camera()
|
|
super().mouseDoubleClickEvent(event)
|
|
|
|
def keyPressEvent(self, event):
|
|
# Esc cancels face-pick mode.
|
|
if self._pick_face_mode and event.key() == Qt.Key_Escape:
|
|
self.set_pick_face_mode(False)
|
|
self.pickFaceCancelled.emit()
|
|
return
|
|
# Esc cancels connector pick mode.
|
|
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
|
|
self.set_connector_pick_mode(False)
|
|
self.connectorPickCancelled.emit()
|
|
return
|
|
# Esc cancels assembly move mode.
|
|
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
|
|
self.set_assembly_move_mode(False)
|
|
return
|
|
# Navigation shortcuts (lowercase = view presets, F = fit,
|
|
# P/O = perspective/orthographic, R = reset).
|
|
self._ensure_initialized()
|
|
key = event.text().lower()
|
|
mapping = {
|
|
"f": "fit",
|
|
"r": "reset",
|
|
"1": "front",
|
|
"2": "back",
|
|
"3": "top",
|
|
"4": "bottom",
|
|
"5": "left",
|
|
"6": "right",
|
|
"7": "iso",
|
|
}
|
|
action = mapping.get(key)
|
|
if action == "fit":
|
|
self.fit_camera()
|
|
return
|
|
if action == "reset":
|
|
if hasattr(self._renderer, "reset_camera"):
|
|
self._renderer.reset_camera()
|
|
self._renderer.render()
|
|
else:
|
|
self.set_view("iso")
|
|
return
|
|
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
|
|
self.set_view(action)
|
|
return
|
|
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
|
|
self._renderer.set_camera_perspective()
|
|
self._renderer.render()
|
|
return
|
|
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
|
|
self._renderer.set_camera_orthographic()
|
|
self._renderer.render()
|
|
return
|
|
super().keyPressEvent(event)
|
|
|
|
|