Coverage for trimesh/ray/ray_pyembree.py: 98%
113 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1"""
2Ray queries using the embreex package with the
3API wrapped to match our native raytracer.
4"""
6import numpy as np
8# `pip install embreex` installs from wheels
9from embreex import rtcore_scene
10from embreex.mesh_construction import TriangleMesh
12from .. import caching, intersections, util
13from ..constants import log_time
14from ..typed import ArrayLike, Integer
15from .ray_util import contains_points
17# embree operates on float32 values
18_embree_dtype = np.float32
20# scale-aware base ray offset to step past hit triangles above f32 ULP
21_ray_offset_factor = 1e-6
22_ray_offset_floor = 1e-8
25class RayMeshIntersector:
26 def __init__(self, geometry, scale_to_box: bool = True):
27 """
28 Do ray- mesh queries.
30 Parameters
31 -------------
32 geometry : Trimesh object
33 Mesh to do ray tests on
34 scale_to_box : bool
35 If true, will scale mesh to approximate
36 unit cube to avoid problems with extreme
37 large or small meshes.
38 """
39 self.mesh = geometry
40 self._scale_to_box = scale_to_box
41 self._cache = caching.Cache(id_function=self.mesh.__hash__)
43 @property
44 def _scale(self):
45 """
46 Scaling factor for precision.
47 """
48 if self._scale_to_box:
49 # scale vertices to approximately a cube to help with
50 # numerical issues at very large/small scales
51 scale = 100.0 / self.mesh.scale
52 else:
53 scale = 1.0
54 return scale
56 @caching.cache_decorator
57 def _scene(self):
58 """
59 A cached version of the embreex scene.
60 """
61 return _EmbreeWrap(
62 vertices=self.mesh.vertices, faces=self.mesh.faces, scale=self._scale
63 )
65 def intersects_location(
66 self,
67 ray_origins: ArrayLike,
68 ray_directions: ArrayLike,
69 multiple_hits: bool = True,
70 ):
71 """
72 Return the location of where a ray hits a surface.
74 Parameters
75 ----------
76 ray_origins : (n, 3) float
77 Origins of rays
78 ray_directions : (n, 3) float
79 Direction (vector) of rays
81 Returns
82 ---------
83 locations : (m) sequence of (p, 3) float
84 Intersection points
85 index_ray : (m,) int
86 Indexes of ray
87 index_tri : (m,) int
88 Indexes of mesh.faces
89 """
90 (index_tri, index_ray, locations) = self.intersects_id(
91 ray_origins=ray_origins,
92 ray_directions=ray_directions,
93 multiple_hits=multiple_hits,
94 return_locations=True,
95 )
97 return locations, index_ray, index_tri
99 @log_time
100 def intersects_id(
101 self,
102 ray_origins: ArrayLike,
103 ray_directions: ArrayLike,
104 multiple_hits: bool = True,
105 max_hits: Integer = 100,
106 return_locations: bool = False,
107 ):
108 """
109 Find the triangles hit by a list of rays, including
110 optionally multiple hits along a single ray.
113 Parameters
114 ----------
115 ray_origins : (n, 3) float
116 Origins of rays
117 ray_directions : (n, 3) float
118 Direction (vector) of rays
119 multiple_hits : bool
120 If True will return every hit along the ray
121 If False will only return first hit
122 max_hits : int
123 Maximum number of hits per ray
124 return_locations : bool
125 Should we return hit locations or not
127 Returns
128 ---------
129 index_tri : (m,) int
130 Indexes of mesh.faces
131 index_ray : (m,) int
132 Indexes of ray
133 locations : (m) sequence of (p, 3) float
134 Intersection points, only returned if return_locations
135 """
137 # make sure input is _dtype for embree
138 ray_origins = np.array(ray_origins, dtype=np.float64)
139 ray_directions = np.array(ray_directions, dtype=np.float64)
140 if ray_origins.shape != ray_directions.shape:
141 raise ValueError("Ray origin and direction don't match!")
142 ray_directions = util.unitize(ray_directions)
144 # stack results for multiple hits into a sequence
145 result_triangle = [np.zeros(0, dtype=np.int64)]
146 result_ray_idx = [np.zeros(0, dtype=np.int64)]
147 result_locations = [np.zeros((0, 3), dtype=np.float64)]
149 if multiple_hits or return_locations:
150 # how much to offset ray to transport to the other side of face
151 base_offset = max(_ray_offset_floor, self.mesh.scale * _ray_offset_factor)
152 ray_offsets = ray_directions * base_offset
154 # grab the planes from triangles
155 plane_origins = self.mesh.triangles[:, 0, :]
156 plane_normals = self.mesh.face_normals
158 # what each ray hit last iteration; -1 means nothing, used to
159 # detect a ray stuck on the same face due to precision issues
160 last_hit = np.full(len(ray_origins), -1, dtype=np.int64)
161 # absolute indices of rays still being queried; shrinks each
162 # iteration as rays miss, escape, or get culled
163 live = np.arange(len(ray_origins))
165 # use a for loop rather than a while to ensure this exits
166 # if a ray is offset from a triangle and then is reported
167 # hitting itself this could get stuck on that one triangle
168 for _depth in range(max_hits):
169 # if you set output=1 embreex returns distance along the ray
170 # which is bizarrely slower than our own plane-line calc
171 # TODO: switch `run(..., output=True)` once embreex>=4.4.0rc1 is stable
172 query = self._scene.run(ray_origins[live], ray_directions[live])
173 hit = query != -1
174 if not hit.any():
175 break
177 # absolute indices and triangle indices for rays that hit
178 hit_rays = live[hit]
179 hit_tris = query[hit]
181 # first-hit-only fast path: no duplicates or locations to track
182 if not multiple_hits and not return_locations:
183 result_triangle.append(hit_tris)
184 result_ray_idx.append(hit_rays)
185 break
187 # rays that hit the same triangle as last iteration are stuck
188 dupe = last_hit[hit_rays] == hit_tris
189 # store the last hit so we can track duplicates
190 last_hit[hit_rays] = hit_tris
191 # subset to separate duplicates and non-duplicates
192 ok_rays = hit_rays[~dupe]
193 ok_tris = hit_tris[~dupe]
194 dupe_rays = hit_rays[dupe]
196 # compute where clean hits actually land on their triangle;
197 # planes_lines silently drops near-parallel rays, so `valid`
198 # shortens new_origins — we must trim ok_rays / ok_tris to match
199 new_origins, valid = intersections.planes_lines(
200 plane_origins=plane_origins[ok_tris],
201 plane_normals=plane_normals[ok_tris],
202 line_origins=ray_origins[ok_rays],
203 line_directions=ray_directions[ok_rays],
204 )
205 ok_rays, ok_tris = ok_rays[valid], ok_tris[valid]
207 result_locations.append(new_origins)
208 result_triangle.append(ok_tris)
209 result_ray_idx.append(ok_rays)
211 if not multiple_hits:
212 break
214 # clean hits step onto the new face with a fresh base offset
215 ray_origins[ok_rays] = new_origins + ray_offsets[ok_rays]
216 ray_offsets[ok_rays] = ray_directions[ok_rays] * base_offset
217 # stuck rays double their offset and step further to try to clear
218 ray_offsets[dupe_rays] *= 2.0
219 ray_origins[dupe_rays] += ray_offsets[dupe_rays]
221 # carry forward only rays we successfully advanced;
222 # dropped rays (misses, near-parallel planes) die here
223 live = np.concatenate([ok_rays, dupe_rays])
225 index_tri = np.concatenate(result_triangle)
226 index_ray = np.concatenate(result_ray_idx)
227 if return_locations:
228 return index_tri, index_ray, np.concatenate(result_locations)
229 return index_tri, index_ray
231 @log_time
232 def intersects_first(self, ray_origins, ray_directions):
233 """
234 Find the index of the first triangle a ray hits.
237 Parameters
238 ----------
239 ray_origins : (n, 3) float
240 Origins of rays
241 ray_directions : (n, 3) float
242 Direction (vector) of rays
244 Returns
245 ----------
246 triangle_index : (n,) int
247 Index of triangle ray hit, or -1 if not hit
248 """
249 # make sure our arrays are in the `embree` dtype
250 ray_origins = np.array(ray_origins, dtype=_embree_dtype)
251 ray_directions = np.array(ray_directions, dtype=_embree_dtype)
252 if ray_origins.shape != ray_directions.shape:
253 raise ValueError("Ray origin and direction don't match!")
254 ray_directions = util.unitize(ray_directions)
256 return self._scene.run(ray_origins, ray_directions)
258 def intersects_any(self, ray_origins, ray_directions):
259 """
260 Check if a list of rays hits the surface.
263 Parameters
264 -----------
265 ray_origins : (n, 3) float
266 Origins of rays
267 ray_directions : (n, 3) float
268 Direction (vector) of rays
270 Returns
271 ----------
272 hit : (n,) bool
273 Did each ray hit the surface
274 """
276 first = self.intersects_first(
277 ray_origins=ray_origins, ray_directions=ray_directions
278 )
279 hit = first != -1
280 return hit
282 def contains_points(self, points):
283 """
284 Check if a mesh contains a list of points, using ray tests.
286 If the point is on the surface of the mesh, behavior is undefined.
288 Parameters
289 ---------
290 points: (n, 3) points in space
292 Returns
293 ---------
294 contains: (n,) bool
295 Whether point is inside mesh or not
296 """
297 return contains_points(self, points)
299 def __getstate__(self):
300 state = self.__dict__.copy()
301 # don't pickle cache
302 state.pop("_cache", None)
303 return state
305 def __setstate__(self, state):
306 self.__dict__.update(state)
307 # Add cache back since it doesn't exist in the pickle
308 self._cache = caching.Cache(id_function=self.mesh.__hash__)
310 def __deepcopy__(self, *args):
311 return self.__copy__()
313 def __copy__(self, *args):
314 return RayMeshIntersector(geometry=self.mesh, scale_to_box=self._scale_to_box)
317class _EmbreeWrap:
318 """
319 A light wrapper for Embreex scene objects which
320 allows queries to be scaled to help with precision
321 issues, as well as selecting the correct dtypes.
322 """
324 def __init__(self, vertices, faces, scale):
325 scaled = np.array(vertices, dtype=np.float64)
326 self.origin = scaled.min(axis=0)
327 self.scale = float(scale)
328 scaled = (scaled - self.origin) * self.scale
330 self.scene = rtcore_scene.EmbreeScene()
331 # assign the geometry to the scene
332 TriangleMesh(
333 scene=self.scene,
334 vertices=scaled.astype(_embree_dtype),
335 indices=faces.view(np.ndarray).astype(np.int32),
336 )
338 def run(self, origins, normals, **kwargs):
339 scaled = (np.array(origins, dtype=np.float64) - self.origin) * self.scale
341 return self.scene.run(
342 scaled.astype(_embree_dtype), normals.astype(_embree_dtype), **kwargs
343 )