Files
fluencyCAD/src/fluency/rendering/occ_renderer.py
T
2026-06-28 21:12:34 +02:00

505 lines
17 KiB
Python

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