- assembly draft

This commit is contained in:
bklronin
2026-07-05 19:36:27 +02:00
parent b595b88e04
commit 3a169007f7
4 changed files with 1357 additions and 134 deletions
+613 -9
View File
@@ -48,6 +48,9 @@ class OCCRenderer(Renderer):
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)."""
@@ -251,16 +254,22 @@ class OCCRenderer(Renderer):
logger.debug("polygon offset unavailable", exc_info=True)
self._context.Display(ais, True)
# Activate selection modes so the viewer can detect/pick the whole
# shape (mode 0) and individual faces (mode for TopAbs_FACE) — needed
# for sketch-on-surface face picking. Left-click orbits the camera
# (see handle_mouse_press), so active selection doesn't interfere.
# 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
face_mode = AIS_Shape.SelectionMode_s(TopAbs_FACE)
self._context.Activate(ais, face_mode)
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("face selection mode activation failed", exc_info=True)
logger.debug("selection mode activation failed", exc_info=True)
defcol = color or (0.5, 0.5, 0.5)
robj = OCCRenderObject(
@@ -467,7 +476,9 @@ class OCCRenderer(Renderer):
self._context.Display(ais, True)
self._preview_ais = ais
if self._view is not None:
self._view.Repaint()
# 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."""
@@ -484,6 +495,7 @@ class OCCRenderer(Renderer):
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:
@@ -1007,6 +1019,598 @@ class OCCRenderer(Renderer):
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
return self._classify_detected_shape(shape)
def _classify_detected_shape(
self, shape: Any, owner_obj_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Classify a detected OCC sub-shape into a snap-candidate dict.
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 the snap
info dict with ``position`` / ``normal`` / ``x_dir`` / ``type`` /
``owner_obj_id`` (+ ``radius`` for holes). When *owner_obj_id* is
omitted it is looked up from the context's currently-detected AIS.
"""
if shape is None:
return None
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
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()
# Parameter extents along the cylinder axis (v = height).
# BRepAdaptor_Surface exposes these via First/Last V
# *Parameter() — NOT a Bounds() method (that quirk crashed
# cylindrical-face picking).
vmin = adaptor.FirstVParameter()
vmax = adaptor.LastVParameter()
# Two candidate snap points: the centers of the cylinder's
# two end circles (the hole openings). A bolt enters a
# hole from the camera-facing opening, so pick the END of
# the axis closest to the camera as the primary snap point.
# The axis location (ax_pos) is already on the cylinder axis
# at v=0; the other end is at v=vmax.
p0 = np.array([
ax_pos.X(), ax_pos.Y(), ax_pos.Z(),
], dtype=float)
p1 = np.array([
ax_pos.X() + ax_dir.X() * (vmax - vmin),
ax_pos.Y() + ax_dir.Y() * (vmax - vmin),
ax_pos.Z() + ax_dir.Z() * (vmax - vmin),
], dtype=float)
# Choose the camera-facing end. The camera looks FROM its
# eye TOWARD its target, so the camera direction is
# (target - eye). The end whose vector-from-camera is MOST
# OPPOSITE to (i.e. faces) the camera is the near opening.
cam_from = None
try:
if self._view is not None:
eye = self._view.Eye()
at = self._view.At()
cam_from = np.array([eye.X(), eye.Y(), eye.Z()], dtype=float)
cam_to = np.array([at.X(), at.Y(), at.Z()], dtype=float)
except Exception:
cam_from = None
if cam_from is not None:
# End closest to the camera eye is the visible opening.
d0 = float(np.linalg.norm(p0 - cam_from))
d1 = float(np.linalg.norm(p1 - cam_from))
near_end = p0 if d0 <= d1 else p1
else:
# Fallback: axial midpoint.
near_end = 0.5 * (p0 + p1)
origin = (float(near_end[0]), float(near_end[1]), float(near_end[2]))
# 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 {
"type": "cylindrical_face",
"position": origin,
"normal": normal,
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
"radius": radius,
}
# 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 None
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(
self, x: int, y: int, radius: int = 18,
) -> List[Dict[str, Any]]:
"""Probe a pixel grid around (x, y) and return visible snap candidates.
Samples a small 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 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 []
# Sample pattern: the exact cursor pixel plus a small ring of
# offsets. The ring catches nearby vertices/edges/holes that sit a
# few pixels away from where the user is pointing.
ring_offsets = [
(0, 0),
(-radius, 0), (radius, 0), (0, -radius), (0, radius),
(-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius),
(-radius // 2, 0), (radius // 2, 0), (0, -radius // 2), (0, radius // 2),
]
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
info = self._classify_detected_shape(shape)
if info is None:
continue
# 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=3.0) -> Optional[str]:
"""Show a small marker sphere at *position* as a snap indicator.
Returns an object id that can be removed later.
"""
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:
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), 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] = _RenderObject(oid, ais, None, None)
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 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()
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, 1.4) # dim, small
# ── 1. Bright primary marker (sphere) ──
_make_sphere(position, gizmo_color, 2.8)
# ── 2. Axis indicator lines (primary only) ──
axis_length = 15.0
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()
# ─── Mouse / keyboard event forwarding ──────────────────────────────
#
# CAD-style navigation: