"""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 # Smart entity picker gizmo objects (snap markers, axis lines, rings). # Keyed by a synthetic id; values are raw AIS_InteractiveObject. self._gizmo_objects: Dict[str, Any] = {} 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 individual # faces, edges and vertices. Required for the smart entity picker # gizmo (assembly connector picks) AND for sketch-on-surface face # picking. OCC assigns higher detection priority to vertices, then # edges, then faces — so hovering near an edge/vertex picks the # edge/vertex rather than the underlying face, which is what we want # for snapping. Left-click still orbits the camera (see # handle_mouse_press); active selection is only consumed by the # explicit pick methods (pick_entity / pick_planar_face). try: from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): mode = AIS_Shape.SelectionMode_s(topo) self._context.Activate(ais, mode) except Exception: logger.debug("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: # OCC's V3d_View exposes ``Redraw`` (not ``Repaint``); calling # the wrong name crashed the live extrude-cut preview callback. self._view.Redraw() 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() self.clear_entity_gizmo() # 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 get_camera_fov(self) -> float: """Return the current vertical FOV in degrees from the OCC camera.""" if self._view is None: return 45.0 cam = self._view.Camera() fov_y = cam.FOVy() # Clamp to a reasonable range for Mitsuba (10–120 deg) return max(10.0, min(120.0, fov_y)) def get_render_camera(self) -> "RenderCamera": """Compute a :class:`RenderCamera` from the current viewport state. Handles both perspective and orthographic projection modes: * **Perspective** — uses the actual eye/target/up/fov directly. * **Orthographic** — translates the view's scale factor (which encodes mouse-wheel zoom) into a camera distance so the render framing matches what the user sees in the viewport. In orthographic mode, zooming changes ``view.Scale()`` rather than moving the eye position, so simply reading ``Eye()`` produces incorrect framing. Returns *None* if the view is not initialised. """ from .render_backend import RenderCamera if self._view is None: return None cam = self._view.Camera() eye_obj = self._view.Eye() at_obj = self._view.At() up_obj = 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()]) eye = _xyz(eye_obj) at = _xyz(at_obj) up = _xyz(up_obj) fov_y = max(10.0, min(120.0, cam.FOVy())) # Check projection type. from OCP.Graphic3d import Graphic3d_Camera proj_type = cam.ProjectionType() is_orthographic = ( proj_type == Graphic3d_Camera.Projection_Orthographic ) if not is_orthographic: # Perspective mode: use the actual eye position directly. return RenderCamera( origin=tuple(float(v) for v in eye), target=tuple(float(v) for v in at), up=tuple(float(v) for v in up), fov=fov_y, ) # ── Orthographic mode: translate scale → camera distance ─────── # In OCC orthographic projection, mouse-wheel zoom changes # ``view.Scale()`` rather than moving the eye. A smaller scale # means "zoomed in" (more world units per pixel), so the render # camera should move closer to match. view_scale = self._view.Scale() # Compute scene bounding box diagonal from displayed objects. from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib bbox = Bnd_Box() try: for robj in self._objects.values(): if robj.ais_shape is not None and robj.ais_type != "workplane": shape = getattr(robj.ais_shape, "Shape", lambda: None)() if shape is not None: BRepBndLib.Add_s(shape, bbox) except Exception: pass xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() diag = float( np.sqrt( (xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2 ) ) # Fallback: if bbox is empty (no objects or all shapes failed), # use the eye-to-at distance as a reasonable estimate. if diag < 1e-9: diag = float(np.linalg.norm(eye - at)) # Base distance: how far the camera must be for the bbox diagonal # to fill the frame at the given vertical FOV. import math base_distance = diag / ( 2.0 * math.tan(math.radians(fov_y / 2.0)) ) # Scale factor maps inversely: larger scale (zoomed in) → closer camera. # Dividing by view_scale ensures that when the user zooms in (scale increases) # the render camera moves closer, matching the viewport. adjusted_distance = base_distance / view_scale # Direction from target toward the original eye position. direction = eye - at norm = np.linalg.norm(direction) if norm < 1e-9: direction = np.array([0.0, 0.0, 1.0]) else: direction /= norm new_eye = at + direction * adjusted_distance return RenderCamera( origin=tuple(float(v) for v in new_eye), target=tuple(float(v) for v in at), up=tuple(float(v) for v in up), fov=fov_y, ) 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 # ─── General entity picking (for assembly connectors / snaps) ─────────── def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]: """Pick the entity under screen pixel (x, y). Returns a dict describing the detected geometry: * ``type`` — one of ``planar_face``, ``cylindrical_face``, ``edge``, ``vertex``. * ``position`` — 3D point (face origin / edge midpoint / vertex). * ``normal`` — direction vector (face normal / cylinder axis / edge tangent / ``None`` for vertex). * ``x_dir`` — stable in-plane reference direction. * ``face`` / ``edge`` / ``vertex`` — the raw OCC sub-shape. * ``owner_obj_id`` — the displayed body this belongs to. Returns *None* if nothing detectable is under the cursor. """ if self._view is None or self._context is None: return None 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 results = self._classify_detected_shape(shape) if not results: return None # For cylinders with two ends, pick the one closest to the camera. if len(results) > 1: eye = None try: if self._view is not None: e = self._view.Eye() eye = np.array([e.X(), e.Y(), e.Z()], dtype=float) except Exception: pass if eye is not None: results.sort(key=lambda c: float(np.linalg.norm( np.array(c["position"]) - eye))) return results[0] def _classify_detected_shape( self, shape: Any, owner_obj_id: Optional[str] = None, ) -> List[Dict[str, Any]]: """Classify a detected OCC sub-shape into snap-candidate dicts. Shared by ``pick_entity`` (single-pixel) and ``probe_snap_candidates`` (multi-pixel grid probing). Determines whether *shape* is a planar face, cylindrical face (hole), edge, or vertex and returns a list of snap info dicts with ``position`` / ``normal`` / ``x_dir`` / ``type`` / ``owner_obj_id`` (+ ``radius`` for holes). Cylindrical faces yield two candidates (one per circular end) so the user can snap to either opening. When *owner_obj_id* is omitted it is looked up from the context's currently-detected AIS. """ if shape is None: return [] from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle from OCP.BRep import BRep_Tool from OCP.TopExp import TopExp_Explorer from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE from OCP.gp import gp_Pnt, gp_Dir from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib from OCP.TopExp import TopExp import numpy as np # Helper: find owner object id if not supplied. if owner_obj_id is None: owner_obj_id = "" 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 # Try face first. face = None try: face = TopoDS.Face_s(shape) _ = BRepAdaptor_Surface(face) except Exception: face = None if face is not None: adaptor = BRepAdaptor_Surface(face) stype = adaptor.GetType() if stype == GeomAbs_Plane: pln = adaptor.Plane() n = pln.Axis().Direction() from OCP.TopAbs import TopAbs_REVERSED if face.Orientation() == TopAbs_REVERSED: n = n.Reversed() nx, ny, nz = n.X(), n.Y(), n.Z() # Center of face bbox projected onto plane. 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 pln_origin = pln.Location() 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 from first edge. x_dir = None try: expl = TopExp_Explorer(face, TopAbs_EDGE_TYPE) 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: px = pln.XAxis().Direction() x_dir = (px.X(), px.Y(), px.Z()) return [{ "type": "planar_face", "position": origin, "normal": (nx, ny, nz), "x_dir": x_dir, "face": face, "owner_obj_id": owner_obj_id, }] elif stype == GeomAbs_Cylinder: cyl = adaptor.Cylinder() axis = cyl.Axis() ax_dir = axis.Direction() ax_pos = axis.Location() radius = cyl.Radius() # Find the actual circular edge loops at each end of the # cylinder face. This is more reliable than computing from # vmin/vmax parameters, which can be offset depending on how # the BRep was constructed (e.g. a hole drilled into a block). # Collect all edges of the face. edge_explorer = TopExp_Explorer(face, TopAbs_EDGE_TYPE) circle_centers: List[np.ndarray] = [] while edge_explorer.More(): edge = TopoDS.Edge_s(edge_explorer.Current()) try: curve_adaptor = BRepAdaptor_Curve(edge) if curve_adaptor.GetType() == GeomAbs_Circle: circ = curve_adaptor.Circle() center_pnt = circ.Location() circle_centers.append(np.array([ center_pnt.X(), center_pnt.Y(), center_pnt.Z() ], dtype=float)) except Exception: pass edge_explorer.Next() # Group circle centers by their position along the axis. # Two distinct groups = two end openings of the cylinder. if len(circle_centers) >= 2: # Project each center onto the axis direction to get a # scalar "height" value. Cluster into two groups. ax_dir_np = np.array([ ax_dir.X(), ax_dir.Y(), ax_dir.Z() ], dtype=float) heights = [np.dot(c, ax_dir_np) for c in circle_centers] # Sort by height (scalar) and split roughly in half. indexed = list(enumerate(heights)) indexed.sort(key=lambda x: x[1]) mid = len(indexed) // 2 idx0 = [i for i, _ in indexed[:mid]] if mid > 0 else [indexed[0][0]] idx1 = [i for i, _ in indexed[mid:]] if mid < len(indexed) else [indexed[-1][0]] group0 = [circle_centers[i] for i in idx0] group1 = [circle_centers[i] for i in idx1] # Average each group to get the center of each end circle. c0 = np.mean(group0, axis=0) c1 = np.mean(group1, axis=0) elif len(circle_centers) == 1: # Only one circular edge found (e.g. open-ended cylinder). c0 = circle_centers[0] c1 = c0 else: # No circular edges found — fall back to parameter-based. vmin = adaptor.FirstVParameter() vmax = adaptor.LastVParameter() c0 = np.array([ ax_pos.X() + ax_dir.X() * vmin, ax_pos.Y() + ax_dir.Y() * vmin, ax_pos.Z() + ax_dir.Z() * vmin, ], dtype=float) c1 = np.array([ ax_pos.X() + ax_dir.X() * vmax, ax_pos.Y() + ax_dir.Y() * vmax, ax_pos.Z() + ax_dir.Z() * vmax, ], dtype=float) # Normal = the cylinder axis direction. This is the "bolt # axis": the direction a bolt would travel INTO the hole. # (Sign is the cylinder's own axis direction; a later flip can # reverse it via the connector dialog's Flip checkbox.) normal = (ax_dir.X(), ax_dir.Y(), ax_dir.Z()) # x_dir: radial direction from the axis center to the hole # wall — gives a stable in-plane reference for the connector # frame. Use the cylinder's own XDirection if available. try: px = cyl.Position().XDirection() x_dir = (px.X(), px.Y(), px.Z()) except Exception: x_dir = (1.0, 0.0, 0.0) # Return BOTH circular ends as snap candidates so the user can # snap to either opening of a cylinder/hole (e.g. bolt-to-bore # on the far side of a part). results: List[Dict[str, Any]] = [] for end_center in [c0, c1]: origin = (float(end_center[0]), float(end_center[1]), float(end_center[2])) results.append({ "type": "cylindrical_face", "position": origin, "normal": normal, "x_dir": x_dir, "face": face, "owner_obj_id": owner_obj_id, "radius": radius, }) return results # Try edge. edge = None try: edge = TopoDS.Edge_s(shape) _ = BRepAdaptor_Curve(edge) except Exception: edge = None if edge is not None: curve = BRepAdaptor_Curve(edge) # Midpoint parameter. ufirst = curve.FirstParameter() ulast = curve.LastParameter() umid = (ufirst + ulast) / 2.0 p_mid = curve.Value(umid) position = (p_mid.X(), p_mid.Y(), p_mid.Z()) # Tangent at midpoint. try: tangent = curve.DN(umid, 1) tx, ty, tz = tangent.X(), tangent.Y(), tangent.Z() tlen = (tx * tx + ty * ty + tz * tz) ** 0.5 if tlen > 1e-9: tangent = (tx / tlen, ty / tlen, tz / tlen) else: tangent = None except Exception: tangent = None # x_dir: perpendicular to tangent (arbitrary but stable). x_dir = None if tangent is not None: t = np.array(tangent) # Find a vector not parallel to t. ref = np.array([1.0, 0.0, 0.0]) if abs(t[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) x = np.cross(t, ref) xlen = np.linalg.norm(x) if xlen > 1e-9: x = x / xlen x_dir = (float(x[0]), float(x[1]), float(x[2])) return [{ "type": "edge", "position": position, "normal": tangent, "x_dir": x_dir, "edge": edge, "owner_obj_id": owner_obj_id, }] # Try vertex. vertex = None try: vertex = TopoDS.Vertex_s(shape) p = BRep_Tool.Pnt_s(vertex) position = (p.X(), p.Y(), p.Z()) return [{ "type": "vertex", "position": position, "normal": None, "x_dir": None, "vertex": vertex, "owner_obj_id": owner_obj_id, }] except Exception: pass return [] def probe_snap_candidates( self, x: int, y: int, radius: int = 30, ) -> List[Dict[str, Any]]: """Probe a pixel grid around (x, y) and return visible snap candidates. Samples a dense ring + centre around the cursor, runs OCC's ``MoveTo`` at each pixel, and classifies every distinct detected sub-shape via :meth:`_classify_detected_shape`. Results are deduplicated by (owner_obj_id, type, rounded position) and sorted by screen-space distance to the cursor, nearest first. This is the general hover snap indicator: it surfaces nearby vertices, edge midpoints, hole centres, and face centres so that the user can see the snap targets in the cursor neighbourhood — not just the single entity directly under the crosshair. Each entry is the same dict shape returned by ``pick_entity`` plus an extra ``screen`` key carrying the (sx, sy) pixel where the entity was first detected. """ if self._view is None or self._context is None: return [] # Dense sample pattern: centre + multiple rings at different radii # to catch small features like hole openings that might be missed by # a single sparse ring. Uses quarter, half, and full radius offsets. q = radius // 4 h = radius // 2 ring_offsets = [ (0, 0), # Full radius ring (cardinal + diagonal) (-radius, 0), (radius, 0), (0, -radius), (0, radius), (-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius), # Half-radius ring (-h, 0), (h, 0), (0, -h), (0, h), (-h, -h), (h, h), (-h, h), (h, -h), # Quarter-radius ring for small features (-q, 0), (q, 0), (0, -q), (0, q), ] candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {} for dx, dy in ring_offsets: sx, sy = x + dx, y + dy try: self._context.MoveTo(sx, sy, self._view, True) except Exception: continue if not self._context.HasDetected(): continue shape = self._context.DetectedShape() if shape is None: continue infos = self._classify_detected_shape(shape) if not infos: continue # infos is a list; for cylinders it contains two ends. for info in infos: # Skip non-trackable hits (no owner — e.g. the workplane plane). if not info.get("owner_obj_id"): continue pos = info.get("position") or (0.0, 0.0, 0.0) # Dedupe key: owner + type + position rounded to 0.1 mm. key = ( info.get("owner_obj_id", ""), info.get("type", ""), (round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)), ) if key not in candidates: info["screen"] = (sx, sy) candidates[key] = info # Sort by screen-space distance to the cursor, nearest first. results = list(candidates.values()) results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2) return results def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]: """Show a marker sphere at *position* as a snap indicator. Returns an object id that can be removed later. The *size* is auto-scaled by camera distance so the marker stays visible at any zoom level. """ if self._context is None: return None from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere from OCP.gp import gp_Pnt from OCP.AIS import AIS_Shape from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB try: scaled_size = size * self._get_gizmo_scale(position) sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), scaled_size).Shape() ais = AIS_Shape(sphere) c = color or (1.0, 0.6, 0.0) # orange ais.SetColor(Quantity_Color(c[0], c[1], c[2], Quantity_TOC_RGB)) ais.SetDisplayMode(1) self._context.Display(ais, True) if self._view is not None: self._view.Update() # Track this as a temporary object; use a synthetic id. oid = f"__snap_{id(ais)}" self._objects[oid] = OCCRenderObject(obj_id=oid, ais_shape=ais, ais_type="snap") return oid except Exception as exc: logger.debug(f"highlight_snap failed: {exc}") return None def remove_highlight_snap(self, obj_id: str) -> None: """Remove a snap marker by its id.""" if obj_id in self._objects: robj = self._objects.pop(obj_id) if self._context is not None and robj.ais_shape is not None: try: self._context.Remove(robj.ais_shape, True) if self._view is not None: self._view.Update() except Exception: pass # ─── Smart Entity Picker Gizmo ───────────────────────────────────────── def _get_gizmo_scale(self, position: Tuple[float, float, float]) -> float: """Compute a scale factor so gizmos stay a consistent % of screen size. Scales gizmo sizes proportional to the camera-to-gizmo distance, so the gizmo appears roughly the same physical size on screen regardless of zoom level. Returns a multiplier applied to the base gizmo sizes. """ if self._view is None: return 1.0 try: eye = self._view.Eye() eye_x, eye_y, eye_z = float(eye.X()), float(eye.Y()), float(eye.Z()) dx = position[0] - eye_x dy = position[1] - eye_y dz = position[2] - eye_z distance = (dx * dx + dy * dy + dz * dz) ** 0.5 # Reference distance at which the original hardcoded sizes (2.8 sphere, # 15.0 axis) looked right. Scale linearly with distance. reference_distance = 50.0 scale = distance / reference_distance # Clamp to avoid absurdly large or tiny gizmos. return max(0.3, min(scale, 8.0)) except Exception: return 1.0 def clear_entity_gizmo(self) -> None: """Remove all gizmo display objects (markers, axes, highlights).""" had_any = bool(self._gizmo_objects) for obj_id in list(self._gizmo_objects.keys()): robj = self._gizmo_objects.pop(obj_id) if self._context is not None and robj is not None: try: self._context.Remove(robj, True) except Exception: pass if had_any and self._view is not None: self._view.Update() def show_entity_gizmo( self, entity_type: str, position: Tuple[float, float, float], normal: Optional[Tuple[float, float, float]] = None, x_dir: Optional[Tuple[float, float, float]] = None, color: Optional[Tuple[float, float, float]] = None, radius: Optional[float] = None, candidates: Optional[List[Dict[str, Any]]] = None, ) -> None: """Display a smart snap gizmo for the given entity type. Renders a composite visual appropriate to the entity type: * **planar_face** — sphere at pick point + white normal axis + x_dir axis * **cylindrical_face** — sphere at the camera-facing hole opening + white bolt-axis line through the hole (the snap 'normal' points along the hole like a bolt) + a ring of the hole radius at the opening + a small radial reference line. * **edge** — sphere at edge midpoint + tangent axis line * **vertex** — sphere + RGB crosshair When *candidates* is provided, every candidate is ALSO rendered with a small DIM half-size sphere in its own per-type colour, while the primary (this method's *position*/*entity_type*) is emphasised with a larger bright sphere + axis indicators. This is the general hover snap indicator: it shows all snap targets in the cursor neighbourhood (vertices, edge midpoints, face centres, hole openings) so the user can see what they can snap to — click then selects the primary. All previously shown gizmo objects are removed first so the gizmo follows the cursor cleanly. """ if self._context is None: return self.clear_entity_gizmo() # Screen-relative scale: keeps gizmo visible regardless of zoom level. gizmo_scale = self._get_gizmo_scale(position) from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge from OCP.AIS import AIS_Shape from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere # Per-entity-type colours (shared by the dim candidate markers). default_colors = { "planar_face": (0.0, 0.8, 1.0), # cyan "cylindrical_face": (1.0, 0.5, 0.0), # orange (hole / bolt axis) "edge": (0.2, 1.0, 0.4), # green "vertex": (1.0, 1.0, 0.0), # yellow } gizmo_color = color or default_colors.get(entity_type, (1.0, 0.6, 0.0)) def _make_sphere(p, c, size): try: s = BRepPrimAPI_MakeSphere(gp_Pnt(*p), size).Shape() a = AIS_Shape(s) a.SetColor(Quantity_Color(*c, Quantity_TOC_RGB)) a.SetDisplayMode(1) self._context.Display(a, True) self._gizmo_objects[f"__gizmo_sphere_{id(a)}"] = a except Exception as exc: logger.debug(f"gizmo sphere failed: {exc}") px, py, pz = position # ── 0. Dim candidate markers (every nearby snap target) ── # # Drawn FIRST so the bright primary sphere renders on top of them. # Each candidate gets a small half-size sphere tinted by its own # entity-type colour; the primary (this method's position) is drawn # brighter & larger in step 1 below so it reads as the active snap. if candidates: for cand in candidates: cpos = cand.get("position") if cpos is None: continue # Skip the primary itself — it gets its own bright marker. if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == ( round(px, 1), round(py, 1), round(pz, 1) ): continue cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7)) _make_sphere(cpos, cc, 2.8 * gizmo_scale) # dim, small # ── 1. Bright primary marker (sphere) ── _make_sphere(position, gizmo_color, 5.6 * gizmo_scale) # ── 2. Axis indicator lines (primary only) ── axis_length = 30.0 * gizmo_scale def _make_axis_line( origin: Tuple[float, float, float], direction: Tuple[float, float, float], length: float, line_color: Tuple[float, float, float], label: str = "axis", ) -> Optional[str]: """Create a small line segment along *direction* from *origin*.""" try: dx, dy, dz = direction norm = (dx * dx + dy * dy + dz * dz) ** 0.5 if norm < 1e-9: return None ux, uy, uz = dx / norm, dy / norm, dz / norm ex = origin[0] + ux * length ey = origin[1] + uy * length ez = origin[2] + uz * length edge = BRepBuilderAPI_MakeEdge( gp_Pnt(*origin), gp_Pnt(ex, ey, ez) ).Edge() ais = AIS_Shape(edge) ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB)) ais.SetDisplayMode(0) # wireframe self._context.Display(ais, True) gid = f"__gizmo_{label}_{id(ais)}" self._gizmo_objects[gid] = ais return gid except Exception as exc: logger.debug(f"gizmo axis line failed: {exc}") return None if entity_type == "planar_face" and normal is not None: # Normal axis (white). _make_axis_line(position, normal, axis_length, (1.0, 1.0, 1.0), "normal") # X direction axis (slightly dimmer). if x_dir is not None: _make_axis_line(position, x_dir, axis_length * 0.6, gizmo_color, "xdir") elif entity_type == "cylindrical_face" and normal is not None: # Hole / bolt axis. The snap point is the camera-facing opening, # so draw the axis going INTO the hole (the +normal direction — # the bolt travel direction) plus a short stub the other way for # visual balance. This reads as 'bolt axis through hole'. _make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in") _make_axis_line( position, (-normal[0], -normal[1], -normal[2]), axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub", ) # Radial reference (same colour as the marker). if x_dir is not None: _make_axis_line(position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial") elif entity_type == "edge" and normal is not None: # Tangent direction at midpoint. _make_axis_line(position, normal, axis_length, gizmo_color, "tangent") elif entity_type == "vertex": # Small crosshair (three short axes) so a vertex snap reads as # a coordinate point even before clicking. _make_axis_line(position, (1, 0, 0), axis_length * 0.5, (1.0, 0.3, 0.3), "cross_x") _make_axis_line(position, (0, 1, 0), axis_length * 0.5, (0.3, 1.0, 0.3), "cross_y") _make_axis_line(position, (0, 0, 1), axis_length * 0.5, (0.3, 0.3, 1.0), "cross_z") # ── 3. For cylindrical faces, ring of the hole radius at the opening ── # # Drawn at the camera-facing opening (the snap point), so the user # sees the actual hole diameter they're snapping a bolt to. if entity_type == "cylindrical_face" and normal is not None and radius is not None: try: center = gp_Pnt(px, py, pz) ax2 = gp_Ax2(center, gp_Dir(*normal)) circ = gp_Circ(ax2, radius) ring_edge = BRepBuilderAPI_MakeEdge(circ).Edge() ring_ais = AIS_Shape(ring_edge) ring_ais.SetColor(Quantity_Color(*gizmo_color, Quantity_TOC_RGB)) ring_ais.SetDisplayMode(0) # wireframe self._context.Display(ring_ais, True) gid = f"__gizmo_ring_{id(ring_ais)}" self._gizmo_objects[gid] = ring_ais except Exception as exc: logger.debug(f"gizmo ring failed: {exc}") if self._view is not None: self._view.Update() # ─── Selection mode control ─────────────────────────────────────────── # # When connector gizmo mode is active, standard OCC face/edge/vertex # selection is deactivated so dynamic highlighting does not interfere # with the gizmo visuals. The geometric probing method below replaces # the selection-system-based probe. def deactivate_selection_modes(self) -> None: """Deactivate OCC face/edge/vertex selection on every tracked AIS shape. Used when entering connector gizmo mode so that standard dynamic highlighting (MoveTo) does not interfere with the gizmo visuals. Call :meth:`activate_selection_modes` to restore. """ if self._context is None: return from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.AIS import AIS_Shape for robj in self._objects.values(): if robj.ais_shape is not None: for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): mode = AIS_Shape.SelectionMode_s(topo) try: self._context.Deactivate(robj.ais_shape, mode) except Exception: pass logger.debug("Selection modes deactivated for all AIS shapes") def activate_selection_modes(self) -> None: """Re-activate OCC face/edge/vertex selection on every tracked AIS shape. Called when exiting connector gizmo mode to restore normal dynamic highlighting and face-pick behaviour. """ if self._context is None: return from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.AIS import AIS_Shape for robj in self._objects.values(): if robj.ais_shape is not None: for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): mode = AIS_Shape.SelectionMode_s(topo) try: self._context.Activate(robj.ais_shape, mode) except Exception: pass logger.debug("Selection modes re-activated for all AIS shapes") # ─── Geometric snap probing (selection-system-independent) ──────────── # # Walks every AIS shape's topology directly, projects each feature to # screen, and returns candidates within *radius* pixels of the cursor. # This replaces the MoveTo-based probe when selection modes are # deactivated (connector gizmo mode). def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]: """Project a 3D world point to (x, y) screen pixel. Uses OCC's ``V3d_View.Convert`` (world → view coords). Returns None if the projection fails (e.g. behind the camera). """ if self._view is None: return None try: # OCC's Convert returns the window pixel coordinates. xpix = self._view.Convert(float(p3d[0]), float(p3d[1]), float(p3d[2])) # Some OCP builds return a tuple (x, y); others return two values. if isinstance(xpix, (tuple, list)) and len(xpix) == 2: return (int(xpix[0]), int(xpix[1])) return None except Exception: # Fall back to ConvertWithProj or ProjTexte if Convert is unavailable. return None def probe_snap_candidates_geometric( self, x: int, y: int, radius: int = 30, ) -> List[Dict[str, Any]]: """Probe snap candidates by iterating geometry directly (no selection system). Uses a two-pass approach for performance: 1. **Bounding-box pre-filter**: projects each shape's 3D bbox to screen; skips shapes whose screen bbox is far from the cursor. 2. **Feature iteration**: for nearby shapes only, walks faces/edges/vertices, projects each feature to screen, and collects candidates within *radius* pixels. This replaces ``probe_snap_candidates`` when selection modes are deactivated (connector gizmo mode). """ if self._view is None or self._context is None: return [] from OCP.TopExp import TopExp_Explorer from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.TopoDS import TopoDS from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib import numpy as np candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {} # Expand the search radius for the bbox pre-filter so features near # the screen edge of a shape are not missed. margin = radius + 40 for robj in self._objects.values(): if robj.ais_shape is None: continue try: shape = robj.ais_shape.Shape() except Exception: continue if shape is None: continue # ── Pass 0: bounding-box pre-filter ── # Project the shape's 3D AABB to screen. If the cursor is # outside the screen bbox (with margin), skip this shape entirely. try: bbox = Bnd_Box() BRepBndLib.Add_s(shape, bbox) if bbox.IsVoid(): continue bx0, by0, bz0, bx1, by1, bz1 = bbox.Get() # Project the 8 AABB corners to screen. corners = [ (bx0, by0, bz0), (bx1, by0, bz0), (bx0, by1, bz0), (bx1, by1, bz0), (bx0, by0, bz1), (bx1, by0, bz1), (bx0, by1, bz1), (bx1, by1, bz1), ] sx_min, sy_min = 99999, 99999 sx_max, sy_max = -99999, -99999 all_behind = True for c in corners: sp = self._project_to_screen(c) if sp is not None: all_behind = False sx_min = min(sx_min, sp[0]) sy_min = min(sy_min, sp[1]) sx_max = max(sx_max, sp[0]) sy_max = max(sy_max, sp[1]) if all_behind: continue # Check if cursor is within margin of the screen bbox. if (x < sx_min - margin or x > sx_max + margin or y < sy_min - margin or y > sy_max + margin): continue except Exception: pass # If bbox fails, fall through and try features. # ── Pass 1: iterate only nearby shapes ── # --- Faces --- face_expl = TopExp_Explorer(shape, TopAbs_FACE) while face_expl.More(): face = TopoDS.Face_s(face_expl.Current()) infos = self._classify_detected_shape(face, robj.obj_id) for info in infos: pos = info.get("position") or (0.0, 0.0, 0.0) sp = self._project_to_screen(pos) if sp is None: continue dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2 if dist2 <= radius * radius: key = ( info.get("owner_obj_id", ""), info.get("type", ""), (round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)), ) if key not in candidates: info["screen"] = sp candidates[key] = info face_expl.Next() # --- Edges --- edge_expl = TopExp_Explorer(shape, TopAbs_EDGE) while edge_expl.More(): edge = TopoDS.Edge_s(edge_expl.Current()) infos = self._classify_detected_shape(edge, robj.obj_id) for info in infos: pos = info.get("position") or (0.0, 0.0, 0.0) sp = self._project_to_screen(pos) if sp is None: continue dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2 if dist2 <= radius * radius: key = ( info.get("owner_obj_id", ""), info.get("type", ""), (round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)), ) if key not in candidates: info["screen"] = sp candidates[key] = info edge_expl.Next() # --- Vertices --- vert_expl = TopExp_Explorer(shape, TopAbs_VERTEX) while vert_expl.More(): vertex = TopoDS.Vertex_s(vert_expl.Current()) infos = self._classify_detected_shape(vertex, robj.obj_id) for info in infos: pos = info.get("position") or (0.0, 0.0, 0.0) sp = self._project_to_screen(pos) if sp is None: continue dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2 if dist2 <= radius * radius: key = ( info.get("owner_obj_id", ""), info.get("type", ""), (round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)), ) if key not in candidates: info["screen"] = sp candidates[key] = info vert_expl.Next() # Sort by screen-space distance to cursor, nearest first. results = list(candidates.values()) results.sort( key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2 ) return results def recognize_composite_features( self, candidates: List[Dict[str, Any]], x: int, y: int, radius: int = 30 ) -> List[Dict[str, Any]]: """Enhance raw entity candidates with composite feature recognition. Groups nearby entities and recognizes composite features like: * **hole** — cylindrical face (bolt/shaft insertion point) * **edge_loop** — circular edge loop (alignment target) * **meeting_edges** — vertex shared by two edges (corner constraint) * **mating_surface** — large planar face (assembly plane) Each candidate gets additional fields: * ``feature_type`` — composite feature name (e.g. "hole", "edge_loop") * ``suggestion`` — human-readable snap suggestion * ``feature_data`` — dict with feature-specific info (radius, axis, etc.) """ import numpy as np from collections import defaultdict # Group candidates by owner_obj_id. by_owner: Dict[str, List[Dict[str, Any]]] = defaultdict(list) for c in candidates: owner = c.get("owner_obj_id", "") if owner: by_owner[owner].append(c) enhanced: List[Dict[str, Any]] = [] for c in candidates: ec = dict(c) # copy etype = c.get("type", "") pos = c.get("position", (0, 0, 0)) owner = c.get("owner_obj_id", "") # ── Cylindrical face → hole / bolt insertion ── if etype == "cylindrical_face": ec["feature_type"] = "hole" ec["suggestion"] = "Bolt / shaft insertion point" ec["feature_data"] = { "axis": c.get("normal"), "radius": c.get("radius"), "center": pos, } enhanced.append(ec) continue # ── Planar face → mating surface ── if etype == "planar_face": ec["feature_type"] = "mating_surface" ec["suggestion"] = "Assembly mating plane" ec["feature_data"] = { "normal": c.get("normal"), "center": pos, } enhanced.append(ec) continue # ── Edge → check for circular edge loop ── if etype == "edge": # Look for other edges nearby that might form a loop. nearby_edges = [ n for n in candidates if n.get("type") == "edge" and n.get("owner_obj_id") == owner and n is not c ] # For now, mark as edge — loop detection is complex. ec["feature_type"] = "edge" ec["suggestion"] = "Edge midpoint snap" ec["feature_data"] = { "tangent": c.get("normal"), "midpoint": pos, } enhanced.append(ec) continue # ── Vertex → check for meeting edges ── if etype == "vertex": # Look for edges that share this vertex (nearby edges). nearby_edges = [ n for n in candidates if n.get("type") == "edge" and n.get("owner_obj_id") == owner ] if len(nearby_edges) >= 2: ec["feature_type"] = "meeting_edges" ec["suggestion"] = "Corner constraint (vertex)" ec["feature_data"] = { "vertex": pos, "edge_count": len(nearby_edges), } else: ec["feature_type"] = "vertex" ec["suggestion"] = "Vertex snap" ec["feature_data"] = {"vertex": pos} enhanced.append(ec) continue # Fallback: pass through unchanged. enhanced.append(ec) return enhanced # ─── 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 centered on viewport # • 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 centered on the viewport (body midpoint) on scroll. Uses the view's scale factor so the zoom is always centered on the viewport centre — the body never drifts to an edge. """ if self._view is None: return delta = event.angleDelta().y() if delta == 0: return factor = 1.15 if delta > 0 else 1.0 / 1.15 self._view.SetScale(self._view.Scale() * factor) 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()