Files
fluencyCAD/src/fluency/rendering/occ_renderer.py
T
2026-07-04 12:10:58 +02:00

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