- assembly draft
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user