Files
fluencyCAD/src/fluency/ui/viewer_widget.py
T
bklronin 9abeb6266a - Assembly instaiated operations
- assembly forward proagation
2026-08-19 20:14:14 +02:00

1391 lines
58 KiB
Python

"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QWidget
logger = logging.getLogger(__name__)
class Viewer3DWidget(QWidget):
"""3D viewer widget using OCC's native AIS display."""
# Emitted when the user picks a planar face to sketch on.
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
facePicked = Signal(tuple, tuple, tuple, object)
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal()
# Emitted when the user picks a face for the fillet tool (ANY face,
# planar or curved). Payload: the raw TopoDS_Face. The owning body is
# read from ``_last_pick_owner_obj_id`` (same stash as facePicked).
filletFacePicked = Signal(object)
# Emitted when fillet pick mode is cancelled (Esc).
filletPickCancelled = Signal()
# Emitted when the user picks a face for the chamfer tool (ANY face,
# planar or curved). Payload: the raw TopoDS_Face.
chamferFacePicked = Signal(object)
# Emitted when chamfer pick mode is cancelled (Esc).
chamferPickCancelled = Signal()
# Emitted when the user picks a cylindrical face for the thread tool.
threadFacePicked = Signal(object)
# Emitted when thread pick mode is cancelled (Esc).
threadPickCancelled = Signal()
# Emitted when the user picks an entity for a connector point (assembly).
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
# Emitted when connector pick mode is cancelled.
connectorPickCancelled = Signal()
# Emitted on mouse move in connector mode to show snap preview.
# Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
connectorHover = Signal(object)
# Emitted when a body is clicked in assembly move mode.
# Payload: owner_obj_id.
assemblyComponentActivated = Signal(str)
# Emitted during a drag move: owner_obj_id, world dx, dy, dz.
assemblyComponentDragged = Signal(str, float, float, float)
# Emitted when a drag move finishes.
assemblyMoveFinished = Signal(str)
# Emitted whenever the camera changes (orbit, pan, zoom).
# Payload: (eye, at, up) — each is a tuple of 3 floats.
cameraChanged = Signal(tuple, tuple, tuple)
# Emitted when the user clicks a part of the world-space sketch gizmo
# (the triad at the sketch midpoint). Payload:
# kind — "center" | "axis_x" | "axis_y" | "axis_z" |
# "plane_xy" | "plane_yz" | "plane_xz"
# position — picked world point (triad origin for axes/center,
# plane quad centre for planes)
# direction — axis unit vector / plane normal / (0,0,0) for center
# normal — sketch workplane normal
# x_dir — sketch workplane x direction
sketchGizmoPicked = Signal(str, tuple, tuple, tuple, tuple)
# Hover feedback: part kind string under the cursor, or None when the
# cursor left the gizmo.
sketchGizmoHover = Signal(object)
# Emitted when gizmo pick mode is cancelled (Esc) so the host can reset.
sketchGizmoCancelled = Signal()
def __init__(self, parent=None):
super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen)
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
# button held — required for the connector-pick hover gizmo (and any
# status-bar hover feedback) to show under the cursor as the user
# moves the mouse over candidate snap entities before clicking.
self.setMouseTracking(True)
# Try OCC renderer first; fall back to pygfx if unavailable.
self._renderer: Any = None
self._initialized = False
self._meshes: Dict[str, Any] = {}
self._selected_normal: Optional[Tuple[float, float, float]] = None
self._centroid: Optional[Tuple[float, float, float]] = None
self._pending_meshes: List[Tuple] = []
# When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False
# When True, a left-click picks ANY face for the fillet tool.
self._fillet_pick_mode: bool = False
# When True, a left-click picks ANY face for the chamfer tool.
self._chamfer_pick_mode: bool = False
# When True, a left-click picks a cylindrical face for the thread tool.
self._thread_pick_mode: bool = False
# When True, a left-click picks an entity for a connector point
# (assembly component connection).
self._connector_pick_mode: bool = False
# Current snap highlight object id (for hover during connector mode).
self._connector_snap_id: Optional[str] = None
# Throttle connector hover probes to avoid UI lag on fast mouse moves.
self._connector_last_hover_time: float = 0.0
self._connector_hover_interval: float = 0.05 # 50 ms between probes
# When True, left-click on a body activates assembly drag-to-move.
self._assembly_move_mode: bool = False
# State for ongoing assembly drag.
self._move_drag_active: bool = False
self._move_owner_obj_id: str = ""
self._move_click_3d: Optional[Tuple[float, float, float]] = None
self._move_click_screen: Optional[Any] = None
self._move_plane_normal: Optional[Tuple[float, float, float]] = None
self._move_initial_position: Optional[Tuple[float, float, float]] = None
# Most recently recorded owning obj_id for the face returned by
# ``pick_planar_face``. Stashed on each pick pass so the host can
# pair the picked face with the body it belongs to (used to auto-
# target a cut/union extrude against the body the sketch was
# projected onto).
self._last_pick_owner_obj_id: Optional[str] = None
# World-space sketch reference gizmo (triad at the sketch midpoint).
# ``_sketch_gizmo_frame`` is (origin, normal, x_dir) of the gizmo;
# None while no triad is shown.
self._sketch_gizmo_frame: Optional[Tuple[tuple, tuple, tuple]] = None
# When True, a left-click picks gizmo parts only (no orbit); Esc
# cancels. Otherwise the gizmo is pickable implicitly during normal
# navigation (Fusion-style) whenever it is shown.
self._sketch_gizmo_pick_mode: bool = False
# Currently hovered gizmo part kind (for highlight bookkeeping).
self._sketch_gizmo_hover_kind: Optional[str] = None
def _init_renderer(self) -> None:
"""Create the best available renderer."""
if self._renderer is not None:
return
import sys as _sys
_sys.stdout.flush()
logger.info("Renderer: starting import...")
from fluency.rendering.occ_renderer import OCCRenderer
from fluency.rendering.pygfx_renderer import PygfxRenderer
logger.info("Renderer: imports done, creating OCCRenderer...")
occ = OCCRenderer()
logger.info("Renderer: calling occ.initialize...")
try:
ok = occ.initialize(self)
except Exception as exc:
logger.warning(f"OCCRenderer init raised: {exc}")
ok = False
logger.info(f"Renderer: OCC result={ok}")
if ok:
self._renderer = occ
logger.info("Using OCCRenderer (native BRep display)")
else:
logger.info("Falling back to PygfxRenderer")
self._renderer = PygfxRenderer()
logger.info("Renderer: calling pygfx initialize...")
self._renderer.initialize(self)
logger.info("Renderer: pygfx init done")
self._initialized = True
logger.info("Renderer: initialization complete")
def showEvent(self, event):
logger.info("Viewer3DWidget showEvent - initializing renderer")
if not self._initialized:
self._init_renderer()
logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}")
for args in self._pending_meshes:
self.add_mesh(*args)
self._pending_meshes.clear()
self._renderer.render()
def _ensure_initialized(self):
if not self._initialized:
logger.debug("Ensuring renderer is initialized")
self._init_renderer()
def get_renderer(self):
self._ensure_initialized()
return self._renderer
def show_shape(
self,
shape: Any,
color=None,
name=None,
auto_fit: bool = True,
) -> str:
"""Display an OCC TopoDS_Shape.
Uses OCCRenderer.add_shape for native AIS display, or falls back to
triangulation + add_mesh for the PygfxRenderer.
*auto_fit* is forwarded to the renderer: pass ``False`` when
rebuilding a scene under explicit camera control so the first
shape does not trigger a whole-scene camera fit.
"""
self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer
if isinstance(self._renderer, OCCRenderer):
oid = self._renderer.add_shape(shape, color, name, auto_fit)
self._renderer.render()
return oid
# Fallback: tessellate and use the mesh pipeline.
from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel()
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
from fluency.geometry_occ.kernel import OCCGeometryObject
obj = OCCGeometryObject(shape)
verts, faces = k.get_mesh(obj)
oid = self._renderer.add_mesh(verts, faces, color, name)
# Edges
try:
e_verts, e_edges = k.get_edges(obj)
if len(e_verts) > 0:
self._renderer.add_wireframe(
e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges"
)
except Exception:
pass
self._renderer.render()
return oid
def add_mesh(self, vertices, faces, color=None, name=None) -> str:
logger.debug(
f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}"
)
if not self._initialized:
self._pending_meshes.append((vertices, faces, color, name))
logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}")
return f"pending_{len(self._pending_meshes)}"
self._ensure_initialized()
mesh_id = self._renderer.add_mesh(vertices, faces, color, name)
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name}
self._renderer.render()
logger.info(f"Added mesh: {mesh_id}, name={name}")
return mesh_id
def update_mesh(self, mesh_id: str, vertices, faces):
self._ensure_initialized()
self._renderer.update_mesh(mesh_id, vertices, faces)
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
self._renderer.render()
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
self._ensure_initialized()
wid = self._renderer.add_wireframe(
vertices, edges, color or (0.9, 0.9, 0.9), line_width, name
)
self._renderer.render()
return wid
def remove_mesh(self, mesh_id: str):
self._ensure_initialized()
self._renderer.remove_mesh(mesh_id)
if mesh_id in self._meshes:
del self._meshes[mesh_id]
self._renderer.render()
def set_visibility(self, mesh_id: str, visible: bool) -> bool:
"""Show or hide a previously-added mesh without removing it.
Used by the per-body visibility toggle on the body list so the
user can quickly hide a body (e.g. to verify a cut worked on
another body). Returns True on success, False if the mesh is
unknown to the renderer or the renderer doesn't support it
(e.g. the Pygfx fallback ABI).
"""
self._ensure_initialized()
fn = getattr(self._renderer, "set_visibility", None)
if fn is None:
return False
ok = fn(mesh_id, visible)
if ok:
self._renderer.render()
return ok
def set_body_color(
self, mesh_id: str, color: Tuple[float, float, float]
) -> bool:
"""Change the colour of a body in the 3D view. Returns True on success."""
self._ensure_initialized()
fn = getattr(self._renderer, "set_color", None)
if fn is None:
return False
ok = fn(mesh_id, color)
if ok:
self._renderer.render()
return ok
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
"""Set a previously-added mesh's transparency (0..1).
Used by the live extrude preview to dim the target body so the
previewed result reads on top of it.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "set_object_transparency", None)
if fn is None:
return False
return fn(mesh_id, transparency)
def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
"""Display a temporary transparent preview of *shape* in the 3D view.
Used by the ExtrudeDialog live preview: as the user drags the
length spinner or toggles Cut/Through-All, the host recomputes
the operation result and shows it here. Call clear_preview()
when the dialog closes.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "preview_shape", None)
if fn is None:
return
fn(shape, color, transparency)
def clear_preview(self) -> None:
"""Remove the live extrude preview shape, if any."""
if not self._initialized or self._renderer is None:
return
fn = getattr(self._renderer, "clear_preview", None)
if fn is None:
return
fn()
def clear_scene(self):
self._ensure_initialized()
self._renderer.clear_scene()
self._meshes.clear()
self._renderer.render()
def fit_camera(self):
self._ensure_initialized()
self._renderer.fit_camera()
self._renderer.render()
def fit_camera_to_box(self, bnd_box: Any, padding: float = 0.05) -> None:
"""Fit the camera to a specific 3D bounding box (``Bnd_Box``).
Used by the array tool so the dialog preview frames exactly the
space the pattern copies occupy. Falls back to fitting the whole
scene on renderers without box fitting (e.g. the Pygfx fallback).
"""
self._ensure_initialized()
fn = getattr(self._renderer, "fit_camera_to_box", None)
if fn is not None:
fn(bnd_box, padding)
self._renderer.render()
return
self.fit_camera()
def box_fully_visible(self, bnd_box: Any, margin: float = 0.05) -> bool:
"""True when the box's 8 corners all project inside the viewport.
The array preview uses this to decide when a grown pattern has
moved copies off-screen and the camera needs re-fitting. On
renderers without the check (Pygfx fallback) it returns True so
no re-fit is forced.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "box_fully_visible", None)
if fn is not None:
return bool(fn(bnd_box, margin))
return True
# ─── Workplane visualization ───────────────────────────────────────────
def show_workplane(
self,
origin: Tuple[float, float, float] = (0, 0, 0),
normal: Tuple[float, float, float] = (0, 0, 1),
x_dir: Tuple[float, float, float] = (1, 0, 0),
size: float = 200.0,
name: Optional[str] = None,
) -> Optional[str]:
"""Display a semi-transparent workplane plane in the 3D view.
Returns the object ID (for later removal) or None if the renderer
doesn't support workplane planes.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "show_workplane_plane", None)
if fn is None:
return None
oid = fn(origin, normal, x_dir, size, name)
self._renderer.render()
return oid
def remove_workplane(self, obj_id: str) -> bool:
"""Remove a workplane plane visual by its ID."""
self._ensure_initialized()
fn = getattr(self._renderer, "remove_workplane_plane", None)
if fn is None:
return False
ok = fn(obj_id)
if ok:
self._renderer.render()
return ok
def mousePressEvent(self, event):
self._ensure_initialized()
# Face-pick mode: a left-click selects a planar face to sketch on.
if self._pick_face_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_face_pick(event)
return
# Fillet pick mode: a left-click selects any face (planar or curved).
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_fillet_face_pick(event)
return
# Chamfer pick mode: a left-click selects any face (planar or curved).
if self._chamfer_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_chamfer_face_pick(event)
return
# Thread pick mode: a left-click selects a cylindrical face.
if self._thread_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_thread_face_pick(event)
return
# Connector pick mode: a left-click selects a face for a connection point.
if self._connector_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_connector_pick(event)
return
# Assembly move mode: start dragging the clicked body.
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_assembly_move_press(event)
return
# World sketch gizmo: a click on a part selects it (Fusion-style),
# even during normal navigation. In explicit gizmo-pick mode an
# off-gizmo click does nothing (no orbit); otherwise it falls
# through to orbit/pan below.
if event.button() == Qt.MouseButton.LeftButton and self._sketch_gizmo_enabled():
fn = getattr(self._renderer, "pick_sketch_gizmo", None)
if fn is not None:
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
kind = fn(pos.x(), pos.y())
if kind is not None:
self._handle_sketch_gizmo_pick(event, kind)
return
if self._sketch_gizmo_pick_mode:
return # explicit mode: off-gizmo clicks do not orbit
self._renderer.handle_mouse_press(event)
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
self._ensure_initialized()
# In connector mode, show snap hover.
# Selection modes are deactivated so we skip the idle MoveTo
# (dynamic highlighting) — only the gizmo hover handler runs.
if self._connector_pick_mode:
self._handle_connector_hover(event)
super().mouseMoveEvent(event)
return
# If connector mode was just exited (gizmo persists after pick),
# clear any lingering gizmo on first mouse move.
gizmo_objs = getattr(self._renderer, "_gizmo_objects", None)
if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
self._clear_connector_snap()
# In face-pick mode, keep dynamic highlighting.
if self._pick_face_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# In fillet pick mode, keep dynamic highlighting too.
if self._fillet_pick_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# In chamfer pick mode, keep dynamic highlighting too.
if self._chamfer_pick_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# In thread pick mode, keep dynamic highlighting.
if self._thread_pick_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# Active drag in assembly move mode.
if self._move_drag_active:
self._handle_assembly_move_move(event)
super().mouseMoveEvent(event)
return
# World sketch gizmo hover: highlight the part under the cursor.
if self._sketch_gizmo_enabled():
self._handle_sketch_gizmo_hover(event)
if self._sketch_gizmo_pick_mode:
super().mouseMoveEvent(event)
return
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
def paintEngine(self) -> Any:
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
return None
def paintEvent(self, event):
"""Empty paintEvent — OCC draws directly via OpenGL."""
pass
def mouseReleaseEvent(self, event):
self._ensure_initialized()
# Finish assembly drag.
if self._move_drag_active:
self._handle_assembly_move_release(event)
return
self._renderer.handle_mouse_release(event)
super().mouseReleaseEvent(event)
# Camera may have changed during orbit/pan — emit signal.
try:
eye, at_, up = self.get_camera_position()
self.cameraChanged.emit(
tuple(float(v) for v in eye),
tuple(float(v) for v in at_),
tuple(float(v) for v in up),
)
except Exception:
pass
def wheelEvent(self, event):
self._ensure_initialized()
self._renderer.handle_wheel(event)
super().wheelEvent(event)
# Zoom changed the camera — emit signal.
try:
eye, at_, up = self.get_camera_position()
self.cameraChanged.emit(
tuple(float(v) for v in eye),
tuple(float(v) for v in at_),
tuple(float(v) for v in up),
)
except Exception:
pass
def resizeEvent(self, event):
super().resizeEvent(event)
self._ensure_initialized()
self._renderer.handle_resize(event.size().width(), event.size().height())
def set_camera_position(self, position, target):
self._ensure_initialized()
self._renderer.set_camera_position(position, target)
self._renderer.render()
def get_camera_position(self):
"""Return the current camera ``(eye, at, up)`` triple.
The underlying renderer's ``get_camera_position`` returns three
``np.ndarray``s. We forward the call so callers (notably
:meth:`MainWindow._collect_view_state`) can persist the camera.
Returns a tuple of ``(np.zeros(3), np.zeros(3), (0,0,1))`` if the
renderer hasn't been initialised yet (e.g. when the window is
being constructed).
"""
self._ensure_initialized()
if hasattr(self._renderer, "get_camera_position"):
return self._renderer.get_camera_position()
import numpy as np
return (
np.zeros(3, dtype=float),
np.zeros(3, dtype=float),
np.array([0.0, 0.0, 1.0], dtype=float),
)
def get_camera_fov(self) -> float:
"""Return the current vertical FOV in degrees from the renderer.
Falls back to 45.0 if the renderer doesn't support it.
"""
self._ensure_initialized()
if hasattr(self._renderer, "get_camera_fov"):
return self._renderer.get_camera_fov()
return 45.0
# ─── World sketch reference gizmo (triad at the sketch midpoint) ────────
def show_sketch_gizmo(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
size: float = 40.0,
) -> None:
"""Show the selectable X/Y/Z triad in the 3D world at *origin*.
*origin* should be the midpoint of the active sketch's geometry and
the triad is aligned to the sketch's workplane frame. Call again
with a new origin to keep it in sync as the sketch is edited.
No-op on renderers without gizmo support (Pygfx fallback).
"""
self._ensure_initialized()
fn = getattr(self._renderer, "show_sketch_gizmo", None)
if fn is None:
return
fn(origin, normal, x_dir, size)
self._sketch_gizmo_frame = (tuple(origin), tuple(normal), tuple(x_dir))
self._sketch_gizmo_hover_kind = None
self._renderer.render()
def remove_sketch_gizmo(self) -> None:
"""Hide the world sketch triad, if any."""
if self._initialized and self._renderer is not None:
fn = getattr(self._renderer, "remove_sketch_gizmo", None)
if fn is not None:
fn()
self._renderer.render()
self._sketch_gizmo_frame = None
self._sketch_gizmo_hover_kind = None
def set_sketch_gizmo_pick_mode(self, enabled: bool) -> None:
"""Toggle explicit gizmo-pick mode.
When enabled, left-clicks select gizmo parts only (the camera does
not orbit) and Esc exits the mode. When disabled, the gizmo is
still pickable implicitly during normal navigation (Fusion-style)
whenever it is shown. Mutually exclusive with the other pick modes.
"""
self._sketch_gizmo_pick_mode = bool(enabled)
if enabled:
self._pick_face_mode = False
self._fillet_pick_mode = False
self._chamfer_pick_mode = False
self._thread_pick_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
self.setFocus()
elif not (
self._pick_face_mode
or self._fillet_pick_mode
or self._chamfer_pick_mode
or self._thread_pick_mode
or self._connector_pick_mode
):
self.unsetCursor()
def is_sketch_gizmo_pick_mode(self) -> bool:
return self._sketch_gizmo_pick_mode
def get_sketch_gizmo_frame(self) -> Optional[Tuple[tuple, tuple, tuple]]:
"""Return the (origin, normal, x_dir) of the shown triad, or None."""
return self._sketch_gizmo_frame
def _sketch_gizmo_enabled(self) -> bool:
"""True when the triad is shown AND no other mode owns the pointer."""
if self._sketch_gizmo_frame is None:
return False
if self._sketch_gizmo_pick_mode:
return True
if self._assembly_move_mode or self._move_drag_active:
return False
return not (
self._pick_face_mode
or self._fillet_pick_mode
or self._chamfer_pick_mode
or self._thread_pick_mode
or self._connector_pick_mode
)
def _handle_sketch_gizmo_hover(self, event) -> None:
"""Highlight the gizmo part under the cursor and emit hover signal."""
fn = getattr(self._renderer, "pick_sketch_gizmo", None)
if fn is None:
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
kind = fn(pos.x(), pos.y())
if kind == self._sketch_gizmo_hover_kind:
return
self._sketch_gizmo_hover_kind = kind
if kind is not None:
hl = getattr(self._renderer, "highlight_sketch_gizmo_part", None)
if hl is not None:
hl(kind)
else:
cl = getattr(self._renderer, "clear_sketch_gizmo_highlight", None)
if cl is not None:
cl()
self.sketchGizmoHover.emit(kind)
def _handle_sketch_gizmo_pick(self, event, kind: str) -> None:
"""Emit sketchGizmoPicked for the clicked part with world metadata."""
frame = self._sketch_gizmo_frame
normal = tuple(frame[1]) if frame else (0.0, 0.0, 1.0)
x_dir = tuple(frame[2]) if frame else (1.0, 0.0, 0.0)
position = tuple(frame[0]) if frame else (0.0, 0.0, 0.0)
direction = (0.0, 0.0, 0.0)
info_fn = getattr(self._renderer, "sketch_gizmo_pick_info", None)
if info_fn is not None:
info = info_fn(kind)
if info:
position = tuple(info["position"])
direction = tuple(info["direction"])
self.sketchGizmoPicked.emit(kind, position, direction, normal, x_dir)
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
def set_pick_face_mode(self, enabled: bool) -> None:
"""Toggle face-pick mode.
When enabled, the cursor selects planar faces for sketch placement
instead of orbiting the camera. Middle button still pans; wheel zooms.
"""
self._pick_face_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.CrossCursor)
else:
self.unsetCursor()
def is_pick_face_mode(self) -> bool:
return self._pick_face_mode
# ─── Fillet pick mode (any-face picking) ────────────────────────────────
def set_fillet_pick_mode(self, enabled: bool) -> None:
"""Toggle fillet face-pick mode (any face — planar or curved).
The cursor selects faces for the fillet tool instead of orbiting the
camera. Mutually exclusive with the other pick modes: entering this
mode switches the others off.
"""
self._fillet_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
# Pick modes are mutually exclusive — entering fillet mode
# disables chamfer / sketch-on-surface / connector / assembly modes.
self._chamfer_pick_mode = False
self._pick_face_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
elif not self._pick_face_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_fillet_pick_mode(self) -> bool:
return self._fillet_pick_mode
# ─── Chamfer pick mode (any-face picking) ──────────────────────────────
def set_chamfer_pick_mode(self, enabled: bool) -> None:
"""Toggle chamfer face-pick mode (any face — planar or curved).
The cursor selects faces for the chamfer tool instead of orbiting
the camera. Mutually exclusive with the other pick modes.
"""
self._chamfer_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self._fillet_pick_mode = False
self._pick_face_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
elif not self._pick_face_mode and not self._fillet_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_chamfer_pick_mode(self) -> bool:
return self._chamfer_pick_mode
def _handle_chamfer_face_pick(self, event: Any) -> None:
"""Detect any face under the click and emit chamferFacePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_face", None)
if picker is None:
logger.warning("Renderer has no pick_face support")
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Chamfer face pick: no face under cursor")
return
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.chamferFacePicked.emit(info["face"])
def highlight_faces(self, faces: List[Any]) -> None:
"""Tint all faces in *faces* so both fillet picks stay visible."""
self._ensure_initialized()
fn = getattr(self._renderer, "highlight_faces", None)
if fn is not None:
fn(faces)
self._renderer.render()
return
# Fallback: single-face highlight for the last picked face.
if faces:
self.highlight_face(faces[-1])
def clear_faces_highlight(self) -> None:
"""Remove the multi-face fillet-pick overlays, if any."""
if not self._initialized or self._renderer is None:
return
fn = getattr(self._renderer, "clear_faces_highlight", None)
if fn is not None:
fn()
self._renderer.render()
def _handle_fillet_face_pick(self, event: Any) -> None:
"""Detect any face under the click and emit filletFacePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_face", None)
if picker is None:
logger.warning("Renderer has no pick_face support")
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Fillet face pick: no face under cursor")
return
# Stash the owner so MainWindow can pair the face with its body
# (same convention as sketch-on-face picking).
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.filletFacePicked.emit(info["face"])
# ─── Thread pick mode ─────────────────────────────────────────────────
def set_thread_pick_mode(self, enabled: bool) -> None:
"""Toggle thread face-pick mode (cylindrical face only).
When enabled, a left-click picks a cylindrical face for the thread
tool. Mutually exclusive with other pick modes.
"""
self._thread_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self._pick_face_mode = False
self._fillet_pick_mode = False
self._chamfer_pick_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
elif not self._pick_face_mode and not self._fillet_pick_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_thread_pick_mode(self) -> bool:
return self._thread_pick_mode
def _handle_thread_face_pick(self, event: Any) -> None:
"""Detect any face under the click and emit threadFacePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_face", None)
if picker is None:
logger.warning("Renderer has no pick_face support")
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Thread face pick: no face under cursor")
return
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.threadFacePicked.emit(info["face"])
def highlight_face(self, face: Any) -> None:
"""Tint the picked face light-blue/transparent in the 3D viewer."""
self._ensure_initialized()
fn = getattr(self._renderer, "highlight_face", None)
if fn is not None:
fn(face)
self._renderer.render()
def clear_face_highlight(self) -> None:
"""Remove the persistent face-selection tint."""
self._ensure_initialized()
fn = getattr(self._renderer, "clear_face_highlight", None)
if fn is not None:
fn()
self._renderer.render()
def highlight_operation(self, shape: Any) -> None:
"""Overlay a hot-pink translucent *shape* to show operation history."""
self._ensure_initialized()
fn = getattr(self._renderer, "highlight_operation_shape", None)
if fn is not None:
fn(shape)
self._renderer.render()
def clear_operation_highlight(self) -> None:
"""Remove the operation-history overlay, if any."""
self._ensure_initialized()
fn = getattr(self._renderer, "clear_operation_highlight", None)
if fn is not None:
fn()
self._renderer.render()
# ─── Connector pick mode (assembly) ────────────────────────────────────
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
"""Toggle connector pick mode for placing connection points.
When enabled, clicking an entity (face, edge, vertex, hole)
on a body in the assembly view captures its position and
direction as a connection point for the SolveSpace solver.
Entering connector mode deactivates standard OCC face/edge/vertex
selection so dynamic highlighting does not clash with the gizmo
visuals. Selection is re-activated on exit.
*clear_gizmo*: if False the gizmo marker is not cleared on exit,
allowing it to persist until the next hover event (used after a
successful pick so the user sees what was selected).
"""
self._connector_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.CrossCursor)
# Disable standard OCC selection so gizmo visuals are not
# interfered with by dynamic face highlighting.
fn = getattr(self._renderer, "deactivate_selection_modes", None)
if fn is not None:
fn()
else:
if clear_gizmo:
self._clear_connector_snap()
# Restore standard OCC selection for face-pick / normal modes.
fn = getattr(self._renderer, "activate_selection_modes", None)
if fn is not None:
fn()
if not self._pick_face_mode:
self.unsetCursor()
def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode
def show_persistent_connector_gizmo(self, origin, normal, x_dir, entity_type, color=(0.0, 1.0, 0.0)):
fn = getattr(self._renderer, "show_persistent_entity_gizmo", None)
if fn is not None:
fn(entity_type=entity_type, position=origin, normal=normal, x_dir=x_dir, color=color)
def clear_persistent_connector_gizmo(self):
fn = getattr(self._renderer, "clear_persistent_entity_gizmo", None)
if fn is not None:
fn()
def _clear_connector_snap(self) -> None:
"""Remove the hover gizmo."""
fn = getattr(self._renderer, "clear_entity_gizmo", None)
if fn is not None:
fn()
# Backwards compat: also try the old method.
if self._connector_snap_id is not None:
fn2 = getattr(self._renderer, "remove_highlight_snap", None)
if fn2 is not None:
fn2(self._connector_snap_id)
self._connector_snap_id = None
def _handle_connector_hover(self, event) -> None:
"""Update the hover snap gizmo during connector pick mode.
Uses geometric probing (direct topology walk) which does not depend
on OCC's selection system — this avoids clashing with the gizmo
visuals since selection modes are deactivated in connector mode.
Probes are throttled to at most once every 50 ms to avoid UI lag
on fast mouse moves.
"""
import time
now = time.monotonic()
if now - self._connector_last_hover_time < self._connector_hover_interval:
return # throttled — skip this mouse move
self._connector_last_hover_time = now
self._ensure_initialized()
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
if probe is not None:
candidates = probe(pos.x(), pos.y())
if not candidates:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
# Primary = the nearest candidate (probe sorts nearest-first).
info = candidates[0]
else:
# Fall back to the selection-system-based probe.
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
if probe2 is not None:
candidates = probe2(pos.x(), pos.y())
if not candidates:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
info = candidates[0]
else:
# Last resort: single-pixel pick.
picker = getattr(self._renderer, "pick_entity", None)
if picker is None:
return
info = picker(pos.x(), pos.y())
candidates = [info] if info else []
if info is None or info.get("owner_obj_id") is None:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
origin = info["position"]
normal = info.get("normal")
entity_type = info["type"]
owner = info.get("owner_obj_id", "")
# ── Feature recognition ──
# Enhance candidates with composite feature info (holes, edge loops, etc.)
recognize = getattr(self._renderer, "recognize_composite_features", None)
if recognize is not None and candidates:
candidates = recognize(candidates, pos.x(), pos.y())
info = candidates[0] # re-read primary after enhancement
origin = info["position"]
normal = info.get("normal")
entity_type = info.get("type", info.get("feature_type", entity_type))
# Show smart entity gizmo — dim candidate markers + bright primary.
self._clear_connector_snap()
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
if gizmo_fn is not None:
gizmo_fn(
entity_type=entity_type,
position=origin,
normal=normal,
x_dir=info.get("x_dir"),
radius=info.get("radius"),
candidates=candidates,
)
else:
# Fallback to old highlight_snap.
fn = getattr(self._renderer, "highlight_snap", None)
if fn is not None:
colors = {
"planar_face": (0.0, 0.8, 1.0), # cyan
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
"edge": (0.0, 1.0, 0.4), # green
"vertex": (1.0, 1.0, 0.0), # yellow
}
c = colors.get(entity_type, (1.0, 0.6, 0.0))
self._connector_snap_id = fn(origin, color=c, size=3.0)
# Build payload with feature recognition info.
payload = {
"origin": origin,
"normal": normal,
"type": entity_type,
"owner_obj_id": owner,
}
# Attach feature info if available.
if "feature_type" in info:
payload["feature_type"] = info["feature_type"]
if "suggestion" in info:
payload["suggestion"] = info["suggestion"]
if "feature_data" in info:
payload["feature_data"] = info["feature_data"]
self.connectorHover.emit(payload)
def _handle_connector_pick(self, event) -> None:
"""Detect an entity under the click and emit connectorPicked.
Uses geometric probing (direct topology walk) so a click selects the
PRIMARY (nearest) snap target — the same one the hover gizmo
emphasised. Falls back to selection-system probe, then single-pixel
``pick_entity``, then ``pick_planar_face``.
"""
self._ensure_initialized()
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info: Optional[Dict[str, Any]] = None
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
if probe is not None:
candidates = probe(pos.x(), pos.y())
if candidates:
info = candidates[0] # nearest = primary
if info is None:
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
if probe2 is not None:
candidates = probe2(pos.x(), pos.y())
if candidates:
info = candidates[0]
if info is None:
picker = getattr(self._renderer, "pick_entity", None)
if picker is None:
# Fallback to planar face only.
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
logger.warning("Renderer has no entity picking support")
return
pinfo = picker(pos.x(), pos.y())
if pinfo is None:
logger.info("Connector pick: no planar face under cursor")
return
owner_obj_id = pinfo.get("owner_obj_id", "")
self.connectorPicked.emit(
tuple(pinfo["origin"]),
tuple(pinfo["normal"]),
tuple(pinfo["x_dir"]),
"planar_face",
pinfo["face"],
owner_obj_id,
)
return
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Connector pick: no entity under cursor")
return
owner_obj_id = info.get("owner_obj_id", "")
if not owner_obj_id:
return
entity_type = info["type"]
origin = info["position"]
normal = info.get("normal") or (0.0, 0.0, 1.0)
x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
# For vertices, pick a sensible normal from the parent face if possible.
if entity_type == "vertex" and normal is None:
normal = (0.0, 0.0, 1.0)
# Package the raw shape appropriately.
raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
self.connectorPicked.emit(
tuple(origin),
tuple(normal),
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
entity_type,
raw_shape,
owner_obj_id,
)
# ─── Assembly move mode (3D drag) ─────────────────────────────────────
def set_assembly_move_mode(self, enabled: bool) -> None:
"""Toggle assembly move mode.
When enabled, clicking on a body and dragging moves its
assembly component in the view plane. Shift+drag moves in Z.
"""
self._assembly_move_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.SizeAllCursor)
elif not self._pick_face_mode and not self._connector_pick_mode:
self.unsetCursor()
if not enabled:
self._move_drag_active = False
self._move_owner_obj_id = ""
self._move_click_3d = None
self._move_click_screen = None
self._move_plane_normal = None
self._move_initial_position = None
def _handle_assembly_move_press(self, event) -> None:
"""Start a drag-to-move for the body under the cursor."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
return
owner_obj_id = info.get("owner_obj_id", "")
if not owner_obj_id or not owner_obj_id.startswith("asm_"):
return
# Store drag state.
self._move_drag_active = True
self._move_owner_obj_id = owner_obj_id
self._move_click_3d = tuple(info["origin"])
self._move_click_screen = pos
self._move_plane_normal = tuple(info["normal"])
# Emit activation signal so MainWindow stores initial position.
self.assemblyComponentActivated.emit(owner_obj_id)
def _handle_assembly_move_move(self, event) -> None:
"""Continue the drag: project mouse delta to world-space and emit."""
if not self._move_drag_active or self._move_click_screen is None:
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
# Screen delta (Qt Y is inverted vs OCC).
dx = pos.x() - self._move_click_screen.x()
dy = -(pos.y() - self._move_click_screen.y()) # invert Y
# Convert screen delta to world units using the view scale.
# view.Scale() returns a scale factor — the smaller the value the
# more world distance per pixel. We use an empirical conversion:
# at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
world_per_pixel = 2.0 / max(scale, 0.001)
# Get camera vectors for proper view-plane projection.
import numpy as np
try:
# Get camera direction and up from the OCC view.
camera = self._renderer._view.Camera()
dir_ = camera.Direction()
up_ = camera.Up()
cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
cam_right = np.cross(cam_dir, cam_up)
cam_right = cam_right / np.linalg.norm(cam_right)
cam_up = cam_up / np.linalg.norm(cam_up)
except Exception:
# Fallback: assume XY plane.
cam_right = np.array([1.0, 0.0, 0.0])
cam_up = np.array([0.0, 0.0, 1.0])
# Compute world-space delta.
modifiers = event.modifiers()
if modifiers & Qt.KeyboardModifier.ShiftModifier:
# Shift+drag: move along camera direction (Z-depth).
dz_world = dx * world_per_pixel
dx_world = 0.0
dy_world = 0.0
else:
# Normal drag: move in view plane.
try:
dx_world = float(
cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel
)
dy_world = float(
cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel
)
dz_world = float(
cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel
)
except (TypeError, ValueError):
dx_world = dy_world = dz_world = 0.0
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
def _handle_assembly_move_release(self, event: Any) -> None:
"""Finish the drag, emit final position."""
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
self._move_drag_active = False
self._move_owner_obj_id = ""
self._move_click_3d = None
self._move_click_screen = None
self._move_plane_normal = None
self._move_initial_position = None
def _handle_face_pick(self, event: Any) -> None:
"""Detect a planar face under the click and emit facePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
logger.warning("Renderer has no pick_planar_face support")
return
# Qt6: prefer position().toPoint() over deprecated pos().
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Face pick: no planar face under cursor")
return
# Stash the owning obj_id so MainWindow._on_face_picked can pair the
# picked face with the body it belongs to (for auto-targeted cut).
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.facePicked.emit(
tuple(info["origin"]),
tuple(info["normal"]),
tuple(info["x_dir"]),
info["face"],
)
def set_view(self, view: str) -> None:
# Prefer the renderer's native orientation snap (preserves target,
# refits the scene). Falls back to absolute eye positions for
# renderers that don't implement set_view_orientation.
self._ensure_initialized()
if hasattr(self._renderer, "set_view_orientation"):
self._renderer.set_view_orientation(view)
self._renderer.render()
return
positions = {
"iso": ((100, 100, 100), (0, 0, 0)),
"top": ((0, 0, 200), (0, 0, 0)),
"front": ((0, -200, 0), (0, 0, 0)),
"right": ((200, 0, 0), (0, 0, 0)),
"back": ((0, 200, 0), (0, 0, 0)),
"left": ((-200, 0, 0), (0, 0, 0)),
"bottom": ((0, 0, -200), (0, 0, 0)),
}
if view in positions:
pos, target = positions[view]
self.set_camera_position(pos, target)
def mouseDoubleClickEvent(self, event: Any) -> None:
# Double-click → fit all (common CAD convention).
self._ensure_initialized()
if event.button() == Qt.MouseButton.LeftButton:
self.fit_camera()
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event: Any) -> None:
# Esc cancels face-pick mode.
if self._pick_face_mode and event.key() == Qt.Key.Key_Escape:
self.set_pick_face_mode(False)
self.pickFaceCancelled.emit()
return
# Esc cancels fillet pick mode.
if self._fillet_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_fillet_pick_mode(False)
self.filletPickCancelled.emit()
return
# Esc cancels chamfer pick mode.
if self._chamfer_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_chamfer_pick_mode(False)
self.chamferPickCancelled.emit()
return
# Esc cancels thread pick mode.
if self._thread_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_thread_pick_mode(False)
self.threadPickCancelled.emit()
return
# Esc cancels connector pick mode.
if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_connector_pick_mode(False)
self.connectorPickCancelled.emit()
return
# Esc cancels assembly move mode.
if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape:
self.set_assembly_move_mode(False)
return
# Esc cancels explicit sketch-gizmo pick mode.
if self._sketch_gizmo_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_sketch_gizmo_pick_mode(False)
self.sketchGizmoCancelled.emit()
return
# Navigation shortcuts (lowercase = view presets, F = fit,
# P/O = perspective/orthographic, R = reset).
self._ensure_initialized()
key = event.text().lower()
mapping = {
"f": "fit",
"r": "reset",
"1": "front",
"2": "back",
"3": "top",
"4": "bottom",
"5": "left",
"6": "right",
"7": "iso",
}
action = mapping.get(key)
if action == "fit":
self.fit_camera()
return
if action == "reset":
if hasattr(self._renderer, "reset_camera"):
self._renderer.reset_camera()
self._renderer.render()
else:
self.set_view("iso")
return
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
self.set_view(action)
return
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
self._renderer.set_camera_perspective()
self._renderer.render()
return
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
self._renderer.set_camera_orthographic()
self._renderer.render()
return
super().keyPressEvent(event)