Files
fluencyCAD/src/fluency/ui/viewer_widget.py
T
2026-07-12 22:21:20 +02:00

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)