"""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)