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 @@ - + 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())