1616 Intent with corrected args + a ``"projected_from": orig_target``
1717 field for auditability.
1818
19+ When the optional native extension ``ghostloop._rt_safety`` is
20+ built AND the workspace carries only Sphere obstacles, the
21+ analytic geometry runs in the allocation-free Rust core instead
22+ of pure Python. The result is numerically identical (verified to
23+ 1e-9 per axis in tests/test_rt_safety_equivalence.py); the
24+ audit/return shape is unchanged. Any workspace with a box
25+ obstacle, or any environment where the native module is not
26+ built, transparently uses the pure-Python path below.
27+
1928 ``project_to_sdf(intent, workspace, extras=())``
2029 Numerical: gradient-descent on the SDF until distance >= 0.
2130 Slower than the analytic version; handles convex polytopes /
3948)
4049
4150
51+ # Optional allocation-free Rust fast path for the sphere-only analytic
52+ # projection. Guarded so ghostloop runs unchanged when the native extension
53+ # has not been built (the pure-Python path below is always available).
54+ try :
55+ from .. import _rt_safety as _RT_SAFETY # type: ignore[attr-defined]
56+ except Exception : # noqa: BLE001 - any import/ABI failure must fall back cleanly.
57+ _RT_SAFETY = None
58+
59+
4260Point3 = tuple [float , float , float ]
4361
4462
@@ -61,6 +79,52 @@ def _set_target(args: dict, p: Point3) -> dict:
6179 return args
6280
6381
82+ def _project_point_native (
83+ target : Point3 , workspace : WorkspaceModel ,
84+ ) -> Point3 | None :
85+ """Run the sphere-only analytic projection in the native Rust core.
86+
87+ Returns the projected point, or ``None`` if the native path does not
88+ apply (module not built, or the workspace has a non-sphere obstacle).
89+ The numerics match the pure-Python branch below bit-for-bit: the Rust
90+ core uses f64 and the identical operation order (clamp to bounds, then
91+ sequential radial push-out per sphere, degenerate ``dist < 1e-9`` sets
92+ ``p.x = center.x + forbidden_r`` and continues).
93+ """
94+ if _RT_SAFETY is None :
95+ return None
96+ spheres = []
97+ for ob in workspace .obstacles :
98+ if not isinstance (ob , Sphere ):
99+ # Box (or any future non-sphere) obstacle: the native core does
100+ # not handle these analytically; defer to pure Python.
101+ return None
102+ spheres .append (
103+ (
104+ float (ob .center [0 ]),
105+ float (ob .center [1 ]),
106+ float (ob .center [2 ]),
107+ float (ob .radius ),
108+ float (ob .inflation ),
109+ )
110+ )
111+ bmin = (
112+ float (workspace .bounds_min [0 ]),
113+ float (workspace .bounds_min [1 ]),
114+ float (workspace .bounds_min [2 ]),
115+ )
116+ bmax = (
117+ float (workspace .bounds_max [0 ]),
118+ float (workspace .bounds_max [1 ]),
119+ float (workspace .bounds_max [2 ]),
120+ )
121+ try :
122+ out = _RT_SAFETY .project_to_workspace (target , bmin , bmax , spheres )
123+ except Exception : # noqa: BLE001 - never let the fast path break a repair.
124+ return None
125+ return float (out [0 ]), float (out [1 ]), float (out [2 ])
126+
127+
64128def project_to_workspace (
65129 intent : Intent , workspace : WorkspaceModel ,
66130) -> Intent :
@@ -73,32 +137,43 @@ def project_to_workspace(
73137 AxisAlignedBox obstacles are NOT pushed out of analytically (the
74138 closest-point-on-box-exterior is geometry that's annoying to
75139 compute robustly without numpy). For boxes use ``project_to_sdf``.
140+
141+ When ``ghostloop._rt_safety`` is built and the workspace has only
142+ Sphere obstacles, the analytic geometry runs in the native Rust core;
143+ the result and the ``projected_from`` audit field are identical to the
144+ pure-Python computation. Otherwise the pure-Python path runs unchanged.
76145 """
77146 target = _extract_target (intent .args )
78147 if target is None :
79148 return intent
80- p = list (target )
81- # Clamp to outer bounds.
82- for i in range (3 ):
83- p [i ] = max (workspace .bounds_min [i ], min (workspace .bounds_max [i ], p [i ]))
84- # Radial pushout from each sphere.
85- for ob in workspace .obstacles :
86- if not isinstance (ob , Sphere ):
87- continue
88- cx , cy , cz = ob .center
89- dx , dy , dz = p [0 ] - cx , p [1 ] - cy , p [2 ] - cz
90- dist = math .sqrt (dx * dx + dy * dy + dz * dz )
91- forbidden_r = ob .radius + ob .inflation
92- if dist < forbidden_r :
93- if dist < 1e-9 :
94- # Degenerate: pick an arbitrary direction.
95- p [0 ] = cx + forbidden_r
149+
150+ native = _project_point_native (target , workspace )
151+ if native is not None :
152+ out_target = native
153+ else :
154+ p = list (target )
155+ # Clamp to outer bounds.
156+ for i in range (3 ):
157+ p [i ] = max (workspace .bounds_min [i ], min (workspace .bounds_max [i ], p [i ]))
158+ # Radial pushout from each sphere.
159+ for ob in workspace .obstacles :
160+ if not isinstance (ob , Sphere ):
96161 continue
97- scale = forbidden_r / dist
98- p [0 ] = cx + dx * scale
99- p [1 ] = cy + dy * scale
100- p [2 ] = cz + dz * scale
101- out_target = (p [0 ], p [1 ], p [2 ])
162+ cx , cy , cz = ob .center
163+ dx , dy , dz = p [0 ] - cx , p [1 ] - cy , p [2 ] - cz
164+ dist = math .sqrt (dx * dx + dy * dy + dz * dz )
165+ forbidden_r = ob .radius + ob .inflation
166+ if dist < forbidden_r :
167+ if dist < 1e-9 :
168+ # Degenerate: pick an arbitrary direction.
169+ p [0 ] = cx + forbidden_r
170+ continue
171+ scale = forbidden_r / dist
172+ p [0 ] = cx + dx * scale
173+ p [1 ] = cy + dy * scale
174+ p [2 ] = cz + dz * scale
175+ out_target = (p [0 ], p [1 ], p [2 ])
176+
102177 if out_target == target :
103178 return intent
104179 new_args = _set_target (intent .args , out_target )
0 commit comments