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