This commit is contained in:
cjw
2026-02-12 23:22:11 +08:00
parent 7b09eb3d89
commit 89660bba4e
5988 changed files with 2517516 additions and 0 deletions
@@ -0,0 +1,15 @@
from . import ray_triangle
# optionally load an interface to the embree raytracer
try:
from . import ray_pyembree
has_embree = True
except BaseException as E:
from .. import exceptions
ray_pyembree = exceptions.ExceptionWrapper(E)
has_embree = False
# add to __all__ as per pep8
__all__ = ["ray_pyembree", "ray_triangle"]
@@ -0,0 +1,360 @@
"""
Ray queries using the embreex package with the
API wrapped to match our native raytracer.
"""
import numpy as np
from .. import caching, intersections, util
from ..constants import log_time
from .ray_util import contains_points
# the factor of geometry.scale to offset a ray from a triangle
# to reliably not hit its origin triangle
_ray_offset_factor = 1e-4
# we want to clip our offset to a sane distance
_ray_offset_floor = 1e-8
try:
# try the preferred wrapper which installs from wheels
from embreex import rtcore_scene
from embreex.mesh_construction import TriangleMesh
# pass embree floats as 32 bit
_embree_dtype = np.float32
except BaseException as E:
try:
# this will be deprecated at some point hopefully soon
from pyembree import __version__, rtcore_scene
from pyembree.mesh_construction import TriangleMesh
# see if we're using a newer version of the pyembree wrapper
_embree_new = tuple([int(i) for i in __version__.split(".")]) >= (0, 1, 4)
# both old and new versions require exact but different type
_embree_dtype = [np.float64, np.float32][int(_embree_new)]
except BaseException:
# raise the embreex error for better log message
raise E
class RayMeshIntersector:
def __init__(self, geometry, scale_to_box=True):
"""
Do ray- mesh queries.
Parameters
-------------
geometry : Trimesh object
Mesh to do ray tests on
scale_to_box : bool
If true, will scale mesh to approximate
unit cube to avoid problems with extreme
large or small meshes.
"""
self.mesh = geometry
self._scale_to_box = scale_to_box
self._cache = caching.Cache(id_function=self.mesh.__hash__)
@property
def _scale(self):
"""
Scaling factor for precision.
"""
if self._scale_to_box:
# scale vertices to approximately a cube to help with
# numerical issues at very large/small scales
scale = 100.0 / self.mesh.scale
else:
scale = 1.0
return scale
@caching.cache_decorator
def _scene(self):
"""
A cached version of the embreex scene.
"""
return _EmbreeWrap(
vertices=self.mesh.vertices, faces=self.mesh.faces, scale=self._scale
)
def intersects_location(self, ray_origins, ray_directions, multiple_hits=True):
"""
Return the location of where a ray hits a surface.
Parameters
----------
ray_origins : (n, 3) float
Origins of rays
ray_directions : (n, 3) float
Direction (vector) of rays
Returns
---------
locations : (m) sequence of (p, 3) float
Intersection points
index_ray : (m,) int
Indexes of ray
index_tri : (m,) int
Indexes of mesh.faces
"""
(index_tri, index_ray, locations) = self.intersects_id(
ray_origins=ray_origins,
ray_directions=ray_directions,
multiple_hits=multiple_hits,
return_locations=True,
)
return locations, index_ray, index_tri
@log_time
def intersects_id(
self,
ray_origins,
ray_directions,
multiple_hits=True,
max_hits=20,
return_locations=False,
):
"""
Find the triangles hit by a list of rays, including
optionally multiple hits along a single ray.
Parameters
----------
ray_origins : (n, 3) float
Origins of rays
ray_directions : (n, 3) float
Direction (vector) of rays
multiple_hits : bool
If True will return every hit along the ray
If False will only return first hit
max_hits : int
Maximum number of hits per ray
return_locations : bool
Should we return hit locations or not
Returns
---------
index_tri : (m,) int
Indexes of mesh.faces
index_ray : (m,) int
Indexes of ray
locations : (m) sequence of (p, 3) float
Intersection points, only returned if return_locations
"""
# make sure input is _dtype for embree
ray_origins = np.array(ray_origins, dtype=np.float64)
ray_directions = np.array(ray_directions, dtype=np.float64)
if ray_origins.shape != ray_directions.shape:
raise ValueError("Ray origin and direction don't match!")
ray_directions = util.unitize(ray_directions)
# since we are constructing all hits, save them to a deque then
# stack into (depth, len(rays)) at the end
result_triangle = []
result_ray_idx = []
result_locations = []
# the mask for which rays are still active
current = np.ones(len(ray_origins), dtype=bool)
if multiple_hits or return_locations:
# how much to offset ray to transport to the other side of face
distance = np.clip(
_ray_offset_factor * self._scale, _ray_offset_floor, np.inf
)
ray_offsets = ray_directions * distance
# grab the planes from triangles
plane_origins = self.mesh.triangles[:, 0, :]
plane_normals = self.mesh.face_normals
# use a for loop rather than a while to ensure this exits
# if a ray is offset from a triangle and then is reported
# hitting itself this could get stuck on that one triangle
for _ in range(max_hits):
# run the embreex query
# if you set output=1 it will calculate distance along
# ray, which is bizzarely slower than our calculation
query = self._scene.run(ray_origins[current], ray_directions[current])
# basically we need to reduce the rays to the ones that hit
# something
hit = query != -1
# which triangle indexes were hit
hit_triangle = query[hit]
# eliminate rays that didn't hit anything from future queries
current_index = np.nonzero(current)[0]
current_index_no_hit = current_index[np.logical_not(hit)]
current_index_hit = current_index[hit]
current[current_index_no_hit] = False
# append the triangle and ray index to the results
result_triangle.append(hit_triangle)
result_ray_idx.append(current_index_hit)
# if we don't need all of the hits, return the first one
if (not multiple_hits and not return_locations) or not hit.any():
break
# find the location of where the ray hit the triangle plane
new_origins, valid = intersections.planes_lines(
plane_origins=plane_origins[hit_triangle],
plane_normals=plane_normals[hit_triangle],
line_origins=ray_origins[current],
line_directions=ray_directions[current],
)
if not valid.all():
# since a plane intersection was invalid we have to go back and
# fix some stuff, we pop the ray index and triangle index,
# apply the valid mask then append it right back to keep our
# indexes intact
result_ray_idx.append(result_ray_idx.pop()[valid])
result_triangle.append(result_triangle.pop()[valid])
# update the current rays to reflect that we couldn't find a
# new origin
current[current_index_hit[np.logical_not(valid)]] = False
# since we had to find the intersection point anyway we save it
# even if we're not going to return it
result_locations.extend(new_origins)
if multiple_hits:
# move the ray origin to the other side of the triangle
ray_origins[current] = new_origins + ray_offsets[current]
else:
break
# stack the dequeues into nice 1D numpy arrays
index_tri = np.hstack(result_triangle)
index_ray = np.hstack(result_ray_idx)
if return_locations:
locations = (
np.zeros((0, 3), float)
if len(result_locations) == 0
else np.array(result_locations)
)
return index_tri, index_ray, locations
return index_tri, index_ray
@log_time
def intersects_first(self, ray_origins, ray_directions):
"""
Find the index of the first triangle a ray hits.
Parameters
----------
ray_origins : (n, 3) float
Origins of rays
ray_directions : (n, 3) float
Direction (vector) of rays
Returns
----------
triangle_index : (n,) int
Index of triangle ray hit, or -1 if not hit
"""
ray_origins = np.array(ray_origins, dtype=np.float64)
ray_directions = np.array(ray_directions, dtype=np.float64)
if ray_origins.shape != ray_directions.shape:
raise ValueError("Ray origin and direction don't match!")
ray_directions = util.unitize(ray_directions)
triangle_index = self._scene.run(ray_origins, ray_directions)
return triangle_index
def intersects_any(self, ray_origins, ray_directions):
"""
Check if a list of rays hits the surface.
Parameters
-----------
ray_origins : (n, 3) float
Origins of rays
ray_directions : (n, 3) float
Direction (vector) of rays
Returns
----------
hit : (n,) bool
Did each ray hit the surface
"""
first = self.intersects_first(
ray_origins=ray_origins, ray_directions=ray_directions
)
hit = first != -1
return hit
def contains_points(self, points):
"""
Check if a mesh contains a list of points, using ray tests.
If the point is on the surface of the mesh, behavior is undefined.
Parameters
---------
points: (n, 3) points in space
Returns
---------
contains: (n,) bool
Whether point is inside mesh or not
"""
return contains_points(self, points)
def __getstate__(self):
state = self.__dict__.copy()
# don't pickle cache
state.pop("_cache", None)
return state
def __setstate__(self, state):
self.__dict__.update(state)
# Add cache back since it doesn't exist in the pickle
self._cache = caching.Cache(id_function=self.mesh.__hash__)
def __deepcopy__(self, *args):
return self.__copy__()
def __copy__(self, *args):
return RayMeshIntersector(geometry=self.mesh, scale_to_box=self._scale_to_box)
class _EmbreeWrap:
"""
A light wrapper for Embreex scene objects which
allows queries to be scaled to help with precision
issues, as well as selecting the correct dtypes.
"""
def __init__(self, vertices, faces, scale):
scaled = np.array(vertices, dtype=np.float64)
self.origin = scaled.min(axis=0)
self.scale = float(scale)
scaled = (scaled - self.origin) * self.scale
self.scene = rtcore_scene.EmbreeScene()
# assign the geometry to the scene
TriangleMesh(
scene=self.scene,
vertices=scaled.astype(_embree_dtype),
indices=faces.view(np.ndarray).astype(np.int32),
)
def run(self, origins, normals, **kwargs):
scaled = (np.array(origins, dtype=np.float64) - self.origin) * self.scale
return self.scene.run(
scaled.astype(_embree_dtype), normals.astype(_embree_dtype), **kwargs
)
@@ -0,0 +1,402 @@
"""
A basic slow implementation of ray- triangle queries.
"""
import numpy as np
from .. import caching, grouping, intersections, util
from .. import triangles as triangles_mod
from ..constants import tol
from .ray_util import contains_points
class RayMeshIntersector:
"""
An object to query a mesh for ray intersections.
Precomputes an r-tree for each triangle on the mesh.
"""
def __init__(self, mesh):
self.mesh = mesh
self._cache = caching.Cache(self.mesh.__hash__)
def intersects_id(
self,
ray_origins,
ray_directions,
return_locations=False,
multiple_hits=True,
**kwargs,
):
"""
Find the intersections between the current mesh and an
array of rays.
Parameters
------------
ray_origins : (m, 3) float
Ray origin points
ray_directions : (m, 3) float
Ray direction vectors
multiple_hits : bool
Consider multiple hits of each ray or not
return_locations : bool
Return hit locations or not
Returns
-----------
index_triangle : (h,) int
Index of triangles hit
index_ray : (h,) int
Index of ray that hit triangle
locations : (h, 3) float
[optional] Position of intersection in space
"""
(index_tri, index_ray, locations) = ray_triangle_id(
triangles=self.mesh.triangles,
ray_origins=ray_origins,
ray_directions=ray_directions,
tree=self.mesh.triangles_tree,
multiple_hits=multiple_hits,
triangles_normal=self.mesh.face_normals,
)
if return_locations:
if len(index_tri) == 0:
return index_tri, index_ray, locations
unique = grouping.unique_rows(np.column_stack((locations, index_ray)))[0]
return index_tri[unique], index_ray[unique], locations[unique]
return index_tri, index_ray
def intersects_location(self, ray_origins, ray_directions, **kwargs):
"""
Return unique cartesian locations where rays hit the mesh.
If you are counting the number of hits a ray had, this method
should be used as if only the triangle index is used on- edge hits
will be counted twice.
Parameters
------------
ray_origins : (m, 3) float
Ray origin points
ray_directions : (m, 3) float
Ray direction vectors
Returns
---------
locations : (n) sequence of (m,3) float
Intersection points
index_ray : (n,) int
Array of ray indexes
index_tri: (n,) int
Array of triangle (face) indexes
"""
(index_tri, index_ray, locations) = self.intersects_id(
ray_origins=ray_origins,
ray_directions=ray_directions,
return_locations=True,
**kwargs,
)
return locations, index_ray, index_tri
def intersects_first(self, ray_origins, ray_directions, **kwargs):
"""
Find the index of the first triangle a ray hits.
Parameters
----------
ray_origins : (n, 3) float
Origins of rays
ray_directions : (n, 3) float
Direction (vector) of rays
Returns
----------
triangle_index : (n,) int
Index of triangle ray hit, or -1 if not hit
"""
(index_tri, index_ray) = self.intersects_id(
ray_origins=ray_origins,
ray_directions=ray_directions,
return_locations=False,
multiple_hits=False,
**kwargs,
)
# put the result into the form of "one triangle index per ray"
result = np.ones(len(ray_origins), dtype=np.int64) * -1
result[index_ray] = index_tri
return result
def intersects_any(self, ray_origins, ray_directions, **kwargs):
"""
Find out if each ray hit any triangle on the mesh.
Parameters
------------
ray_origins : (m, 3) float
Ray origin points
ray_directions : (m, 3) float
Ray direction vectors
Returns
---------
hit : (m,) bool
Whether any ray hit any triangle on the mesh
"""
_index_tri, index_ray = self.intersects_id(ray_origins, ray_directions)
hit_any = np.zeros(len(ray_origins), dtype=bool)
hit_idx = np.unique(index_ray)
if len(hit_idx) > 0:
hit_any[hit_idx] = True
return hit_any
def contains_points(self, points):
"""
Check if a mesh contains a list of points, using ray tests.
If the point is on the surface of the mesh the behavior
is undefined.
Parameters
------------
points : (n, 3) float
Points in space
Returns
---------
contains : (n,) bool
Whether point is inside mesh or not
"""
return contains_points(self, points)
def ray_triangle_id(
triangles,
ray_origins,
ray_directions,
triangles_normal=None,
tree=None,
multiple_hits=True,
):
"""
Find the intersections between a group of triangles and rays
Parameters
-------------
triangles : (n, 3, 3) float
Triangles in space
ray_origins : (m, 3) float
Ray origin points
ray_directions : (m, 3) float
Ray direction vectors
triangles_normal : (n, 3) float
Normal vector of triangles, optional
tree : rtree.Index
Rtree object holding triangle bounds
Returns
-----------
index_triangle : (h,) int
Index of triangles hit
index_ray : (h,) int
Index of ray that hit triangle
locations : (h, 3) float
Position of intersection in space
"""
triangles = np.asanyarray(triangles, dtype=np.float64)
ray_origins = np.asanyarray(ray_origins, dtype=np.float64)
ray_directions = np.asanyarray(ray_directions, dtype=np.float64)
# if we didn't get passed an r-tree for the bounds of each
# triangle create one here
if tree is None:
tree = triangles_mod.bounds_tree(triangles)
# find the list of likely triangles and which ray they
# correspond with, via rtree queries
ray_candidates, ray_id = ray_triangle_candidates(
ray_origins=ray_origins, ray_directions=ray_directions, tree=tree
)
# get subsets which are corresponding rays and triangles
# (c,3,3) triangle candidates
triangle_candidates = triangles[ray_candidates]
# (c,3) origins and vectors for the rays
line_origins = ray_origins[ray_id]
line_directions = ray_directions[ray_id]
# get the plane origins and normals from the triangle candidates
plane_origins = triangle_candidates[:, 0, :]
if triangles_normal is None:
plane_normals, triangle_ok = triangles_mod.normals(triangle_candidates)
if not triangle_ok.all():
raise ValueError("Invalid triangles!")
else:
plane_normals = triangles_normal[ray_candidates]
# find the intersection location of the rays with the planes
location, valid = intersections.planes_lines(
plane_origins=plane_origins,
plane_normals=plane_normals,
line_origins=line_origins,
line_directions=line_directions,
)
if len(triangle_candidates) == 0 or not valid.any():
# we got no hits so return early with empty array
return (
np.array([], dtype=np.int64),
np.array([], dtype=np.int64),
np.array([], dtype=np.float64),
)
# find the barycentric coordinates of each plane intersection on the
# triangle candidates
barycentric = triangles_mod.points_to_barycentric(
triangle_candidates[valid], location
)
# the plane intersection is inside the triangle if all barycentric
# coordinates are between 0.0 and 1.0
hit = np.logical_and(
(barycentric > -tol.zero).all(axis=1), (barycentric < (1 + tol.zero)).all(axis=1)
)
# the result index of the triangle is a candidate with a valid
# plane intersection and a triangle which contains the plane
# intersection point
index_tri = ray_candidates[valid][hit]
# the ray index is a subset with a valid plane intersection and
# contained by a triangle
index_ray = ray_id[valid][hit]
# locations are already valid plane intersections, just mask by hits
location = location[hit]
# only return points that are forward from the origin
vector = location - ray_origins[index_ray]
distance = util.diagonal_dot(vector, ray_directions[index_ray])
forward = distance > -1e-6
index_tri = index_tri[forward]
index_ray = index_ray[forward]
location = location[forward]
distance = distance[forward]
if multiple_hits:
return index_tri, index_ray, location
# since we are not returning multiple hits, we need to
# figure out which hit is first
if len(index_ray) == 0:
return index_tri, index_ray, location
# find the first hit
first = np.array([g[distance[g].argmin()] for g in grouping.group(index_ray)])
return index_tri[first], index_ray[first], location[first]
def ray_triangle_candidates(ray_origins, ray_directions, tree):
"""
Do broad- phase search for triangles that the rays
may intersect.
Does this by creating a bounding box for the ray as it
passes through the volume occupied by the tree
Parameters
------------
ray_origins : (m, 3) float
Ray origin points.
ray_directions : (m, 3) float
Ray direction vectors
tree : rtree object
Ccontains AABB of each triangle
Returns
----------
ray_candidates : (n,) int
Triangle indexes
ray_id : (n,) int
Corresponding ray index for a triangle candidate
"""
bounding = ray_bounds(
ray_origins=ray_origins, ray_directions=ray_directions, bounds=tree.bounds
)
index = []
candidates = []
for i, bounds in enumerate(bounding):
cand = list(tree.intersection(bounds))
candidates.extend(cand)
index.extend([i] * len(cand))
return np.array(candidates, dtype=np.int64), np.array(index, dtype=np.int64)
def ray_bounds(ray_origins, ray_directions, bounds, buffer_dist=1e-5):
"""
Given a set of rays and a bounding box for the volume of interest
where the rays will be passing through, find the bounding boxes
of the rays as they pass through the volume.
Parameters
------------
ray_origins: (m,3) float, ray origin points
ray_directions: (m,3) float, ray direction vectors
bounds: (2,3) bounding box (min, max)
buffer_dist: float, distance to pad zero width bounding boxes
Returns
---------
ray_bounding: (n) set of AABB of rays passing through volume
"""
ray_origins = np.asanyarray(ray_origins, dtype=np.float64)
ray_directions = np.asanyarray(ray_directions, dtype=np.float64)
# bounding box we are testing against
bounds = np.asanyarray(bounds)
# find the primary axis of the vector
axis = np.abs(ray_directions).argmax(axis=1)
axis_bound = bounds.reshape((2, -1)).T[axis]
axis_ori = np.array([ray_origins[i][a] for i, a in enumerate(axis)]).reshape((-1, 1))
axis_dir = np.array([ray_directions[i][a] for i, a in enumerate(axis)]).reshape(
(-1, 1)
)
# parametric equation of a line
# point = direction*t + origin
# p = dt + o
# t = (p-o)/d
nonzero = (axis_dir != 0.0).reshape(-1)
t = np.zeros_like(axis_bound)
t[nonzero] = (axis_bound[nonzero] - axis_ori[nonzero]) / axis_dir[nonzero]
# prevent the bounding box from including triangles
# behind the ray origin
t[t < buffer_dist] = buffer_dist
# the value of t for both the upper and lower bounds
t_a = t[:, 0].reshape((-1, 1))
t_b = t[:, 1].reshape((-1, 1))
# the cartesian point for where the line hits the plane defined by
# axis
on_a = (ray_directions * t_a) + ray_origins
on_b = (ray_directions * t_b) + ray_origins
on_plane = np.column_stack((on_a, on_b)).reshape((-1, 2, ray_directions.shape[1]))
ray_bounding = np.hstack((on_plane.min(axis=1), on_plane.max(axis=1)))
# pad the bounding box by TOL_BUFFER
# not sure if this is necessary, but if the ray is axis aligned
# this function will otherwise return zero volume bounding boxes
# which may or may not screw up the r-tree intersection queries
ray_bounding += np.array([-1, -1, -1, 1, 1, 1]) * buffer_dist
return ray_bounding
@@ -0,0 +1,117 @@
import numpy as np
from .. import bounds, constants, util
@constants.log_time
def contains_points(intersector, points, check_direction=None):
"""
Check if a mesh contains a set of points, using ray tests.
If the point is on the surface of the mesh, behavior is
undefined.
Parameters
---------
mesh: Trimesh object
points: (n,3) points in space
Returns
---------
contains : (n) bool
Whether point is inside mesh or not
"""
# convert points to float and make sure they are 3D
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)")
# placeholder result with no hits we'll fill in later
contains = np.zeros(len(points), dtype=bool)
# cull points outside of the axis aligned bounding box
# this avoids running ray tests unless points are close
inside_aabb = bounds.contains(intersector.mesh.bounds, points)
# if everything is outside the AABB, exit early
if not inside_aabb.any():
return contains
# default ray direction is random, but we are not generating
# uniquely each time so the behavior of this function is easier to debug
default_direction = np.array([0.4395064455, 0.617598629942, 0.652231566745])
if check_direction is None:
# if no check direction is specified use the default
# stack it only for points inside the AABB
ray_directions = np.tile(default_direction, (inside_aabb.sum(), 1))
else:
# if a direction is passed use it
ray_directions = np.tile(
np.array(check_direction).reshape(3), (inside_aabb.sum(), 1)
)
# cast a ray both forwards and backwards
_location, index_ray, _c = intersector.intersects_location(
np.vstack((points[inside_aabb], points[inside_aabb])),
np.vstack((ray_directions, -ray_directions)),
)
# if we hit nothing in either direction just return with no hits
if len(index_ray) == 0:
return contains
# reshape so bi_hits[0] is the result in the forward direction and
# bi_hits[1] is the result in the backwards directions
bi_hits = np.bincount(index_ray, minlength=len(ray_directions) * 2).reshape((2, -1))
# a point is probably inside if it hits a surface an odd number of times
bi_contains = np.mod(bi_hits, 2) == 1
# if the mod of the hit count is the same in both
# directions, we can save that result and move on
agree = np.equal(*bi_contains)
# in order to do an assignment we can only have one
# level of boolean indexes, for example this doesn't work:
# contains[inside_aabb][agree] = bi_contains[0][agree]
# no error is thrown, but nothing gets assigned
# to get around that, we create a single mask for assignment
mask = inside_aabb.copy()
mask[mask] = agree
# set contains flags for things inside the AABB and who have
# ray tests that agree in both directions
contains[mask] = bi_contains[0][agree]
# if one of the rays in either direction hit nothing
# it is a very solid indicator we are in free space
# as the edge cases we are working around tend to
# add hits rather than miss hits
one_freespace = (bi_hits == 0).any(axis=0)
# rays where they don't agree and one isn't in free space
# are deemed to be broken
broken = np.logical_and(np.logical_not(agree), np.logical_not(one_freespace))
# if all rays agree return
if not broken.any():
return contains
# try to run again with a new random vector
# only do it if check_direction isn't specified
# to avoid infinite recursion
if check_direction is None:
# we're going to run the check again in a random direction
new_direction = util.unitize(np.random.random(3) - 0.5)
# do the mask trick again to be able to assign results
mask = inside_aabb.copy()
mask[mask] = broken
contains[mask] = contains_points(
intersector, points[inside_aabb][broken], check_direction=new_direction
)
constants.log.debug(
"detected %d broken contains test, attempted to fix", broken.sum()
)
return contains