1104 lines
42 KiB
Python
1104 lines
42 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
|
|
self._pan_start_x: int = 0
|
|
self._pan_start_y: int = 0
|
|
self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
|
|
# Persistent light-blue transparent overlay marking the selected face.
|
|
self._highlight_ais: Any = None
|
|
# Temporary transparent preview AIS for the live extrude/cut dialog.
|
|
self._preview_ais: Any = None
|
|
|
|
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,
|
|
Aspect_TypeOfTriedronPosition,
|
|
)
|
|
from OCP.OpenGl import OpenGl_GraphicDriver
|
|
from OCP.V3d import (
|
|
V3d_Viewer,
|
|
V3d_View,
|
|
V3d_TypeOfView,
|
|
V3d_DirectionalLight,
|
|
V3d_AmbientLight,
|
|
V3d_TypeOfOrientation,
|
|
)
|
|
from OCP.AIS import AIS_InteractiveContext
|
|
from OCP.Graphic3d import (
|
|
Graphic3d_Camera,
|
|
Graphic3d_TypeOfShadingModel,
|
|
Graphic3d_MaterialAspect,
|
|
Graphic3d_NameOfMaterial,
|
|
)
|
|
from OCP.Quantity import (
|
|
Quantity_Color,
|
|
Quantity_TOC_RGB,
|
|
Quantity_NameOfColor,
|
|
)
|
|
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)
|
|
|
|
# ── Lighting: replace defaults with a tuned 3-light rig ───────
|
|
# Key light (warm directional from upper-front-right) + fill
|
|
# (cool, softer) + ambient for base lift. Gives proper shading
|
|
# on curved BRep surfaces (cylinders, spheres).
|
|
viewer.SetLightOff() # clear default lights first
|
|
key = V3d_DirectionalLight(
|
|
V3d_TypeOfOrientation.V3d_XposYnegZpos,
|
|
Quantity_Color(1.0, 0.96, 0.88, Quantity_TOC_RGB),
|
|
True,
|
|
)
|
|
fill = V3d_DirectionalLight(
|
|
V3d_TypeOfOrientation.V3d_XnegYnegZpos,
|
|
Quantity_Color(0.55, 0.62, 0.78, Quantity_TOC_RGB),
|
|
True,
|
|
)
|
|
rim = V3d_DirectionalLight(
|
|
V3d_TypeOfOrientation.V3d_XposYposZneg,
|
|
Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB),
|
|
True,
|
|
)
|
|
ambient = V3d_AmbientLight(
|
|
Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB)
|
|
)
|
|
for light in (key, fill, rim, ambient):
|
|
viewer.SetLightOn(light)
|
|
|
|
view = V3d_View(viewer, V3d_TypeOfView.V3d_ORTHOGRAPHIC)
|
|
|
|
# ── Background gradient (dark studio look) ───────────────────
|
|
viewer.SetDefaultBgGradientColors(
|
|
Quantity_Color(0.18, 0.20, 0.24, Quantity_TOC_RGB),
|
|
Quantity_Color(0.08, 0.09, 0.11, Quantity_TOC_RGB),
|
|
Aspect_GFM_VER,
|
|
)
|
|
view.SetBgGradientColors(
|
|
Quantity_Color(0.18, 0.20, 0.24, Quantity_TOC_RGB),
|
|
Quantity_Color(0.08, 0.09, 0.11, Quantity_TOC_RGB),
|
|
Aspect_GFM_VER,
|
|
True,
|
|
)
|
|
|
|
# ── Rendering quality: MSAA + Phong shading + AA ─────────────
|
|
params = view.ChangeRenderingParams()
|
|
params.NbMsaaSamples = 4 # 4x MSAA for smoother edges
|
|
params.IsAntialiasingEnabled = True
|
|
params.LineFeather = 0.6
|
|
view.SetShadingModel(Graphic3d_TypeOfShadingModel.Graphic3d_TypeOfShadingModel_Phong)
|
|
|
|
# ── Corner orientation trihedron (axis gizmo) ────────────────
|
|
try:
|
|
view.TriedronDisplay(
|
|
Aspect_TypeOfTriedronPosition.Aspect_TOTP_LEFT_LOWER,
|
|
Quantity_Color(Quantity_NameOfColor.Quantity_NOC_WHITE),
|
|
0.08,
|
|
)
|
|
except Exception:
|
|
logger.debug("TriedronDisplay unavailable", exc_info=True)
|
|
|
|
context = AIS_InteractiveContext(viewer)
|
|
# Default display mode = shaded (AIS_Shaded = 1)
|
|
context.SetDisplayMode(1, True)
|
|
|
|
# Style the dynamic (hover) highlight as light-blue so the face
|
|
# pick preview matches the persistent selection overlay below.
|
|
try:
|
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
|
# Modify the existing dynamic-highlight drawer in place (per
|
|
# OCC docs this is safer than building a fresh Prs3d_Drawer).
|
|
hd = context.HighlightStyle()
|
|
hd.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
|
|
hd.SetDisplayMode(1)
|
|
except Exception:
|
|
logger.debug("dynamic highlight style unavailable", exc_info=True)
|
|
|
|
# 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)
|
|
# Ensure the depth range follows the scene so nothing clips.
|
|
view.SetAutoZFitMode(True)
|
|
|
|
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)
|
|
|
|
# Order matters: set material *before* color so the per-channel color
|
|
# overrides only the diffuse albedo and the plastic BRDF is retained.
|
|
ais.SetMaterial(self._default_material())
|
|
if color is not None:
|
|
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
|
|
ais.SetColor(qcol)
|
|
|
|
# Shaded display with boundary edges drawn on top for readability.
|
|
ais.SetDisplayMode(1) # 1 = AIS_Shaded
|
|
drawer = ais.Attributes()
|
|
try:
|
|
# Always draw face boundaries — makes solid edges crisp.
|
|
drawer.SetFaceBoundaryDraw(True)
|
|
except Exception:
|
|
logger.debug("boundary-draw attrs unavailable", exc_info=True)
|
|
# Slight polygon offset so boundary edges don't z-fight the surface.
|
|
# Mode 3 = Graphic3d_POM_Fill (offset filled polygons).
|
|
try:
|
|
ais.SetPolygonOffsets(3, 1.0, 0.0)
|
|
except Exception:
|
|
logger.debug("polygon offset unavailable", exc_info=True)
|
|
|
|
self._context.Display(ais, True)
|
|
# Activate selection modes so the viewer can detect/pick the whole
|
|
# shape (mode 0) and individual faces (mode for TopAbs_FACE) — needed
|
|
# for sketch-on-surface face picking. Left-click orbits the camera
|
|
# (see handle_mouse_press), so active selection doesn't interfere.
|
|
try:
|
|
from OCP.TopAbs import TopAbs_FACE
|
|
face_mode = AIS_Shape.SelectionMode_s(TopAbs_FACE)
|
|
self._context.Activate(ais, face_mode)
|
|
except Exception:
|
|
logger.debug("face selection mode activation failed", exc_info=True)
|
|
|
|
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.
|
|
|
|
Plastic gives a clean, readable matte surface that responds well to
|
|
the 3-light rig set up in :meth:`initialize`.
|
|
"""
|
|
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
|
|
|
|
mat = Graphic3d_MaterialAspect(
|
|
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
|
|
)
|
|
return mat
|
|
|
|
# ─── 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 set_visibility(self, obj_id: str, visible: bool) -> bool:
|
|
"""Show or hide an object by ID, preserving its place in the scene.
|
|
|
|
Unlike ``remove_mesh``, this doesn't free the object — the user can
|
|
toggle it back on later. Returns True on success, False if the
|
|
object isn't found (e.g. it was already removed).
|
|
"""
|
|
obj = self._objects.get(obj_id)
|
|
if obj is None:
|
|
return False
|
|
self.set_object_visible(obj, visible)
|
|
return True
|
|
|
|
def set_object_transparency(self, obj_id: str, transparency: float) -> bool:
|
|
"""Set the transparency of an object by ID (0.0 opaque..1.0 invisible).
|
|
|
|
Used by the live extrude preview to dim the existing target body
|
|
so the user can see the previewed result through/over it. Returns
|
|
True on success, False if the object isn't found.
|
|
"""
|
|
obj = self._objects.get(obj_id)
|
|
if obj is None or obj.ais_shape is None:
|
|
return False
|
|
try:
|
|
obj.ais_shape.SetTransparency(transparency)
|
|
self._context.RecomputePrsOnly(obj.ais_shape, True)
|
|
except Exception:
|
|
logger.debug("set_object_transparency failed", exc_info=True)
|
|
return False
|
|
return True
|
|
|
|
# ─── Live preview (extrude/cut preview) ──────────────────────────────
|
|
|
|
_PREVIEW_ID = "__extrude_preview__"
|
|
|
|
def preview_shape(
|
|
self,
|
|
shape: Any,
|
|
color: Optional[Tuple[float, float, float]] = None,
|
|
transparency: float = 0.60,
|
|
) -> None:
|
|
"""Display a temporary transparent preview of *shape* (TopoDS_Shape).
|
|
|
|
The preview lives under a fixed id (``__extrude_preview__``) so a
|
|
subsequent call replaces the previous preview in place. Call
|
|
:meth:`clear_preview` to remove it. This is independent of the
|
|
tracked ``_objects`` dict — the preview is NOT a body and won't
|
|
be returned by ``pick_planar_face``'s owner scan.
|
|
"""
|
|
if self._context is None:
|
|
return
|
|
# Clear any previous preview (uses the same id).
|
|
self.clear_preview()
|
|
from OCP.AIS import AIS_Shape
|
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
|
|
|
ais = AIS_Shape(shape)
|
|
try:
|
|
ais.SetMaterial(self._default_material())
|
|
except Exception:
|
|
logger.debug("preview material set failed", exc_info=True)
|
|
col = color or (0.45, 0.80, 0.95) # cyan-ish for preview
|
|
ais.SetColor(Quantity_Color(*col, Quantity_TOC_RGB))
|
|
ais.SetDisplayMode(1) # shaded
|
|
try:
|
|
ais.SetTransparency(transparency)
|
|
except Exception:
|
|
logger.debug("preview transparency set failed", exc_info=True)
|
|
# Draw face boundaries so the preview shape reads cleanly.
|
|
try:
|
|
drawer = ais.Attributes()
|
|
drawer.SetFaceBoundaryDraw(True)
|
|
except Exception:
|
|
pass
|
|
self._context.Display(ais, True)
|
|
self._preview_ais = ais
|
|
if self._view is not None:
|
|
self._view.Repaint()
|
|
|
|
def clear_preview(self) -> None:
|
|
"""Remove the live extrude preview shape."""
|
|
if self._context is None or getattr(self, "_preview_ais", None) is None:
|
|
return
|
|
try:
|
|
self._context.Remove(self._preview_ais, True)
|
|
finally:
|
|
self._preview_ais = None
|
|
|
|
def clear_scene(self) -> None:
|
|
"""Remove all objects from the scene."""
|
|
if self._context is None:
|
|
return
|
|
self.clear_preview()
|
|
self.clear_face_highlight()
|
|
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
|
|
# path; fall back to iterating the displayed list if unavailable.
|
|
try:
|
|
self._context.RemoveAll(True)
|
|
except Exception:
|
|
from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive
|
|
lst = AIS_ListOfInteractive()
|
|
self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst)
|
|
for ais in lst:
|
|
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 eye, target (at) and up vectors."""
|
|
if self._view is None:
|
|
return
|
|
self._view.SetEye(float(position[0]), float(position[1]), float(position[2]))
|
|
self._view.SetAt(float(target[0]), float(target[1]), float(target[2]))
|
|
self._view.SetUp(float(up[0]), float(up[1]), float(up[2]))
|
|
self._view.ZFitAll()
|
|
self._view.Redraw()
|
|
|
|
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()
|
|
at = self._view.At()
|
|
up = self._view.Up()
|
|
|
|
def _xyz(v):
|
|
if isinstance(v, (tuple, list)):
|
|
return np.array([float(v[0]), float(v[1]), float(v[2])])
|
|
return np.array([v.X(), v.Y(), v.Z()])
|
|
|
|
return (_xyz(eye), _xyz(at), _xyz(up))
|
|
|
|
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``. A small value like 0.05 adds 5% margin.
|
|
"""
|
|
if self._view is None:
|
|
return
|
|
margin = max(0.0, min(padding, 0.99))
|
|
self._view.FitAll(margin)
|
|
self._view.ZFitAll()
|
|
|
|
def set_view_orientation(self, orientation: str = "iso") -> None:
|
|
"""Snap the camera to a standard CAD view.
|
|
|
|
orientation ∈ {"front","back","top","bottom","left","right","iso"}.
|
|
Preserves the current target and distance, then refits.
|
|
"""
|
|
if self._view is None:
|
|
return
|
|
from OCP.V3d import V3d_TypeOfOrientation
|
|
|
|
mapping = {
|
|
"front": V3d_TypeOfOrientation.V3d_Yneg,
|
|
"back": V3d_TypeOfOrientation.V3d_Ypos,
|
|
"top": V3d_TypeOfOrientation.V3d_Zpos,
|
|
"bottom": V3d_TypeOfOrientation.V3d_Zneg,
|
|
"left": V3d_TypeOfOrientation.V3d_Xneg,
|
|
"right": V3d_TypeOfOrientation.V3d_Xpos,
|
|
"iso": V3d_TypeOfOrientation.V3d_XposYnegZpos,
|
|
}
|
|
orient = mapping.get(orientation.lower())
|
|
if orient is None:
|
|
logger.warning(f"unknown view orientation: {orientation}")
|
|
return
|
|
self._view.SetProj(orient)
|
|
self.fit_camera()
|
|
self._view.Redraw()
|
|
|
|
def reset_camera(self) -> None:
|
|
"""Reset to the default isometric view and fit all."""
|
|
self.set_view_orientation("iso")
|
|
|
|
def set_camera_perspective(
|
|
self, fov: float = 45.0, near: float = 0.1, far: float = 100000.0
|
|
) -> None:
|
|
"""Switch to a perspective camera with the given FOV (degrees).
|
|
|
|
Near/far planes are auto-fit by OCC (``ZFitAll``) — the *near*/*far*
|
|
args are accepted for API compatibility but ignored.
|
|
"""
|
|
if self._view is None:
|
|
return
|
|
from OCP.Graphic3d import Graphic3d_Camera
|
|
|
|
cam = self._view.Camera()
|
|
cam.SetProjectionType(Graphic3d_Camera.Projection_Perspective)
|
|
cam.SetFOVy(fov)
|
|
self._view.ZFitAll()
|
|
self._view.Redraw()
|
|
|
|
def set_camera_orthographic(
|
|
self, width: float = 100.0, near: float = 0.1, far: float = 100000.0
|
|
) -> None:
|
|
"""Switch to an orthographic camera."""
|
|
if self._view is None:
|
|
return
|
|
from OCP.Graphic3d import Graphic3d_Camera
|
|
|
|
cam = self._view.Camera()
|
|
cam.SetProjectionType(Graphic3d_Camera.Projection_Orthographic)
|
|
self._view.ZFitAll()
|
|
self._view.Redraw()
|
|
|
|
# ─── 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)
|
|
|
|
# ─── Workplane visualization ────────────────────────────────────────
|
|
|
|
_WORKPLANE_BASE_ID: str = "__workplane__"
|
|
_WORKPLANE_GRID_SIZE: float = 200.0 # total extent of the visual plane
|
|
|
|
def show_workplane_plane(
|
|
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,
|
|
) -> str:
|
|
"""Display a semi-transparent planar grid at the workplane location.
|
|
|
|
The plane is a single large rectangular face with a grid of lines
|
|
drawn on it, rendered at low opacity so it does not obscure the
|
|
existing bodies. Returns an object ID that can be passed to
|
|
:meth:`remove_workplane_plane`.
|
|
|
|
If *name* is provided it is used as the object ID; otherwise a
|
|
unique one is generated. Passing an existing workplane ID will
|
|
replace that workplane visual in place.
|
|
"""
|
|
import numpy as np
|
|
from OCP.gp import gp_Pnt, gp_Dir
|
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
|
from OCP.gp import gp_Pln
|
|
from OCP.AIS import AIS_Shape
|
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
|
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
|
|
|
|
obj_id = name or f"{self._WORKPLANE_BASE_ID}_{uuid.uuid4().hex[:8]}"
|
|
|
|
# Compute orthonormal basis.
|
|
n = np.asarray(normal, dtype=float)
|
|
n = n / np.linalg.norm(n)
|
|
x = np.asarray(x_dir, dtype=float)
|
|
x = x - np.dot(x, n) * n
|
|
x_norm = np.linalg.norm(x)
|
|
if x_norm < 1e-9:
|
|
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
|
x = fallback - np.dot(fallback, n) * n
|
|
x_norm = np.linalg.norm(x)
|
|
x = x / x_norm
|
|
y = np.cross(n, x)
|
|
|
|
hs = size / 2.0
|
|
# Four corners of the plane in local coords.
|
|
corners_3d = [
|
|
(
|
|
origin[0] + (-hs) * x[0] + (-hs) * y[0],
|
|
origin[1] + (-hs) * x[1] + (-hs) * y[1],
|
|
origin[2] + (-hs) * x[2] + (-hs) * y[2],
|
|
),
|
|
(
|
|
origin[0] + (hs) * x[0] + (-hs) * y[0],
|
|
origin[1] + (hs) * x[1] + (-hs) * y[1],
|
|
origin[2] + (hs) * x[2] + (-hs) * y[2],
|
|
),
|
|
(
|
|
origin[0] + (hs) * x[0] + (hs) * y[0],
|
|
origin[1] + (hs) * x[1] + (hs) * y[1],
|
|
origin[2] + (hs) * x[2] + (hs) * y[2],
|
|
),
|
|
(
|
|
origin[0] + (-hs) * x[0] + (hs) * y[0],
|
|
origin[1] + (-hs) * x[1] + (hs) * y[1],
|
|
origin[2] + (-hs) * x[2] + (hs) * y[2],
|
|
),
|
|
]
|
|
|
|
# Build the planar face: point + normal direction.
|
|
pln = gp_Pln(
|
|
gp_Pnt(*origin),
|
|
gp_Dir(n[0], n[1], n[2]),
|
|
)
|
|
# Use gp_Pnt for the corners to make a bounded face.
|
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
|
mp = BRepBuilderAPI_MakePolygon()
|
|
for c in corners_3d:
|
|
mp.Add(gp_Pnt(*c))
|
|
mp.Close()
|
|
wire = mp.Wire()
|
|
face_maker = BRepBuilderAPI_MakeFace(pln, wire)
|
|
face = face_maker.Face()
|
|
|
|
# Check if a workplane with this id already exists and remove it.
|
|
existing = self._objects.get(obj_id)
|
|
if existing is not None:
|
|
self.remove_object(existing)
|
|
|
|
ais = AIS_Shape(face)
|
|
# Semi-transparent blue-ish tint.
|
|
ais.SetColor(Quantity_Color(0.55, 0.75, 0.95, Quantity_TOC_RGB)) # light blue
|
|
try:
|
|
ais.SetTransparency(0.75)
|
|
except Exception:
|
|
pass
|
|
ais.SetDisplayMode(1) # shaded
|
|
|
|
# Draw the plane with a slight polygon offset so it doesn't z-fight.
|
|
try:
|
|
ais.SetPolygonOffsets(3, 1.0, -1.0)
|
|
except Exception:
|
|
pass
|
|
|
|
self._context.Display(ais, True)
|
|
# Deactivate selection on the workplane plane so that face-picking
|
|
# (``pick_planar_face``) does NOT intercept the workplane's face
|
|
# when the user clicks through it to select a body face underneath.
|
|
# The workplane is decorative reference geometry — it must never
|
|
# be a target of face-pick mode.
|
|
try:
|
|
self._context.Deactivate(ais)
|
|
except Exception:
|
|
pass
|
|
|
|
robj = OCCRenderObject(
|
|
obj_id=obj_id,
|
|
ais_shape=ais,
|
|
ais_type="workplane",
|
|
color=RenderColor(0.55, 0.75, 0.95),
|
|
)
|
|
self._objects[obj_id] = robj
|
|
self._view.Redraw()
|
|
return obj_id
|
|
|
|
def remove_workplane_plane(self, obj_id: str) -> bool:
|
|
"""Remove a workplane plane visual by its object ID."""
|
|
robj = self._objects.get(obj_id)
|
|
if robj is not None:
|
|
return self.remove_object(robj)
|
|
return False
|
|
|
|
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)
|
|
|
|
# ─── Face picking (for sketch-on-surface) ────────────────────────────
|
|
|
|
def pick_planar_face(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
|
"""Pick the planar face under screen pixel (x, y).
|
|
|
|
Returns a dict with ``origin``, ``normal``, ``x_dir`` (a stable
|
|
in-plane axis) and ``face`` (the ``TopoDS_Face``), or *None* if the
|
|
hit is not a planar face. The plane is derived from the face's
|
|
underlying surface via ``BRepAdaptor_Surface``; *x_dir* is taken
|
|
from the face's first edge direction so the UV frame is aligned to
|
|
the face geometry.
|
|
"""
|
|
if self._view is None or self._context is None:
|
|
return None
|
|
|
|
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
from OCP.GeomAbs import GeomAbs_Plane
|
|
from OCP.TopoDS import TopoDS_Face, TopoDS
|
|
from OCP.TopExp import TopExp_Explorer
|
|
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
|
|
from OCP.BRep import BRep_Tool
|
|
from OCP.gp import gp_Pln, gp_Dir, gp_Pnt
|
|
import numpy as np
|
|
|
|
# Detect what's under the cursor.
|
|
self._context.MoveTo(x, y, self._view, True)
|
|
if not self._context.HasDetected():
|
|
return None
|
|
|
|
shape = self._context.DetectedShape()
|
|
if shape is None:
|
|
return None
|
|
|
|
# The detected sub-shape is returned as a TopoDS_Shape; with face
|
|
# selection mode active it downcasts to a TopoDS_Face. Try the
|
|
# downcast; if it isn't a face, give up (non-planar/edge/vertex hits).
|
|
face = None
|
|
try:
|
|
candidate = TopoDS.Face_s(shape)
|
|
# Verify it really is a face by building an adaptor.
|
|
_ = BRepAdaptor_Surface(candidate)
|
|
face = candidate
|
|
except Exception:
|
|
face = None
|
|
if face is None:
|
|
return None
|
|
|
|
try:
|
|
adaptor = BRepAdaptor_Surface(face)
|
|
if adaptor.GetType() != GeomAbs_Plane:
|
|
return None # non-planar faces can't host a flat UV sketch
|
|
pln: gp_Pln = adaptor.Plane()
|
|
except Exception:
|
|
return None
|
|
|
|
# Outward normal: the plane's geometric axis is independent of the
|
|
# face's orientation (TopAbs_FORWARD / TopAbs_REVERSED). For a face
|
|
# on a solid the TRUE outward normal is the axis when FORWARD and its
|
|
# negation when REVERSED. Without this correction a top face whose
|
|
# axis happens to point inward would return an inward normal, so a
|
|
# default (non-inverted) extrude would punch back into the body
|
|
# instead of building outward on top of it.
|
|
from OCP.TopAbs import TopAbs_REVERSED
|
|
n = pln.Axis().Direction()
|
|
if face.Orientation() == TopAbs_REVERSED:
|
|
n = n.Reversed()
|
|
|
|
# Plane origin: use the face's bounding-box centre projected onto the
|
|
# plane, so the UV frame is centred on the face (nicer for sketching).
|
|
from OCP.Bnd import Bnd_Box
|
|
from OCP.BRepBndLib import BRepBndLib
|
|
bbox = Bnd_Box()
|
|
BRepBndLib.Add_s(face, bbox)
|
|
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
|
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
|
|
# Project the bbox centre onto the plane.
|
|
pln_origin = pln.Location() # gp_Pnt
|
|
nx, ny, nz = n.X(), n.Y(), n.Z()
|
|
# signed distance from bbox centre to plane
|
|
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
|
|
origin = (cx - d * nx, cy - d * ny, cz - d * nz)
|
|
|
|
# x_dir: direction of the face's first edge (stable, in-plane).
|
|
x_dir = None
|
|
try:
|
|
from OCP.TopExp import TopExp
|
|
from OCP.BRep import BRep_Tool
|
|
expl = TopExp_Explorer(face, TopAbs_EDGE)
|
|
if expl.More():
|
|
edge = TopoDS.Edge_s(expl.Current())
|
|
v1 = TopExp.FirstVertex_s(edge, True)
|
|
v2 = TopExp.LastVertex_s(edge, True)
|
|
p1 = BRep_Tool.Pnt_s(v1)
|
|
p2 = BRep_Tool.Pnt_s(v2)
|
|
ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
|
|
elen = (ex * ex + ey * ey + ez * ez) ** 0.5
|
|
if elen > 1e-9:
|
|
x_dir = (ex / elen, ey / elen, ez / elen)
|
|
except Exception:
|
|
pass
|
|
if x_dir is None:
|
|
# Fall back to the plane's own X axis.
|
|
px = pln.XAxis().Direction()
|
|
x_dir = (px.X(), px.Y(), px.Z())
|
|
|
|
# Identify the displayed body that owns this face, so the host can
|
|
# auto-target it as the cut/union body when the user extrudes the
|
|
# sketch-on-face. ``DetectedInteractive`` returns the AIS_
|
|
# InteractiveObject that the picked sub-shape belongs to; we match
|
|
# it against the renderer's tracked objects by AIS identity.
|
|
owner_obj_id: Optional[str] = None
|
|
try:
|
|
owner_ais = self._context.DetectedInteractive()
|
|
except Exception:
|
|
owner_ais = None
|
|
if owner_ais is not None:
|
|
for oid, robj in self._objects.items():
|
|
if robj.ais_shape is owner_ais:
|
|
owner_obj_id = oid
|
|
break
|
|
|
|
return {
|
|
"origin": origin,
|
|
"normal": (nx, ny, nz),
|
|
"x_dir": x_dir,
|
|
"face": face,
|
|
"owner_obj_id": owner_obj_id,
|
|
}
|
|
|
|
def highlight_face(self, face: Any) -> None:
|
|
"""Overlay a persistent, mostly-transparent light-blue tint on *face*.
|
|
|
|
Used to mark the planar face the user has selected for sketch-on-
|
|
surface. The overlay is an independent ``AIS_Shape`` so it survives
|
|
until :meth:`clear_face_highlight` is called.
|
|
"""
|
|
if self._context is None:
|
|
return
|
|
self.clear_face_highlight()
|
|
from OCP.AIS import AIS_Shape
|
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
|
|
|
ais = AIS_Shape(face)
|
|
try:
|
|
ais.SetMaterial(self._default_material())
|
|
except Exception:
|
|
logger.debug("highlight material set failed", exc_info=True)
|
|
ais.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
|
|
ais.SetDisplayMode(1) # shaded — tint the whole face, not just edges
|
|
try:
|
|
# Mostly transparent so the underlying face stays visible.
|
|
ais.SetTransparency(0.78)
|
|
except Exception:
|
|
logger.debug("highlight transparency set failed", exc_info=True)
|
|
try:
|
|
# Bias the overlay slightly toward the camera so it draws on top
|
|
# of the coincident face surface without z-fighting.
|
|
# mode 3 = Graphic3d_POM_Fill; negative units pull forward.
|
|
ais.SetPolygonOffsets(3, 1.0, -0.5)
|
|
except Exception:
|
|
logger.debug("highlight polygon offset failed", exc_info=True)
|
|
|
|
self._context.Display(ais, True)
|
|
self._highlight_ais = ais
|
|
if self._view is not None:
|
|
self._view.Update()
|
|
|
|
def clear_face_highlight(self) -> None:
|
|
"""Remove the persistent face-selection overlay, if any."""
|
|
if self._context is None or self._highlight_ais is None:
|
|
return
|
|
try:
|
|
self._context.Remove(self._highlight_ais, True)
|
|
except Exception:
|
|
logger.debug("clear_face_highlight remove failed", exc_info=True)
|
|
self._highlight_ais = None
|
|
|
|
# ─── Mouse / keyboard event forwarding ──────────────────────────────
|
|
#
|
|
# CAD-style navigation:
|
|
# • Left button drag → orbit (rotate around target)
|
|
# • Middle button drag → pan
|
|
# • Right button → (reserved for future use)
|
|
# • Wheel → zoom toward cursor
|
|
# • Double-click left → fit all (handled by the widget)
|
|
|
|
def _qt_buttons(self, event) -> Any:
|
|
"""Return the PySide6 Qt enum module lazily."""
|
|
from PySide6.QtCore import Qt
|
|
return Qt
|
|
|
|
def handle_mouse_press(self, event) -> None:
|
|
"""Begin an orbit / pan / zoom gesture based on the pressed button."""
|
|
if self._view is None or self._context is None:
|
|
return
|
|
Qt = self._qt_buttons(event)
|
|
x, y = event.x(), event.y()
|
|
btn = event.button()
|
|
|
|
if btn == Qt.LeftButton:
|
|
self._nav_mode = "rotate"
|
|
# zRotationThreshold=0.4 enables screen-Z spin outside the inner
|
|
# circle, matching the FreeCAD/OCC viewer feel.
|
|
self._view.StartRotation(x, y, 0.4)
|
|
elif btn == Qt.MiddleButton:
|
|
self._nav_mode = "pan"
|
|
# Record the gesture start; OCC's Pan(..., Start=False) expects
|
|
# deltas CUMULATIVE from this point, not per-frame deltas.
|
|
self._pan_start_x = x
|
|
self._pan_start_y = y
|
|
self._view.Pan(0, 0, 1.0, True)
|
|
else:
|
|
# Right button (and any other) is reserved — no gesture yet.
|
|
self._nav_mode = None
|
|
|
|
self._last_mouse_x = x
|
|
self._last_mouse_y = y
|
|
|
|
def handle_mouse_move(self, event) -> None:
|
|
"""Continue the active gesture; otherwise just hover-detect."""
|
|
if self._view is None or self._context is None:
|
|
return
|
|
Qt = self._qt_buttons(event)
|
|
x, y = event.x(), event.y()
|
|
buttons = event.buttons()
|
|
|
|
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
|
|
self._view.Rotation(x, y)
|
|
elif self._nav_mode == "pan" and (buttons & Qt.MiddleButton):
|
|
# Cumulative delta from the gesture start — OCC interprets
|
|
# Pan(..., Start=False) as an absolute offset from the start point.
|
|
dx = x - self._pan_start_x
|
|
dy = y - self._pan_start_y
|
|
# dy negated because Qt y grows downward while OCC y grows upward.
|
|
self._view.Pan(dx, -dy, 1.0, False)
|
|
else:
|
|
# Idle: dynamic highlighting under the cursor.
|
|
self._context.MoveTo(x, y, self._view, True)
|
|
|
|
self._last_mouse_x = x
|
|
self._last_mouse_y = y
|
|
|
|
def handle_mouse_release(self, event) -> None:
|
|
"""End the active gesture."""
|
|
Qt = self._qt_buttons(event)
|
|
if event.button() in (Qt.LeftButton, Qt.MiddleButton, Qt.RightButton):
|
|
self._nav_mode = None
|
|
|
|
def handle_wheel(self, event) -> None:
|
|
"""Zoom toward the cursor on scroll."""
|
|
if self._view is None:
|
|
return
|
|
# Qt6: QWheelEvent has no .x()/.y(); use position().toPoint().
|
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
x, y = pos.x(), pos.y()
|
|
delta = event.angleDelta().y()
|
|
if delta == 0:
|
|
return
|
|
# ZoomAtPoint(startX, startY, endX, endY): move the cursor anchor
|
|
# by a few pixels to drive a smooth zoom centered on the pointer.
|
|
step = 30 if delta > 0 else -30
|
|
self._view.ZoomAtPoint(x, y, x, y - step)
|
|
self._view.ZFitAll()
|
|
|
|
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()
|