2217 lines
90 KiB
Python
2217 lines
90 KiB
Python
"""OCC-based 3D renderer — displays BRep shapes directly on the GPU.
|
||
|
||
Uses OCC's ``AIS_Shape`` and ``V3d_Viewer`` so that curved surfaces (cylinders,
|
||
spheres, etc.) render smoothly without manual triangulation, and edges/faces are
|
||
natively selectable.
|
||
"""
|
||
|
||
import logging
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import numpy as np
|
||
|
||
from .base import Renderer, RenderObject, RenderColor
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class OCCRenderObject(RenderObject):
|
||
"""Internal object state for the OCC renderer."""
|
||
|
||
obj_id: str = ""
|
||
ais_shape: Any = None # AIS_Shape
|
||
ais_type: str = "shape" # "shape" | "wireframe" | "points"
|
||
color: RenderColor = field(default_factory=lambda: RenderColor(0.5, 0.5, 0.5))
|
||
|
||
|
||
class OCCRenderer(Renderer):
|
||
"""Renderer that uses OCC's native AIS display for smooth BRep rendering."""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self._viewer: Any = None
|
||
self._view: Any = None
|
||
self._context: Any = None
|
||
self._window: Any = None
|
||
self._initialized: bool = False
|
||
self._objects: Dict[str, OCCRenderObject] = {}
|
||
self._parent_widget: Any = None
|
||
self._last_mouse_x: int = 0
|
||
self._last_mouse_y: int = 0
|
||
self._pan_start_x: int = 0
|
||
self._pan_start_y: int = 0
|
||
self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
|
||
# Persistent light-blue transparent overlay marking the selected face.
|
||
self._highlight_ais: Any = None
|
||
# Temporary transparent preview AIS for the live extrude/cut dialog.
|
||
self._preview_ais: Any = None
|
||
# 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()
|