"""OCC-based 3D renderer — displays BRep shapes directly on the GPU. Uses OCC's ``AIS_Shape`` and ``V3d_Viewer`` so that curved surfaces (cylinders, spheres, etc.) render smoothly without manual triangulation, and edges/faces are natively selectable. """ import logging import uuid from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple import numpy as np from .base import Renderer, RenderObject, RenderColor logger = logging.getLogger(__name__) @dataclass class OCCRenderObject(RenderObject): """Internal object state for the OCC renderer.""" obj_id: str = "" ais_shape: Any = None # AIS_Shape ais_type: str = "shape" # "shape" | "wireframe" | "points" color: RenderColor = field(default_factory=lambda: RenderColor(0.5, 0.5, 0.5)) class OCCRenderer(Renderer): """Renderer that uses OCC's native AIS display for smooth BRep rendering.""" def __init__(self) -> None: super().__init__() self._viewer: Any = None self._view: Any = None self._context: Any = None self._window: Any = None self._initialized: bool = False self._objects: Dict[str, OCCRenderObject] = {} self._parent_widget: Any = None self._last_mouse_x: int = 0 self._last_mouse_y: int = 0 def initialize(self, parent_widget: Any) -> bool: """Initialise OCC viewer inside *parent_widget* (a QWidget).""" self._parent_widget = parent_widget import os as _os if _os.environ.get("QT_QPA_PLATFORM") == "offscreen": logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)") return False logger.info("OCCRenderer imports starting...") from OCP.Aspect import ( Aspect_DisplayConnection, Aspect_NeutralWindow, Aspect_GFM_VER, ) from OCP.OpenGl import OpenGl_GraphicDriver from OCP.V3d import V3d_Viewer, V3d_View, V3d_TypeOfView from OCP.AIS import AIS_InteractiveContext from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB logger.info("OCCRenderer imports complete") hwnd = int(parent_widget.winId()) if hwnd <= 1: logger.warning("OCCRenderer skipped (no native window handle)") return False logger.info("OCCRenderer creating objects...") try: display = Aspect_DisplayConnection() driver = OpenGl_GraphicDriver(display) viewer = V3d_Viewer(driver) viewer.SetDefaultLights() viewer.SetLightOn() view = V3d_View(viewer, V3d_TypeOfView.V3d_ORTHOGRAPHIC) viewer.SetDefaultBgGradientColors( Quantity_Color(0.15, 0.15, 0.2, Quantity_TOC_RGB), Quantity_Color(0.25, 0.25, 0.3, Quantity_TOC_RGB), Aspect_GFM_VER, ) context = AIS_InteractiveContext(viewer) # Attach OCC view to the Qt widget via the native window handle. win = Aspect_NeutralWindow() win.SetNativeHandle(hwnd) w, h = parent_widget.width(), parent_widget.height() win.SetSize(w, h) view.SetWindow(win) self._viewer = viewer self._view = view self._context = context self._window = win self._initialized = True logger.info("OCCRenderer initialised (native OCC display)") return True except Exception as exc: logger.error(f"OCCRenderer initialisation failed: {exc}") self._initialized = False return False def shutdown(self) -> None: """Clean up OCC viewer resources.""" self.clear_scene() if self._view is not None: self._view.Remove() self._view = None if self._viewer is not None: self._viewer.Remove() self._viewer = None self._initialized = False # ─── BRep shape display (primary entry point) ─────────────────────── def add_shape( self, shape: Any, color: Optional[Tuple[float, float, float]] = None, name: Optional[str] = None, ) -> str: """Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``. Returns a unique object ID (or *name* if provided). """ from OCP.AIS import AIS_Shape from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Prs3d import Prs3d_Drawer obj_id = name or f"shape_{uuid.uuid4().hex[:8]}" ais = AIS_Shape(shape) if color is not None: qcol = Quantity_Color(*color, Quantity_TOC_RGB) ais.SetColor(qcol) # Enable selection of edges and faces. ais.SetDisplayMode(1) # 0 = wireframe, 1 = shaded ais.SetMaterial( self._default_material() ) # use a helper to get the default material self._context.Display(ais, True) self._context.Deactivate(ais) defcol = color or (0.5, 0.5, 0.5) robj = OCCRenderObject( obj_id=obj_id, ais_shape=ais, ais_type="shape", color=RenderColor(*defcol), ) self._objects[obj_id] = robj # Fit camera on first shape added. if len(self._objects) == 1: try: self.fit_camera() except Exception: logger.warning("fit_camera failed on first shape", exc_info=True) return obj_id def _default_material(self): """Return a default Graphic3d_MaterialAspect for shading.""" from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial return Graphic3d_MaterialAspect( Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC ) # ─── Legacy mesh / wireframe (kept for backward compat) ──────────── def add_mesh( self, vertices: np.ndarray, faces: np.ndarray, color: Tuple[float, float, float] = (0.2, 0.4, 0.8), name: Optional[str] = None, ) -> str: """Add triangulated mesh by converting it to an OCC polygonal shape. Prefer :meth:`add_shape` for native BRep display — it avoids meshing artifacts on curved surfaces. """ from OCP.Poly import Poly_Triangulation, Poly_Triangle from OCP.TopoDS import TopoDS_Face from OCP.BRep import BRep_Builder from OCP.gp import gp_Pnt n_verts = len(vertices) n_tris = len(faces) # Build triangulation via (nbNodes, nbTriangles, hasUVNodes) constructor tri = Poly_Triangulation(n_verts, n_tris, False) for i, (x, y, z) in enumerate(vertices): tri.SetNode(i + 1, gp_Pnt(float(x), float(y), float(z))) for i, (a, b, cc) in enumerate(faces): tri.SetTriangle(i + 1, Poly_Triangle(int(a) + 1, int(b) + 1, int(cc) + 1)) builder = BRep_Builder() shape = TopoDS_Face() builder.MakeFace(shape, tri) return self.add_shape(shape, color, name) def add_wireframe( self, vertices: np.ndarray, edges: np.ndarray, color: Tuple[float, float, float] = (1.0, 1.0, 1.0), line_width: float = 1.0, name: Optional[str] = None, ) -> str: """Add a wireframe from edge data (legacy, kept for compatibility). For new code prefer :meth:`add_shape` — OCC's AIS displays the shape boundary automatically. """ obj_id = name or f"wf_{uuid.uuid4().hex[:8]}" logger.debug(f"add_wireframe {obj_id} — ignored (AIS draws edges natively)") # Wireframes are already provided by the AIS shaded display, so we # skip explicit line geometry unless there is a specific need. return obj_id def add_points( self, points: np.ndarray, color: Tuple[float, float, float] = (1.0, 0.0, 0.0), size: float = 5.0, name: Optional[str] = None, ) -> str: """Add point cloud (not yet implemented with OCC renderer).""" obj_id = name or f"pts_{uuid.uuid4().hex[:8]}" logger.debug(f"add_points {obj_id} — not implemented in OCCRenderer") return obj_id def add_lines( self, start_points: np.ndarray, end_points: np.ndarray, color: Tuple[float, float, float] = (1.0, 1.0, 1.0), line_width: float = 1.0, name: Optional[str] = None, ) -> str: """Add line segments (not yet implemented with OCC renderer).""" obj_id = name or f"ln_{uuid.uuid4().hex[:8]}" logger.debug(f"add_lines {obj_id} — not implemented in OCCRenderer") return obj_id # ─── Object management ───────────────────────────────────────────── def remove_object(self, obj: OCCRenderObject) -> bool: """Remove an object from the scene.""" if obj.ais_shape is not None: self._context.Remove(obj.ais_shape, True) if obj.obj_id in self._objects: del self._objects[obj.obj_id] return True return False def remove_mesh(self, obj_id: str) -> None: """Remove an object by ID (legacy compatibility).""" obj = self._objects.get(obj_id) if obj is not None: self.remove_object(obj) def clear_scene(self) -> None: """Remove all objects from the scene.""" if self._context is None: return # Remove all displayed AIS objects. from OCP.AIS import AIS_KindOfInteractive objs = self._context.DisplayedObjects() for ais in objs: self._context.Remove(ais, True) self._objects.clear() def update_mesh( self, obj: OCCRenderObject, vertices: np.ndarray, faces: np.ndarray, ) -> bool: """Update mesh geometry (not supported for OCC shapes; re-add instead).""" logger.warning("update_mesh not supported for OCC shapes — remove + re-add") return False # ─── Display properties ──────────────────────────────────────────── def set_object_color( self, obj: OCCRenderObject, color: Tuple[float, float, float], ) -> None: """Set the colour of an object.""" from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB if obj.ais_shape is not None: qcol = Quantity_Color(*color, Quantity_TOC_RGB) obj.ais_shape.SetColor(qcol) self._context.RecomputePrsOnly(obj.ais_shape, True) def set_object_visible(self, obj: OCCRenderObject, visible: bool) -> None: """Show or hide an object.""" if obj.ais_shape is not None: if visible: self._context.Display(obj.ais_shape, True) else: self._context.Erase(obj.ais_shape, True) # ─── Camera ──────────────────────────────────────────────────────── def set_camera_position( self, position: Tuple[float, float, float], target: Tuple[float, float, float] = (0, 0, 0), up: Tuple[float, float, float] = (0, 0, 1), ) -> None: """Set camera position and orientation.""" if self._view is None: return from OCP.gp import gp_Pnt, gp_Dir, gp_Vec self._view.SetProj(gp_Dir(position[0], position[1], position[2])) self._view.SetEye(gp_Pnt(*position)) self._view.SetCenter(gp_Pnt(*target)) self._view.SetUp(gp_Dir(*up)) def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """Get camera position, target, and up vector.""" if self._view is None: return ( np.array([1, 1, 1]), np.array([0, 0, 0]), np.array([0, 0, 1]), ) eye = self._view.Eye() center = self._view.Center() up = self._view.Up() return ( np.array([eye.X(), eye.Y(), eye.Z()]), np.array([center.X(), center.Y(), center.Z()]), np.array([up.X(), up.Y(), up.Z()]), ) def fit_camera(self, padding: float = 0.05) -> None: """Fit camera to show all displayed objects. *padding* is the margin coefficient (0 ≤ padding < 1.0) passed to OCC's ``FitAll``, not a multiplicative factor. A small value like 0.05 adds 5% margin around the bounding box. """ if self._view is None: return margin = max(0.0, min(padding, 0.99)) self._view.FitAll(margin) def set_camera_perspective( self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0 ) -> None: """Set perspective camera.""" if self._view is None: return from OCP.V3d import V3d_PERSPECTIVE self._view.SetComputedMode(False) # manual mode self._view.ChangeRenderingParams() # perspective/orthographic toggle handled in set_camera_orthographic def set_camera_orthographic( self, width: float = 100.0, near: float = 0.1, far: float = 10000.0 ) -> None: """Set orthographic camera.""" if self._view is None: return from OCP.V3d import V3d_ORTHOGRAPHIC self._view.SetComputedMode(False) # ─── Rendering ───────────────────────────────────────────────────── def render(self) -> None: """Redraw the OCC view.""" if self._view is None: return self._view.Redraw() def screenshot(self, width: int, height: int) -> bytes: """Take a screenshot.""" return b"" # ─── Stub implementations for remaining abstract methods ────────── def add_axes(self, size: float = 100.0) -> None: """Add coordinate axes.""" pass def add_grid(self, size: float = 100.0) -> None: """Add a reference grid.""" pass def get_screen_size(self) -> Tuple[int, int]: if self._parent_widget: return self._parent_widget.width(), self._parent_widget.height() return (800, 600) def on_camera_change(self, callback: Any) -> None: pass def on_pick(self, callback: Any) -> None: pass def project_to_screen( self, point: Tuple[float, float, float] ) -> Tuple[float, float]: return (0.0, 0.0) def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None: logger.warning("save_screenshot not implemented for OCCRenderer") def set_background_color(self, color: Tuple[float, float, float]) -> None: if self._view is None: return from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB qcol = Quantity_Color(*color, Quantity_TOC_RGB) self._view.SetBackgroundColor(qcol) def take_screenshot(self) -> bytes: return b"" def unproject_from_screen( self, x: float, y: float ) -> Tuple[float, float, float]: return (0.0, 0.0, 0.0) # ─── Mouse event forwarding ──────────────────────────────────────── def handle_mouse_press(self, event) -> None: """Forward a QMouseEvent to the OCC view for orbit/pan/zoom/select.""" from OCP.Aspect import Aspect_VKeyMouse from OCP.AIS import AIS_SelectionScheme if self._view is None or self._context is None: return # Middle mouse → start rotation if event.button() == 4: # Qt.MiddleButton self._view.StartRotation(event.x(), event.y()) # Left mouse → try selection (OCC picks nearest shape) elif event.button() == 1: # Qt.LeftButton self._context.Select(True) def handle_mouse_move(self, event) -> None: """Forward mouse motion to OCC view (rotation, dynamic highlighting).""" from OCP.Aspect import Aspect_VKeyMouse if self._view is None or self._context is None: return buttons = event.buttons() if buttons & 4: # Qt.MiddleButton self._view.Rotation(event.x(), event.y()) elif buttons & 2: # Qt.RightButton dx = event.x() - self._last_mouse_x dy = event.y() - self._last_mouse_y self._view.Pan(dx, dy) # Dynamic highlighting (detect) self._context.MoveTo(event.x(), event.y(), self._view, True) self._last_mouse_x = event.x() self._last_mouse_y = event.y() def handle_mouse_release(self, event) -> None: """End rotation/pan.""" pass def handle_wheel(self, event) -> None: """Zoom on scroll.""" if self._view is None: return delta = event.angleDelta().y() if delta > 0: self._view.SetZoom(1.1) else: self._view.SetZoom(0.9) def handle_resize(self, w: int, h: int) -> None: """Resize the OCC view when the widget is resized.""" if self._window is not None: self._window.SetSize(w, h) if self._view is not None: self._view.MustBeResized() self._view.Redraw()