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