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

1""" 

2Ray queries using the embreex package with the 

3API wrapped to match our native raytracer. 

4""" 

5 

6import numpy as np 

7 

8# `pip install embreex` installs from wheels 

9from embreex import rtcore_scene 

10from embreex.mesh_construction import TriangleMesh 

11 

12from .. import caching, intersections, util 

13from ..constants import log_time 

14from ..typed import ArrayLike, Integer 

15from .ray_util import contains_points 

16 

17# embree operates on float32 values 

18_embree_dtype = np.float32 

19 

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 

23 

24 

25class RayMeshIntersector: 

26 def __init__(self, geometry, scale_to_box: bool = True): 

27 """ 

28 Do ray- mesh queries. 

29 

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__) 

42 

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 

55 

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 ) 

64 

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. 

73 

74 Parameters 

75 ---------- 

76 ray_origins : (n, 3) float 

77 Origins of rays 

78 ray_directions : (n, 3) float 

79 Direction (vector) of rays 

80 

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 ) 

96 

97 return locations, index_ray, index_tri 

98 

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. 

111 

112 

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 

126 

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 """ 

136 

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) 

143 

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)] 

148 

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 

153 

154 # grab the planes from triangles 

155 plane_origins = self.mesh.triangles[:, 0, :] 

156 plane_normals = self.mesh.face_normals 

157 

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)) 

164 

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 

176 

177 # absolute indices and triangle indices for rays that hit 

178 hit_rays = live[hit] 

179 hit_tris = query[hit] 

180 

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 

186 

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] 

195 

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] 

206 

207 result_locations.append(new_origins) 

208 result_triangle.append(ok_tris) 

209 result_ray_idx.append(ok_rays) 

210 

211 if not multiple_hits: 

212 break 

213 

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] 

220 

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]) 

224 

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 

230 

231 @log_time 

232 def intersects_first(self, ray_origins, ray_directions): 

233 """ 

234 Find the index of the first triangle a ray hits. 

235 

236 

237 Parameters 

238 ---------- 

239 ray_origins : (n, 3) float 

240 Origins of rays 

241 ray_directions : (n, 3) float 

242 Direction (vector) of rays 

243 

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) 

255 

256 return self._scene.run(ray_origins, ray_directions) 

257 

258 def intersects_any(self, ray_origins, ray_directions): 

259 """ 

260 Check if a list of rays hits the surface. 

261 

262 

263 Parameters 

264 ----------- 

265 ray_origins : (n, 3) float 

266 Origins of rays 

267 ray_directions : (n, 3) float 

268 Direction (vector) of rays 

269 

270 Returns 

271 ---------- 

272 hit : (n,) bool 

273 Did each ray hit the surface 

274 """ 

275 

276 first = self.intersects_first( 

277 ray_origins=ray_origins, ray_directions=ray_directions 

278 ) 

279 hit = first != -1 

280 return hit 

281 

282 def contains_points(self, points): 

283 """ 

284 Check if a mesh contains a list of points, using ray tests. 

285 

286 If the point is on the surface of the mesh, behavior is undefined. 

287 

288 Parameters 

289 --------- 

290 points: (n, 3) points in space 

291 

292 Returns 

293 --------- 

294 contains: (n,) bool 

295 Whether point is inside mesh or not 

296 """ 

297 return contains_points(self, points) 

298 

299 def __getstate__(self): 

300 state = self.__dict__.copy() 

301 # don't pickle cache 

302 state.pop("_cache", None) 

303 return state 

304 

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__) 

309 

310 def __deepcopy__(self, *args): 

311 return self.__copy__() 

312 

313 def __copy__(self, *args): 

314 return RayMeshIntersector(geometry=self.mesh, scale_to_box=self._scale_to_box) 

315 

316 

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 """ 

323 

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 

329 

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 ) 

337 

338 def run(self, origins, normals, **kwargs): 

339 scaled = (np.array(origins, dtype=np.float64) - self.origin) * self.scale 

340 

341 return self.scene.run( 

342 scaled.astype(_embree_dtype), normals.astype(_embree_dtype), **kwargs 

343 )