1391 lines
58 KiB
Python
1391 lines
58 KiB
Python
"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from PySide6.QtCore import Qt, Signal
|
|
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 a face for the fillet tool (ANY face,
|
|
# planar or curved). Payload: the raw TopoDS_Face. The owning body is
|
|
# read from ``_last_pick_owner_obj_id`` (same stash as facePicked).
|
|
filletFacePicked = Signal(object)
|
|
# Emitted when fillet pick mode is cancelled (Esc).
|
|
filletPickCancelled = Signal()
|
|
|
|
# Emitted when the user picks a face for the chamfer tool (ANY face,
|
|
# planar or curved). Payload: the raw TopoDS_Face.
|
|
chamferFacePicked = Signal(object)
|
|
# Emitted when chamfer pick mode is cancelled (Esc).
|
|
chamferPickCancelled = Signal()
|
|
|
|
# Emitted when the user picks a cylindrical face for the thread tool.
|
|
threadFacePicked = Signal(object)
|
|
# Emitted when thread pick mode is cancelled (Esc).
|
|
threadPickCancelled = 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)
|
|
|
|
# Emitted whenever the camera changes (orbit, pan, zoom).
|
|
# Payload: (eye, at, up) — each is a tuple of 3 floats.
|
|
cameraChanged = Signal(tuple, tuple, tuple)
|
|
|
|
# Emitted when the user clicks a part of the world-space sketch gizmo
|
|
# (the triad at the sketch midpoint). Payload:
|
|
# kind — "center" | "axis_x" | "axis_y" | "axis_z" |
|
|
# "plane_xy" | "plane_yz" | "plane_xz"
|
|
# position — picked world point (triad origin for axes/center,
|
|
# plane quad centre for planes)
|
|
# direction — axis unit vector / plane normal / (0,0,0) for center
|
|
# normal — sketch workplane normal
|
|
# x_dir — sketch workplane x direction
|
|
sketchGizmoPicked = Signal(str, tuple, tuple, tuple, tuple)
|
|
# Hover feedback: part kind string under the cursor, or None when the
|
|
# cursor left the gizmo.
|
|
sketchGizmoHover = Signal(object)
|
|
# Emitted when gizmo pick mode is cancelled (Esc) so the host can reset.
|
|
sketchGizmoCancelled = Signal()
|
|
|
|
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.WidgetAttribute.WA_PaintOnScreen)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
|
|
self.setAutoFillBackground(False)
|
|
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
|
self.setFocusPolicy(Qt.FocusPolicy.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 ANY face for the fillet tool.
|
|
self._fillet_pick_mode: bool = False
|
|
# When True, a left-click picks ANY face for the chamfer tool.
|
|
self._chamfer_pick_mode: bool = False
|
|
# When True, a left-click picks a cylindrical face for the thread tool.
|
|
self._thread_pick_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
|
|
# World-space sketch reference gizmo (triad at the sketch midpoint).
|
|
# ``_sketch_gizmo_frame`` is (origin, normal, x_dir) of the gizmo;
|
|
# None while no triad is shown.
|
|
self._sketch_gizmo_frame: Optional[Tuple[tuple, tuple, tuple]] = None
|
|
# When True, a left-click picks gizmo parts only (no orbit); Esc
|
|
# cancels. Otherwise the gizmo is pickable implicitly during normal
|
|
# navigation (Fusion-style) whenever it is shown.
|
|
self._sketch_gizmo_pick_mode: bool = False
|
|
# Currently hovered gizmo part kind (for highlight bookkeeping).
|
|
self._sketch_gizmo_hover_kind: 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,
|
|
auto_fit: bool = True,
|
|
) -> str:
|
|
"""Display an OCC TopoDS_Shape.
|
|
|
|
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
|
triangulation + add_mesh for the PygfxRenderer.
|
|
|
|
*auto_fit* is forwarded to the renderer: pass ``False`` when
|
|
rebuilding a scene under explicit camera control so the first
|
|
shape does not trigger a whole-scene camera fit.
|
|
"""
|
|
self._ensure_initialized()
|
|
from fluency.rendering.occ_renderer import OCCRenderer
|
|
|
|
if isinstance(self._renderer, OCCRenderer):
|
|
oid = self._renderer.add_shape(shape, color, name, auto_fit)
|
|
self._renderer.render()
|
|
return oid
|
|
# Fallback: tessellate and use the mesh pipeline.
|
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
|
|
|
k = OCGeometryKernel()
|
|
# 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_body_color(
|
|
self, mesh_id: str, color: Tuple[float, float, float]
|
|
) -> bool:
|
|
"""Change the colour of a body in the 3D view. Returns True on success."""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "set_color", None)
|
|
if fn is None:
|
|
return False
|
|
ok = fn(mesh_id, color)
|
|
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()
|
|
|
|
def fit_camera_to_box(self, bnd_box: Any, padding: float = 0.05) -> None:
|
|
"""Fit the camera to a specific 3D bounding box (``Bnd_Box``).
|
|
|
|
Used by the array tool so the dialog preview frames exactly the
|
|
space the pattern copies occupy. Falls back to fitting the whole
|
|
scene on renderers without box fitting (e.g. the Pygfx fallback).
|
|
"""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "fit_camera_to_box", None)
|
|
if fn is not None:
|
|
fn(bnd_box, padding)
|
|
self._renderer.render()
|
|
return
|
|
self.fit_camera()
|
|
|
|
def box_fully_visible(self, bnd_box: Any, margin: float = 0.05) -> bool:
|
|
"""True when the box's 8 corners all project inside the viewport.
|
|
|
|
The array preview uses this to decide when a grown pattern has
|
|
moved copies off-screen and the camera needs re-fitting. On
|
|
renderers without the check (Pygfx fallback) it returns True so
|
|
no re-fit is forced.
|
|
"""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "box_fully_visible", None)
|
|
if fn is not None:
|
|
return bool(fn(bnd_box, margin))
|
|
return True
|
|
|
|
# ─── 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.MouseButton.LeftButton:
|
|
self._handle_face_pick(event)
|
|
return
|
|
# Fillet pick mode: a left-click selects any face (planar or curved).
|
|
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
self._handle_fillet_face_pick(event)
|
|
return
|
|
# Chamfer pick mode: a left-click selects any face (planar or curved).
|
|
if self._chamfer_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
self._handle_chamfer_face_pick(event)
|
|
return
|
|
# Thread pick mode: a left-click selects a cylindrical face.
|
|
if self._thread_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
self._handle_thread_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.MouseButton.LeftButton:
|
|
self._handle_connector_pick(event)
|
|
return
|
|
# Assembly move mode: start dragging the clicked body.
|
|
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
self._handle_assembly_move_press(event)
|
|
return
|
|
# World sketch gizmo: a click on a part selects it (Fusion-style),
|
|
# even during normal navigation. In explicit gizmo-pick mode an
|
|
# off-gizmo click does nothing (no orbit); otherwise it falls
|
|
# through to orbit/pan below.
|
|
if event.button() == Qt.MouseButton.LeftButton and self._sketch_gizmo_enabled():
|
|
fn = getattr(self._renderer, "pick_sketch_gizmo", None)
|
|
if fn is not None:
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
kind = fn(pos.x(), pos.y())
|
|
if kind is not None:
|
|
self._handle_sketch_gizmo_pick(event, kind)
|
|
return
|
|
if self._sketch_gizmo_pick_mode:
|
|
return # explicit mode: off-gizmo clicks do not orbit
|
|
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
|
|
# In fillet pick mode, keep dynamic highlighting too.
|
|
if self._fillet_pick_mode:
|
|
if hasattr(self._renderer, "handle_mouse_move"):
|
|
self._renderer.handle_mouse_move(event)
|
|
super().mouseMoveEvent(event)
|
|
return
|
|
# In chamfer pick mode, keep dynamic highlighting too.
|
|
if self._chamfer_pick_mode:
|
|
if hasattr(self._renderer, "handle_mouse_move"):
|
|
self._renderer.handle_mouse_move(event)
|
|
super().mouseMoveEvent(event)
|
|
return
|
|
# In thread pick mode, keep dynamic highlighting.
|
|
if self._thread_pick_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
|
|
# World sketch gizmo hover: highlight the part under the cursor.
|
|
if self._sketch_gizmo_enabled():
|
|
self._handle_sketch_gizmo_hover(event)
|
|
if self._sketch_gizmo_pick_mode:
|
|
super().mouseMoveEvent(event)
|
|
return
|
|
self._renderer.handle_mouse_move(event)
|
|
super().mouseMoveEvent(event)
|
|
|
|
def paintEngine(self) -> Any:
|
|
"""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)
|
|
# Camera may have changed during orbit/pan — emit signal.
|
|
try:
|
|
eye, at_, up = self.get_camera_position()
|
|
self.cameraChanged.emit(
|
|
tuple(float(v) for v in eye),
|
|
tuple(float(v) for v in at_),
|
|
tuple(float(v) for v in up),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def wheelEvent(self, event):
|
|
self._ensure_initialized()
|
|
self._renderer.handle_wheel(event)
|
|
super().wheelEvent(event)
|
|
# Zoom changed the camera — emit signal.
|
|
try:
|
|
eye, at_, up = self.get_camera_position()
|
|
self.cameraChanged.emit(
|
|
tuple(float(v) for v in eye),
|
|
tuple(float(v) for v in at_),
|
|
tuple(float(v) for v in up),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
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
|
|
|
|
# ─── World sketch reference gizmo (triad at the sketch midpoint) ────────
|
|
|
|
def show_sketch_gizmo(
|
|
self,
|
|
origin: Tuple[float, float, float],
|
|
normal: Tuple[float, float, float],
|
|
x_dir: Tuple[float, float, float],
|
|
size: float = 40.0,
|
|
) -> None:
|
|
"""Show the selectable X/Y/Z triad in the 3D world at *origin*.
|
|
|
|
*origin* should be the midpoint of the active sketch's geometry and
|
|
the triad is aligned to the sketch's workplane frame. Call again
|
|
with a new origin to keep it in sync as the sketch is edited.
|
|
No-op on renderers without gizmo support (Pygfx fallback).
|
|
"""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "show_sketch_gizmo", None)
|
|
if fn is None:
|
|
return
|
|
fn(origin, normal, x_dir, size)
|
|
self._sketch_gizmo_frame = (tuple(origin), tuple(normal), tuple(x_dir))
|
|
self._sketch_gizmo_hover_kind = None
|
|
self._renderer.render()
|
|
|
|
def remove_sketch_gizmo(self) -> None:
|
|
"""Hide the world sketch triad, if any."""
|
|
if self._initialized and self._renderer is not None:
|
|
fn = getattr(self._renderer, "remove_sketch_gizmo", None)
|
|
if fn is not None:
|
|
fn()
|
|
self._renderer.render()
|
|
self._sketch_gizmo_frame = None
|
|
self._sketch_gizmo_hover_kind = None
|
|
|
|
def set_sketch_gizmo_pick_mode(self, enabled: bool) -> None:
|
|
"""Toggle explicit gizmo-pick mode.
|
|
|
|
When enabled, left-clicks select gizmo parts only (the camera does
|
|
not orbit) and Esc exits the mode. When disabled, the gizmo is
|
|
still pickable implicitly during normal navigation (Fusion-style)
|
|
whenever it is shown. Mutually exclusive with the other pick modes.
|
|
"""
|
|
self._sketch_gizmo_pick_mode = bool(enabled)
|
|
if enabled:
|
|
self._pick_face_mode = False
|
|
self._fillet_pick_mode = False
|
|
self._chamfer_pick_mode = False
|
|
self._thread_pick_mode = False
|
|
self._connector_pick_mode = False
|
|
self._assembly_move_mode = False
|
|
self._move_drag_active = False
|
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
|
self.setFocus()
|
|
elif not (
|
|
self._pick_face_mode
|
|
or self._fillet_pick_mode
|
|
or self._chamfer_pick_mode
|
|
or self._thread_pick_mode
|
|
or self._connector_pick_mode
|
|
):
|
|
self.unsetCursor()
|
|
|
|
def is_sketch_gizmo_pick_mode(self) -> bool:
|
|
return self._sketch_gizmo_pick_mode
|
|
|
|
def get_sketch_gizmo_frame(self) -> Optional[Tuple[tuple, tuple, tuple]]:
|
|
"""Return the (origin, normal, x_dir) of the shown triad, or None."""
|
|
return self._sketch_gizmo_frame
|
|
|
|
def _sketch_gizmo_enabled(self) -> bool:
|
|
"""True when the triad is shown AND no other mode owns the pointer."""
|
|
if self._sketch_gizmo_frame is None:
|
|
return False
|
|
if self._sketch_gizmo_pick_mode:
|
|
return True
|
|
if self._assembly_move_mode or self._move_drag_active:
|
|
return False
|
|
return not (
|
|
self._pick_face_mode
|
|
or self._fillet_pick_mode
|
|
or self._chamfer_pick_mode
|
|
or self._thread_pick_mode
|
|
or self._connector_pick_mode
|
|
)
|
|
|
|
def _handle_sketch_gizmo_hover(self, event) -> None:
|
|
"""Highlight the gizmo part under the cursor and emit hover signal."""
|
|
fn = getattr(self._renderer, "pick_sketch_gizmo", None)
|
|
if fn is None:
|
|
return
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
kind = fn(pos.x(), pos.y())
|
|
if kind == self._sketch_gizmo_hover_kind:
|
|
return
|
|
self._sketch_gizmo_hover_kind = kind
|
|
if kind is not None:
|
|
hl = getattr(self._renderer, "highlight_sketch_gizmo_part", None)
|
|
if hl is not None:
|
|
hl(kind)
|
|
else:
|
|
cl = getattr(self._renderer, "clear_sketch_gizmo_highlight", None)
|
|
if cl is not None:
|
|
cl()
|
|
self.sketchGizmoHover.emit(kind)
|
|
|
|
def _handle_sketch_gizmo_pick(self, event, kind: str) -> None:
|
|
"""Emit sketchGizmoPicked for the clicked part with world metadata."""
|
|
frame = self._sketch_gizmo_frame
|
|
normal = tuple(frame[1]) if frame else (0.0, 0.0, 1.0)
|
|
x_dir = tuple(frame[2]) if frame else (1.0, 0.0, 0.0)
|
|
position = tuple(frame[0]) if frame else (0.0, 0.0, 0.0)
|
|
direction = (0.0, 0.0, 0.0)
|
|
info_fn = getattr(self._renderer, "sketch_gizmo_pick_info", None)
|
|
if info_fn is not None:
|
|
info = info_fn(kind)
|
|
if info:
|
|
position = tuple(info["position"])
|
|
direction = tuple(info["direction"])
|
|
self.sketchGizmoPicked.emit(kind, position, direction, normal, x_dir)
|
|
|
|
# ─── 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._sketch_gizmo_pick_mode = False
|
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
|
else:
|
|
self.unsetCursor()
|
|
|
|
def is_pick_face_mode(self) -> bool:
|
|
return self._pick_face_mode
|
|
|
|
# ─── Fillet pick mode (any-face picking) ────────────────────────────────
|
|
|
|
def set_fillet_pick_mode(self, enabled: bool) -> None:
|
|
"""Toggle fillet face-pick mode (any face — planar or curved).
|
|
|
|
The cursor selects faces for the fillet tool instead of orbiting the
|
|
camera. Mutually exclusive with the other pick modes: entering this
|
|
mode switches the others off.
|
|
"""
|
|
self._fillet_pick_mode = bool(enabled)
|
|
if enabled:
|
|
self._sketch_gizmo_pick_mode = False
|
|
# Pick modes are mutually exclusive — entering fillet mode
|
|
# disables chamfer / sketch-on-surface / connector / assembly modes.
|
|
self._chamfer_pick_mode = False
|
|
self._pick_face_mode = False
|
|
self._connector_pick_mode = False
|
|
self._assembly_move_mode = False
|
|
self._move_drag_active = False
|
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
|
elif not self._pick_face_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
|
|
self.unsetCursor()
|
|
|
|
def is_fillet_pick_mode(self) -> bool:
|
|
return self._fillet_pick_mode
|
|
|
|
# ─── Chamfer pick mode (any-face picking) ──────────────────────────────
|
|
|
|
def set_chamfer_pick_mode(self, enabled: bool) -> None:
|
|
"""Toggle chamfer face-pick mode (any face — planar or curved).
|
|
|
|
The cursor selects faces for the chamfer tool instead of orbiting
|
|
the camera. Mutually exclusive with the other pick modes.
|
|
"""
|
|
self._chamfer_pick_mode = bool(enabled)
|
|
if enabled:
|
|
self._sketch_gizmo_pick_mode = False
|
|
self._fillet_pick_mode = False
|
|
self._pick_face_mode = False
|
|
self._connector_pick_mode = False
|
|
self._assembly_move_mode = False
|
|
self._move_drag_active = False
|
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
|
elif not self._pick_face_mode and not self._fillet_pick_mode and not self._connector_pick_mode:
|
|
self.unsetCursor()
|
|
|
|
def is_chamfer_pick_mode(self) -> bool:
|
|
return self._chamfer_pick_mode
|
|
|
|
def _handle_chamfer_face_pick(self, event: Any) -> None:
|
|
"""Detect any face under the click and emit chamferFacePicked."""
|
|
self._ensure_initialized()
|
|
picker = getattr(self._renderer, "pick_face", None)
|
|
if picker is None:
|
|
logger.warning("Renderer has no pick_face support")
|
|
return
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
info = picker(pos.x(), pos.y())
|
|
if info is None:
|
|
logger.info("Chamfer face pick: no face under cursor")
|
|
return
|
|
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
|
self.chamferFacePicked.emit(info["face"])
|
|
|
|
def highlight_faces(self, faces: List[Any]) -> None:
|
|
"""Tint all faces in *faces* so both fillet picks stay visible."""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "highlight_faces", None)
|
|
if fn is not None:
|
|
fn(faces)
|
|
self._renderer.render()
|
|
return
|
|
# Fallback: single-face highlight for the last picked face.
|
|
if faces:
|
|
self.highlight_face(faces[-1])
|
|
|
|
def clear_faces_highlight(self) -> None:
|
|
"""Remove the multi-face fillet-pick overlays, if any."""
|
|
if not self._initialized or self._renderer is None:
|
|
return
|
|
fn = getattr(self._renderer, "clear_faces_highlight", None)
|
|
if fn is not None:
|
|
fn()
|
|
self._renderer.render()
|
|
|
|
def _handle_fillet_face_pick(self, event: Any) -> None:
|
|
"""Detect any face under the click and emit filletFacePicked."""
|
|
self._ensure_initialized()
|
|
picker = getattr(self._renderer, "pick_face", None)
|
|
if picker is None:
|
|
logger.warning("Renderer has no pick_face support")
|
|
return
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
info = picker(pos.x(), pos.y())
|
|
if info is None:
|
|
logger.info("Fillet face pick: no face under cursor")
|
|
return
|
|
# Stash the owner so MainWindow can pair the face with its body
|
|
# (same convention as sketch-on-face picking).
|
|
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
|
self.filletFacePicked.emit(info["face"])
|
|
|
|
# ─── Thread pick mode ─────────────────────────────────────────────────
|
|
|
|
def set_thread_pick_mode(self, enabled: bool) -> None:
|
|
"""Toggle thread face-pick mode (cylindrical face only).
|
|
|
|
When enabled, a left-click picks a cylindrical face for the thread
|
|
tool. Mutually exclusive with other pick modes.
|
|
"""
|
|
self._thread_pick_mode = bool(enabled)
|
|
if enabled:
|
|
self._sketch_gizmo_pick_mode = False
|
|
self._pick_face_mode = False
|
|
self._fillet_pick_mode = False
|
|
self._chamfer_pick_mode = False
|
|
self._connector_pick_mode = False
|
|
self._assembly_move_mode = False
|
|
self._move_drag_active = False
|
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
|
elif not self._pick_face_mode and not self._fillet_pick_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
|
|
self.unsetCursor()
|
|
|
|
def is_thread_pick_mode(self) -> bool:
|
|
return self._thread_pick_mode
|
|
|
|
def _handle_thread_face_pick(self, event: Any) -> None:
|
|
"""Detect any face under the click and emit threadFacePicked."""
|
|
self._ensure_initialized()
|
|
picker = getattr(self._renderer, "pick_face", None)
|
|
if picker is None:
|
|
logger.warning("Renderer has no pick_face support")
|
|
return
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
info = picker(pos.x(), pos.y())
|
|
if info is None:
|
|
logger.info("Thread face pick: no face under cursor")
|
|
return
|
|
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
|
self.threadFacePicked.emit(info["face"])
|
|
|
|
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()
|
|
|
|
def highlight_operation(self, shape: Any) -> None:
|
|
"""Overlay a hot-pink translucent *shape* to show operation history."""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "highlight_operation_shape", None)
|
|
if fn is not None:
|
|
fn(shape)
|
|
self._renderer.render()
|
|
|
|
def clear_operation_highlight(self) -> None:
|
|
"""Remove the operation-history overlay, if any."""
|
|
self._ensure_initialized()
|
|
fn = getattr(self._renderer, "clear_operation_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._sketch_gizmo_pick_mode = False
|
|
self.setCursor(Qt.CursorShape.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 show_persistent_connector_gizmo(self, origin, normal, x_dir, entity_type, color=(0.0, 1.0, 0.0)):
|
|
fn = getattr(self._renderer, "show_persistent_entity_gizmo", None)
|
|
if fn is not None:
|
|
fn(entity_type=entity_type, position=origin, normal=normal, x_dir=x_dir, color=color)
|
|
|
|
def clear_persistent_connector_gizmo(self):
|
|
fn = getattr(self._renderer, "clear_persistent_entity_gizmo", None)
|
|
if fn is not None:
|
|
fn()
|
|
|
|
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._sketch_gizmo_pick_mode = False
|
|
self.setCursor(Qt.CursorShape.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
|
|
|
|
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.KeyboardModifier.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.
|
|
try:
|
|
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
|
|
)
|
|
except (TypeError, ValueError):
|
|
dx_world = dy_world = dz_world = 0.0
|
|
|
|
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
|
|
|
|
def _handle_assembly_move_release(self, event: Any) -> 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: Any) -> 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) -> None:
|
|
# 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: Any) -> None:
|
|
# Double-click → fit all (common CAD convention).
|
|
self._ensure_initialized()
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self.fit_camera()
|
|
super().mouseDoubleClickEvent(event)
|
|
|
|
def keyPressEvent(self, event: Any) -> None:
|
|
# Esc cancels face-pick mode.
|
|
if self._pick_face_mode and event.key() == Qt.Key.Key_Escape:
|
|
self.set_pick_face_mode(False)
|
|
self.pickFaceCancelled.emit()
|
|
return
|
|
# Esc cancels fillet pick mode.
|
|
if self._fillet_pick_mode and event.key() == Qt.Key.Key_Escape:
|
|
self.set_fillet_pick_mode(False)
|
|
self.filletPickCancelled.emit()
|
|
return
|
|
# Esc cancels chamfer pick mode.
|
|
if self._chamfer_pick_mode and event.key() == Qt.Key.Key_Escape:
|
|
self.set_chamfer_pick_mode(False)
|
|
self.chamferPickCancelled.emit()
|
|
return
|
|
# Esc cancels thread pick mode.
|
|
if self._thread_pick_mode and event.key() == Qt.Key.Key_Escape:
|
|
self.set_thread_pick_mode(False)
|
|
self.threadPickCancelled.emit()
|
|
return
|
|
# Esc cancels connector pick mode.
|
|
if self._connector_pick_mode and event.key() == Qt.Key.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.Key_Escape:
|
|
self.set_assembly_move_mode(False)
|
|
return
|
|
# Esc cancels explicit sketch-gizmo pick mode.
|
|
if self._sketch_gizmo_pick_mode and event.key() == Qt.Key.Key_Escape:
|
|
self.set_sketch_gizmo_pick_mode(False)
|
|
self.sketchGizmoCancelled.emit()
|
|
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)
|