diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 69a5a93..dcee416 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -6,7 +6,7 @@
-
+
@@ -359,7 +359,15 @@
1783456842297
-
+
+
+ 1783755278516
+
+
+
+ 1783755278516
+
+
diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py
index 1a184ea..8e53afd 100644
--- a/src/fluency/rendering/occ_renderer.py
+++ b/src/fluency/rendering/occ_renderer.py
@@ -1047,27 +1047,44 @@ class OCCRenderer(Renderer):
shape = self._context.DetectedShape()
if shape is None:
return None
- return self._classify_detected_shape(shape)
+ 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,
- ) -> Optional[Dict[str, Any]]:
- """Classify a detected OCC sub-shape into a snap-candidate dict.
+ ) -> 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 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.
+ 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 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
+ 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
@@ -1139,14 +1156,14 @@ class OCCRenderer(Renderer):
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
- return {
+ 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()
@@ -1155,52 +1172,67 @@ class OCCRenderer(Renderer):
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()
+ # 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).
- # 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)
+ # 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()
- # 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
+ # 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:
- # Fallback: axial midpoint.
- near_end = 0.5 * (p0 + p1)
+ # 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)
- 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
@@ -1216,15 +1248,22 @@ class OCCRenderer(Renderer):
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,
- }
+ # 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
@@ -1267,14 +1306,14 @@ class OCCRenderer(Renderer):
x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2]))
- return {
+ return [{
"type": "edge",
"position": position,
"normal": tangent,
"x_dir": x_dir,
"edge": edge,
"owner_obj_id": owner_obj_id,
- }
+ }]
# Try vertex.
vertex = None
@@ -1282,18 +1321,18 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z())
- return {
+ return [{
"type": "vertex",
"position": position,
"normal": None,
"x_dir": None,
"vertex": vertex,
"owner_obj_id": owner_obj_id,
- }
+ }]
except Exception:
pass
- return None
+ return []
def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]:
"""Project a 3D world point to (x, y) screen pixel.
@@ -1315,19 +1354,19 @@ class OCCRenderer(Renderer):
return None
def probe_snap_candidates(
- self, x: int, y: int, radius: int = 18,
+ 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 small ring + centre around the cursor, runs OCC's
+ 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 the
- user can see the snap targets in the cursor neighbourhood — not
+ 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
@@ -1337,14 +1376,21 @@ class OCCRenderer(Renderer):
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.
+ # 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),
- (-radius // 2, 0), (radius // 2, 0), (0, -radius // 2), (0, radius // 2),
+ # 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]] = {}
@@ -1359,22 +1405,24 @@ class OCCRenderer(Renderer):
shape = self._context.DetectedShape()
if shape is None:
continue
- info = self._classify_detected_shape(shape)
- if info is None:
+ infos = self._classify_detected_shape(shape)
+ if not infos:
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
+ # 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())