505 lines
17 KiB
Python
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()
|