- Tons of addtions
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user