Files
fluencyCAD/src/fluency/rendering/occ_renderer.py
T
2026-07-13 23:01:35 +02:00

2217 lines
90 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (10120 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()