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,133 @@
"""
https://github.com/mikedh/trimesh
------------------------------------
Trimesh is a pure Python (2.7- 3.3+) library for loading and using triangular
meshes with an emphasis on watertight meshes. The goal of the library is to
provide a fully featured Trimesh object which allows for easy manipulation
and analysis, in the style of the Polygon object in the Shapely library.
"""
# avoid a circular import in trimesh.base
from . import (
boolean,
bounds,
caching,
collision,
comparison,
convex,
creation,
curvature,
decomposition,
exceptions,
geometry,
graph,
grouping,
inertia,
intersections,
iteration,
nsphere,
permutate,
poses,
primitives,
proximity,
ray,
registration,
remesh,
repair,
sample,
smoothing,
transformations,
triangles,
units,
util,
)
from .base import Trimesh
# general numeric tolerances
from .constants import tol
# loader functions
from .exchange.load import (
available_formats,
load,
load_mesh,
load_path,
load_remote,
load_scene,
)
# geometry objects
from .parent import Geometry
from .points import PointCloud
from .scene.scene import Scene
from .transformations import transform_points
# utility functions
from .util import unitize
from .version import __version__
try:
# handle vector paths
from . import path
except BaseException as E:
# raise a useful error if path hasn't loaded
path = exceptions.ExceptionWrapper(E)
try:
from . import voxel
except BaseException as E:
# requires non-minimal imports
voxel = exceptions.ExceptionWrapper(E)
__all__ = [
"Geometry",
"PointCloud",
"Scene",
"Trimesh",
"__version__",
"available_formats",
"boolean",
"bounds",
"caching",
"collision",
"comparison",
"convex",
"creation",
"curvature",
"decomposition",
"exceptions",
"geometry",
"graph",
"grouping",
"inertia",
"intersections",
"iteration",
"load",
"load_mesh",
"load_path",
"load_remote",
"load_scene",
"nsphere",
"path",
"permutate",
"poses",
"primitives",
"proximity",
"ray",
"registration",
"remesh",
"repair",
"sample",
"smoothing",
"tol",
"transform_points",
"transformations",
"triangles",
"unitize",
"units",
"util",
"voxel",
]
@@ -0,0 +1,69 @@
import argparse
def main():
"""
A simple command line utility for accessing trimesh functions.
To display a mesh:
> trimesh hi.stl
To convert a mesh:
> trimesh hi.stl -e hey.glb
To print some information about a mesh:
> trimesh hi.stl --statistics
"""
from .exchange.load import load
parser = argparse.ArgumentParser()
parser.add_argument("file_name", nargs="?")
parser.add_argument(
"-i",
"--interact",
action="store_true",
help="Get an interactive terminal with trimesh and loaded geometry",
)
parser.add_argument("-e", "--export", help="Export a loaded geometry to a new file.")
args = parser.parse_args()
if args.file_name is None:
parser.print_help()
return
else:
scene = load(args.file_name)
summary(scene)
if args.export is not None:
scene.export(args.export)
if args.interact:
return interactive(scene)
scene.show()
def summary(geom):
""" """
print(geom)
def interactive(scene):
"""
Run an interactive session with a loaded scene and trimesh.
This uses the standard library `code.InteractiveConsole`
"""
local = locals()
from code import InteractiveConsole
# filter out junk variables.
InteractiveConsole(locals={k: v for k, v in local.items() if k != "local"}).interact()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
"""
boolean.py
-------------
Do boolean operations on meshes using either Blender or Manifold.
"""
import numpy as np
from . import interfaces
from .exceptions import ExceptionWrapper
from .iteration import reduce_cascade
from .typed import BooleanEngineType, BooleanOperationType, Callable, Dict, Sequence
try:
from manifold3d import Manifold, Mesh
except BaseException as E:
Mesh = ExceptionWrapper(E)
Manifold = ExceptionWrapper(E)
def difference(
meshes: Sequence,
engine: BooleanEngineType = None,
check_volume: bool = True,
**kwargs,
):
"""
Compute the boolean difference between a mesh an n other meshes.
Parameters
----------
meshes : sequence of trimesh.Trimesh
Meshes to be processed.
engine
Which backend to use, i.e. 'blender' or 'manifold'
check_volume
Raise an error if not all meshes are watertight
positive volumes. Advanced users may want to ignore
this check as it is expensive.
kwargs
Passed through to the `engine`.
Returns
----------
difference
A `Trimesh` that contains `meshes[0] - meshes[1:]`
"""
return _engines[engine](
meshes, operation="difference", check_volume=check_volume, **kwargs
)
def union(
meshes: Sequence,
engine: BooleanEngineType = None,
check_volume: bool = True,
**kwargs,
):
"""
Compute the boolean union between a mesh an n other meshes.
Parameters
----------
meshes : list of trimesh.Trimesh
Meshes to be processed
engine : str
Which backend to use, i.e. 'blender' or 'manifold'
check_volume
Raise an error if not all meshes are watertight
positive volumes. Advanced users may want to ignore
this check as it is expensive.
kwargs
Passed through to the `engine`.
Returns
----------
union
A `Trimesh` that contains the union of all passed meshes.
"""
return _engines[engine](
meshes, operation="union", check_volume=check_volume, **kwargs
)
def intersection(
meshes: Sequence,
engine: BooleanEngineType = None,
check_volume: bool = True,
**kwargs,
):
"""
Compute the boolean intersection between a mesh and other meshes.
Parameters
----------
meshes : list of trimesh.Trimesh
Meshes to be processed
engine : str
Which backend to use, i.e. 'blender' or 'manifold'
check_volume
Raise an error if not all meshes are watertight
positive volumes. Advanced users may want to ignore
this check as it is expensive.
kwargs
Passed through to the `engine`.
Returns
----------
intersection
A `Trimesh` that contains the intersection geometry.
"""
return _engines[engine](
meshes, operation="intersection", check_volume=check_volume, **kwargs
)
def boolean_manifold(
meshes: Sequence,
operation: BooleanOperationType,
check_volume: bool = True,
**kwargs,
):
"""
Run an operation on a set of meshes using the Manifold engine.
Parameters
----------
meshes : list of trimesh.Trimesh
Meshes to be processed
operation
Which boolean operation to do.
check_volume
Raise an error if not all meshes are watertight
positive volumes. Advanced users may want to ignore
this check as it is expensive.
kwargs
Passed through to the `engine`.
"""
if check_volume and not all(m.is_volume for m in meshes):
raise ValueError("Not all meshes are volumes!")
# Convert to manifold meshes
manifolds = [
Manifold(
mesh=Mesh(
vert_properties=np.array(mesh.vertices, dtype=np.float32),
tri_verts=np.array(mesh.faces, dtype=np.uint32),
)
)
for mesh in meshes
]
# Perform operations
if operation == "difference":
if len(meshes) < 2:
raise ValueError("Difference only defined over two meshes.")
elif len(meshes) == 2:
# apply the single difference
result_manifold = manifolds[0] - manifolds[1]
elif len(meshes) > 2:
# union all the meshes to be subtracted from the final result
unioned = reduce_cascade(lambda a, b: a + b, manifolds[1:])
# apply the difference
result_manifold = manifolds[0] - unioned
elif operation == "union":
result_manifold = reduce_cascade(lambda a, b: a + b, manifolds)
elif operation == "intersection":
result_manifold = reduce_cascade(lambda a, b: a ^ b, manifolds)
else:
raise ValueError(f"Invalid boolean operation: '{operation}'")
# Convert back to trimesh meshes
from . import Trimesh
result_mesh = result_manifold.to_mesh()
return Trimesh(
vertices=result_mesh.vert_properties, faces=result_mesh.tri_verts, process=False
)
# which backend boolean engines do we have
_engines: Dict[str, Callable] = {}
if isinstance(Manifold, ExceptionWrapper):
# manifold isn't available so use the import error
_engines["manifold"] = Manifold
else:
# manifold3d is the preferred option
_engines["manifold"] = boolean_manifold
if interfaces.blender.exists:
# we have `blender` in the path which we can call with subprocess
_engines["blender"] = interfaces.blender.boolean
else:
# failing that add a helpful error message
_engines["blender"] = ExceptionWrapper(ImportError("`blender` is not in `PATH`"))
# pick the first value that isn't an ExceptionWrapper.
_engines[None] = next(
(v for v in _engines.values() if not isinstance(v, ExceptionWrapper)),
ExceptionWrapper(
ImportError("No boolean backend: `pip install manifold3d` or install `blender`")
),
)
engines_available = {
k for k, v in _engines.items() if not isinstance(v, ExceptionWrapper)
}
@@ -0,0 +1,582 @@
import numpy as np
from . import convex, geometry, grouping, nsphere, transformations, util
from .constants import log, now
from .typed import ArrayLike, NDArray
try:
# scipy is a soft dependency
from scipy import optimize
from scipy.spatial import ConvexHull
except BaseException as E:
# raise the exception when someone tries to use it
from . import exceptions
ConvexHull = exceptions.ExceptionWrapper(E)
optimize = exceptions.ExceptionWrapper(E)
try:
from scipy.spatial import QhullError
except BaseException:
QhullError = BaseException
# a 90 degree rotation
_flip = transformations.planar_matrix(theta=np.pi / 2)
_flip.flags.writeable = False
def oriented_bounds_2D(points, qhull_options="QbB"):
"""
Find an oriented bounding box for an array of 2D points.
Details on qhull options:
http://www.qhull.org/html/qh-quick.htm#options
Parameters
----------
points : (n,2) float
Points in 2D.
Returns
----------
transform : (3,3) float
Homogeneous 2D transformation matrix to move the
input points so that the axis aligned bounding box
is CENTERED AT THE ORIGIN.
rectangle : (2,) float
Size of extents once input points are transformed
by transform
"""
# create a convex hull object of our points
# 'QbB' is a qhull option which has it scale the input to unit
# box to avoid precision issues with very large/small meshes
convex = ConvexHull(points, qhull_options=qhull_options)
# (n,2,3) line segments
hull_edges = convex.points[convex.simplices]
# (n,2) points on the convex hull
hull_points = convex.points[convex.vertices]
# unit vector direction of the edges of the hull polygon
# filter out zero- magnitude edges via check_valid
edge_vectors = hull_edges[:, 1] - hull_edges[:, 0]
edge_norm = np.sqrt(np.dot(edge_vectors**2, [1, 1]))
edge_nonzero = edge_norm > 1e-10
edge_vectors = edge_vectors[edge_nonzero] / edge_norm[edge_nonzero].reshape((-1, 1))
# create a set of perpendicular vectors
perp_vectors = np.fliplr(edge_vectors) * [-1.0, 1.0]
# find the projection of every hull point on every edge vector
# this does create a potentially gigantic n^2 array in memory,
# and there is the 'rotating calipers' algorithm which avoids this
# however, we have reduced n with a convex hull and numpy dot products
# are extremely fast so in practice this usually ends up being fine
x = np.dot(edge_vectors, hull_points.T)
y = np.dot(perp_vectors, hull_points.T)
# reduce the projections to maximum and minimum per edge vector
bounds = np.column_stack((x.min(axis=1), y.min(axis=1), x.max(axis=1), y.max(axis=1)))
# calculate the extents and area for each edge vector pair
extents = np.diff(bounds.reshape((-1, 2, 2)), axis=1).reshape((-1, 2))
area = np.prod(extents, axis=1)
area_min = area.argmin()
# (2,) float of smallest rectangle size
rectangle = extents[area_min]
# find the (3,3) homogeneous transformation which moves the input
# points to have a bounding box centered at the origin
offset = -bounds[area_min][:2] - (rectangle * 0.5)
theta = np.arctan2(*edge_vectors[area_min][::-1])
transform = transformations.planar_matrix(offset, theta)
# we would like to consistently return an OBB with
# the largest dimension along the X axis rather than
# the long axis being arbitrarily X or Y.
if rectangle[1] > rectangle[0]:
# apply the rotation
transform = np.dot(_flip, transform)
# switch X and Y in the OBB extents
rectangle = rectangle[::-1]
return transform, rectangle
def oriented_bounds(obj, angle_digits=1, ordered=True, normal=None, coplanar_tol=1e-12):
"""
Find the oriented bounding box for a Trimesh
Parameters
----------
obj : trimesh.Trimesh, (n, 2) float, or (n, 3) float
Mesh object or points in 2D or 3D space
angle_digits : int
How much angular precision do we want on our result.
Even with less precision the returned extents will cover
the mesh albeit with larger than minimal volume, and may
experience substantial speedups.
ordered : bool
Return a consistent order for bounds
normal : None or (3,) float
Override search for normal on 3D meshes.
coplanar_tol : float
If a convex hull fails and we are checking to see if the
points are coplanar this is the maximum deviation from
a plane where the points will be considered coplanar.
Returns
----------
to_origin : (4,4) float
Transformation matrix which will move the center of the
bounding box of the input mesh to the origin.
extents: (3,) float
The extents of the mesh once transformed with to_origin
"""
def oriented_bounds_coplanar(points):
"""
Find an oriented bounding box for an array of coplanar 3D points.
Parameters
----------
points : (n, 3) float
Points in 3D that occupy a 2D subspace.
Returns
----------
to_origin : (4, 4) float
Transformation matrix which will move the center of the
bounding box of the input mesh to the origin.
extents : (3,) float
The extents of the mesh once transformed with to_origin
"""
# Shift points about the origin and rotate into the xy plane
points_mean = np.mean(points, axis=0)
points_demeaned = points - points_mean
_, _, vh = np.linalg.svd(points_demeaned, full_matrices=False)
points_2d = np.matmul(points_demeaned, vh.T)
if np.any(np.abs(points_2d[:, 2]) > coplanar_tol):
raise ValueError("Points must be coplanar")
# Construct a homogeneous matrix representing the transformation above
to_2d = np.eye(4)
to_2d[:3, :3] = vh
to_2d[:3, 3] = -np.matmul(vh, points_mean)
# Find the 2D bounding box using the polygon
to_origin_2d, extents_2d = oriented_bounds_2D(points_2d[:, :2])
# Make extents 3D
extents = np.append(extents_2d, 0.0)
# convert transformation from 2D to 3D and combine
to_origin = np.matmul(transformations.planar_matrix_to_3D(to_origin_2d), to_2d)
return to_origin, extents
try:
# extract a set of convex hull vertices and normals from the input
# we bother to do this to avoid recomputing the full convex hull if
# possible
if hasattr(obj, "convex_hull"):
# if we have been passed a mesh, use its existing convex hull to pull from
# cache rather than recomputing. This version of the cached convex hull has
# normals pointing in arbitrary directions (straight from qhull)
# using this avoids having to compute the expensive corrected normals
# that mesh.convex_hull uses since normal directions don't matter
# here
hull = obj.convex_hull
elif util.is_sequence(obj):
# we've been passed a list of points
points = np.asanyarray(obj)
if util.is_shape(points, (-1, 2)):
return oriented_bounds_2D(points)
elif util.is_shape(points, (-1, 3)):
hull = convex.convex_hull(points, repair=True)
else:
raise ValueError("Points are not (n,3) or (n,2)!")
else:
raise ValueError("Oriented bounds must be passed a mesh or a set of points!")
except QhullError:
# Try to recover from Qhull error if due to mesh being less than 3
# dimensional
if hasattr(obj, "vertices"):
points = obj.vertices.view(np.ndarray)
elif util.is_sequence(obj):
points = np.asanyarray(obj)
else:
raise
return oriented_bounds_coplanar(points)
vertices = hull.vertices
hull_adj = hull.face_adjacency.T
hull_edge = hull.face_adjacency_edges
hull_normals = hull.face_normals
# matrices which will rotate each hull normal to [0,0,1]
if normal is None:
# convert face normals to spherical coordinates on the upper hemisphere
# the vector_hemisphere call effectively merges negative but otherwise
# identical vectors
spherical_coords = util.vector_to_spherical(util.vector_hemisphere(hull_normals))
# the unique_rows call on merge angles gets unique spherical directions to check
# we get a substantial speedup in the transformation matrix creation
# inside the loop by converting to angles ahead of time
spherical_unique = grouping.unique_rows(spherical_coords, digits=angle_digits)[0]
matrices = [
transformations.spherical_matrix(*s).T
for s in spherical_coords[spherical_unique]
]
normals = util.spherical_to_vector(spherical_coords[spherical_unique])
else:
# if explicit normal was passed use it and skip the grouping
matrices = [geometry.align_vectors(normal, [0, 0, 1])]
normals = [normal]
tic = now()
min_2D = None
min_volume = np.inf
# we now need to loop through all the possible candidate
# directions for aligning our oriented bounding box.
for normal, to_2D in zip(normals, matrices):
# we could compute the hull in 2D for every direction
# but since we know we're dealing with a convex blob
# we can do back-face culling and then take the boundary
# start by picking the normal direction with fewer edges
side = np.dot(hull_normals, normal) > -1e-10
# for coplanar points this could be empty
if not side.any():
continue
# this line is a heavy lift as it is finding the pairs of
# adjacent faces where *exactly one* out of two of the faces
# is visible (xor) and then using the index to get the edge
edges = hull_edge[np.bitwise_xor(*side[hull_adj])]
# project the 3D convex hull vertices onto the plane
projected = np.dot(to_2D[:3, :3], vertices.T).T[:, :3]
# get the line segments of edges in 2D
edge_vert = projected[:, :2][edges]
# now get them as unit vectors
edge_vectors = edge_vert[:, 1, :] - edge_vert[:, 0, :]
edge_norm = np.sqrt(np.dot(edge_vectors**2, [1, 1]))
edge_nonzero = edge_norm > 1e-10
edge_vectors = edge_vectors[edge_nonzero] / edge_norm[edge_nonzero].reshape(
(-1, 1)
)
# create a set of perpendicular vectors
perp_vectors = np.fliplr(edge_vectors) * [-1.0, 1.0]
# find the projection of every hull point on every edge vector
# this does create a potentially gigantic n^2 array in memory
# and there is the 'rotating calipers' algorithm which avoids this
# however, we have reduced n with a convex hull and numpy dot products
# are extremely fast so in practice this usually ends up being fine
x = np.dot(edge_vectors, edge_vert[:, 0, :2].T)
y = np.dot(perp_vectors, edge_vert[:, 0, :2].T)
area = ((x.max(axis=1) - x.min(axis=1)) * (y.max(axis=1) - y.min(axis=1))).min()
# the volume is 2D area plus the projected height
volume = area * np.ptp(projected[:, 2])
# store this transform if it's better than one we've seen
if volume < min_volume:
min_volume = volume
min_2D = to_2D
# we know the minimum volume transform which should be the expensive
# part so now we need to do the bookkeeping to find the box
vert_ones = np.column_stack((vertices, np.ones(len(vertices)))).T
projected = np.dot(min_2D, vert_ones).T[:, :3]
height = np.ptp(projected[:, 2])
rotation_2D, box = oriented_bounds_2D(projected[:, :2])
min_extents = np.append(box, height)
rotation_2D[:2, 2] = 0.0
rotation_Z = transformations.planar_matrix_to_3D(rotation_2D)
# combine the 2D OBB transformation with the 2D projection transform
to_origin = np.dot(rotation_Z, min_2D)
# transform points using our matrix to find the translation
transformed = transformations.transform_points(vertices, to_origin)
box_center = transformed.min(axis=0) + np.ptp(transformed, axis=0) * 0.5
to_origin[:3, 3] = -box_center
# return ordered 3D extents
if ordered:
# sort the three extents
order = min_extents.argsort()
# generate a matrix which will flip transform
# to match the new ordering
flip = np.eye(4)
flip[:3, :3] = -np.eye(3)[order]
# make sure transform isn't mangling triangles
# by reversing windings on triangles
if not np.isclose(np.linalg.det(flip[:3, :3]), 1.0):
flip[:3, :3] = np.dot(flip[:3, :3], -np.eye(3))
# apply the flip to the OBB transform
to_origin = np.dot(flip, to_origin)
# apply the order to the extents
min_extents = min_extents[order]
log.debug("oriented_bounds checked %d vectors in %0.4fs", len(matrices), now() - tic)
return to_origin, min_extents
def minimum_cylinder(obj, sample_count=6, angle_tol=0.001):
"""
Find the approximate minimum volume cylinder which contains
a mesh or a a list of points.
Samples a hemisphere then uses scipy.optimize to pick the
final orientation of the cylinder.
A nice discussion about better ways to implement this is here:
https://www.staff.uni-mainz.de/schoemer/publications/ALGO00.pdf
Parameters
----------
obj : trimesh.Trimesh, or (n, 3) float
Mesh object or points in space
sample_count : int
How densely should we sample the hemisphere.
Angular spacing is 180 degrees / this number
Returns
----------
result : dict
With keys:
'radius' : float, radius of cylinder
'height' : float, height of cylinder
'transform' : (4,4) float, transform from the origin
to centered cylinder
"""
def volume_from_angles(spherical, return_data=False):
"""
Takes spherical coordinates and calculates the volume
of a cylinder along that vector
Parameters
---------
spherical : (2,) float
Theta and phi
return_data : bool
Flag for returned
Returns
--------
if return_data:
transform ((4,4) float)
radius (float)
height (float)
else:
volume (float)
"""
to_2D = transformations.spherical_matrix(*spherical, axes="rxyz")
projected = transformations.transform_points(hull, matrix=to_2D)
height = np.ptp(projected[:, 2])
try:
center_2D, radius = nsphere.minimum_nsphere(projected[:, :2])
except ValueError:
return np.inf
volume = np.pi * height * (radius**2)
if return_data:
center_3D = np.append(center_2D, projected[:, 2].min() + (height * 0.5))
transform = np.dot(
np.linalg.inv(to_2D), transformations.translation_matrix(center_3D)
)
return transform, radius, height
return volume
# we've been passed a mesh with radial symmetry
# use center mass and symmetry axis and go home early
if hasattr(obj, "symmetry") and obj.symmetry == "radial":
# find our origin
if obj.is_watertight:
# set origin to center of mass
origin = obj.center_mass
else:
# convex hull should be watertight
origin = obj.convex_hull.center_mass
# will align symmetry axis with Z and move origin to zero
to_2D = geometry.plane_transform(origin=origin, normal=obj.symmetry_axis)
# transform vertices to plane to check
on_plane = transformations.transform_points(obj.vertices, to_2D)
# cylinder height is overall Z span
height = np.ptp(on_plane[:, 2])
# center mass is correct on plane, but position
# along symmetry axis may be wrong so slide it
slide = transformations.translation_matrix(
[0, 0, (height / 2.0) - on_plane[:, 2].max()]
)
to_2D = np.dot(slide, to_2D)
# radius is maximum radius
radius = (on_plane[:, :2] ** 2).sum(axis=1).max() ** 0.5
# save kwargs
result = {"height": height, "radius": radius, "transform": np.linalg.inv(to_2D)}
return result
# get the points on the convex hull of the result
hull = convex.hull_points(obj)
if not util.is_shape(hull, (-1, 3)):
raise ValueError("Input must be reducable to 3D points!")
# sample a hemisphere so local hill climbing can do its thing
samples = util.grid_linspace([[0, 0], [np.pi, np.pi]], sample_count)
# if it's rotationally symmetric the bounding cylinder
# is almost certainly along one of the PCI vectors
if hasattr(obj, "principal_inertia_vectors"):
# add the principal inertia vectors if we have a mesh
samples = np.vstack(
(samples, util.vector_to_spherical(obj.principal_inertia_vectors))
)
tic = [now()]
# the projected volume at each sample
volumes = np.array([volume_from_angles(i) for i in samples])
# the best vector in (2,) spherical coordinates
best = samples[volumes.argmin()]
tic.append(now())
# since we already explored the global space, set the bounds to be
# just around the sample that had the lowest volume
step = 2 * np.pi / sample_count
bounds = [(best[0] - step, best[0] + step), (best[1] - step, best[1] + step)]
# run the local optimization
r = optimize.minimize(
volume_from_angles, best, tol=angle_tol, method="SLSQP", bounds=bounds
)
tic.append(now())
log.debug("Performed search in %f and minimize in %f", *np.diff(tic))
# actually chunk the information about the cylinder
transform, radius, height = volume_from_angles(r["x"], return_data=True)
result = {"transform": transform, "radius": radius, "height": height}
return result
def to_extents(bounds):
"""
Convert an axis aligned bounding box to extents and
transform.
Parameters
------------
bounds : (2, 3) float
Axis aligned bounds in space
Returns
------------
extents : (3,) float
Extents of the bounding box
transform : (4, 4) float
Homogeneous transform moving extents to bounds
"""
bounds = np.asanyarray(bounds, dtype=np.float64)
if bounds.shape != (2, 3):
raise ValueError("bounds must be (2, 3)")
extents = np.ptp(bounds, axis=0)
transform = np.eye(4)
transform[:3, 3] = bounds.mean(axis=0)
return extents, transform
def corners(bounds):
"""
Given a pair of axis aligned bounds, return all
8 corners of the bounding box.
Parameters
----------
bounds : (2,3) or (2,2) float
Axis aligned bounds
Returns
----------
corners : (8,3) float
Corner vertices of the cube
"""
bounds = np.asanyarray(bounds, dtype=np.float64)
if util.is_shape(bounds, (2, 2)):
bounds = np.column_stack((bounds, [0, 0]))
elif not util.is_shape(bounds, (2, 3)):
raise ValueError("bounds must be (2,2) or (2,3)!")
minx, miny, minz, maxx, maxy, maxz = np.arange(6)
corner_index = np.array(
[
minx,
miny,
minz,
maxx,
miny,
minz,
maxx,
maxy,
minz,
minx,
maxy,
minz,
minx,
miny,
maxz,
maxx,
miny,
maxz,
maxx,
maxy,
maxz,
minx,
maxy,
maxz,
]
).reshape((-1, 3))
corners = bounds.reshape(-1)[corner_index]
return corners
def contains(bounds: ArrayLike, points: ArrayLike) -> NDArray[np.bool_]:
"""
Do an axis aligned bounding box check on an array of points.
Parameters
-----------
bounds : (2, dimension) float
Axis aligned bounding box
points : (n, dimension) float
Points in space
Returns
-----------
points_inside : (n,) bool
True if points are inside the AABB
"""
# make sure we have correct input types
bounds = np.asanyarray(bounds, dtype=np.float64)
points = np.asanyarray(points, dtype=np.float64)
if len(bounds) != 2:
raise ValueError("bounds must be (2,dimension)!")
if not util.is_shape(points, (-1, bounds.shape[1])):
raise ValueError("bounds shape must match points!")
# run the simple check
points_inside = np.logical_and(
(points > bounds[0]).all(axis=1), (points < bounds[1]).all(axis=1)
)
return points_inside
@@ -0,0 +1,699 @@
"""
caching.py
-----------
Functions and classes that help with tracking changes
in `numpy.ndarray` and clearing cached values based
on those changes.
You should really `pip install xxhash`:
```
In [23]: %timeit int(blake2b(d).hexdigest(), 16)
102 us +/- 684 ns per loop
In [24]: %timeit int(sha256(d).hexdigest(), 16)
142 us +/- 3.73 us
In [25]: %timeit xxh3_64_intdigest(d)
3.37 us +/- 116 ns per loop
```
"""
import os
import sys
import time
from functools import wraps
from hashlib import sha256 as _sha256
import numpy as np
from .constants import log
from .util import is_sequence
try:
from collections.abc import Mapping
except BaseException:
from collections.abc import Mapping
def sha256(item) -> int:
return int(_sha256(item).hexdigest(), 16)
if sys.version_info >= (3, 9):
# blake2b is available on Python 3 and
from hashlib import blake2b as _blake2b
def hash_fallback(item):
return int(_blake2b(item, usedforsecurity=False).hexdigest(), 16)
else:
# fallback to sha256
hash_fallback = sha256
# xxhash is up to 30x faster than sha256:
# `pip install xxhash`
try:
# newest version of algorithm
from xxhash import xxh3_64_intdigest as hash_fast
except BaseException:
try:
# older version of the algorithm
from xxhash import xxh64_intdigest as hash_fast
except BaseException:
# use hashlib as a fallback hashing library
log.debug(
"falling back to hashlib "
+ "hashing: `pip install xxhash`"
+ "for 50x faster cache checks"
)
hash_fast = hash_fallback
def tracked_array(array, dtype=None):
"""
Properly subclass a numpy ndarray to track changes.
Avoids some pitfalls of subclassing by forcing contiguous
arrays and does a view into a TrackedArray.
Parameters
------------
array : array- like object
To be turned into a TrackedArray
dtype : np.dtype
Which dtype to use for the array
Returns
------------
tracked : TrackedArray
Contains input array data.
"""
# if someone passed us None, just create an empty array
if array is None:
array = []
# make sure it is contiguous then view it as our subclass
tracked = np.ascontiguousarray(array, dtype=dtype).view(TrackedArray)
# should always be contiguous here
assert tracked.flags["C_CONTIGUOUS"]
return tracked
def cache_decorator(function):
"""
A decorator for class methods, replaces @property
but will store and retrieve function return values
in object cache.
Parameters
------------
function : method
This is used as a decorator:
```
@cache_decorator
def foo(self, things):
return 'happy days'
```
"""
# use wraps to preserve docstring
@wraps(function)
def get_cached(*args, **kwargs):
"""
Only execute the function if its value isn't stored
in cache already.
"""
self = args[0]
# use function name as key in cache
name = function.__name__
# do the dump logic ourselves to avoid
# verifying cache twice per call
self._cache.verify()
# access cache dict to avoid automatic validation
# since we already called cache.verify manually
if name in self._cache.cache:
# already stored so return value
return self._cache.cache[name]
# value not in cache so execute the function
value = function(*args, **kwargs)
# store the value
if (
self._cache.force_immutable
and hasattr(value, "flags")
and len(value.shape) > 0
):
value.flags.writeable = False
self._cache.cache[name] = value
return value
# all cached values are also properties
# so they can be accessed like value attributes
# rather than functions
return property(get_cached)
class TrackedArray(np.ndarray):
"""
Subclass of numpy.ndarray that provides hash methods
to track changes.
General method is to aggressively set 'modified' flags
on operations which might (but don't necessarily) alter
the array, ideally we sometimes compute hashes when we
don't need to, but we don't return wrong hashes ever.
We store boolean modified flag for each hash type to
make checks fast even for queries of different hashes.
Methods
----------
__hash__ : int
Runs the fastest available hash in this order:
`xxh3_64, xxh_64, blake2b, sha256`
"""
def __array_finalize__(self, obj):
"""
Sets a modified flag on every TrackedArray
This flag will be set on every change as well as
during copies and certain types of slicing.
"""
self._dirty_hash = True
if isinstance(obj, type(self)):
obj._dirty_hash = True
def __array_wrap__(self, out_arr, context=None, *args, **kwargs):
"""
Return a numpy scalar if array is 0d.
See https://github.com/numpy/numpy/issues/5819
"""
if out_arr.ndim:
return np.ndarray.__array_wrap__(self, out_arr, context, *args, **kwargs)
# Match numpy's behavior and return a numpy dtype scalar
return out_arr[()]
@property
def mutable(self):
return self.flags["WRITEABLE"]
@mutable.setter
def mutable(self, value):
self.flags.writeable = value
def __hash__(self):
"""
Return a fast hash of the contents of the array.
Returns
-------------
hash : long int
A hash of the array contents.
"""
# repeat the bookkeeping to get a contiguous array
if not self._dirty_hash and hasattr(self, "_hashed"):
# we have a valid hash without recomputing.
return self._hashed
# run a hashing function on the C-order bytes copy
hashed = hash_fast(self.tobytes(order="C"))
# assign the value and set the flag
self._hashed = hashed
self._dirty_hash = False
return hashed
def __iadd__(self, *args, **kwargs):
"""
In-place addition.
The i* operations are in- place and modify the array,
so we better catch all of them.
"""
self._dirty_hash = True
return super(self.__class__, self).__iadd__(*args, **kwargs)
def __isub__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__isub__(*args, **kwargs)
def fill(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).fill(*args, **kwargs)
def partition(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).partition(*args, **kwargs)
def put(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).put(*args, **kwargs)
def byteswap(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).byteswap(*args, **kwargs)
def itemset(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).itemset(*args, **kwargs)
def sort(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).sort(*args, **kwargs)
def setflags(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).setflags(*args, **kwargs)
def __imul__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__imul__(*args, **kwargs)
def __idiv__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__idiv__(*args, **kwargs)
def __itruediv__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__itruediv__(*args, **kwargs)
def __imatmul__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__imatmul__(*args, **kwargs)
def __ipow__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__ipow__(*args, **kwargs)
def __imod__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__imod__(*args, **kwargs)
def __ifloordiv__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__ifloordiv__(*args, **kwargs)
def __ilshift__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__ilshift__(*args, **kwargs)
def __irshift__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__irshift__(*args, **kwargs)
def __iand__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__iand__(*args, **kwargs)
def __ixor__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__ixor__(*args, **kwargs)
def __ior__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__ior__(*args, **kwargs)
def __setitem__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__setitem__(*args, **kwargs)
def __setslice__(self, *args, **kwargs):
self._dirty_hash = True
return super(self.__class__, self).__setslice__(*args, **kwargs)
class Cache:
"""
Class to cache values which will be stored until the
result of an ID function changes.
"""
def __init__(self, id_function, force_immutable=False):
"""
Create a cache object.
Parameters
------------
id_function : function
Returns hashable value
force_immutable : bool
If set will make all numpy arrays read-only
"""
self._id_function = id_function
# for stored numpy arrays set `flags.writable = False`
self.force_immutable = bool(force_immutable)
# call the id function for initial value
self.id_current = None
# a counter for locks
self._lock = 0
# actual store for data
self.cache = {}
def delete(self, key):
"""
Remove a key from the cache.
"""
if key in self.cache:
self.cache.pop(key, None)
def verify(self):
"""
Verify that the cached values are still for the same
value of id_function and delete all stored items if
the value of id_function has changed.
"""
# if we are in a lock don't check anything
if self._lock != 0:
return
# check the hash of our data
id_new = self._id_function()
# things changed
if id_new != self.id_current:
if len(self.cache) > 0:
log.debug(
"%d items cleared from cache: %s",
len(self.cache),
str(list(self.cache.keys())),
)
# hash changed, so dump the cache
# do it manually rather than calling clear()
# as we are internal logic and can avoid function calls
self.cache = {}
# set the id to the new data hash
self.id_current = id_new
def clear(self, exclude=None):
"""
Remove elements in the cache.
Parameters
-----------
exclude : list
List of keys in cache to not clear.
"""
if exclude is None:
self.cache = {}
else:
self.cache = {k: v for k, v in self.cache.items() if k in exclude}
def update(self, items):
"""
Update the cache with a set of key, value pairs without
checking id_function.
"""
self.cache.update(items)
if self.force_immutable:
for v in self.cache.values():
if hasattr(v, "flags") and len(v.shape) > 0:
v.flags.writeable = False
self.id_set()
def id_set(self):
"""
Set the current ID to the value of the ID function.
"""
self.id_current = self._id_function()
def __getitem__(self, key):
"""
Get an item from the cache. If the item
is not in the cache, it will return None
Parameters
-------------
key : hashable
Key in dict
Returns
-------------
cached : object, or None
Object that was stored
"""
self.verify()
if key in self.cache:
return self.cache[key]
return None
def __setitem__(self, key, value):
"""
Add an item to the cache.
Parameters
------------
key : hashable
Key to reference value
value : any
Value to store in cache
"""
# dumpy cache if ID function has changed
self.verify()
# make numpy arrays read-only if asked to
if self.force_immutable and hasattr(value, "flags") and len(value.shape) > 0:
value.flags.writeable = False
# assign data to dict
self.cache[key] = value
return value
def __contains__(self, key):
self.verify()
return key in self.cache
def __len__(self):
self.verify()
return len(self.cache)
def __enter__(self):
self._lock += 1
def __exit__(self, *args):
self._lock -= 1
self.id_current = self._id_function()
class DiskCache:
"""
Store results of expensive operations on disk
with an option to expire the results. This is used
to cache the multi-gigabyte test corpuses in
`tests/corpus.py`
"""
def __init__(self, path, expire_days=30):
"""
Create a cache on disk for storing expensive results.
Parameters
--------------
path : str
A writeable location on the current file path.
expire_days : int or float
How old should results be considered expired.
"""
# store how old we allow results to be
self.expire_days = expire_days
# store the location for saving results
self.path = os.path.abspath(os.path.expanduser(path))
# make sure the specified path exists
os.makedirs(self.path, exist_ok=True)
def get(self, key, fetch):
"""
Get a key from the cache or run a calculation.
Parameters
-----------
key : str
Key to reference item with
fetch : function
If key isn't stored and recent run this
function and store its result on disk.
"""
# hash the key so we have a fixed length string
key_hash = _sha256(key.encode("utf-8")).hexdigest()
# full path of result on local disk
path = os.path.join(self.path, key_hash)
# check to see if we can use the cache
if os.path.isfile(path):
# compute the age of the existing file in days
age_days = (time.time() - os.stat(path).st_mtime) / 86400.0
if age_days < self.expire_days:
# this nested condition means that
# the file both exists and is recent
# enough, so just return its contents
with open(path, "rb") as f:
return f.read()
log.debug(f"not in cache fetching: `{key}`")
# since we made it here our data isn't cached
# run the expensive function to fetch the file
raw = fetch()
# write the data so we can save it
with open(path, "wb") as f:
f.write(raw)
# return the data
return raw
class DataStore(Mapping):
"""
A class to store multiple numpy arrays and track them all
for changes.
Operates like a dict that only stores numpy.ndarray
"""
def __init__(self):
self.data = {}
def __iter__(self):
return iter(self.data)
def pop(self, key):
return self.data.pop(key, None)
def __delitem__(self, key):
self.data.pop(key, None)
@property
def mutable(self):
"""
Is data allowed to be altered or not.
Returns
-----------
is_mutable : bool
Can data be altered in the DataStore
"""
return getattr(self, "_mutable", True)
@mutable.setter
def mutable(self, value):
"""
Is data allowed to be altered or not.
Parameters
------------
is_mutable : bool
Should data be allowed to be altered
"""
# make sure passed value is a bool
is_mutable = bool(value)
# apply the flag to any data stored
for v in self.data.values():
if isinstance(v, TrackedArray):
v.mutable = value
# save the mutable setting
self._mutable = is_mutable
def is_empty(self):
"""
Is the current DataStore empty or not.
Returns
----------
empty : bool
False if there are items in the DataStore
"""
if len(self.data) == 0:
return True
for v in self.data.values():
if is_sequence(v):
if len(v) == 0:
return True
else:
return False
elif bool(np.isreal(v)):
return False
return True
def clear(self):
"""
Remove all data from the DataStore.
"""
self.data = {}
def __getitem__(self, key):
return self.data[key]
def __setitem__(self, key, data):
"""
Store an item in the DataStore.
Parameters
-------------
key
A hashable key to store under
data
Usually a numpy array which will be subclassed
but anything hashable should be able to be stored.
"""
# we shouldn't allow setting on immutable datastores
if not self.mutable:
raise ValueError("DataStore is configured immutable!")
if isinstance(data, TrackedArray):
# don't bother to re-track TrackedArray
tracked = data
elif isinstance(data, (np.ndarray, list, set, tuple)):
# wrap data if it is array-like
tracked = tracked_array(data)
else:
try:
# will raise if this is not a hashable type
hash(data)
except BaseException:
raise ValueError(f"unhashable `{key}:{type(data)}`")
tracked = data
# apply our mutability setting
if hasattr(self, "_mutable"):
# apply our mutability setting only if it was explicitly set
tracked.mutable = self.mutable
# store data
self.data[key] = tracked
def __contains__(self, key):
return key in self.data
def __len__(self):
return len(self.data)
def update(self, values):
if not isinstance(values, dict):
raise ValueError("Update only implemented for dicts")
for key, value in values.items():
self[key] = value
def __hash__(self):
"""
Get a hash reflecting everything in the DataStore.
Returns
----------
hash : str
hash of data in hexadecimal
"""
# only hash values that aren't None
# or if they are arrays require length greater than zero
return hash_fast(
np.array(
[
hash(v)
for v in self.data.values()
if v is not None and (not hasattr(v, "__len__") or len(v) > 0)
],
dtype=np.int64,
).tobytes()
)
@@ -0,0 +1,757 @@
import collections
import numpy as np
try:
# pip install python-fcl
import fcl
except BaseException:
fcl = None
class ContactData:
"""
Data structure for holding information about a collision contact.
"""
def __init__(self, names, contact):
"""
Initialize a ContactData.
Parameters
----------
names : list of str
The names of the two objects in order.
contact : fcl.Contact
The contact in question.
"""
self.names = set(names)
self._inds = {names[0]: contact.b1, names[1]: contact.b2}
self._normal = contact.normal
self._point = contact.pos
self._depth = contact.penetration_depth
@property
def normal(self):
"""
The 3D intersection normal for this contact.
Returns
-------
normal : (3,) float
The intersection normal.
"""
return self._normal
@property
def point(self):
"""
The 3D point of intersection for this contact.
Returns
-------
point : (3,) float
The intersection point.
"""
return self._point
@property
def depth(self):
"""
The penetration depth of the 3D point of intersection for this contact.
Returns
-------
depth : float
The penetration depth.
"""
return self._depth
def index(self, name):
"""
Returns the index of the face in contact for the mesh with
the given name.
Parameters
----------
name : str
The name of the target object.
Returns
-------
index : int
The index of the face in collision
"""
return self._inds[name]
class DistanceData:
"""
Data structure for holding information about a distance query.
"""
def __init__(self, names, result):
"""
Initialize a DistanceData.
Parameters
----------
names : list of str
The names of the two objects in order.
contact : fcl.DistanceResult
The distance query result.
"""
self.names = set(names)
self._inds = {names[0]: result.b1, names[1]: result.b2}
self._points = {
names[0]: result.nearest_points[0],
names[1]: result.nearest_points[1],
}
self._distance = result.min_distance
@property
def distance(self):
"""
Returns the distance between the two objects.
Returns
-------
distance : float
The euclidean distance between the objects.
"""
return self._distance
def index(self, name):
"""
Returns the index of the closest face for the mesh with
the given name.
Parameters
----------
name : str
The name of the target object.
Returns
-------
index : int
The index of the face in collisoin.
"""
return self._inds[name]
def point(self, name):
"""
The 3D point of closest distance on the mesh with the given name.
Parameters
----------
name : str
The name of the target object.
Returns
-------
point : (3,) float
The closest point.
"""
return self._points[name]
class CollisionManager:
"""
A mesh-mesh collision manager.
"""
def __init__(self):
"""
Initialize a mesh-mesh collision manager.
"""
if fcl is None:
raise ValueError("No FCL Available! Please install the python-fcl library")
# {name: {geom:, obj}}
self._objs = {}
# {id(bvh) : str, name}
# unpopulated values will return None
self._names = collections.defaultdict(lambda: None)
self._manager = fcl.DynamicAABBTreeCollisionManager()
self._manager.setup()
def add_object(self, name, mesh, transform=None):
"""
Add an object to the collision manager.
If an object with the given name is already in the manager,
replace it.
Parameters
----------
name : str
An identifier for the object
mesh : Trimesh object
The geometry of the collision object
transform : (4,4) float
Homogeneous transform matrix for the object
"""
# if no transform passed, assume identity transform
if transform is None:
transform = np.eye(4)
transform = np.asanyarray(transform, dtype=np.float32)
if transform.shape != (4, 4):
raise ValueError("transform must be (4,4)!")
# create BVH/Convex
geom = self._get_fcl_obj(mesh)
# create the FCL transform from (4,4) matrix
t = fcl.Transform(transform[:3, :3], transform[:3, 3])
o = fcl.CollisionObject(geom, t)
# Add collision object to set
if name in self._objs:
self._manager.unregisterObject(self._objs[name])
self._objs[name] = {"obj": o, "geom": geom}
# store the name of the geometry
self._names[id(geom)] = name
self._manager.registerObject(o)
self._manager.update()
return o
def remove_object(self, name):
"""
Delete an object from the collision manager.
Parameters
----------
name : str
The identifier for the object
"""
if name in self._objs:
self._manager.unregisterObject(self._objs[name]["obj"])
self._manager.update(self._objs[name]["obj"])
# remove objects from _objs
geom_id = id(self._objs.pop(name)["geom"])
# remove names
self._names.pop(geom_id)
else:
raise ValueError(f"{name} not in collision manager!")
def set_transform(self, name, transform):
"""
Set the transform for one of the manager's objects.
This replaces the prior transform.
Parameters
----------
name : str
An identifier for the object already in the manager
transform : (4,4) float
A new homogeneous transform matrix for the object
"""
if name in self._objs:
o = self._objs[name]["obj"]
o.setRotation(transform[:3, :3])
o.setTranslation(transform[:3, 3])
self._manager.update(o)
else:
raise ValueError(f"{name} not in collision manager!")
def in_collision_single(
self, mesh, transform=None, return_names=False, return_data=False
):
"""
Check a single object for collisions against all objects in the
manager.
Parameters
----------
mesh : Trimesh object
The geometry of the collision object
transform : (4,4) float
Homogeneous transform matrix
return_names : bool
If true, a set is returned containing the names
of all objects in collision with the object
return_data : bool
If true, a list of ContactData is returned as well
Returns
------------
is_collision : bool
True if a collision occurs and False otherwise
names : set of str
[OPTIONAL] The set of names of objects that collided with the
provided one
contacts : list of ContactData
[OPTIONAL] All contacts detected
"""
if transform is None:
transform = np.eye(4)
# create BVH/Convex
geom = self._get_fcl_obj(mesh)
# create the FCL transform from (4,4) matrix
t = fcl.Transform(transform[:3, :3], transform[:3, 3])
o = fcl.CollisionObject(geom, t)
# Collide with manager's objects
cdata = fcl.CollisionData()
if return_names or return_data:
cdata = fcl.CollisionData(
request=fcl.CollisionRequest(num_max_contacts=100000, enable_contact=True)
)
self._manager.collide(o, cdata, fcl.defaultCollisionCallback)
result = cdata.result.is_collision
# If we want to return the objects that were collision, collect them.
objs_in_collision = set()
contact_data = []
if return_names or return_data:
for contact in cdata.result.contacts:
cg = contact.o1
if cg == geom:
cg = contact.o2
name = self._extract_name(cg)
names = (name, "__external")
if cg == contact.o2:
names = tuple(reversed(names))
if return_names:
objs_in_collision.add(name)
if return_data:
contact_data.append(ContactData(names, contact))
if return_names and return_data:
return result, objs_in_collision, contact_data
elif return_names:
return result, objs_in_collision
elif return_data:
return result, contact_data
else:
return result
def in_collision_internal(self, return_names=False, return_data=False):
"""
Check if any pair of objects in the manager collide with one another.
Parameters
----------
return_names : bool
If true, a set is returned containing the names
of all pairs of objects in collision.
return_data : bool
If true, a list of ContactData is returned as well
Returns
-------
is_collision : bool
True if a collision occurred between any pair of objects
and False otherwise
names : set of 2-tup
The set of pairwise collisions. Each tuple
contains two names in alphabetical order indicating
that the two corresponding objects are in collision.
contacts : list of ContactData
All contacts detected
"""
cdata = fcl.CollisionData()
if return_names or return_data:
cdata = fcl.CollisionData(
request=fcl.CollisionRequest(num_max_contacts=100000, enable_contact=True)
)
self._manager.collide(cdata, fcl.defaultCollisionCallback)
result = cdata.result.is_collision
objs_in_collision = set()
contact_data = []
if return_names or return_data:
for contact in cdata.result.contacts:
names = (self._extract_name(contact.o1), self._extract_name(contact.o2))
if return_names:
objs_in_collision.add(tuple(sorted(names)))
if return_data:
contact_data.append(ContactData(names, contact))
if return_names and return_data:
return result, objs_in_collision, contact_data
elif return_names:
return result, objs_in_collision
elif return_data:
return result, contact_data
else:
return result
def in_collision_other(self, other_manager, return_names=False, return_data=False):
"""
Check if any object from this manager collides with any object
from another manager.
Parameters
-------------------
other_manager : CollisionManager
Another collision manager object
return_names : bool
If true, a set is returned containing the names
of all pairs of objects in collision.
return_data : bool
If true, a list of ContactData is returned as well
Returns
-------------
is_collision : bool
True if a collision occurred between any pair of objects
and False otherwise
names : set of 2-tup
The set of pairwise collisions. Each tuple
contains two names (first from this manager,
second from the other_manager) indicating
that the two corresponding objects are in collision.
contacts : list of ContactData
All contacts detected
"""
cdata = fcl.CollisionData()
if return_names or return_data:
cdata = fcl.CollisionData(
request=fcl.CollisionRequest(num_max_contacts=100000, enable_contact=True)
)
self._manager.collide(other_manager._manager, cdata, fcl.defaultCollisionCallback)
result = cdata.result.is_collision
objs_in_collision = set()
contact_data = []
if return_names or return_data:
for contact in cdata.result.contacts:
reverse = False
names = (
self._extract_name(contact.o1),
other_manager._extract_name(contact.o2),
)
if names[0] is None:
names = (
self._extract_name(contact.o2),
other_manager._extract_name(contact.o1),
)
reverse = True
if return_names:
objs_in_collision.add(names)
if return_data:
if reverse:
names = tuple(reversed(names))
contact_data.append(ContactData(names, contact))
if return_names and return_data:
return result, objs_in_collision, contact_data
elif return_names:
return result, objs_in_collision
elif return_data:
return result, contact_data
else:
return result
def min_distance_single(
self, mesh, transform=None, return_name=False, return_data=False
):
"""
Get the minimum distance between a single object and any
object in the manager.
Parameters
---------------
mesh : Trimesh object
The geometry of the collision object
transform : (4,4) float
Homogeneous transform matrix for the object
return_names : bool
If true, return name of the closest object
return_data : bool
If true, a DistanceData object is returned as well
Returns
-------------
distance : float
Min distance between mesh and any object in the manager
name : str
The name of the object in the manager that was closest
data : DistanceData
Extra data about the distance query
"""
if transform is None:
transform = np.eye(4)
# create BVH/Convex
geom = self._get_fcl_obj(mesh)
# create the FCL transform from (4,4) matrix
t = fcl.Transform(transform[:3, :3], transform[:3, 3])
o = fcl.CollisionObject(geom, t)
# Collide with manager's objects
ddata = fcl.DistanceData(fcl.DistanceRequest(enable_signed_distance=True))
if return_data:
ddata = fcl.DistanceData(
fcl.DistanceRequest(
enable_nearest_points=True, enable_signed_distance=True
),
fcl.DistanceResult(),
)
self._manager.distance(o, ddata, fcl.defaultDistanceCallback)
distance = ddata.result.min_distance
# If we want to return the objects that were collision, collect them.
name, data = None, None
if return_name or return_data:
cg = ddata.result.o1
if cg == geom:
cg = ddata.result.o2
name = self._extract_name(cg)
names = (name, "__external")
if cg == ddata.result.o2:
names = tuple(reversed(names))
data = DistanceData(names, ddata.result)
if return_name and return_data:
return distance, name, data
elif return_name:
return distance, name
elif return_data:
return distance, data
else:
return distance
def min_distance_internal(self, return_names=False, return_data=False):
"""
Get the minimum distance between any pair of objects in the manager.
Parameters
-------------
return_names : bool
If true, a 2-tuple is returned containing the names
of the closest objects.
return_data : bool
If true, a DistanceData object is returned as well
Returns
-----------
distance : float
Min distance between any two managed objects
names : (2,) str
The names of the closest objects
data : DistanceData
Extra data about the distance query
"""
ddata = fcl.DistanceData(fcl.DistanceRequest(enable_signed_distance=True))
if return_data:
ddata = fcl.DistanceData(
fcl.DistanceRequest(
enable_nearest_points=True,
enable_signed_distance=True,
),
fcl.DistanceResult(),
)
self._manager.distance(ddata, fcl.defaultDistanceCallback)
distance = ddata.result.min_distance
names, data = None, None
if return_names or return_data:
names = (
self._extract_name(ddata.result.o1),
self._extract_name(ddata.result.o2),
)
data = DistanceData(names, ddata.result)
names = tuple(sorted(names))
if return_names and return_data:
return distance, names, data
elif return_names:
return distance, names
elif return_data:
return distance, data
else:
return distance
def min_distance_other(self, other_manager, return_names=False, return_data=False):
"""
Get the minimum distance between any pair of objects,
one in each manager.
Parameters
----------
other_manager : CollisionManager
Another collision manager object
return_names : bool
If true, a 2-tuple is returned containing
the names of the closest objects.
return_data : bool
If true, a DistanceData object is returned as well
Returns
-----------
distance : float
The min distance between a pair of objects,
one from each manager.
names : 2-tup of str
A 2-tuple containing two names (first from this manager,
second from the other_manager) indicating
the two closest objects.
data : DistanceData
Extra data about the distance query
"""
ddata = fcl.DistanceData(fcl.DistanceRequest(enable_signed_distance=True))
if return_data:
ddata = fcl.DistanceData(
fcl.DistanceRequest(
enable_nearest_points=True,
enable_signed_distance=True,
),
fcl.DistanceResult(),
)
self._manager.distance(other_manager._manager, ddata, fcl.defaultDistanceCallback)
distance = ddata.result.min_distance
names, data = None, None
if return_names or return_data:
reverse = False
names = (
self._extract_name(ddata.result.o1),
other_manager._extract_name(ddata.result.o2),
)
if names[0] is None:
reverse = True
names = (
self._extract_name(ddata.result.o2),
other_manager._extract_name(ddata.result.o1),
)
dnames = tuple(names)
if reverse:
dnames = tuple(reversed(dnames))
data = DistanceData(dnames, ddata.result)
if return_names and return_data:
return distance, names, data
elif return_names:
return distance, names
elif return_data:
return distance, data
else:
return distance
def _get_fcl_obj(self, mesh):
"""
Get a BVH or Convex for a mesh.
Parameters
-------------
mesh : Trimesh
Mesh to create BVH/Convex for
Returns
--------------
obj : fcl.BVHModel or fcl.Convex
BVH/Convex object of source mesh
"""
if mesh.is_convex:
obj = mesh_to_convex(mesh)
else:
obj = mesh_to_BVH(mesh)
return obj
def _extract_name(self, geom):
"""
Retrieve the name of an object from the manager by its
CollisionObject, or return None if not found.
Parameters
-----------
geom : CollisionObject or BVHModel
Input model
Returns
------------
names : hashable
Name of input geometry
"""
return self._names[id(geom)]
def mesh_to_BVH(mesh):
"""
Create a BVHModel object from a Trimesh object
Parameters
-----------
mesh : Trimesh
Input geometry
Returns
------------
bvh : fcl.BVHModel
BVH of input geometry
"""
bvh = fcl.BVHModel()
bvh.beginModel(num_tris_=len(mesh.faces), num_vertices_=len(mesh.vertices))
bvh.addSubModel(verts=mesh.vertices, triangles=mesh.faces)
bvh.endModel()
return bvh
def mesh_to_convex(mesh):
"""
Create a Convex object from a Trimesh object
Parameters
-----------
mesh : Trimesh
Input geometry
Returns
------------
convex : fcl.Convex
Convex of input geometry
"""
fs = np.concatenate(
(3 * np.ones((len(mesh.faces), 1), dtype=np.int64), mesh.faces), axis=1
)
return fcl.Convex(mesh.vertices, len(fs), fs.flatten())
def scene_to_collision(scene):
"""
Create collision objects from a trimesh.Scene object.
Parameters
------------
scene : trimesh.Scene
Scene to create collision objects for
Returns
------------
manager : CollisionManager
CollisionManager for objects in scene
objects: {node name: CollisionObject}
Collision objects for nodes in scene
"""
manager = CollisionManager()
objects = {}
for node in scene.graph.nodes_geometry:
T, geometry = scene.graph[node]
objects[node] = manager.add_object(
name=node, mesh=scene.geometry[geometry], transform=T
)
return manager, objects
@@ -0,0 +1,148 @@
"""
comparison.py
----------------
Provide methods for quickly hashing and comparing meshes.
"""
from hashlib import sha256
import numpy as np
from . import util
from .constants import tol
# how many significant figures to use for each
# field of the identifier based on hand-tuning
id_sigfig = np.array(
[
5, # area
10, # euler number
5, # area/volume ratio
2, # convex/mesh area ratio
2, # convex area/volume ratio
3, # max radius squared / area
1,
]
) # sign of triangle count for mirrored
def identifier_simple(mesh):
"""
Return a basic identifier for a mesh consisting of
properties that have been hand tuned to be somewhat
robust to rigid transformations and different
tessellations.
Parameters
------------
mesh : trimesh.Trimesh
Source geometry
Returns
----------
identifier : (7,) float
Identifying values of the mesh
"""
# verify the cache once
mesh._cache.verify()
# don't check hashes during identifier as we aren't
# changing any data values of the mesh inside block
# if we did change values in cache block things would break
with mesh._cache:
# pre-allocate identifier so indexes of values can't move around
# like they might if we used hstack or something else
identifier = np.zeros(7, dtype=np.float64)
# avoid thrashing the cache unnecessarily
mesh_area = mesh.area
# start with properties that are valid regardless of watertightness
# note that we're going to try to make all parameters relative
# to area so other values don't get blown up at weird scales
identifier[0] = mesh_area
# avoid divide-by-zero later
if mesh_area < tol.merge:
mesh_area = 1.0
# topological constant and the only thing we can really
# trust in this fallen world
identifier[1] = mesh.euler_number
# if we have a watertight mesh include volume and inertia
if mesh.is_volume:
# side length of a cube ratio
# 1.0 for cubes, different values for other things
identifier[2] = ((mesh_area / 6.0) ** (1.0 / 2.0)) / (
mesh.volume ** (1.0 / 3.0)
)
else:
# if we don't have a watertight mesh add information about the
# convex hull which is slow to compute and unreliable
try:
# get the hull area and volume
hull = mesh.convex_hull
hull_area = hull.area
hull_volume = hull.volume
except BaseException:
# in-plane or single point geometry has no hull
hull_area = 6.0
hull_volume = 1.0
# just what we're looking for in a hash but hey
identifier[3] = mesh_area / hull_area
# cube side length ratio for the hull
if hull_volume > 1e-12:
identifier[4] = ((hull_area / 6.0) ** (1.0 / 2.0)) / (
hull_volume ** (1.0 / 3.0)
)
# calculate maximum mesh radius
vertices = mesh.vertices - mesh.centroid
# add in max radius^2 to area ratio
R2 = np.dot((vertices**2), [1, 1, 1]).max()
identifier[5] = R2 / mesh_area
# mirrored meshes will look identical in terms of
# area, volume, etc: use a count of relative edge
# lengths to differentiate identical but mirrored meshes
# this doesn't work well on meshes with a small number of faces
if len(mesh.faces) > 50:
# does this mesh have edges that differ substantially in length
# if not this method for detecting reflection will not work
# and the result will definitely be garbage
edges_length = mesh.edges_unique_length
variance = edges_length.std() / edges_length.mean()
if variance > 0.25:
# the length of each edge in faces
norms = edges_length[mesh.edges_unique_inverse].reshape((-1, 3))
# stack edge length and get the relative difference
stack = np.diff(np.column_stack((norms, norms[:, 0])), axis=1)
pick_idx = np.abs(stack).argmin(axis=1)
# get the edge length diff
pick = stack.reshape(-1)[pick_idx + (np.arange(len(pick_idx)) * 3)]
# reduce to the bare minimum that tests stable
identifier[6] = np.sign(pick.sum())
return identifier
def identifier_hash(identifier):
"""
Hash an identifier array in a way that is hand-tuned to be
somewhat robust to likely changes.
Parameters
------------
identifier : (n,) float
Vector of properties
Returns
----------
hash : (64,) str
A SHA256 of the identifier vector at hand-tuned precision.
"""
# convert identifier to integers and order of magnitude
as_int, multiplier = util.sigfig_int(identifier, id_sigfig)
# make all scales positive
if (multiplier < 0).any():
multiplier += np.abs(multiplier.min())
data = (as_int * (10**multiplier)).astype(np.int64)
return sha256(data.tobytes()).hexdigest()
@@ -0,0 +1,158 @@
from dataclasses import dataclass
import numpy as np
from .util import decimal_to_digits, log, now
@dataclass
class ToleranceMesh:
"""
ToleranceMesh objects hold tolerance information about meshes.
Parameters
----------------
tol.zero : float
Floating point numbers smaller than this are considered zero
tol.merge : float
When merging vertices, consider vertices closer than this
to be the same vertex. Here we use the same value (1e-8)
as SolidWorks uses, according to their documentation.
tol.planar : float
The maximum distance from a plane a point can be and
still be considered to be on the plane
tol.facet_threshold : float
Threshold for two facets to be considered coplanar
tol.strict : bool
If True, run additional in- process checks (slower)
"""
# set our zero for floating point comparison to 100x
# the resolution of float64 which works out to 1e-13
zero: float = np.finfo(np.float64).resolution * 100
# vertices closer than this should be merged
merge: float = 1e-8
# peak to valley flatness to be considered planar
planar: float = 1e-5
# coplanar threshold: ratio of (radius / span) ** 2
facet_threshold: int = 5000
# should additional slow checks be run inside functions
strict: bool = False
@dataclass
class TolerancePath:
"""
TolerancePath objects contain tolerance information used in
Path objects.
Parameters
---------------
tol.zero : float
Floating point numbers smaller than this are considered zero
tol.merge : float
When merging vertices, consider vertices closer than this
to be the same vertex. Here we use the same value (1e-8)
as SolidWorks uses, according to their documentation.
tol.planar : float
The maximum distance from a plane a point can be and
still be considered to be on the plane
tol.seg_frac : float
When simplifying line segments what percentage of the drawing
scale can a segment be and have a curve fitted
tol.seg_angle : float
When simplifying line segments to arcs, what angle
can a segment span to be acceptable.
tol.aspect_frac : float
When simplifying line segments to closed arcs (circles)
what percentage can the aspect ratio differfrom 1:1
before escaping the fit early
tol.radius_frac : float
When simplifying line segments to arcs, what percentage
of the fit radius can vertices deviate to be acceptable
tol.radius_min :
When simplifying line segments to arcs, what is the minimum
radius multiplied by document scale for an acceptable fit
tol.radius_max :
When simplifying line segments to arcs, what is the maximum
radius multiplied by document scale for an acceptable fit
tol.tangent :
When simplifying line segments to curves, what is the maximum
angle the end sections can deviate from tangent that is
acceptable.
"""
zero: float = 1e-12
merge: float = 1e-5
planar: float = 1e-5
seg_frac: float = 0.125
seg_angle: float = float(np.radians(50))
seg_angle_min: float = float(np.radians(1))
seg_angle_frac: float = 0.5
aspect_frac: float = 0.1
radius_frac: float = 0.02
radius_min: float = 1e-4
radius_max: float = 50.0
tangent: float = float(np.radians(20))
strict: bool = False
@property
def merge_digits(self) -> int:
return decimal_to_digits(self.merge)
@dataclass
class ResolutionPath:
"""
res.seg_frac : float
When discretizing curves, what percentage of the drawing
scale should we aim to make a single segment
res.seg_angle : float
When discretizing curves, what angle should a section span
res.max_sections : int
When discretizing splines, what is the maximum number
of segments per control point
res.min_sections : int
When discretizing splines, what is the minimum number
of segments per control point
res.export : str
Format string to use when exporting floating point vertices
"""
seg_frac: float = 0.05
seg_angle: float = 0.08
max_sections: float = 500.0
min_sections: float = 20.0
export: str = "0.10f"
# instantiate mesh tolerances with defaults
tol = ToleranceMesh()
# instantiate path tolerances with defaults
tol_path = TolerancePath()
res_path = ResolutionPath()
def log_time(method):
"""
A decorator for methods which will time the method
and then emit a log.debug message with the method name
and how long it took to execute.
"""
def timed(*args, **kwargs):
tic = now()
result = method(*args, **kwargs)
log.debug("%s executed in %.4f seconds.", method.__name__, now() - tic)
return result
timed.__name__ = method.__name__
timed.__doc__ = method.__doc__
return timed
@@ -0,0 +1,389 @@
"""
convex.py
Deal with creating and checking convex objects in 2, 3 and N dimensions.
Convex is defined as:
1) "Convex, meaning "curving out" or "extending outward" (compare to concave)
2) having an outline or surface curved like the exterior of a circle or sphere.
3) (of a polygon) having only interior angles measuring less than 180
"""
from dataclasses import dataclass, fields
import numpy as np
from . import triangles, util
from .constants import tol
from .parent import Geometry3D
from .typed import NDArray, Union
try:
from scipy.spatial import ConvexHull
except ImportError as E:
from .exceptions import ExceptionWrapper
ConvexHull = ExceptionWrapper(E)
try:
from scipy.spatial import QhullError
except BaseException:
QhullError = BaseException
@dataclass
class QhullOptions:
"""
A helper class for constructing correct Qhull option strings.
More details available at: http://www.qhull.org/html/qh-quick.htm#options
Currently only includes the boolean flag options, which is most of them.
Parameters
-----------
Qa
Allow input with fewer or more points than coordinates
Qc
Keep coplanar points with nearest facet
Qi
Keep interior points with nearest facet.
QJ
Joggled input to avoid precision problems
Qt
Triangulated output.
Qu
Compute upper hull for furthest-site Delaunay triangulation
Qw
Allow warnings about Qhull options
Qbb
Scale last coordinate to [0,m] for Delaunay
Qs
Search all points for the initial simplex
Qv
Test vertex neighbors for convexity
Qx
Exact pre-merges (allows coplanar facets)
Qz
Add a point-at-infinity for Delaunay triangulations
QbB
Scale input to fit the unit cube
QR0
Random rotation (n=seed, n=0 time, n=-1 time/no rotate)
Qg
only build good facets (needs 'QGn', 'QVn', or 'Pdk')
Pp
Do not print statistics about precision problems and remove
some of the warnings including the narrow hull warning.
"""
Qa: bool = False
""" Allow input with fewer or more points than coordinates"""
Qc: bool = False
""" Keep coplanar points with nearest facet"""
Qi: bool = False
""" Keep interior points with nearest facet. """
QJ: bool = False
""" Joggled input to avoid precision problems """
Qt: bool = False
""" Triangulated output. """
Qu: bool = False
""" Compute upper hull for furthest-site Delaunay triangulation """
Qw: bool = False
""" Allow warnings about Qhull options """
# Precision handling
Qbb: bool = False
""" Scale last coordinate to [0,m] for Delaunay """
Qs: bool = False
""" Search all points for the initial simplex """
Qv: bool = False
""" Test vertex neighbors for convexity """
Qx: bool = False
""" Exact pre-merges (allows coplanar facets) """
Qz: bool = False
""" Add a point-at-infinity for Delaunay triangulations """
QbB: bool = False
""" Scale input to fit the unit cube """
QR0: bool = False
""" Random rotation (n=seed, n=0 time, n=-1 time/no rotate) """
# Select facets
Qg: bool = False
""" Only build good facets (needs 'QGn', 'QVn', or 'Pdk') """
Pp: bool = False
""" Do not print statistics about precision problems and remove
some of the warnings including the narrow hull warning. """
# TODO : not included non-boolean options
# QBk: Optional[Floating] = None
# """ Scale coord[k] to upper bound of n (default 0.5) """
# Qbk: Optional[Floating] = None
# """ Scale coord[k] to low bound of n (default -0.5) """
# Qbk:0Bk:0
# """ drop dimension k from input """
# QGn
# good facet if visible from point n, -n for not visible
# QVn
# good facet if it includes point n, -n if not
def __str__(self) -> str:
"""
Construct the `qhull_options` string used by `scipy.spatial`
objects and functions.
Returns
----------
qhull_options
Can be passed to `scipy.spatial.[ConvexHull,Delaunay,Voronoi]`
"""
return " ".join(f.name for f in fields(self) if getattr(self, f.name))
QHULL_DEFAULT = QhullOptions(QbB=True, Pp=True, Qt=True)
def convex_hull(
obj: Union[Geometry3D, NDArray],
qhull_options: Union[QhullOptions, str, None] = QHULL_DEFAULT,
repair: bool = True,
) -> "trimesh.Trimesh": # noqa: F821
"""
Get a new Trimesh object representing the convex hull of the
current mesh attempting to return a watertight mesh with correct
normals.
Arguments
--------
obj
Mesh or `(n, 3)` points.
qhull_options
Options to pass to qhull.
Returns
--------
convex
Mesh of convex hull.
"""
# would be a circular import at the module level
from .base import Trimesh
# compose the
if qhull_options is None:
qhull_str = None
elif isinstance(qhull_options, QhullOptions):
# use the __str__ method to compose this options string
qhull_str = str(qhull_options)
elif isinstance(qhull_options, str):
qhull_str = qhull_options
else:
raise TypeError(type(qhull_options))
if hasattr(obj, "vertices"):
points = obj.vertices.view(np.ndarray)
else:
# will remove subclassing
points = np.asarray(obj, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("Object must be Trimesh or (n,3) points!")
try:
hull = ConvexHull(points, qhull_options=qhull_str)
except QhullError:
util.log.debug("Failed to compute convex hull: retrying with `QJ`", exc_info=True)
# try with "joggle" enabled
hull = ConvexHull(points, qhull_options="QJ")
# hull object doesn't remove unreferenced vertices
# create a mask to re- index faces for only referenced vertices
vid = np.sort(hull.vertices)
mask = np.zeros(len(hull.points), dtype=np.int64)
mask[vid] = np.arange(len(vid))
# remove unreferenced vertices here
faces = mask[hull.simplices].copy()
# rescale vertices back to original size
vertices = hull.points[vid].copy()
if not repair:
# create the Trimesh object for the convex hull
return Trimesh(vertices=vertices, faces=faces, process=True, validate=False)
# qhull returns faces with random winding
# calculate the returned normal of each face
crosses = triangles.cross(vertices[faces])
# qhull returns zero magnitude faces like an asshole
normals, valid = util.unitize(crosses, check_valid=True)
# remove zero magnitude faces
faces = faces[valid]
crosses = crosses[valid]
# each triangle area and mean center
triangles_area = triangles.area(crosses=crosses)
triangles_center = vertices[faces].mean(axis=1)
# since the convex hull is (hopefully) convex, the vector from
# the centroid to the center of each face
# should have a positive dot product with the normal of that face
# if it doesn't it is probably backwards
# note that this sometimes gets screwed up by precision issues
centroid = np.average(triangles_center, weights=triangles_area, axis=0)
# a vector from the centroid to a point on each face
test_vector = triangles_center - centroid
# check the projection against face normals
backwards = util.diagonal_dot(normals, test_vector) < 0.0
# flip the winding outward facing
faces[backwards] = np.fliplr(faces[backwards])
# flip the normal
normals[backwards] *= -1.0
# save the work we did to the cache so it doesn't have to be recomputed
initial_cache = {
"triangles_cross": crosses,
"triangles_center": triangles_center,
"area_faces": triangles_area,
"centroid": centroid,
}
# create the Trimesh object for the convex hull
convex = Trimesh(
vertices=vertices,
faces=faces,
face_normals=normals,
initial_cache=initial_cache,
process=True,
validate=False,
)
# we did the gross case above, but sometimes precision issues
# leave some faces backwards anyway
# this call will exit early if the winding is consistent
# and if not will fix it by traversing the adjacency graph
convex.fix_normals(multibody=False)
# sometimes the QbB option will cause precision issues
# so try the hull again without it and
# check for qhull_options is None to avoid infinite recursion
if qhull_options is None and not convex.is_winding_consistent:
return convex_hull(convex, qhull_options=None)
return convex
def adjacency_projections(mesh):
"""
Test if a mesh is convex by projecting the vertices of
a triangle onto the normal of its adjacent face.
Parameters
----------
mesh : Trimesh
Input geometry
Returns
----------
projection : (len(mesh.face_adjacency),) float
Distance of projection of adjacent vertex onto plane
"""
# normals and origins from the first column of face adjacency
normals = mesh.face_normals[mesh.face_adjacency[:, 0]]
# one of the vertices on the shared edge
origins = mesh.vertices[mesh.face_adjacency_edges[:, 0]]
# faces from the second column of face adjacency
vid_other = mesh.face_adjacency_unshared[:, 1]
vector_other = mesh.vertices[vid_other] - origins
# get the projection with a dot product
dots = util.diagonal_dot(vector_other, normals)
return dots
def is_convex(mesh):
"""
Check if a mesh is convex.
Parameters
-----------
mesh : Trimesh
Input geometry
Returns
-----------
convex : bool
Was passed mesh convex or not
"""
# non-watertight meshes are not convex
# meshes with multiple bodies are not convex
if not mesh.is_watertight or mesh.body_count != 1:
return False
# don't consider zero- area faces
nonzero = mesh.area_faces > tol.zero
# adjacencies with two nonzero faces
adj_ok = nonzero[mesh.face_adjacency].all(axis=1)
# if none of our face pairs are both nonzero exit
# TODO : is this the correct check?
# or should we just compare the projections
# to the mesh scale
if not adj_ok.any():
return False
# make threshold of convexity scale- relative
threshold = tol.planar * mesh.scale
# if projections of vertex onto plane of adjacent
# face is negative, it means the face pair is locally
# convex, and if that is true for all faces the mesh is convex
convex = bool(mesh.face_adjacency_projections[adj_ok].max() < threshold)
return convex
def hull_points(obj, qhull_options="QbB Pp"):
"""
Try to extract a convex set of points from multiple input formats.
Details on qhull options:
http://www.qhull.org/html/qh-quick.htm#options
Parameters
---------
obj: Trimesh object
(n,d) points
(m,) Trimesh objects
Returns
--------
points: (o,d) convex set of points
"""
if hasattr(obj, "convex_hull"):
return obj.convex_hull.vertices
initial = np.asanyarray(obj, dtype=np.float64)
if len(initial.shape) != 2:
raise ValueError("points must be (n, dimension)!")
hull = ConvexHull(initial, qhull_options=qhull_options)
points = hull.points[hull.vertices]
return points
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,196 @@
"""
curvature.py
---------------
Query mesh curvature.
"""
import numpy as np
from . import util
try:
from scipy.sparse import coo_matrix
except ImportError as E:
from . import exceptions
coo_matrix = exceptions.ExceptionWrapper(E)
def face_angles_sparse(mesh):
"""
A sparse matrix representation of the face angles.
Returns
----------
sparse : scipy.sparse.coo_matrix
matrix is float shaped (len(vertices), len(faces))
"""
matrix = coo_matrix(
(mesh.face_angles.flatten(), (mesh.faces_sparse.row, mesh.faces_sparse.col)),
mesh.faces_sparse.shape,
)
return matrix
def vertex_defects(mesh):
"""
Return the vertex defects, or (2*pi) minus the sum of the
angles of every face that includes that vertex.
If a vertex is only included by coplanar triangles, this
will be zero. For convex regions this is positive, and
concave negative.
Returns
--------
vertex_defect : (len(self.vertices), ) float
Vertex defect at the every vertex
"""
angle_sum = np.array(mesh.face_angles_sparse.sum(axis=1)).flatten()
defect = (2 * np.pi) - angle_sum
return defect
def discrete_gaussian_curvature_measure(mesh, points, radius):
"""
Return the discrete gaussian curvature measure of a sphere
centered at a point as detailed in 'Restricted Delaunay
triangulations and normal cycle'- Cohen-Steiner and Morvan.
This is the sum of the vertex defects at all vertices
within the radius for each point.
Parameters
----------
points : (n, 3) float
Points in space
radius : float ,
The sphere radius, which can be zero if vertices
passed are points.
Returns
--------
gaussian_curvature: (n,) float
Discrete gaussian curvature measure.
"""
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)!")
nearest = mesh.kdtree.query_ball_point(points, radius)
gauss_curv = [mesh.vertex_defects[vertices].sum() for vertices in nearest]
return np.asarray(gauss_curv)
def discrete_mean_curvature_measure(mesh, points, radius):
"""
Return the discrete mean curvature measure of a sphere
centered at a point as detailed in 'Restricted Delaunay
triangulations and normal cycle'- Cohen-Steiner and Morvan.
This is the sum of the angle at all edges contained in the
sphere for each point.
Parameters
----------
points : (n, 3) float
Points in space
radius : float
Sphere radius which should typically be greater than zero
Returns
--------
mean_curvature : (n,) float
Discrete mean curvature measure.
"""
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)!")
# axis aligned bounds
bounds = np.column_stack((points - radius, points + radius))
# line segments that intersect axis aligned bounding box
candidates = [list(mesh.face_adjacency_tree.intersection(b)) for b in bounds]
mean_curv = np.zeros(len(points))
for i, (x, x_candidates) in enumerate(zip(points, candidates)):
endpoints = mesh.vertices[mesh.face_adjacency_edges[x_candidates]]
lengths = line_ball_intersection(
endpoints[:, 0], endpoints[:, 1], center=x, radius=radius
)
angles = mesh.face_adjacency_angles[x_candidates]
signs = np.where(mesh.face_adjacency_convex[x_candidates], 1, -1)
mean_curv[i] = (lengths * angles * signs).sum() / 2
return mean_curv
def line_ball_intersection(start_points, end_points, center, radius):
"""
Compute the length of the intersection of a line segment with a ball.
Parameters
----------
start_points : (n,3) float, list of points in space
end_points : (n,3) float, list of points in space
center : (3,) float, the sphere center
radius : float, the sphere radius
Returns
--------
lengths: (n,) float, the lengths.
"""
# We solve for the intersection of |x-c|**2 = r**2 and
# x = o + dL. This yields
# d = (-l.(o-c) +- sqrt[ l.(o-c)**2 - l.l((o-c).(o-c) - r^**2) ]) / l.l
L = end_points - start_points
oc = start_points - center # o-c
r = radius
ldotl = np.einsum("ij, ij->i", L, L) # l.l
ldotoc = np.einsum("ij, ij->i", L, oc) # l.(o-c)
ocdotoc = np.einsum("ij, ij->i", oc, oc) # (o-c).(o-c)
discrims = ldotoc**2 - ldotl * (ocdotoc - r**2)
# If discriminant is non-positive, then we have zero length
lengths = np.zeros(len(start_points))
# Otherwise we solve for the solns with d2 > d1.
m = discrims > 0 # mask
d1 = (-ldotoc[m] - np.sqrt(discrims[m])) / ldotl[m]
d2 = (-ldotoc[m] + np.sqrt(discrims[m])) / ldotl[m]
# Line segment means we have 0 <= d <= 1
d1 = np.clip(d1, 0, 1)
d2 = np.clip(d2, 0, 1)
# Length is |o + d2 l - o + d1 l| = (d2 - d1) |l|
lengths[m] = (d2 - d1) * np.sqrt(ldotl[m])
return lengths
def sphere_ball_intersection(R, r):
"""
Compute the surface area of the intersection of sphere of radius R centered
at (0, 0, 0) with a ball of radius r centered at (R, 0, 0).
Parameters
----------
R : float, sphere radius
r : float, ball radius
Returns
--------
area: float, the surface are.
"""
x = (2 * R**2 - r**2) / (2 * R) # x coord of plane
if x >= -R:
return 2 * np.pi * R * (R - x)
if x < -R:
return 4 * np.pi * R**2
@@ -0,0 +1,51 @@
import numpy as np
from .typed import Dict, List
def convex_decomposition(mesh, **kwargs) -> List[Dict]:
"""
Compute an approximate convex decomposition of a mesh.
VHACD Parameters which can be passed as kwargs:
Name Default
-----------------------------------------
maxConvexHulls 64
resolution 400000
minimumVolumePercentErrorAllowed 1.0
maxRecursionDepth 10
shrinkWrap True
fillMode "flood"
maxNumVerticesPerCH 64
asyncACD True
minEdgeLength 2
findBestPlane False
Parameters
----------
mesh : trimesh.Trimesh
Mesh to be decomposed into convex parts
**kwargs : VHACD keyword arguments
Returns
-------
mesh_args : list
List of **kwargs for Trimeshes that are nearly
convex and approximate the original.
"""
from vhacdx import compute_vhacd
# the faces are triangulated in a (len(face), ...vertex-index)
# for vtkPolyData
# i.e. so if shaped to four columns the first column is all 3
faces = (
np.column_stack((np.ones(len(mesh.faces), dtype=np.int64) * 3, mesh.faces))
.ravel()
.astype(np.uint32)
)
return [
{"vertices": v, "faces": f}
for v, f in compute_vhacd(mesh.vertices, faces, **kwargs)
]
@@ -0,0 +1,40 @@
"""
exceptions.py
----------------
Wrap exceptions.
"""
class ExceptionWrapper:
"""
Create a dummy object which will raise an exception when attributes
are accessed (i.e. when used as a module) or when called (i.e.
when used like a function)
For soft dependencies we want to survive failing to import but
we would like to raise an appropriate error when the functionality is
actually requested so the user gets an easily debuggable message.
"""
def __init__(self, exception: BaseException):
# store the exception type and the args rather than the whole thing
# this prevents the locals from the time of the exception
# from being stored as well.
self.exception = (type(exception), exception.args)
def __getattribute__(self, *args, **kwargs):
# will raise when this object is accessed like an object
# if it's asking for our class type return None
# this allows isinstance() checks to not re-raise
if args[0] == "__class__":
return None.__class__
# re-create our original exception from the type and arguments
exc_type, exc_args = super().__getattribute__("exception")
raise exc_type(*exc_args)
def __call__(self, *args, **kwargs):
# behave the same when this object is called like a function
# as when someone tries to access an attribute like a module
self.__getattribute__("exception")
@@ -0,0 +1,14 @@
"""
trimesh/exchange
----------------
Contains the importers and exporters for various mesh formats.
Note that *you should probably not be using these directly*, if
you call `trimesh.load` it will then call and wrap the result
of the various loaders:
```
mesh = trimesh.load(file_name)
```
"""
@@ -0,0 +1,575 @@
"""
Parsing functions for Binvox files.
https://www.patrickmin.com/binvox/binvox.html
Exporting meshes as binvox files requires the
`binvox` executable to be in your path.
"""
import collections
import os
import subprocess
from tempfile import TemporaryDirectory
import numpy as np
from .. import util
from ..base import Trimesh
# find the executable for binvox in PATH
binvox_encoder = util.which("binvox")
Binvox = collections.namedtuple("Binvox", ["rle_data", "shape", "translate", "scale"])
def parse_binvox_header(fp):
"""
Read the header from a binvox file.
Spec available:
https://www.patrickmin.com/binvox/binvox.html
Parameters
------------
fp: file-object
File like object with binvox file
Returns
----------
shape : tuple
Shape of binvox according to binvox spec
translate : tuple
Translation
scale : float
Scale of voxels
Raises
------------
IOError
If invalid binvox file.
"""
line = fp.readline().strip()
if hasattr(line, "decode"):
binvox = b"#binvox"
space = b" "
else:
binvox = "#binvox"
space = " "
if not line.startswith(binvox):
raise OSError("Not a binvox file")
shape = tuple(int(s) for s in fp.readline().strip().split(space)[1:])
translate = tuple(float(s) for s in fp.readline().strip().split(space)[1:])
scale = float(fp.readline().strip().split(space)[1])
fp.readline()
return shape, translate, scale
def parse_binvox(fp, writeable=False):
"""
Read a binvox file, spec at
https://www.patrickmin.com/binvox/binvox.html
Parameters
------------
fp: file-object
File like object with binvox file
Returns
----------
binvox : namedtuple
Containing data
rle : numpy array
Run length encoded data
Raises
------------
IOError
If invalid binvox file
"""
# get the header info
shape, translate, scale = parse_binvox_header(fp)
# get the rest of the file
data = fp.read()
# convert to numpy array
rle_data = np.frombuffer(data, dtype=np.uint8)
if writeable:
rle_data = rle_data.copy()
return Binvox(rle_data, shape, translate, scale)
_binvox_header = """#binvox 1
dim {sx} {sy} {sz}
translate {tx} {ty} {tz}
scale {scale}
data
"""
def binvox_header(shape, translate, scale):
"""
Get a binvox header string.
Parameters
--------
shape: length 3 iterable of ints denoting shape of voxel grid.
translate: length 3 iterable of floats denoting translation.
scale: num length of entire voxel grid.
Returns
--------
string including "data\n" line.
"""
sx, sy, sz = (int(s) for s in shape)
tx, ty, tz = translate
return _binvox_header.format(sx=sx, sy=sy, sz=sz, tx=tx, ty=ty, tz=tz, scale=scale)
def binvox_bytes(rle_data, shape, translate=(0, 0, 0), scale=1):
"""Get a binary representation of binvox data.
Parameters
--------
rle_data : numpy array
Run-length encoded numpy array.
shape : (3,) int
Shape of voxel grid.
translate : (3,) float
Translation of voxels
scale : float
Length of entire voxel grid.
Returns
--------
data : bytes
Suitable for writing to binary file
"""
if rle_data.dtype != np.uint8:
raise ValueError(f"rle_data.dtype must be np.uint8, got {rle_data.dtype}")
header = binvox_header(shape, translate, scale).encode()
return header + rle_data.tobytes()
def voxel_from_binvox(rle_data, shape, translate=None, scale=1.0, axis_order="xzy"):
"""
Factory for building from data associated with binvox files.
Parameters
---------
rle_data : numpy
Run-length-encoded of flat voxel
values, or a `trimesh.rle.RunLengthEncoding` object.
See `trimesh.rle` documentation for description of encoding
shape : (3,) int
Shape of voxel grid.
translate : (3,) float
Translation of voxels
scale : float
Length of entire voxel grid.
encoded_axes : iterable
With values in ('x', 'y', 'z', 0, 1, 2),
where x => 0, y => 1, z => 2
denoting the order of axes in the encoded data. binvox by
default saves in xzy order, but using `xyz` (or (0, 1, 2)) will
be faster in some circumstances.
Returns
---------
result : VoxelGrid
Loaded voxels
"""
# shape must be uniform else scale is ambiguous
from .. import transformations
from ..voxel import encoding as enc
from ..voxel.base import VoxelGrid
if isinstance(rle_data, enc.RunLengthEncoding):
encoding = rle_data
else:
encoding = enc.RunLengthEncoding(rle_data, dtype=bool)
# translate = np.asanyarray(translate) * scale)
# translate = [0, 0, 0]
transform = transformations.scale_and_translate(
scale=scale / (np.array(shape) - 1), translate=translate
)
if axis_order == "xzy":
perm = (0, 2, 1)
shape = tuple(shape[p] for p in perm)
encoding = encoding.reshape(shape).transpose(perm)
elif axis_order is None or axis_order == "xyz":
encoding = encoding.reshape(shape)
else:
raise ValueError(
"Invalid axis_order '%s': must be None, 'xyz' or 'xzy'", axis_order
)
assert encoding.shape == shape
return VoxelGrid(encoding, transform)
def load_binvox(file_obj, resolver=None, axis_order="xzy", file_type=None):
"""
Load trimesh `VoxelGrid` instance from file.
Parameters
-----------
file_obj : file-like object
Contains binvox data
resolver : unused
axis_order : str
Order of axes in encoded data.
Binvox default is 'xzy', but 'xyz' may be faster
where this is not relevant.
Returns
---------
result : trimesh.voxel.VoxelGrid
Loaded voxel data
"""
if file_type is not None and file_type != "binvox":
raise ValueError(f"file_type must be None or binvox, got {file_type}")
data = parse_binvox(file_obj, writeable=True)
return voxel_from_binvox(
rle_data=data.rle_data,
shape=data.shape,
translate=data.translate,
scale=data.scale,
axis_order=axis_order,
)
def export_binvox(voxel, axis_order="xzy"):
"""
Export `trimesh.voxel.VoxelGrid` instance to bytes
Parameters
------------
voxel : `trimesh.voxel.VoxelGrid`
Assumes axis ordering of `xyz` and encodes
in binvox default `xzy` ordering.
axis_order : str
Eements in ('x', 'y', 'z', 0, 1, 2), the order
of axes to encode data (standard is 'xzy' for binvox). `voxel`
data is assumed to be in order 'xyz'.
Returns
-----------
result : bytes
Representation according to binvox spec
"""
translate = voxel.translation
scale = voxel.scale * (np.array(voxel.shape) - 1)
(neg_scale,) = np.where(scale < 0)
encoding = voxel.encoding.flip(neg_scale)
scale = np.abs(scale)
if not util.allclose(scale[0], scale[1:], 1e-6 * scale[0] + 1e-8):
raise ValueError("Can only export binvox with uniform scale")
scale = scale[0]
if axis_order == "xzy":
encoding = encoding.transpose((0, 2, 1))
elif axis_order != "xyz":
raise ValueError('Invalid axis_order: must be one of ("xyz", "xzy")')
rle_data = encoding.flat.run_length_data(dtype=np.uint8)
return binvox_bytes(rle_data, shape=voxel.shape, translate=translate, scale=scale)
class Binvoxer:
"""
Interface for binvox CL tool.
This class is responsible purely for making calls to the CL tool. It
makes no attempt to integrate with the rest of trimesh at all.
Constructor args configure command line options.
`Binvoxer.__call__` operates on the path to a mode file.
If using this interface in published works, please cite the references
below.
See CL tool website for further details.
https://www.patrickmin.com/binvox/
@article{nooruddin03,
author = {Fakir S. Nooruddin and Greg Turk},
title = {Simplification and Repair of Polygonal Models Using Volumetric
Techniques},
journal = {IEEE Transactions on Visualization and Computer Graphics},
volume = {9},
number = {2},
pages = {191--205},
year = {2003}
}
@Misc{binvox,
author = {Patrick Min},
title = {binvox},
howpublished = {{\tt http://www.patrickmin.com/binvox} or
{\tt https://www.google.com/search?q=binvox}},
year = {2004 - 2019},
note = {Accessed: yyyy-mm-dd}
}
"""
SUPPORTED_INPUT_TYPES = (
"ug",
"obj",
"off",
"dfx",
"xgl",
"pov",
"brep",
"ply",
"jot",
)
SUPPORTED_OUTPUT_TYPES = (
"binvox",
"hips",
"mira",
"vtk",
"raw",
"schematic",
"msh",
)
def __init__(
self,
dimension=32,
file_type="binvox",
z_buffer_carving=True,
z_buffer_voting=True,
dilated_carving=False,
exact=True,
bounding_box=None,
remove_internal=False,
center=False,
rotate_x=0,
rotate_z=0,
wireframe=False,
fit=False,
block_id=None,
use_material_block_id=False,
use_offscreen_pbuffer=False,
downsample_factor=None,
downsample_threshold=None,
verbose=False,
binvox_path=None,
):
"""
Configure the voxelizer.
Parameters
------------
dimension: voxel grid size (max 1024 when not using exact)
file_type: str
Output file type, supported types are:
'binvox'
'hips'
'mira'
'vtk'
'raw'
'schematic'
'msh'
z_buffer_carving : use z buffer based carving. At least one of
`z_buffer_carving` and `z_buffer_voting` must be True.
z_buffer_voting: use z-buffer based parity voting method.
dilated_carving: stop carving 1 voxel before intersection.
exact: any voxel with part of a triangle gets set. Does not use
graphics card.
bounding_box: 6-element float list/tuple of min, max values,
(minx, miny, minz, maxx, maxy, maxz)
remove_internal: remove internal voxels if True. Note there is some odd
behaviour if boundary voxels are occupied.
center: center model inside unit cube.
rotate_x: number of 90 degree ccw rotations around x-axis before
voxelizing.
rotate_z: number of 90 degree cw rotations around z-axis before
voxelizing.
wireframe: also render the model in wireframe (helps with thin parts).
fit: only write voxels in the voxel bounding box.
block_id: when converting to schematic, use this as the block ID.
use_matrial_block_id: when converting from obj to schematic, parse
block ID from material spec "usemtl blockid_<id>" (ids 1-255 only).
use_offscreen_pbuffer: use offscreen pbuffer instead of onscreen
window.
downsample_factor: downsample voxels by this factor in each dimension.
Must be a power of 2 or None. If not None/1 and `core dumped`
errors occur, try slightly adjusting dimensions.
downsample_threshold: when downsampling, destination voxel is on if
more than this number of voxels are on.
verbose : bool
If False, silences stdout/stderr from subprocess call.
binvox_path : str
Path to binvox executable. The default looks for an
executable called `binvox` on your `PATH`.
"""
if binvox_path is None:
encoder = binvox_encoder
else:
encoder = binvox_path
if encoder is None:
raise OSError(
" ".join(
[
"No `binvox_path` provided and no binvox executable found",
"on PATH, please go to https://www.patrickmin.com/binvox/ and",
"download the appropriate version.",
]
)
)
if dimension > 1024 and not exact:
raise ValueError("Maximum dimension using exact is 1024, got %d", dimension)
if file_type not in Binvoxer.SUPPORTED_OUTPUT_TYPES:
raise ValueError(
f"file_type {file_type} not in set of supported output types {Binvoxer.SUPPORTED_OUTPUT_TYPES!s}"
)
args = [encoder, "-d", str(dimension), "-t", file_type]
if exact:
args.append("-e")
if z_buffer_carving:
if z_buffer_voting:
pass
else:
args.append("-c")
elif z_buffer_voting:
args.append("-v")
else:
raise ValueError(
"One of `z_buffer_carving` or `z_buffer_voting` must be True"
)
if dilated_carving:
args.append("-dc")
# Additional parameters
if bounding_box is not None:
if len(bounding_box) != 6:
raise ValueError("bounding_box must have 6 elements")
args.append("-bb")
args.extend(str(b) for b in bounding_box)
if remove_internal:
args.append("-ri")
if center:
args.append("-cb")
args.extend(("-rotx",) * rotate_x)
args.extend(("-rotz",) * rotate_z)
if wireframe:
args.append("-aw")
if fit:
args.append("-fit")
if block_id is not None:
args.extend(("-bi", block_id))
if use_material_block_id:
args.append("-mb")
if use_offscreen_pbuffer:
args.append("-pb")
if downsample_factor is not None:
times = np.log2(downsample_factor)
if int(times) != times:
raise ValueError(
"downsample_factor must be a power of 2, got %d", downsample_factor
)
args.extend(("-down",) * int(times))
if downsample_threshold is not None:
args.extend(("-dmin", str(downsample_threshold)))
args.append("PATH")
self._args = args
self._file_type = file_type
self.verbose = verbose
@property
def file_type(self):
return self._file_type
def __call__(self, path, overwrite=False):
"""
Create an voxel file in the same directory as model at `path`.
Parameters
------------
path: string path to model file. Supported types:
'ug'
'obj'
'off'
'dfx'
'xgl'
'pov'
'brep'
'ply'
'jot' (polygongs only)
overwrite: if False, checks the output path (head.file_type) is empty
before running. If True and a file exists, raises an IOError.
Returns
------------
string path to voxel file. File type give by file_type in constructor.
"""
head, ext = os.path.splitext(path)
ext = ext[1:].lower()
if ext not in Binvoxer.SUPPORTED_INPUT_TYPES:
raise ValueError(
f"file_type {ext} not in set of supported input types {Binvoxer.SUPPORTED_INPUT_TYPES!s}"
)
out_path = f"{head}.{self._file_type}"
if os.path.isfile(out_path) and not overwrite:
raise OSError("Attempted to voxelize object at existing path")
self._args[-1] = path
# generalizes to python2 and python3
# will capture terminal output into variable rather than printing
verbosity = subprocess.check_output(self._args)
# if requested print ourselves
if self.verbose:
util.log.debug(verbosity)
return out_path
def voxelize_mesh(mesh, binvoxer=None, export_type="off", **binvoxer_kwargs):
"""
Interface for voxelizing Trimesh object via the binvox tool.
Implementation simply saved the mesh in the specified export_type then
runs the `Binvoxer.__call__` (using either the supplied `binvoxer` or
creating one via `binvoxer_kwargs`)
Parameters
------------
mesh: Trimesh object to voxelize.
binvoxer: optional Binvoxer instance.
export_type: file type to export mesh as temporarily for Binvoxer to
operate on.
**binvoxer_kwargs: kwargs for creating a new Binvoxer instance. If binvoxer
if provided, this must be empty.
Returns
------------
`VoxelGrid` object resulting.
"""
if not isinstance(mesh, Trimesh):
raise ValueError(f"mesh must be Trimesh instance, got {mesh!s}")
if binvoxer is None:
binvoxer = Binvoxer(**binvoxer_kwargs)
elif len(binvoxer_kwargs) > 0:
raise ValueError("Cannot provide binvoxer and binvoxer_kwargs")
if binvoxer.file_type != "binvox":
raise ValueError('Only "binvox" binvoxer `file_type` currently supported')
with TemporaryDirectory() as folder:
model_path = os.path.join(folder, f"model.{export_type}")
with open(model_path, "wb") as fp:
mesh.export(fp, file_type=export_type)
out_path = binvoxer(model_path)
with open(out_path, "rb") as fp:
out_model = load_binvox(fp)
return out_model
_binvox_loaders = {"binvox": load_binvox}
@@ -0,0 +1,74 @@
import os
import tempfile
from ..exceptions import ExceptionWrapper
from ..typed import BinaryIO, Dict, Number, Optional
# used as an intermediate format
from .gltf import load_glb
def load_step(
file_obj: BinaryIO,
file_type,
tol_linear: Optional[Number] = None,
tol_angular: Optional[Number] = None,
tol_relative: Optional[bool] = False,
merge_primitives: bool = True,
**kwargs,
) -> Dict:
"""
Use `cascadio` a packaged version of OpenCASCADE
to load a STEP file using GLB as an intermediate.
Parameters
-----------
file_obj
STEP file to load.
**kwargs
Passed to `cascadio.step_to_glb`
Returns
----------
kwargs
Keyword arguments for a Scene.
"""
# TODO : update upstream `cascadio` to accept bytes objects
# so that we don't need to write a temporary file to disc!
with tempfile.TemporaryDirectory() as F:
# temporarily copy the STEP
stepfile = os.path.join(F, "data.step")
with open(stepfile, "wb") as f:
f.write(file_obj.read())
# where to save the converted GLB
glbfile = os.path.join(F, "converted.glb")
# the arguments for cascadio are not optional so
# filter out any `None` value arguments here
cascadio_kwargs = {
"merge_primitives": bool(merge_primitives),
"tol_linear": tol_linear,
"tol_angular": tol_angular,
"tol_relative": tol_relative,
}
# run the conversion
cascadio.step_to_glb(
stepfile,
glbfile,
**{k: v for k, v in cascadio_kwargs.items() if v is not None},
)
with open(glbfile, "rb") as f:
# return the parsed intermediate file
return load_glb(file_obj=f, merge_primitives=merge_primitives, **kwargs)
try:
# wheels for most platforms: `pip install cascadio`
import cascadio
_cascade_loaders = {"stp": load_step, "step": load_step}
except BaseException as E:
wrapper = ExceptionWrapper(E)
_cascade_loaders = {"stp": wrapper, "step": wrapper}
@@ -0,0 +1,457 @@
import copy
import io
import uuid
import numpy as np
from .. import util, visual
from ..constants import log
from ..util import unique_name
_EYE = np.eye(4)
_EYE.flags.writeable = False
def load_collada(file_obj, resolver=None, ignore_broken=True, **kwargs):
"""
Load a COLLADA (.dae) file into a list of trimesh kwargs.
Parameters
----------
file_obj : file object
Containing a COLLADA file
resolver : trimesh.visual.Resolver or None
For loading referenced files, like texture images
ignore_broken: bool
Ignores broken references during loading:
[collada.common.DaeUnsupportedError,
collada.common.DaeBrokenRefError]
kwargs : **
Passed to trimesh.Trimesh.__init__
Returns
-------
loaded : list of dict
kwargs for Trimesh constructor
"""
import collada
if ignore_broken:
ignores = [
collada.common.DaeError,
collada.common.DaeIncompleteError,
collada.common.DaeMalformedError,
collada.common.DaeBrokenRefError,
collada.common.DaeUnsupportedError,
collada.common.DaeIncompleteError,
]
else:
ignores = None
# load scene using pycollada
c = collada.Collada(file_obj, ignore=ignores)
# Create material map from Material ID to trimesh material
material_map = {}
for m in c.materials:
effect = m.effect
material_map[m.id] = _parse_material(effect, resolver)
unit = c.assetInfo.unitmeter
if unit is None or np.isclose(unit, 1.0):
metadata = {"units": "meters"}
else:
metadata = {"units": f"{unit} * meters"}
# name : kwargs
meshes = {}
# increments to enable `unique_name` to avoid n^2 behavior
meshes_count = {}
# list of dict
graph = []
for node in c.scene.nodes:
_parse_node(
node=node,
parent_matrix=_EYE,
material_map=material_map,
meshes=meshes,
meshes_count=meshes_count,
graph=graph,
resolver=resolver,
metadata=metadata,
)
return {"class": "Scene", "graph": graph, "geometry": meshes}
def export_collada(mesh, **kwargs):
"""
Export a mesh or a list of meshes as a COLLADA .dae file.
Parameters
-----------
mesh: Trimesh object or list of Trimesh objects
The mesh(es) to export.
Returns
-----------
export: str, string of COLLADA format output
"""
import collada
meshes = mesh
if not isinstance(mesh, (list, tuple, set, np.ndarray)):
meshes = [mesh]
c = collada.Collada()
nodes = []
for i, m in enumerate(meshes):
# Load uv, colors, materials
uv = None
colors = None
mat = _unparse_material(None)
if m.visual.defined:
if m.visual.kind == "texture":
mat = _unparse_material(m.visual.material)
uv = m.visual.uv
elif m.visual.kind == "vertex":
colors = (m.visual.vertex_colors / 255.0)[:, :3]
mat.effect.diffuse = np.array(m.visual.main_color) / 255.0
elif m.visual.kind == "face":
mat.effect.diffuse = np.array(m.visual.main_color) / 255.0
c.effects.append(mat.effect)
c.materials.append(mat)
# Create geometry object
vertices = collada.source.FloatSource(
"verts-array", m.vertices.flatten(), ("X", "Y", "Z")
)
normals = collada.source.FloatSource(
"normals-array", m.vertex_normals.flatten(), ("X", "Y", "Z")
)
input_list = collada.source.InputList()
input_list.addInput(0, "VERTEX", "#verts-array")
input_list.addInput(1, "NORMAL", "#normals-array")
arrays = [vertices, normals]
if (uv is not None) and (len(uv) > 0):
texcoords = collada.source.FloatSource(
"texcoords-array", uv.flatten(), ("U", "V")
)
input_list.addInput(2, "TEXCOORD", "#texcoords-array")
arrays.append(texcoords)
if colors is not None:
idx = 2
if uv:
idx = 3
colors = collada.source.FloatSource(
"colors-array", colors.flatten(), ("R", "G", "B")
)
input_list.addInput(idx, "COLOR", "#colors-array")
arrays.append(colors)
geom = collada.geometry.Geometry(c, uuid.uuid4().hex, uuid.uuid4().hex, arrays)
indices = np.repeat(m.faces.flatten(), len(arrays))
matref = f"material{i}"
triset = geom.createTriangleSet(indices, input_list, matref)
geom.primitives.append(triset)
c.geometries.append(geom)
matnode = collada.scene.MaterialNode(matref, mat, inputs=[])
geomnode = collada.scene.GeometryNode(geom, [matnode])
node = collada.scene.Node(f"node{i}", children=[geomnode])
nodes.append(node)
scene = collada.scene.Scene("scene", nodes)
c.scenes.append(scene)
c.scene = scene
b = io.BytesIO()
c.write(b)
b.seek(0)
return b.read()
def _parse_node(
node, parent_matrix, material_map, meshes, meshes_count, graph, resolver, metadata
):
"""
Recursively parse COLLADA scene nodes.
"""
import collada
# Parse mesh node
if isinstance(node, collada.scene.GeometryNode):
geometry = node.geometry
# Create local material map from material symbol to actual material
local_material_map = {}
for mn in node.materials:
symbol = mn.symbol
m = mn.target
if m.id in material_map:
local_material_map[symbol] = material_map[m.id]
else:
local_material_map[symbol] = _parse_material(m, resolver)
# Iterate over primitives of geometry
for primitive in geometry.primitives:
if isinstance(primitive, collada.polylist.Polylist):
primitive = primitive.triangleset()
if isinstance(primitive, collada.triangleset.TriangleSet):
vertex = primitive.vertex
if vertex is None:
continue
vertex_index = primitive.vertex_index
vertices = vertex[vertex_index].reshape(len(vertex_index) * 3, 3)
# Get normals if present
normals = None
if primitive.normal is not None:
normal = primitive.normal
normal_index = primitive.normal_index
normals = normal[normal_index].reshape(len(normal_index) * 3, 3)
# Get colors if present
colors = None
s = primitive.sources
if "COLOR" in s and len(s["COLOR"]) > 0 and len(primitive.index) > 0:
color = s["COLOR"][0][4].data
color_index = primitive.index[:, :, s["COLOR"][0][0]]
colors = color[color_index].reshape(len(color_index) * 3, -1)
faces = np.arange(vertices.shape[0]).reshape(vertices.shape[0] // 3, 3)
# Get UV coordinates if possible
vis = None
if primitive.material in local_material_map:
material = copy.copy(local_material_map[primitive.material])
uv = None
if len(primitive.texcoordset) > 0:
texcoord = primitive.texcoordset[0]
texcoord_index = primitive.texcoord_indexset[0]
uv = texcoord[texcoord_index].reshape(
(len(texcoord_index) * 3, 2)
)
vis = visual.texture.TextureVisuals(uv=uv, material=material)
geom_name = unique_name(geometry.id, contains=meshes, counts=meshes_count)
meshes[geom_name] = {
"vertices": vertices,
"faces": faces,
"vertex_normals": normals,
"vertex_colors": colors,
"visual": vis,
"metadata": metadata,
}
graph.append(
{
"frame_to": geom_name,
"matrix": parent_matrix,
"geometry": geom_name,
}
)
# recurse down tree for nodes with children
elif isinstance(node, collada.scene.Node):
if node.children is not None:
for child in node.children:
# create the new matrix
matrix = np.dot(parent_matrix, node.matrix)
# parse the child node
_parse_node(
node=child,
parent_matrix=matrix,
material_map=material_map,
meshes=meshes,
meshes_count=meshes_count,
graph=graph,
resolver=resolver,
metadata=metadata,
)
elif isinstance(node, collada.scene.CameraNode):
# TODO: convert collada cameras to trimesh cameras
pass
elif isinstance(node, collada.scene.LightNode):
# TODO: convert collada lights to trimesh lights
pass
def _load_texture(file_name, resolver):
"""
Load a texture from a file into a PIL image.
"""
from PIL import Image
file_data = resolver.get(file_name)
image = Image.open(util.wrap_as_stream(file_data))
return image
def _parse_material(effect, resolver):
"""
Turn a COLLADA effect into a trimesh material.
"""
import collada
# Compute base color
baseColorFactor = np.ones(4)
baseColorTexture = None
if isinstance(effect.diffuse, collada.material.Map):
try:
baseColorTexture = _load_texture(
effect.diffuse.sampler.surface.image.path, resolver
)
except BaseException:
log.debug("unable to load base texture", exc_info=True)
elif effect.diffuse is not None:
baseColorFactor = effect.diffuse
# Compute emission color
emissiveFactor = np.zeros(3)
emissiveTexture = None
if isinstance(effect.emission, collada.material.Map):
try:
emissiveTexture = _load_texture(
effect.diffuse.sampler.surface.image.path, resolver
)
except BaseException:
log.warning("unable to load emissive texture", exc_info=True)
elif effect.emission is not None:
emissiveFactor = effect.emission[:3]
# Compute roughness
roughnessFactor = 1.0
if (
not isinstance(effect.shininess, collada.material.Map)
and effect.shininess is not None
):
try:
shininess_value = float(effect.shininess)
roughnessFactor = np.sqrt(2.0 / (2.0 + shininess_value))
except (TypeError, ValueError):
log.warning(
f"Invalid shininess value: {effect.shininess}, using default roughness"
)
# Compute metallic factor
metallicFactor = 0.0
# Compute normal texture
normalTexture = None
if effect.bumpmap is not None:
try:
normalTexture = _load_texture(
effect.bumpmap.sampler.surface.image.path, resolver
)
except BaseException:
log.warning("unable to load bumpmap", exc_info=True)
# Compute opacity
if effect.transparent is not None and not isinstance(
effect.transparent, collada.material.Map
):
baseColorFactor = tuple(
np.append(baseColorFactor[:3], float(effect.transparent[3]))
)
return visual.material.PBRMaterial(
emissiveFactor=emissiveFactor,
emissiveTexture=emissiveTexture,
normalTexture=normalTexture,
baseColorTexture=baseColorTexture,
baseColorFactor=baseColorFactor,
metallicFactor=metallicFactor,
roughnessFactor=roughnessFactor,
)
def _unparse_material(material):
"""
Turn a trimesh material into a COLLADA material.
"""
import collada
# TODO EXPORT TEXTURES
if isinstance(material, visual.material.PBRMaterial):
diffuse = material.baseColorFactor
if diffuse is None:
diffuse = np.array([255.0, 255.0, 255.0, 255.0])
diffuse = diffuse / 255.0
if diffuse is not None:
diffuse = list(diffuse)
emission = material.emissiveFactor
if emission is not None:
emission = [float(emission[0]), float(emission[1]), float(emission[2]), 1.0]
shininess = material.roughnessFactor
if shininess is None:
shininess = 1.0
if shininess is not None:
shininess = 2.0 / shininess**2 - 2.0
effect = collada.material.Effect(
uuid.uuid4().hex,
params=[],
shadingtype="phong",
diffuse=diffuse,
emission=emission,
specular=[1.0, 1.0, 1.0, 1.0],
shininess=float(shininess),
)
material = collada.material.Material(uuid.uuid4().hex, "pbrmaterial", effect)
else:
effect = collada.material.Effect(uuid.uuid4().hex, params=[], shadingtype="phong")
material = collada.material.Material(uuid.uuid4().hex, "defaultmaterial", effect)
return material
def load_zae(file_obj, resolver=None, **kwargs):
"""
Load a ZAE file, which is just a zipped DAE file.
Parameters
-------------
file_obj : file object
Contains ZAE data
resolver : trimesh.visual.Resolver
Resolver to load additional assets
kwargs : dict
Passed to load_collada
Returns
------------
loaded : dict
Results of loading
"""
# a dict, {file name : file object}
archive = util.decompress(file_obj, file_type="zip")
# load the first file with a .dae extension
file_name = next(i for i in archive.keys() if i.lower().endswith(".dae"))
# a resolver so the loader can load textures / etc
resolver = visual.resolvers.ZipResolver(archive)
# run the regular collada loader
loaded = load_collada(archive[file_name], resolver=resolver, **kwargs)
return loaded
# only provide loaders if `pycollada` is installed
_collada_loaders = {}
_collada_exporters = {}
if util.has_module("collada"):
_collada_loaders["dae"] = load_collada
_collada_loaders["zae"] = load_zae
_collada_exporters["dae"] = export_collada
else:
# store an exception to raise later
from ..exceptions import ExceptionWrapper
_exc = ExceptionWrapper(ImportError("missing `pip install pycollada`"))
_collada_loaders.update({"dae": _exc, "zae": _exc})
_collada_exporters["dae"] = _exc
@@ -0,0 +1,332 @@
import json
import os
import numpy as np
from .. import resolvers, util
from ..constants import log
from .dae import _collada_exporters
from .gltf import export_glb, export_gltf
from .obj import export_obj
from .off import _off_exporters
from .ply import _ply_exporters
from .stl import export_stl, export_stl_ascii
from .threemf import _3mf_exporters
from .urdf import export_urdf # NOQA
from .xyz import _xyz_exporters
def export_mesh(mesh, file_obj, file_type=None, resolver=None, **kwargs):
"""
Export a Trimesh object to a file- like object, or to a filename
Parameters
-----------
file_obj : str, file-like
Where should mesh be exported to
file_type : str or None
Represents file type (eg: 'stl')
resolver : None or trimesh.resolvers.Resolver
Resolver to write referenced assets to
Returns
----------
exported : bytes or str
Result of exporter
"""
# if we opened a file object in this function
# we will want to close it when we're done
was_opened = False
file_name = None
if util.is_pathlib(file_obj):
# handle `pathlib` objects by converting to string
file_obj = str(file_obj.absolute())
if isinstance(file_obj, str):
if file_type is None:
# get file type from file name
file_type = (str(file_obj).split(".")[-1]).lower()
if file_type in _mesh_exporters:
was_opened = True
file_name = file_obj
# get full path of file before opening
file_path = os.path.abspath(os.path.expanduser(file_obj))
file_obj = open(file_path, "wb")
if resolver is None:
# create a resolver which can write files to the path
resolver = resolvers.FilePathResolver(file_path)
# make sure file type is lower case
file_type = str(file_type).lower()
if file_type not in _mesh_exporters:
raise ValueError("%s exporter not available!", file_type)
if isinstance(mesh, (list, tuple, set, np.ndarray)):
faces = 0
for m in mesh:
faces += len(m.faces)
log.debug(
"Exporting %d meshes with a total of %d faces as %s",
len(mesh),
faces,
file_type.upper(),
)
elif hasattr(mesh, "faces"):
# if the mesh has faces log the number
log.debug("Exporting %d faces as %s", len(mesh.faces), file_type.upper())
# OBJ files save assets everywhere
if file_type == "obj":
kwargs["resolver"] = resolver
# run the exporter
export = _mesh_exporters[file_type](mesh, **kwargs)
# if the export is multiple files (i.e. GLTF)
if isinstance(export, dict):
# if we have a filename rename the default GLTF
if file_name is not None and "model.gltf" in export:
export[os.path.basename(file_name)] = export.pop("model.gltf")
# write the files if a resolver has been passed
if resolver is not None:
for name, data in export.items():
resolver.write(name=name, data=data)
return export
if hasattr(file_obj, "write"):
result = util.write_encoded(file_obj, export)
else:
result = export
# if we opened anything close it here
if was_opened:
file_obj.close()
return result
def export_dict64(mesh):
"""
Export a mesh as a dictionary, with data encoded
to base64.
"""
return export_dict(mesh, encoding="base64")
def export_dict(mesh, encoding=None):
"""
Export a mesh to a dict
Parameters
------------
mesh : trimesh.Trimesh
Mesh to be exported
encoding : str or None
Such as 'base64'
Returns
-------------
export : dict
Data stored in dict
"""
def encode(item, dtype=None):
if encoding is None:
return item.tolist()
else:
if dtype is None:
dtype = item.dtype
return util.array_to_encoded(item, dtype=dtype, encoding=encoding)
# metadata keys we explicitly want to preserve
# sometimes there are giant datastructures we don't
# care about in metadata which causes exports to be
# extremely slow, so skip all but known good keys
meta_keys = ["units", "file_name", "file_path"]
metadata = {k: v for k, v in mesh.metadata.items() if k in meta_keys}
export = {
"metadata": metadata,
"faces": encode(mesh.faces),
"face_normals": encode(mesh.face_normals),
"vertices": encode(mesh.vertices),
}
if mesh.visual.kind == "face":
export["face_colors"] = encode(mesh.visual.face_colors)
elif mesh.visual.kind == "vertex":
export["vertex_colors"] = encode(mesh.visual.vertex_colors)
return export
def scene_to_dict(scene, use_base64=False, include_metadata=True):
"""
Export a Scene object as a dict.
Parameters
-------------
scene : trimesh.Scene
Scene object to be exported
Returns
-------------
as_dict : dict
Scene as a dict
"""
# save some basic data about the scene
export = {
"graph": scene.graph.to_edgelist(),
"geometry": {},
"scene_cache": {
"bounds": scene.bounds.tolist(),
"extents": scene.extents.tolist(),
"centroid": scene.centroid.tolist(),
"scale": scene.scale,
},
}
if include_metadata:
try:
# jsonify will convert numpy arrays to lists recursively
# a little silly round-tripping to json but it is pretty fast
export["metadata"] = json.loads(util.jsonify(scene.metadata))
except BaseException:
log.warning("failed to serialize metadata", exc_info=True)
# encode arrays with base64 or not
if use_base64:
file_type = "dict64"
else:
file_type = "dict"
# if the mesh has an export method use it
# otherwise put the mesh itself into the export object
for geometry_name, geometry in scene.geometry.items():
if hasattr(geometry, "export"):
# export the data
exported = {
"data": geometry.export(file_type=file_type),
"file_type": file_type,
}
export["geometry"][geometry_name] = exported
else:
# case where mesh object doesn't have exporter
# might be that someone replaced the mesh with a URL
export["geometry"][geometry_name] = geometry
return export
def export_scene(scene, file_obj, file_type=None, resolver=None, **kwargs):
"""
Export a snapshot of the current scene.
Parameters
----------
file_obj : str, file-like, or None
File object to export to
file_type : str or None
What encoding to use for meshes
IE: dict, dict64, stl
Returns
----------
export : bytes
Only returned if file_obj is None
"""
if len(scene.geometry) == 0:
raise ValueError("Can't export empty scenes!")
if util.is_pathlib(file_obj):
# handle `pathlib` objects by converting to string
file_obj = str(file_obj.absolute())
# if we weren't passed a file type extract from file_obj
if file_type is None:
if isinstance(file_obj, str):
file_type = str(file_obj).split(".")[-1]
else:
raise ValueError("file_type not specified!")
# always remove whitespace and leading characters
file_type = file_type.strip().lower().lstrip(".")
# now handle our different scene export types
if file_type == "gltf":
data = export_gltf(scene, **kwargs)
elif file_type == "glb":
data = export_glb(scene, **kwargs)
elif file_type == "dict":
data = scene_to_dict(scene, *kwargs)
elif file_type == "obj":
# if we are exporting by name automatically create a
# resolver which lets the exporter write assets like
# the materials and textures next to the exported mesh
if resolver is None and isinstance(file_obj, str):
resolver = resolvers.FilePathResolver(file_obj)
data = export_obj(scene, resolver=resolver, **kwargs)
elif file_type == "dict64":
data = scene_to_dict(scene, use_base64=True)
elif file_type == "svg":
from trimesh.path.exchange import svg_io
data = svg_io.export_svg(scene, **kwargs)
elif file_type == "ply":
data = _mesh_exporters["ply"](scene.to_mesh(), **kwargs)
elif file_type == "stl":
data = export_stl(scene.to_mesh(), **kwargs)
elif file_type == "3mf":
data = _mesh_exporters["3mf"](scene, **kwargs)
else:
raise ValueError(f"unsupported export format: {file_type}")
# now write the data or return bytes of result
if isinstance(data, dict):
# GLTF files return a dict-of-bytes as they
# represent multiple files so create a filepath
# resolver and write the files if someone passed
# a path we can write to.
if resolver is None and isinstance(file_obj, str):
resolver = resolvers.FilePathResolver(file_obj)
# the requested "gltf"
bare_path = os.path.split(file_obj)[-1]
for name, blob in data.items():
if name == "model.gltf":
# write the root data to specified file
resolver.write(bare_path, blob)
else:
# write the supporting files
resolver.write(name, blob)
return data
if hasattr(file_obj, "write"):
# if it's just a regular file object
return util.write_encoded(file_obj, data)
elif isinstance(file_obj, str):
# assume strings are file paths
file_path = os.path.abspath(os.path.expanduser(file_obj))
with open(file_path, "wb") as f:
util.write_encoded(f, data)
# no writeable file object so return data
return data
_mesh_exporters = {
"stl": export_stl,
"dict": export_dict,
"glb": export_glb,
"obj": export_obj,
"gltf": export_gltf,
"dict64": export_dict64,
"stl_ascii": export_stl_ascii,
}
_mesh_exporters.update(_ply_exporters)
_mesh_exporters.update(_off_exporters)
_mesh_exporters.update(_collada_exporters)
_mesh_exporters.update(_xyz_exporters)
_mesh_exporters.update(_3mf_exporters)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,683 @@
import json
import os
from copy import deepcopy
import numpy as np
from .. import resolvers, util
from ..base import Trimesh
from ..exceptions import ExceptionWrapper
from ..parent import Geometry, LoadSource
from ..points import PointCloud
from ..scene.scene import Scene, append_scenes
from ..typed import Dict, Loadable, Optional, Set
from ..util import log
from . import misc
from .binvox import _binvox_loaders
from .cascade import _cascade_loaders
from .dae import _collada_loaders
from .gltf import _gltf_loaders
from .misc import _misc_loaders
from .obj import _obj_loaders
from .off import _off_loaders
from .ply import _ply_loaders
from .stl import _stl_loaders
from .threedxml import _threedxml_loaders
from .threemf import _three_loaders
from .xaml import _xaml_loaders
from .xyz import _xyz_loaders
try:
from ..path.exchange.load import load_path, path_formats
except BaseException as E:
# save a traceback to see why path didn't import
load_path = ExceptionWrapper(E)
# no path formats available
def path_formats() -> set:
return set()
def mesh_formats() -> Set[str]:
"""
Get a list of mesh formats available to load.
Returns
-----------
loaders
Extensions of available mesh loaders
i.e. `{'stl', 'ply'}`
"""
# filter out exceptionmodule loaders
return {k for k, v in mesh_loaders.items() if not isinstance(v, ExceptionWrapper)}
def available_formats() -> Set[str]:
"""
Get a list of all available loaders
Returns
-----------
loaders
Extensions of all available loaders
i.e. `{'stl', 'ply', 'dxf'}`
"""
loaders = mesh_formats()
loaders.update(path_formats())
loaders.update(compressed_loaders.keys())
return loaders
def load(
file_obj: Loadable,
file_type: Optional[str] = None,
resolver: Optional[resolvers.ResolverLike] = None,
force: Optional[str] = None,
allow_remote: bool = False,
**kwargs,
) -> Geometry:
"""
THIS FUNCTION IS DEPRECATED but there are no current plans for it to be removed.
For new code the typed load functions `trimesh.load_scene` or `trimesh.load_mesh`
are recommended over `trimesh.load` which is a backwards-compatibility wrapper
that mimics the behavior of the old function and can return any geometry type.
Parameters
-----------
file_obj : str, or file- like object
The source of the data to be loadeded
file_type: str
What kind of file type do we have (eg: 'stl')
resolver : trimesh.visual.Resolver
Object to load referenced assets like materials and textures
force : None or str
For 'mesh': try to coerce scenes into a single mesh
For 'scene': try to coerce everything into a scene
allow_remote
If True allow this load call to work on a remote URL.
kwargs : dict
Passed to geometry __init__
Returns
---------
geometry : Trimesh, Path2D, Path3D, Scene
Loaded geometry as trimesh classes
"""
# call the most general loading case into a `Scene`.
loaded = load_scene(
file_obj=file_obj,
file_type=file_type,
resolver=resolver,
allow_remote=allow_remote,
**kwargs,
)
if force == "mesh":
# new code should use `load_mesh` for this
log.debug(
"`trimesh.load(force='mesh')` is a compatibility wrapper for `trimesh.load_mesh`"
)
return loaded.to_mesh()
elif force == "scene":
# new code should use `load_scene` for this
log.debug(
"`trimesh.load(force='scene')` is a compatibility wrapper for `trimesh.load_scene`"
)
return loaded
###########################################
# we are matching old, deprecated behavior here!
kind = loaded.source.file_type
always_scene = {"glb", "gltf", "zip", "3dxml", "tar.gz"}
if kind not in always_scene and len(loaded.geometry) == 1:
geom = next(iter(loaded.geometry.values()))
geom.metadata.update(loaded.metadata)
if isinstance(geom, PointCloud) or kind in {
"obj",
"stl",
"ply",
"svg",
"binvox",
"xaml",
"dxf",
"off",
"msh",
}:
return geom
return loaded
def load_scene(
file_obj: Loadable,
file_type: Optional[str] = None,
resolver: Optional[resolvers.ResolverLike] = None,
allow_remote: bool = False,
metadata: Optional[Dict] = None,
**kwargs,
) -> Scene:
"""
Load geometry into the `trimesh.Scene` container. This may contain
any `parent.Geometry` object, including `Trimesh`, `Path2D`, `Path3D`,
or a `PointCloud`.
Parameters
-----------
file_obj : str, or file- like object
The source of the data to be loadeded
file_type: str
What kind of file type do we have (eg: 'stl')
resolver : trimesh.visual.Resolver
Object to load referenced assets like materials and textures
force : None or str
For 'mesh': try to coerce scenes into a single mesh
For 'scene': try to coerce everything into a scene
allow_remote
If True allow this load call to work on a remote URL.
kwargs : dict
Passed to geometry __init__
Returns
---------
geometry : Trimesh, Path2D, Path3D, Scene
Loaded geometry as trimesh classes
"""
# parse all possible values of file objects into simple types
arg = _parse_file_args(
file_obj=file_obj,
file_type=file_type,
resolver=resolver,
allow_remote=allow_remote,
)
try:
if isinstance(file_obj, dict):
# we've been passed a dictionary so treat them as keyword arguments
loaded = _load_kwargs(file_obj)
elif arg.file_type in path_formats():
# use path loader
loaded = load_path(
file_obj=arg.file_obj,
file_type=arg.file_type,
metadata=metadata,
**kwargs,
)
elif arg.file_type in mesh_loaders:
# use mesh loader
parsed = deepcopy(kwargs)
parsed.update(
mesh_loaders[arg.file_type](
file_obj=arg.file_obj,
file_type=arg.file_type,
resolver=arg.resolver,
metadata=metadata,
**kwargs,
)
)
loaded = _load_kwargs(**parsed)
elif arg.file_type in compressed_loaders:
# for archives, like ZIP files
loaded = _load_compressed(arg.file_obj, file_type=arg.file_type, **kwargs)
elif arg.file_type in voxel_loaders:
loaded = voxel_loaders[arg.file_type](
file_obj=arg.file_obj,
file_type=arg.file_type,
resolver=arg.resolver,
**kwargs,
)
else:
raise NotImplementedError(f"file_type '{arg.file_type}' not supported")
finally:
# if we opened the file ourselves from a file name
# close any opened files even if we crashed out
if arg.was_opened:
arg.file_obj.close()
if not isinstance(loaded, Scene):
# file name may be used for nodes
loaded._source = arg
loaded = Scene(loaded)
# add on the loading information
loaded._source = arg
for g in loaded.geometry.values():
g._source = arg
return loaded
def load_mesh(*args, **kwargs) -> Trimesh:
"""
Load a file into a Trimesh object.
Parameters
-----------
file_obj : str or file object
File name or file with mesh data
file_type : str or None
Which file type, e.g. 'stl'
kwargs : dict
Passed to Trimesh constructor
Returns
----------
mesh
Loaded geometry data.
"""
return load_scene(*args, **kwargs).to_mesh()
def _load_compressed(file_obj, file_type=None, resolver=None, mixed=False, **kwargs):
"""
Given a compressed archive load all the geometry that
we can from it.
Parameters
----------
file_obj : open file-like object
Containing compressed data
file_type : str
Type of the archive file
mixed : bool
If False, for archives containing both 2D and 3D
data will only load the 3D data into the Scene.
Returns
----------
scene : trimesh.Scene
Geometry loaded in to a Scene object
"""
# parse the file arguments into clean loadable form
arg = _parse_file_args(file_obj=file_obj, file_type=file_type, resolver=resolver)
# store loaded geometries as a list
geometries = []
# so loaders can access textures/etc
archive = util.decompress(file_obj=arg.file_obj, file_type=arg.file_type)
resolver = resolvers.ZipResolver(archive)
# try to save the files with meaningful metadata
# archive_name = arg.file_path or "archive"
meta_archive = {}
# populate our available formats
if mixed:
available = available_formats()
else:
# all types contained in ZIP archive
contains = {util.split_extension(n).lower() for n in resolver.keys()}
# if there are no mesh formats available
if contains.isdisjoint(mesh_formats()):
available = path_formats()
else:
available = mesh_formats()
for file_name, file_obj in archive.items():
try:
# only load formats that we support
compressed_type = util.split_extension(file_name).lower()
# if file has metadata type include it
if compressed_type in ("yaml", "yml"):
import yaml
continue
meta_archive[file_name] = yaml.safe_load(file_obj)
elif compressed_type == "json":
import json
meta_archive[file_name] = json.load(file_obj)
continue
elif compressed_type not in available:
# don't raise an exception, just try the next one
continue
# load the individual geometry
geometries.append(
load_scene(
file_obj=file_obj,
file_type=compressed_type,
resolver=resolver,
**kwargs,
)
)
except BaseException:
log.debug("failed to load file in zip", exc_info=True)
# if we opened the file in this function
# clean up after ourselves
if arg.was_opened:
arg.file_obj.close()
# append meshes or scenes into a single Scene object
result = append_scenes(geometries)
# append any archive metadata files
if isinstance(result, Scene):
result.metadata.update(meta_archive)
return result
def load_remote(url: str, **kwargs) -> Scene:
"""
Load a mesh at a remote URL into a local trimesh object.
This is a thin wrapper around:
`trimesh.load_scene(file_obj=url, allow_remote=True, **kwargs)`
Parameters
------------
url
URL containing mesh file
**kwargs
Passed to `load_scene`
Returns
------------
loaded : Trimesh, Path, Scene
Loaded result
"""
return load_scene(file_obj=url, allow_remote=True, **kwargs)
def _load_kwargs(*args, **kwargs) -> Geometry:
"""
Load geometry from a properly formatted dict or kwargs
"""
def handle_scene() -> Scene:
"""
Load a scene from our kwargs.
class: Scene
geometry: dict, name: Trimesh kwargs
graph: list of dict, kwargs for scene.graph.update
base_frame: str, base frame of graph
"""
graph = kwargs.get("graph", None)
geometry = {k: _load_kwargs(v) for k, v in kwargs["geometry"].items()}
if graph is not None:
scene = Scene()
scene.geometry.update(geometry)
for k in graph:
if isinstance(k, dict):
scene.graph.update(**k)
elif util.is_sequence(k) and len(k) == 3:
scene.graph.update(k[1], k[0], **k[2])
else:
scene = Scene(geometry)
# camera, if it exists
camera = kwargs.get("camera")
if camera:
scene.camera = camera
scene.camera_transform = kwargs.get("camera_transform")
if "base_frame" in kwargs:
scene.graph.base_frame = kwargs["base_frame"]
metadata = kwargs.get("metadata")
if isinstance(metadata, dict):
scene.metadata.update(kwargs["metadata"])
elif isinstance(metadata, str):
# some ways someone might have encoded a string
# note that these aren't evaluated until we
# actually call the lambda in the loop
candidates = [
lambda: json.loads(metadata),
lambda: json.loads(metadata.replace("'", '"')),
]
for c in candidates:
try:
scene.metadata.update(c())
break
except BaseException:
pass
elif metadata is not None:
log.warning("unloadable metadata")
return scene
def handle_mesh() -> Trimesh:
"""
Handle the keyword arguments for a Trimesh object
"""
# if they've been serialized as a dict
if isinstance(kwargs["vertices"], dict) or isinstance(kwargs["faces"], dict):
return Trimesh(**misc.load_dict(kwargs))
# otherwise just load that puppy
return Trimesh(**kwargs)
def handle_export():
"""
Handle an exported mesh.
"""
data, file_type = kwargs["data"], kwargs["file_type"]
if isinstance(data, dict):
return _load_kwargs(data)
elif file_type in mesh_loaders:
return Trimesh(**mesh_loaders[file_type](data, file_type=file_type))
raise NotImplementedError(f"`{file_type}` is not supported")
def handle_path():
from ..path import Path2D, Path3D
shape = np.shape(kwargs["vertices"])
if len(shape) < 2:
return Path2D()
if shape[1] == 2:
return Path2D(**kwargs)
elif shape[1] == 3:
return Path3D(**kwargs)
else:
raise ValueError("Vertices must be 2D or 3D!")
def handle_pointcloud():
return PointCloud(**kwargs)
# if we've been passed a single dict instead of kwargs
# substitute the dict for kwargs
if len(kwargs) == 0 and len(args) == 1 and isinstance(args[0], dict):
kwargs = args[0]
# (function, tuple of expected keys)
# order is important
handlers = (
(handle_scene, ("geometry",)),
(handle_mesh, ("vertices", "faces")),
(handle_path, ("entities", "vertices")),
(handle_pointcloud, ("vertices",)),
(handle_export, ("file_type", "data")),
)
# filter out keys with a value of None
kwargs = {k: v for k, v in kwargs.items() if v is not None}
# loop through handler functions and expected key
for func, expected in handlers:
if all(i in kwargs for i in expected):
# all expected kwargs exist
return func()
raise ValueError(f"unable to determine type: {kwargs.keys()}")
def _parse_file_args(
file_obj,
file_type: Optional[str],
resolver: Optional[resolvers.ResolverLike] = None,
allow_remote: bool = False,
**kwargs,
) -> LoadSource:
"""
Given a file_obj and a file_type try to magically convert
arguments to a file-like object and a lowercase string of
file type.
Parameters
-----------
file_obj : str
if string represents a file path, returns:
file_obj: an 'rb' opened file object of the path
file_type: the extension from the file path
if string is NOT a path, but has JSON-like special characters:
file_obj: the same string passed as file_obj
file_type: set to 'json'
if string is a valid-looking URL
file_obj: an open 'rb' file object with retrieved data
file_type: from the extension
if string is none of those:
raise ValueError as we can't do anything with input
if file like object:
ValueError will be raised if file_type is None
file_obj: same as input
file_type: same as input
if other object: like a shapely.geometry.Polygon, etc:
file_obj: same as input
file_type: if None initially, set to the class name
(in lower case), otherwise passed through
file_type : str
type of file and handled according to above
Returns
-----------
args
Populated `_FileArg` message
"""
# try to save a file path from various inputs
file_path = None
# keep track if we opened a file ourselves and thus are
# responsible for closing it at the end of loading
was_opened = False
if util.is_pathlib(file_obj):
# convert pathlib objects to string
file_obj = str(file_obj.absolute())
if util.is_file(file_obj) and file_type is None:
raise ValueError("`file_type` must be set for file objects!")
if isinstance(file_obj, str):
try:
# clean up file path to an absolute location
file_path = os.path.abspath(os.path.expanduser(file_obj))
# check to see if this path exists
exists = os.path.isfile(file_path)
except BaseException:
exists = False
file_path = None
# file obj is a string which exists on filesystm
if exists:
# if not passed create a resolver to find other files
if resolver is None:
resolver = resolvers.FilePathResolver(file_path)
# save the file name and path to metadata
# if file_obj is a path that exists use extension as file_type
if file_type is None:
file_type = util.split_extension(file_path, special=["tar.gz", "tar.bz2"])
# actually open the file
file_obj = open(file_path, "rb")
# save that we opened it so we can cleanup later
was_opened = True
else:
if "{" in file_obj:
# if a bracket is in the string it's probably straight JSON
file_type = "json"
file_obj = util.wrap_as_stream(file_obj)
elif "https://" in file_obj or "http://" in file_obj:
if not allow_remote:
raise ValueError("unable to load URL with `allow_remote=False`")
import urllib
# remove the url-safe encoding and query params
file_type = util.split_extension(
urllib.parse.unquote(file_obj).split("?", 1)[0].split("/")[-1].strip()
)
# create a web resolver to do the fetching and whatnot
resolver = resolvers.WebResolver(url=file_obj)
# fetch the base file
file_obj = util.wrap_as_stream(resolver.get_base())
elif file_type is None:
raise ValueError(f"string is not a file: `{file_obj}`")
if isinstance(file_type, str) and "." in file_type:
# if someone has passed the whole filename as the file_type
# use the file extension as the file_type
path = os.path.abspath(os.path.expanduser(file_type))
file_type = util.split_extension(file_type)
if os.path.exists(path):
file_path = path
if resolver is None:
resolver = resolvers.FilePathResolver(file_path)
# all our stored extensions reference in lower case
if file_type is not None:
file_type = file_type.lower()
# if we still have no resolver try using file_obj name
if (
resolver is None
and hasattr(file_obj, "name")
and file_obj.name is not None
and len(file_obj.name) > 0
):
resolver = resolvers.FilePathResolver(file_obj.name)
return LoadSource(
file_obj=file_obj,
file_type=file_type,
file_path=file_path,
was_opened=was_opened,
resolver=resolver,
)
# loader functions for compressed extensions
compressed_loaders = {
"zip": _load_compressed,
"tar.bz2": _load_compressed,
"tar.gz": _load_compressed,
"bz2": _load_compressed,
}
# map file_type to loader function
mesh_loaders = {}
mesh_loaders.update(_misc_loaders)
mesh_loaders.update(_stl_loaders)
mesh_loaders.update(_ply_loaders)
mesh_loaders.update(_obj_loaders)
mesh_loaders.update(_off_loaders)
mesh_loaders.update(_collada_loaders)
mesh_loaders.update(_gltf_loaders)
mesh_loaders.update(_xaml_loaders)
mesh_loaders.update(_threedxml_loaders)
mesh_loaders.update(_three_loaders)
mesh_loaders.update(_xyz_loaders)
mesh_loaders.update(_cascade_loaders)
# collect loaders which return voxel types
voxel_loaders = {}
voxel_loaders.update(_binvox_loaders)
@@ -0,0 +1,153 @@
import json
from tempfile import NamedTemporaryFile
from .. import util
from ..exceptions import ExceptionWrapper
def load_dict(file_obj, **kwargs):
"""
Load multiple input types into kwargs for a Trimesh constructor.
Tries to extract keys:
'faces'
'vertices'
'face_normals'
'vertex_normals'
Parameters
----------
file_obj : dict
accepts multiple forms
-dict: has keys for vertices and faces as (n,3) numpy arrays
-dict: has keys for vertices/faces (n,3) arrays encoded as dicts/base64
with trimesh.util.array_to_encoded/trimesh.util.encoded_to_array
-str: json blob as dict with either straight array or base64 values
-file object: json blob of dict
file_type: not used
Returns
-----------
loaded: dict with keys
-vertices: (n,3) float
-faces: (n,3) int
-face_normals: (n,3) float (optional)
"""
if file_obj is None:
raise ValueError("file_obj passed to load_dict was None!")
if util.is_instance_named(file_obj, "Trimesh"):
return file_obj
if isinstance(file_obj, str):
if "{" not in file_obj:
raise ValueError("Object is not a JSON encoded dictionary!")
file_obj = json.loads(file_obj.decode("utf-8"))
elif util.is_file(file_obj):
file_obj = json.load(file_obj)
# what shape should the file_obj be to be usable
mesh_file_obj = {
"vertices": (-1, 3),
"faces": (-1, (3, 4)),
"face_normals": (-1, 3),
"face_colors": (-1, (3, 4)),
"vertex_normals": (-1, 3),
"vertex_colors": (-1, (3, 4)),
}
# now go through file_obj structure and if anything is encoded as base64
# pull it back into numpy arrays
if not isinstance(file_obj, dict):
raise ValueError(f"`{type(file_obj)}` object passed to dict loader!")
loaded = {}
file_obj = util.decode_keys(file_obj, "utf-8")
for key, shape in mesh_file_obj.items():
if key in file_obj:
loaded[key] = util.encoded_to_array(file_obj[key])
if not util.is_shape(loaded[key], shape):
raise ValueError(
"Shape of %s is %s, not %s!",
key,
str(loaded[key].shape),
str(shape),
)
if len(loaded) == 0:
raise ValueError("Unable to extract a mesh from the dict!")
return loaded
def load_meshio(file_obj, file_type: str, **kwargs):
"""
Load a meshio-supported file into the kwargs for a Trimesh
constructor.
Parameters
----------
file_obj : file object
Contains a meshio file
file_type : str
File extension, aka 'vtk'
Returns
----------
loaded : dict
kwargs for Trimesh constructor
"""
# trimesh "file types" are really filename extensions
# meshio may return multiple answers for each file extension
file_formats = meshio.extension_to_filetypes["." + file_type]
mesh = None
exceptions = []
# meshio appears to only support loading by file name so use a tempfile
with NamedTemporaryFile(suffix=f".{file_type}") as temp:
temp.write(file_obj.read())
temp.flush()
# try the loaders in order
for file_format in file_formats:
try:
mesh = meshio.read(temp.name, file_format=file_format)
break
except BaseException as E:
exceptions.append(str(E))
if mesh is None:
raise ValueError("Failed to load file:" + "\n".join(exceptions))
# save file_obj as kwargs for a trimesh.Trimesh
result = {}
# pass kwargs to mesh constructor
result.update(kwargs)
# add vertices
result["vertices"] = mesh.points
try:
# add faces
result["faces"] = mesh.get_cells_type("triangle")
except BaseException:
util.log.warning("unable to get faces", exc_info=True)
result["faces"] = []
return result
_misc_loaders = {"dict": load_dict, "dict64": load_dict}
_misc_loaders = {}
try:
import meshio
# add meshio loaders here
_meshio_loaders = {k[1:]: load_meshio for k in meshio.extension_to_filetypes.keys()}
_misc_loaders.update(_meshio_loaders)
except BaseException:
_meshio_loaders = {}
try:
import openctm
_misc_loaders["ctm"] = openctm.load_ctm
except BaseException as E:
_misc_loaders["ctm"] = ExceptionWrapper(E)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
import re
import numpy as np
from ..geometry import triangulate_quads
from ..util import array_to_string, comment_strip, decode_text
def load_off(file_obj, **kwargs) -> dict:
"""
Load an OFF file into the kwargs for a Trimesh constructor.
Parameters
----------
file_obj : file object
Contains an OFF file
Returns
----------
loaded : dict
kwargs for Trimesh constructor
"""
text = file_obj.read()
# will magically survive weird encoding sometimes
# comment strip will handle all cases of commenting
text = comment_strip(decode_text(text)).strip()
# split the first key
_, header, raw = re.split("(COFF|OFF)", text, maxsplit=1)
if header.upper() not in ["OFF", "COFF"]:
raise NameError(f"Not an OFF file! Header was: `{header}`")
# split into lines and remove whitespace
splits = [i.strip() for i in str.splitlines(str(raw))]
# remove empty lines
splits = [i for i in splits if len(i) > 0]
# the first non-comment line should be the counts
header = np.array(splits[0].split(), dtype=np.int64)
vertex_count, face_count = header[:2]
vertices = np.array(
[i.split()[:3] for i in splits[1 : vertex_count + 1]], dtype=np.float64
)
# will fail if incorrect number of vertices loaded
vertices = vertices.reshape((vertex_count, 3))
# get lines with face data
faces = [i.split() for i in splits[vertex_count + 1 : vertex_count + face_count + 1]]
# the first value is count
faces = [line[1 : int(line[0]) + 1] for line in faces]
faces = triangulate_quads(faces)
# save data as kwargs for a trimesh.Trimesh
kwargs = {"vertices": vertices, "faces": faces}
return kwargs
def export_off(mesh, digits=10) -> str:
"""
Export a mesh as an OFF file, a simple text format
Parameters
-----------
mesh : trimesh.Trimesh
Geometry to export
digits : int
Number of digits to include on floats
Returns
-----------
export : str
OFF format output
"""
# make sure specified digits is an int
digits = int(digits)
# prepend a 3 (face count) to each face
faces_stacked = np.column_stack((np.ones(len(mesh.faces)) * 3, mesh.faces)).astype(
np.int64
)
# the header is vertex count, face count, another number
export = "\n".join(
[
"OFF",
str(len(mesh.vertices)) + " " + str(len(mesh.faces)) + " 0",
array_to_string(mesh.vertices, col_delim=" ", row_delim="\n", digits=digits),
array_to_string(faces_stacked, col_delim=" ", row_delim="\n"),
"",
]
)
return export
_off_loaders = {"off": load_off}
_off_exporters = {"off": export_off}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
import numpy as np
from .. import util
class HeaderError(Exception):
# the exception raised if an STL file object doesn't match its header
pass
# define a numpy datatype for the data section of a binary STL file
# everything in STL is always Little Endian
# this works natively on Little Endian systems, but blows up on Big Endians
# so we always specify byteorder
_stl_dtype = np.dtype(
[("normals", "<f4", (3)), ("vertices", "<f4", (3, 3)), ("attributes", "<u2")]
)
# define a numpy datatype for the header of a binary STL file
_stl_dtype_header = np.dtype([("header", np.void, 80), ("face_count", "<u4")])
def load_stl(file_obj, **kwargs):
"""
Load an STL file from a file object.
Parameters
----------
file_obj : open file-like object
Containing STL data
Returns
----------
loaded : dict
kwargs for a Trimesh constructor with keys:
vertices: (n,3) float, vertices
faces: (m,3) int, indexes of vertices
face_normals: (m,3) float, normal vector of each face
"""
# save start of file obj
file_pos = file_obj.tell()
try:
# check the file for a header which matches the file length
# if that is true, it is almost certainly a binary STL file
# if the header doesn't match the file length a HeaderError will be
# raised
return load_stl_binary(file_obj)
except HeaderError:
# move the file back to where it was initially
file_obj.seek(file_pos)
# try to load the file as an ASCII STL
# if the header doesn't match the file length
# HeaderError will be raised
return load_stl_ascii(file_obj)
def load_stl_binary(file_obj):
"""
Load a binary STL file from a file object.
Parameters
----------
file_obj : open file- like object
Containing STL data
Returns
----------
loaded: kwargs for a Trimesh constructor with keys:
vertices: (n,3) float, vertices
faces: (m,3) int, indexes of vertices
face_normals: (m,3) float, normal vector of each face
"""
# the header is always 84 bytes long, we just reference the dtype.itemsize
# to be explicit about where that magical number comes from
header_length = _stl_dtype_header.itemsize
header_data = file_obj.read(header_length)
if len(header_data) < header_length:
raise HeaderError("Binary STL shorter than a fixed header!")
try:
header = np.frombuffer(header_data, dtype=_stl_dtype_header)
except BaseException:
raise HeaderError("Binary header incorrect type")
try:
# save the header block as a string
# there could be any garbage in there so wrap in try
metadata = {"header": util.decode_text(bytes(header["header"][0])).strip()}
except BaseException:
metadata = {}
# now we check the length from the header versus the length of the file
# data_start should always be position 84, but hard coding that felt ugly
data_start = file_obj.tell()
# this seeks to the end of the file
# position 0, relative to the end of the file 'whence=2'
file_obj.seek(0, 2)
# we save the location of the end of the file and seek back to where we
# started from
data_end = file_obj.tell()
file_obj.seek(data_start)
# the binary format has a rigidly defined structure, and if the length
# of the file doesn't match the header, the loaded version is almost
# certainly going to be garbage.
len_data = data_end - data_start
len_expected = header["face_count"] * _stl_dtype.itemsize
# this check is to see if this really is a binary STL file.
# if we don't do this and try to load a file that isn't structured properly
# we will be producing garbage or crashing hard
# so it's much better to raise an exception here.
if len_data != len_expected:
raise HeaderError(
f"Binary STL has incorrect length in header: {len_data} vs {len_expected}"
)
blob = np.frombuffer(file_obj.read(), dtype=_stl_dtype)
# return empty geometry if there are no vertices
if not len(blob["vertices"]):
return {"geometry": {}}
# all of our vertices will be loaded in order
# so faces are just sequential indices reshaped.
faces = np.arange(header["face_count"][0] * 3).reshape((-1, 3))
# there are two bytes per triangle saved for anything
# which is sometimes used for face color
result = {
"vertices": blob["vertices"].reshape((-1, 3)),
"face_normals": blob["normals"].reshape((-1, 3)),
"faces": faces,
"face_attributes": {"stl": blob["attributes"]},
"metadata": metadata,
}
return result
def load_stl_ascii(file_obj):
"""
Load an ASCII STL file from a file object.
Parameters
----------
file_obj : open file- like object
Containing input data
Returns
----------
loaded : dict
kwargs for a Trimesh constructor with keys:
vertices: (n, 3) float, vertices
faces: (m, 3) int, indexes of vertices
face_normals: (m, 3) float, normal vector of each face
"""
# read all text into one string
raw_mixed = util.decode_text(file_obj.read()).strip()
# convert to lower case for solids and name capture
raw_lower = raw_mixed.lower()
# collect the keyword arguments for the Trimesh constructor
kwargs = {}
# keep track of our position in the file
position = 0
# use a for loop to avoid any possibility of infinite looping
for _ in range(len(raw_mixed)):
# find the start of the solid chunk
solid_start = raw_lower.find("solid", position)
# find the end of the solid chunk
solid_end = raw_lower.find("endsolid", position)
# on the next loop we don't have to check the text we've consumed
position = solid_end + len("endsolid")
# delimiter wasn't found for a chunk so exit
if solid_end < 0 or solid_start < 0:
break
# end delimiter order is wrong so this file is very malformed
if solid_start > solid_end:
raise ValueError("`endsolid` precedes `solid`!")
# get the chunk of text with this particular solid
solid = raw_lower[solid_start:solid_end]
# extract the vertices
vertex_text = solid.split("vertex")
vertices = np.fromstring(
" ".join(line[: line.find("\n")] for line in vertex_text[1:]),
sep=" ",
dtype=np.float64,
)
if len(vertices) < 3:
continue
if len(vertices) % 3 != 0:
raise ValueError("incorrect number of vertices")
# reshape vertices to final 3D shape
vertices = vertices.reshape((-1, 3))
faces = np.arange(len(vertices)).reshape((-1, 3))
# try to extract the face normals the same way
face_normals = None
try:
normal_text = solid.split("normal")
normals = np.fromstring(
" ".join(line[: line.find("\n")] for line in normal_text[1:]),
sep=" ",
dtype=np.float64,
)
if len(normals) == len(vertices):
face_normals = normals.reshape((-1, 3))
except BaseException:
util.log.warning("failed to extract face_normals", exc_info=True)
try:
# Previously checked to make sure there was matching 'solid' for 'endsolid'
# the name is right after the `solid` keyword if it exists
name = raw_mixed[solid_start : solid_start + solid.find("\n")][6:].strip()
except BaseException:
# will be filled in by unique_name
name = None
# make sure geometry has a unique name for the scene
name = util.unique_name(name, kwargs)
# save the constructor arguments
kwargs[name] = {
"vertices": vertices.reshape((-1, 3)),
"face_normals": face_normals,
"faces": faces,
"metadata": {"name": name},
}
if len(kwargs) == 1:
return next(iter(kwargs.values()))
return {"geometry": kwargs}
def export_stl(mesh) -> bytes:
"""
Convert a Trimesh object into a binary STL file.
Parameters
---------
mesh
Trimesh object to export.
Returns
---------
export
Represents mesh in binary STL form
"""
header = np.zeros(1, dtype=_stl_dtype_header)
if hasattr(mesh, "faces"):
header["face_count"] = len(mesh.faces)
export = header.tobytes()
if hasattr(mesh, "faces"):
packed = np.zeros(len(mesh.faces), dtype=_stl_dtype)
packed["normals"] = mesh.face_normals
packed["vertices"] = mesh.triangles
export += packed.tobytes()
return export
def export_stl_ascii(mesh) -> str:
"""
Convert a Trimesh object into an ASCII STL file.
Parameters
---------
mesh : trimesh.Trimesh
Returns
---------
export
Mesh represented as an ASCII STL file
"""
# move all the data that's going into the STL file into one array
blob = np.zeros((len(mesh.faces), 4, 3))
blob[:, 0, :] = mesh.face_normals
blob[:, 1:, :] = mesh.triangles
# create a lengthy format string for the data section of the file
formatter = (
"\n".join(
[
"facet normal {} {} {}",
"outer loop",
"vertex {} {} {}\nvertex {} {} {}\nvertex {} {} {}",
"endloop",
"endfacet",
"",
]
)
) * len(mesh.faces)
# try applying the name from metadata if it exists
name = mesh.metadata.get("name", "")
if not isinstance(name, str):
name = ""
if len(name) > 80 or "\n" in name:
name = ""
# concatenate the header, data, and footer, and a new line
return "\n".join([f"solid {name}", formatter.format(*blob.reshape(-1)), "endsolid\n"])
_stl_loaders = {"stl": load_stl, "stl_ascii": load_stl}
@@ -0,0 +1,418 @@
"""
threedxml.py
-------------
Load 3DXML files, a scene format from Dassault products like Solidworks, Abaqus, Catia
"""
import numpy as np
try:
# `pip install pillow`
# optional: used for textured meshes
from PIL import Image
except BaseException as E:
# if someone tries to use Image re-raise
# the import error so they can debug easily
from ..exceptions import ExceptionWrapper
Image = ExceptionWrapper(E)
import collections
import json
from .. import util
from ..visual.texture import TextureVisuals
def load_3DXML(file_obj, *args, **kwargs):
"""
Load a 3DXML scene into kwargs. 3DXML is a CAD format
that can be exported from Solidworks
Parameters
------------
file_obj : file object
Open and containing 3DXML data
Returns
-----------
kwargs : dict
Can be passed to trimesh.exchange.load.load_kwargs
"""
archive = util.decompress(file_obj, file_type="zip")
# a dictionary of file name : lxml etree
as_etree = {}
for k, v in archive.items():
# wrap in try statement, as sometimes 3DXML
# contains non- xml files, like JPG previews
try:
as_etree[k] = etree.XML(v.read())
except etree.XMLSyntaxError:
# move the file object back to the file start
v.seek(0)
# the file name of the root scene
root_file = as_etree["Manifest.xml"].find("{*}Root").text
# the etree of the scene layout
tree = as_etree[root_file]
# index of root element of directed acyclic graph
root_id = tree.find("{*}ProductStructure").attrib["root"]
# load the materials library from the materials elements
colors = {}
images = {}
# but only if it exists
material_key = "CATMaterialRef.3dxml"
if material_key in as_etree:
material_tree = as_etree[material_key]
for MaterialDomain in material_tree.iter("{*}MaterialDomain"):
material_id = MaterialDomain.attrib["id"]
material_file = MaterialDomain.attrib["associatedFile"].split("urn:3DXML:")[
-1
]
rend = as_etree[material_file].find("{*}Feature[@Alias='RenderingFeature']")
diffuse = rend.find("{*}Attr[@Name='DiffuseColor']")
# specular = rend.find("{*}Attr[@Name='SpecularColor']")
# emissive = rend.find("{*}Attr[@Name='EmissiveColor']")
if diffuse is not None:
rgb = (np.array(json.loads(diffuse.attrib["Value"])) * 255).astype(
np.uint8
)
colors[material_id] = rgb
texture = rend.find("{*}Attr[@Name='TextureImage']")
if texture is not None:
tex_file, tex_id = texture.attrib["Value"].split(":")[-1].split("#")
rep_image = as_etree[tex_file].find(
f"{{*}}CATRepImage/{{*}}CATRepresentationImage[@id='{tex_id}']"
)
if rep_image is not None:
image_file = rep_image.get("associatedFile", "").split(":")[-1]
images[material_id] = Image.open(archive[image_file])
# copy indexes for instances of colors
for MaterialDomainInstance in material_tree.iter("{*}MaterialDomainInstance"):
instance = MaterialDomainInstance.find("{*}IsInstanceOf")
# colors[b.attrib['id']] = colors[instance.text]
for aggregate in MaterialDomainInstance.findall("{*}IsAggregatedBy"):
colors[aggregate.text] = colors.get(instance.text)
images[aggregate.text] = images.get(instance.text)
# references which hold the 3DXML scene structure as a dict
# element id : {key : value}
references = collections.defaultdict(dict)
def get_rgba(color):
"""
Return (4,) uint8 color array defined by Color element attributes.
Parameters
-----------
color : lxml.Element
Element containing RGBA colors.
Returns
-----------
as_int : (4,) np.uint8
Colors as uint8 RGBA.
"""
assert "RGBAColorType" in color.attrib.values()
# colors will be float 0.0 - 1.0
rgba = np.array(
[color.get(channel, 1.0) for channel in ("red", "green", "blue", "alpha")],
dtype=np.float64,
)
# convert to int colors
return (rgba * 255).astype(np.uint8)
# the 3DXML can specify different visual properties for occurrences
view = tree.find("{*}DefaultView")
if view is not None:
for ViewProp in view.iter("{*}DefaultViewProperty"):
color = ViewProp.find(
"{*}GraphicProperties/" + "{*}SurfaceAttributes/{*}Color"
)
if color is None:
continue
rgba = get_rgba(color)
for occurrence in ViewProp.findall("{*}OccurenceId/{*}id"):
reference_id = occurrence.text.split("#")[-1]
references[reference_id]["color"] = rgba
# geometries will hold meshes
geometries = {}
# get geometry
for ReferenceRep in tree.iter(tag="{*}ReferenceRep"):
# the str of an int that represents this meshes unique ID
part_id = ReferenceRep.attrib["id"]
# which part file in the archive contains the geometry we care about
part_file = ReferenceRep.attrib["associatedFile"].split(":")[-1]
# the format of the geometry file
part_format = ReferenceRep.attrib["format"]
if part_format not in ("TESSELLATED",):
util.log.warning(
f"ReferenceRep {part_file!r} unsupported format {part_format!r}"
)
continue
# load actual geometry
mesh_faces = []
mesh_colors = []
mesh_normals = []
mesh_vertices = []
mesh_uv = []
mesh_image = None
if part_file not in as_etree and part_file in archive:
# the data is stored in some binary format
util.log.warning(f"unable to load Rep {part_file!r}")
# data = archive[part_file]
continue
# the geometry is stored in a Rep
for Rep in as_etree[part_file].iter("{*}Rep"):
rep_faces = [] # faces sharing the same list of vertices
vertices = Rep.find("{*}VertexBuffer/{*}Positions")
if vertices is None:
continue
# they mix delimiters like we couldn't figure it out from the
# shape :(
# load vertices into (n, 3) float64
mesh_vertices.append(
np.fromstring(
vertices.text.replace(",", " "), sep=" ", dtype=np.float64
).reshape((-1, 3))
)
# load vertex normals into (n, 3) float64
normals = Rep.find("{*}VertexBuffer/{*}Normals")
mesh_normals.append(
np.fromstring(
normals.text.replace(",", " "), sep=" ", dtype=np.float64
).reshape((-1, 3))
)
uv = Rep.find("{*}VertexBuffer/{*}TextureCoordinates")
if uv is not None: # texture coordinates are available
rep_uv = np.fromstring(
uv.text.replace(",", " "), sep=" ", dtype=np.float64
)
if "1D" == uv.get("dimension"):
mesh_uv.append(np.stack([rep_uv, np.zeros(len(rep_uv))], axis=1))
else: # 2D
mesh_uv.append(rep_uv.reshape(-1, 2))
material = Rep.find(
"{*}SurfaceAttributes/" + "{*}MaterialApplication/" + "{*}MaterialId"
)
if material is None:
material_id = None
else:
(material_file, material_id) = (
material.attrib["id"].split("urn:3DXML:")[-1].split("#")
)
mesh_image = images.get(material_id) # texture for this Rep, if any
for faces in Rep.iter("{*}Faces"):
triangles = [] # mesh triangles for this Faces element
for face in faces.iter("{*}Face"):
# Each Face may have optional strips, triangles or fans attributes
if "strips" in face.attrib:
# triangle strips, sequence of arbitrary length lists
# np.fromstring is substantially faster than np.array(i.split())
# inside the list comprehension
strips = [
np.fromstring(i, sep=" ", dtype=np.int64)
for i in face.attrib["strips"].split(",")
]
# convert strips to (m, 3) int triangles
triangles.extend(util.triangle_strips_to_faces(strips))
if "triangles" in face.attrib:
triangles.extend(
np.fromstring(
face.attrib["triangles"], sep=" ", dtype=np.int64
).reshape((-1, 3))
)
if "fans" in face.attrib:
fans = [
np.fromstring(i, sep=" ", dtype=np.int64)
for i in face.attrib["fans"].split(",")
]
# convert fans to (m, 3) int triangles
triangles.extend(util.triangle_fans_to_faces(fans))
rep_faces.extend(triangles)
# store the material information as (m, 3) uint8 FACE COLORS
faceColor = colors.get(material_id, [128, 128, 128])
# each Face may have its own color
colorElement = face.find("{*}SurfaceAttributes/{*}Color")
if colorElement is not None:
faceColor = get_rgba(colorElement)[:3]
mesh_colors.append(np.tile(faceColor, (len(triangles), 1)))
mesh_faces.append(rep_faces)
# save each mesh as the kwargs for a trimesh.Trimesh constructor
# aka, a Trimesh object can be created with trimesh.Trimesh(**mesh)
# this avoids needing trimesh- specific imports in this IO function
mesh = {}
(mesh["vertices"], mesh["faces"]) = util.append_faces(mesh_vertices, mesh_faces)
mesh["vertex_normals"] = np.vstack(mesh_normals)
if mesh_uv and mesh_image:
mesh["visual"] = TextureVisuals(uv=np.vstack(mesh_uv), image=mesh_image)
else:
mesh["face_colors"] = np.vstack(mesh_colors)
# as far as I can tell, all 3DXML files are exported as
# implicit millimeters (it isn't specified in the file)
mesh["metadata"] = {"units": "mm"}
mesh["class"] = "Trimesh"
geometries[part_id] = mesh
references[part_id]["geometry"] = part_id
# a Reference3D maps to a subassembly or assembly
for Reference3D in tree.iter("{*}Reference3D"):
references[Reference3D.attrib["id"]] = {
"name": Reference3D.attrib["name"],
"type": "Reference3D",
}
# a node that is the connectivity between a geometry and the Reference3D
for InstanceRep in tree.iter("{*}InstanceRep"):
current = InstanceRep.attrib["id"]
instance = InstanceRep.find("{*}IsInstanceOf").text
aggregate = InstanceRep.find("{*}IsAggregatedBy").text
references[current].update(
{"aggregate": aggregate, "instance": instance, "type": "InstanceRep"}
)
# an Instance3D maps basically to a part
for Instance3D in tree.iter("{*}Instance3D"):
matrix = np.eye(4)
relative = Instance3D.find("{*}RelativeMatrix")
if relative is not None:
relative = np.array(relative.text.split(), dtype=np.float64)
# rotation component
matrix[:3, :3] = relative[:9].reshape((3, 3)).T
# translation component
matrix[:3, 3] = relative[9:]
current = Instance3D.attrib["id"]
name = Instance3D.attrib["name"]
instance = Instance3D.find("{*}IsInstanceOf").text
aggregate = Instance3D.find("{*}IsAggregatedBy").text
references[current].update(
{
"aggregate": aggregate,
"instance": instance,
"matrix": matrix,
"name": name,
"type": "Instance3D",
}
)
# turn references into directed graph for path finding
graph = nx.DiGraph()
for k, v in references.items():
# IsAggregatedBy points up to a parent
if "aggregate" in v:
graph.add_edge(v["aggregate"], k)
# IsInstanceOf indicates a child
if "instance" in v:
graph.add_edge(k, v["instance"])
# the 3DXML format is stored as a directed acyclic graph that needs all
# paths from the root to a geometry to generate the tree of the scene
paths = []
for geometry_id in geometries.keys():
paths.extend(nx.all_simple_paths(graph, source=root_id, target=geometry_id))
# the name of the root frame
root_name = references[root_id]["name"]
# create a list of kwargs to send to the scene.graph.update function
# start with a transform from the graphs base frame to our root name
graph_kwargs = [{"frame_to": root_name, "matrix": np.eye(4)}]
# we are going to collect prettier geometry names as we traverse paths
geom_names = {}
# loop through every simple path and generate transforms tree
# note that we are flattening the transform tree here
for path in paths:
name = ""
if "name" in references[path[-3]]:
name = references[path[-3]]["name"]
geom_names[path[-1]] = name
# we need a unique node name for our geometry instance frame
# due to the nature of the DAG names specified by the file may not
# be unique, so we add an Instance3D name then append the path ids
node_name = name + "#" + ":".join(path)
# pull all transformations in the path
matrices = [references[i]["matrix"] for i in path if "matrix" in references[i]]
if len(matrices) == 0:
matrix = np.eye(4)
elif len(matrices) == 1:
matrix = matrices[0]
else:
matrix = util.multi_dot(matrices)
graph_kwargs.append(
{
"matrix": matrix,
"frame_from": root_name,
"frame_to": node_name,
"geometry": path[-1],
}
)
# remap geometry names from id numbers to the name string
# we extracted from the 3DXML tree
geom_final = {}
for key, value in geometries.items():
if key in geom_names:
geom_final[geom_names[key]] = value
# change geometry names in graph kwargs in place
for kwarg in graph_kwargs:
if "geometry" not in kwarg:
continue
kwarg["geometry"] = geom_names[kwarg["geometry"]]
# create the kwargs for load_kwargs
result = {"class": "Scene", "geometry": geom_final, "graph": graph_kwargs}
return result
def print_element(element):
"""
Pretty-print an lxml.etree element.
Parameters
------------
element : etree element
"""
pretty = etree.tostring(element, pretty_print=True).decode("utf-8")
return pretty
try:
# soft dependencies
import networkx as nx
from lxml import etree
_threedxml_loaders = {"3dxml": load_3DXML}
except BaseException as E:
# set loader to exception wrapper
from ..exceptions import ExceptionWrapper
_threedxml_loaders = {"3dxml": ExceptionWrapper(E)}
@@ -0,0 +1,505 @@
import io
import uuid
import zipfile
from collections import defaultdict
import numpy as np
from .. import graph, util
from ..constants import log
from ..util import unique_name
def _read_mesh(mesh):
"""
Read a `<mesh ` XML element into Numpy vertices and faces.
This is generally the most expensive operation in the load as it
has to operate in Python-space on every single vertex and face.
Parameters
----------
mesh : lxml.etree.Element
Input mesh element with `vertex` and `triangle` children.
Returns
----------
vertex_array : (n, 3) float64
Vertices
face_array : (n, 3) int64
Indexes of vertices forming triangles.
"""
# get the XML elements for vertices and faces
vertices = mesh.find("{*}vertices")
faces = mesh.find("{*}triangles")
# get every value as a flat space-delimited string
# this is very sensitive as it is large, i.e. it is
# much faster with the full list comprehension before
# the `.join` as the giant string can be fully allocated
vs = " ".join(
[
f"{i.attrib['x']} {i.attrib['y']} {i.attrib['z']}"
for i in vertices.iter("{*}vertex")
]
)
# convert every value to floating point in one-shot rather than in a loop
v_array = np.fromstring(vs, dtype=np.float64, sep=" ").reshape((-1, 3))
# do the same behavior for faces but as an integer
fs = " ".join(
[
f"{i.attrib['v1']} {i.attrib['v2']} {i.attrib['v3']}"
for i in faces.iter("{*}triangle")
]
)
f_array = np.fromstring(fs, dtype=np.int64, sep=" ").reshape((-1, 3))
return v_array, f_array
def load_3MF(file_obj, postprocess=True, **kwargs):
"""
Load a 3MF formatted file into a Trimesh scene.
Parameters
------------
file_obj : file-like
Contains 3MF formatted data
Returns
------------
kwargs : dict
Constructor arguments for `trimesh.Scene`
"""
# dict, {name in archive: BytesIo}
archive = util.decompress(file_obj, file_type="zip")
# get model with case-insensitive keys
model = next(iter(v for k, v in archive.items() if "3d/3dmodel.model" in k.lower()))
# read root attributes only from XML first
_event, root = next(etree.iterparse(model, tag=("{*}model"), events=("start",)))
# collect unit information from the tree
if "unit" in root.attrib:
metadata = {"units": root.attrib["unit"]}
else:
# the default units, defined by the specification
metadata = {"units": "millimeters"}
# { mesh id : mesh name}
id_name = {}
# { mesh id: (n,3) float vertices}
v_seq = defaultdict(list)
# { mesh id: (n,3) int faces}
f_seq = defaultdict(list)
# components are objects that contain other objects
# {id : [other ids]}
components = defaultdict(list)
# load information about the scene graph
# each instance is a single geometry
build_items = []
# keep track of names we can use
consumed_counts = {}
consumed_names = set()
# iterate the XML object and build elements with an LXML iterator
# loaded elements are cleared to avoid ballooning memory
model.seek(0)
for _, obj in etree.iterparse(model, tag=("{*}object", "{*}build"), events=("end",)):
# parse objects
if "object" in obj.tag:
# id is mandatory
index = obj.attrib["id"]
# start with stored name
# apparently some exporters name multiple meshes
# the same thing so check to see if it's been used
name = unique_name(
obj.attrib.get("name", str(index)), consumed_names, consumed_counts
)
consumed_names.add(name)
# store name reference on the index
id_name[index] = name
# if the object has actual geometry data parse here
for mesh in obj.iter("{*}mesh"):
v, f = _read_mesh(mesh)
v_seq[index].append(v)
f_seq[index].append(f)
# components are references to other geometries
for c in obj.iter("{*}component"):
mesh_index = c.attrib["objectid"]
transform = _attrib_to_transform(c.attrib)
components[index].append((mesh_index, transform))
# if this references another file as the `path` attrib
path = next(
(v.strip("/") for k, v in c.attrib.items() if k.endswith("path")),
None,
)
if path is not None and path in archive:
archive[path].seek(0)
name = unique_name(
obj.attrib.get("name", str(mesh_index)),
consumed_names,
consumed_counts,
)
consumed_names.add(name)
# store name reference on the index
id_name[mesh_index] = name
for _, m in etree.iterparse(
archive[path], tag=("{*}mesh"), events=("end",)
):
v, f = _read_mesh(m)
v_seq[mesh_index].append(v)
f_seq[mesh_index].append(f)
# parse build
if "build" in obj.tag:
# scene graph information stored here, aka "build" the scene
for item in obj.iter("{*}item"):
# get a transform from the item's attributes
transform = _attrib_to_transform(item.attrib)
# the index of the geometry this item instantiates
build_items.append((item.attrib["objectid"], transform))
# have one mesh per 3MF object
# one mesh per geometry ID, store as kwargs for the object
meshes = {}
for gid in v_seq.keys():
v, f = util.append_faces(v_seq[gid], f_seq[gid])
name = id_name[gid]
meshes[name] = {
"vertices": v,
"faces": f,
"metadata": metadata.copy(),
}
# apply any keyword arguments that aren't None
meshes[name].update({k: v for k, v in kwargs.items() if v is not None})
# turn the item / component representation into
# a MultiDiGraph to compound our pain
g = nx.MultiDiGraph()
# build items are the only things that exist according to 3MF
# so we accomplish that by linking them to the base frame
for gid, tf in build_items:
g.add_edge("world", gid, matrix=tf)
# components are instances which need to be linked to base
# frame by a build_item
for start, group in components.items():
for gid, tf in group:
g.add_edge(start, gid, matrix=tf)
# turn the graph into kwargs for a scene graph
# flatten the scene structure and simplify to
# a single unique node per instance
graph_args = []
parents = defaultdict(set)
for path in graph.multigraph_paths(G=g, source="world"):
# collect all the transform on the path
transforms = graph.multigraph_collect(G=g, traversal=path, attrib="matrix")
# combine them into a single transform
if len(transforms) == 1:
transform = transforms[0]
else:
transform = util.multi_dot(transforms)
# the last element of the path should be the geometry
last = path[-1][0]
# if someone included an undefined component, skip it
if last not in id_name:
log.warning(f"id {last} included but not defined!")
continue
# frame names unique
name = id_name[last] + util.unique_id()
# index in meshes
geom = id_name[last]
# collect parents if we want to combine later
if len(path) > 2:
parent = path[-2][0]
parents[parent].add(last)
graph_args.append(
{
"frame_from": "world",
"frame_to": name,
"matrix": transform,
"geometry": geom,
}
)
# solidworks will export each body as its own mesh with the part
# name as the parent so optionally rename and combine these bodies
if postprocess and all("body" in i.lower() for i in meshes.keys()):
# don't rename by default
rename = {k: k for k in meshes.keys()}
for parent, mesh_name in parents.items():
# only handle the case where a parent has a single child
# if there are multiple children we would do a combine op
if len(mesh_name) != 1:
continue
# rename the part
rename[id_name[next(iter(mesh_name))]] = id_name[parent].split("(")[0]
# apply the rename operation meshes
meshes = {rename[k]: m for k, m in meshes.items()}
# rename geometry references in the scene graph
for arg in graph_args:
if "geometry" in arg:
arg["geometry"] = rename[arg["geometry"]]
# construct the kwargs to load the scene
kwargs = {
"base_frame": "world",
"graph": graph_args,
"geometry": meshes,
"metadata": metadata,
}
return kwargs
def export_3MF(mesh, batch_size=4096, compression=zipfile.ZIP_DEFLATED, compresslevel=5):
"""
Converts a Trimesh object into a 3MF file.
Parameters
---------
mesh trimesh.trimesh
Mesh or Scene to export.
batch_size : int
Number of nodes to write per batch.
compression : zipfile.ZIP_*
Type of zip compression to use in this export.
compresslevel : int
For Python > 3.7 specify the 0-9 compression level.
Returns
---------
export : bytes
Represents geometry as a 3MF file.
"""
from ..scene.scene import Scene
if not isinstance(mesh, Scene):
mesh = Scene(mesh)
geometry = mesh.geometry
graph = mesh.graph.to_networkx()
base_frame = mesh.graph.base_frame
# xml namespaces
model_nsmap = {
None: "http://schemas.microsoft.com/3dmanufacturing/core/2015/02",
"m": "http://schemas.microsoft.com/3dmanufacturing/material/2015/02",
"p": "http://schemas.microsoft.com/3dmanufacturing/production/2015/06",
"b": "http://schemas.microsoft.com/3dmanufacturing/beamlattice/2017/02",
"s": "http://schemas.microsoft.com/3dmanufacturing/slice/2015/07",
"sc": "http://schemas.microsoft.com/3dmanufacturing/securecontent/2019/04",
}
rels_nsmap = {None: "http://schemas.openxmlformats.org/package/2006/relationships"}
# model ids
models = []
def model_id(x):
if x not in models:
models.append(x)
return str(models.index(x) + 1)
# 3mf archive dict {path: BytesIO}
file_obj = io.BytesIO()
# specify the parameters for the zip container
zip_kwargs = {"compression": compression}
# compresslevel was added in Python 3.7
zip_kwargs["compresslevel"] = compresslevel
with zipfile.ZipFile(file_obj, mode="w", **zip_kwargs) as z:
# 3dmodel.model
with z.open("3D/3dmodel.model", mode="w") as f, etree.xmlfile(
f, encoding="utf-8"
) as xf:
xf.write_declaration()
# stream elements
with xf.element("model", {"unit": "millimeter"}, nsmap=model_nsmap):
# objects with mesh data and/or references to other objects
with xf.element("resources"):
# stream objects with actual mesh data
for i, (name, m) in enumerate(geometry.items()):
# attributes for object
attribs = {
"id": model_id(name),
"name": name,
"type": "model",
"p:UUID": str(uuid.uuid4()),
}
with xf.element("object", **attribs):
with xf.element("mesh"):
with xf.element("vertices"):
# vertex nodes are written directly to the file
# so make sure lxml's buffer is flushed
xf.flush()
for i in range(0, len(m.vertices), batch_size):
batch = m.vertices[i : i + batch_size]
fragment = (
'<vertex x="{}" y="{}" z="{}" />' * len(batch)
)
f.write(
fragment.format(*batch.flatten()).encode(
"utf-8"
)
)
with xf.element("triangles"):
xf.flush()
for i in range(0, len(m.faces), batch_size):
batch = m.faces[i : i + batch_size]
fragment = (
'<triangle v1="{}" v2="{}" v3="{}" />'
* len(batch)
)
f.write(
fragment.format(*batch.flatten()).encode(
"utf-8"
)
)
# stream components
for node in graph.nodes:
if node == base_frame or node.startswith("camera"):
continue
if len(graph[node]) == 0:
continue
attribs = {
"id": model_id(node),
"name": node,
"type": "model",
"p:UUID": str(uuid.uuid4()),
}
with xf.element("object", **attribs):
with xf.element("components"):
for next, data in graph[node].items():
transform = " ".join(
str(i)
for i in np.array(data["matrix"])[
:3, :4
].T.flatten()
)
xf.write(
etree.Element(
"component",
{
"objectid": model_id(data["geometry"])
if "geometry" in data
else model_id(next),
"transform": transform,
},
)
)
# stream build (objects on base_frame)
with xf.element("build", {"p:UUID": str(uuid.uuid4())}):
for node, data in graph[base_frame].items():
if node.startswith("camera"):
continue
transform = " ".join(
str(i) for i in np.array(data["matrix"])[:3, :4].T.flatten()
)
uuid_tag = "{{{}}}UUID".format(model_nsmap["p"])
xf.write(
etree.Element(
"item",
{
"objectid": model_id(data.get('geometry', node)),
"transform": transform,
uuid_tag: str(uuid.uuid4()),
},
nsmap=model_nsmap,
)
)
# .rels
with z.open("_rels/.rels", "w") as f, etree.xmlfile(f, encoding="utf-8") as xf:
xf.write_declaration()
# stream elements
with xf.element("Relationships", nsmap=rels_nsmap):
rt = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
xf.write(
etree.Element(
"Relationship",
Type=rt,
Target="/3D/3dmodel.model",
Id="rel0",
)
)
# [Content_Types].xml
with z.open("[Content_Types].xml", "w") as f, etree.xmlfile(
f, encoding="utf-8"
) as xf:
xf.write_declaration()
# xml namespaces
nsmap = {None: "http://schemas.openxmlformats.org/package/2006/content-types"}
# stream elements
types = [
("jpeg", "image/jpeg"),
("jpg", "image/jpeg"),
("model", "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"),
("png", "image/png"),
("rels", "application/vnd.openxmlformats-package.relationships+xml"),
(
"texture",
"application/vnd.ms-package.3dmanufacturing-3dmodeltexture",
),
]
with xf.element("Types", nsmap=nsmap):
for ext, ctype in types:
xf.write(etree.Element("Default", Extension=ext, ContentType=ctype))
return file_obj.getvalue()
def _attrib_to_transform(attrib):
"""
Extract a homogeneous transform from a dictionary.
Parameters
------------
attrib: dict, optionally containing 'transform'
Returns
------------
transform: (4, 4) float, homogeonous transformation
"""
transform = np.eye(4, dtype=np.float64)
if "transform" in attrib:
# wangle their transform format
values = np.array(attrib["transform"].split(), dtype=np.float64).reshape((4, 3)).T
transform[:3, :4] = values
return transform
# do import here to keep lxml a soft dependency
try:
import networkx as nx
from lxml import etree
_three_loaders = {"3mf": load_3MF}
_3mf_exporters = {"3mf": export_3MF}
except BaseException as E:
from ..exceptions import ExceptionWrapper
_three_loaders = {"3mf": ExceptionWrapper(E)}
_3mf_exporters = {"3mf": ExceptionWrapper(E)}
@@ -0,0 +1,163 @@
import os
import numpy as np
from ..constants import log, tol
from ..version import __version__
def export_urdf(mesh, directory, scale=1.0, color=None, **kwargs):
"""
Convert a Trimesh object into a URDF package for physics
simulation. This breaks the mesh into convex pieces and
writes them to the same directory as the .urdf file.
Parameters
---------
mesh : trimesh.Trimesh
Input geometry
directory : str
The directory path for the URDF package
Returns
---------
mesh : Trimesh
Multi-body mesh containing convex decomposition
"""
import lxml.etree as et
# TODO: fix circular import
from .export import export_mesh
# Extract the save directory and the file name
fullpath = os.path.abspath(directory)
name = os.path.basename(fullpath)
_, ext = os.path.splitext(name)
if ext != "":
raise ValueError("URDF path must be a directory!")
# Create directory if needed
if not os.path.exists(fullpath):
os.mkdir(fullpath)
elif not os.path.isdir(fullpath):
raise ValueError("URDF path must be a directory!")
# Perform a convex decomposition
try:
convex_pieces = mesh.convex_decomposition()
except BaseException:
log.error("problem with convex decomposition, using hull", exc_info=True)
convex_pieces = [mesh.convex_hull]
# Get the effective density of the mesh
effective_density = mesh.volume / sum([m.volume for m in convex_pieces])
# open an XML tree
root = et.Element("robot", name="root")
# Loop through all pieces, adding each as a link
prev_link_name = None
for i, piece in enumerate(convex_pieces):
# Save each nearly convex mesh out to a file
piece_name = f"{name}_convex_piece_{i}"
piece_filename = f"{piece_name}.obj"
piece_filepath = os.path.join(fullpath, piece_filename)
export_mesh(piece, piece_filepath)
# Set the mass properties of the piece
piece.center_mass = mesh.center_mass
piece.density = effective_density * mesh.density
link_name = f"link_{piece_name}"
geom_name = f"{piece_filename}"
I = [["{:.2E}".format(y) for y in x] for x in piece.moment_inertia] # NOQA
# Write the link out to the XML Tree
link = et.SubElement(root, "link", name=link_name)
# Inertial information
inertial = et.SubElement(link, "inertial")
et.SubElement(inertial, "origin", xyz="0 0 0", rpy="0 0 0")
et.SubElement(inertial, "mass", value=f"{piece.mass:.2E}")
et.SubElement(
inertial,
"inertia",
ixx=I[0][0],
ixy=I[0][1],
ixz=I[0][2],
iyy=I[1][1],
iyz=I[1][2],
izz=I[2][2],
)
# Visual Information
visual = et.SubElement(link, "visual")
et.SubElement(visual, "origin", xyz="0 0 0", rpy="0 0 0")
geometry = et.SubElement(visual, "geometry")
et.SubElement(
geometry,
"mesh",
filename=geom_name,
scale=f"{scale:.4E} {scale:.4E} {scale:.4E}",
)
material = et.SubElement(visual, "material", name="")
if color is not None:
et.SubElement(
material, "color", rgba=f"{color[0]:.2E} {color[1]:.2E} {color[2]:.2E} 1"
)
# Collision Information
collision = et.SubElement(link, "collision")
et.SubElement(collision, "origin", xyz="0 0 0", rpy="0 0 0")
geometry = et.SubElement(collision, "geometry")
et.SubElement(
geometry,
"mesh",
filename=geom_name,
scale=f"{scale:.4E} {scale:.4E} {scale:.4E}",
)
# Create rigid joint to previous link
if prev_link_name is not None:
joint_name = f"{link_name}_joint"
joint = et.SubElement(root, "joint", name=joint_name, type="fixed")
et.SubElement(joint, "origin", xyz="0 0 0", rpy="0 0 0")
et.SubElement(joint, "parent", link=prev_link_name)
et.SubElement(joint, "child", link=link_name)
prev_link_name = link_name
# Write URDF file
tree = et.ElementTree(root)
urdf_filename = f"{name}.urdf"
tree.write(os.path.join(fullpath, urdf_filename), pretty_print=True)
# Write Gazebo config file
root = et.Element("model")
model = et.SubElement(root, "name")
model.text = name
version = et.SubElement(root, "version")
version.text = "1.0"
sdf = et.SubElement(root, "sdf", version="1.4")
sdf.text = f"{name}.urdf"
author = et.SubElement(root, "author")
et.SubElement(author, "name").text = f"trimesh {__version__}"
et.SubElement(author, "email").text = "blank@blank.blank"
description = et.SubElement(root, "description")
description.text = name
tree = et.ElementTree(root)
if tol.strict:
from ..resources import get_stream
# todo : we don't pass the URDF schema validation
schema = et.XMLSchema(file=get_stream("schema/urdf.xsd"))
if not schema.validate(tree):
# actual error isn't raised by validate
log.debug(schema.error_log)
tree.write(os.path.join(fullpath, "model.config"))
return np.sum(convex_pieces)
@@ -0,0 +1,156 @@
"""
xaml.py
---------
Load 3D XAMl files, an export option from Solidworks.
"""
import collections
import numpy as np
from .. import transformations as tf
from .. import util, visual
def load_XAML(file_obj, *args, **kwargs):
"""
Load a 3D XAML file.
Parameters
----------
file_obj : file object
Open XAML file.
Returns
----------
result : dict
Kwargs for a Trimesh constructor.
"""
def element_to_color(element):
"""
Turn an XML element into a (4,) np.uint8 RGBA color
"""
if element is None:
return visual.DEFAULT_COLOR
hexcolor = int(element.attrib["Color"].replace("#", ""), 16)
opacity = float(element.attrib["Opacity"])
rgba = [
(hexcolor >> 16) & 0xFF,
(hexcolor >> 8) & 0xFF,
(hexcolor & 0xFF),
opacity * 0xFF,
]
rgba = np.array(rgba, dtype=np.uint8)
return rgba
def element_to_transform(element):
"""
Turn an XML element into a (4,4) np.float64
transformation matrix.
"""
try:
matrix = next(element.iter(tag=ns + "MatrixTransform3D")).attrib["Matrix"]
matrix = np.array(matrix.split(), dtype=np.float64).reshape((4, 4)).T
return matrix
except StopIteration:
# this will be raised if the MatrixTransform3D isn't in the passed
# elements tree
return np.eye(4)
# read the file and parse XML
file_data = file_obj.read()
root = etree.XML(file_data)
# the XML namespace
ns = root.tag.split("}")[0] + "}"
# the linked lists our results are going in
vertices = []
faces = []
colors = []
normals = []
# iterate through the element tree
# the GeometryModel3D tag contains a material and geometry
for geometry in root.iter(tag=ns + "GeometryModel3D"):
# get the diffuse and specular colors specified in the material
color_search = ".//{ns}{color}Material/*/{ns}SolidColorBrush"
diffuse = geometry.find(color_search.format(ns=ns, color="Diffuse"))
specular = geometry.find(color_search.format(ns=ns, color="Specular"))
# convert the element into a (4,) np.uint8 RGBA color
diffuse = element_to_color(diffuse)
specular = element_to_color(specular)
# to get the final transform of a component we'll have to traverse
# all the way back to the root node and save transforms we find
current = geometry
transforms = collections.deque()
# when the root node is reached its parent will be None and we stop
while current is not None:
# element.find will only return elements that are direct children
# of the current element as opposed to element.iter,
# which will return any depth of child
transform_element = current.find(ns + "ModelVisual3D.Transform")
if transform_element is not None:
# we are traversing the tree backwards, so append new
# transforms to the left of the deque
transforms.appendleft(element_to_transform(transform_element))
# we are going from the lowest level of the tree to the highest
# this avoids having to traverse any branches that don't have
# geometry
current = current.getparent()
if len(transforms) == 0:
# no transforms in the tree mean an identity matrix
transform = np.eye(4)
elif len(transforms) == 1:
# one transform in the tree we can just use
transform = transforms.pop()
else:
# multiple transforms we apply all of them in order
transform = util.multi_dot(transforms)
# iterate through the contained mesh geometry elements
for g in geometry.iter(tag=ns + "MeshGeometry3D"):
c_normals = np.array(
g.attrib["Normals"].replace(",", " ").split(), dtype=np.float64
).reshape((-1, 3))
c_vertices = np.array(
g.attrib["Positions"].replace(",", " ").split(), dtype=np.float64
).reshape((-1, 3))
# bake in the transform as we're saving
c_vertices = tf.transform_points(c_vertices, transform)
c_faces = np.array(
g.attrib["TriangleIndices"].replace(",", " ").split(), dtype=np.int64
).reshape((-1, 3))
# save data to a sequence
vertices.append(c_vertices)
faces.append(c_faces)
colors.append(np.tile(diffuse, (len(c_faces), 1)))
normals.append(c_normals)
# compile the results into clean numpy arrays
result = {"units": "meters"}
result["vertices"], result["faces"] = util.append_faces(vertices, faces)
result["face_colors"] = np.vstack(colors)
result["vertex_normals"] = np.vstack(normals)
return result
try:
from lxml import etree
_xaml_loaders = {"xaml": load_XAML}
except BaseException as E:
# create a dummy module which will raise the ImportError
# or other exception only when someone tries to use networkx
from ..exceptions import ExceptionWrapper
_xaml_loaders = {"xaml": ExceptionWrapper(E)}
@@ -0,0 +1,103 @@
import numpy as np
from .. import util
from ..points import PointCloud
def load_xyz(file_obj, delimiter=None, **kwargs):
"""
Load an XYZ file into a PointCloud.
Parameters
------------
file_obj : an open file-like object
Source data, ASCII XYZ
delimiter : None or string
Characters used to separate the columns of the file
If not passed will use whitespace or commas
Returns
----------
kwargs : dict
Data which can be passed to PointCloud constructor
"""
# read the whole file into memory as a string
raw = util.decode_text(file_obj.read()).strip()
# get the first line to look at
first = raw[: raw.find("\n")].strip()
# guess the column count by looking at the first line
columns = len(first.split())
if columns < 3:
raise ValueError("not enough columns in xyz file!")
if delimiter is None and "," in first:
# if no delimiter passed and file has commas
delimiter = ","
if delimiter is not None:
# replace delimiter with whitespace so split works
raw = raw.replace(delimiter, " ")
# use string splitting to get array
array = np.array(raw.split(), dtype=np.float64)
# reshape to column count
# if file has different numbers of values
# per row this will fail as it should
data = array.reshape((-1, columns))
# start with no colors
colors = None
# vertices are the first three columns
vertices = data[:, :3]
if columns == 6:
# RGB colors
colors = np.array(data[:, 3:], dtype=np.uint8)
colors = np.concatenate(
(colors, np.ones((len(data), 1), dtype=np.uint8) * 255), axis=1
)
elif columns >= 7:
# extract RGBA colors
colors = np.array(data[:, 3:8], dtype=np.uint8)
# add extracted colors and vertices to kwargs
kwargs.update({"vertices": vertices, "colors": colors})
return kwargs
def export_xyz(cloud, write_colors=True, delimiter=None):
"""
Export a PointCloud object to an XYZ format string.
Parameters
-------------
cloud : trimesh.PointCloud
Geometry in space
write_colors : bool
Write colors or not
delimiter : None or str
What to separate columns with
Returns
--------------
export : str
Pointcloud in XYZ format
"""
if not isinstance(cloud, PointCloud):
raise ValueError("object must be PointCloud")
# compile data into a blob
data = cloud.vertices
if write_colors and hasattr(cloud, "colors") and cloud.colors is not None:
# stack colors and vertices
data = np.hstack((data, cloud.colors))
# if delimiter not passed use whitespace
if delimiter is None:
delimiter = " "
# stack blob into XYZ format
export = util.array_to_string(data, col_delim=delimiter)
return export
_xyz_loaders = {"xyz": load_xyz}
_xyz_exporters = {"xyz": export_xyz}
@@ -0,0 +1,462 @@
import numpy as np
from . import util
from .constants import log
from .typed import NDArray
try:
import scipy.sparse
except BaseException as E:
from . import exceptions
# raise E again if anyone tries to use sparse
scipy = exceptions.ExceptionWrapper(E)
def plane_transform(origin, normal):
"""
Given the origin and normal of a plane find the transform
that will move that plane to be coplanar with the XY plane.
Parameters
----------
origin : (3,) float
Point that lies on the plane
normal : (3,) float
Vector that points along normal of plane
Returns
---------
transform: (4,4) float
Transformation matrix to move points onto XY plane
"""
transform = align_vectors(normal, [0, 0, 1])
if origin is not None:
transform[:3, 3] = -np.dot(transform, np.append(origin, 1))[:3]
return transform
def align_vectors(a, b, return_angle=False):
"""
Find the rotation matrix that transforms one 3D vector
to another.
Parameters
------------
a : (3,) float
Unit vector
b : (3,) float
Unit vector
return_angle : bool
Return the angle between vectors or not
Returns
-------------
matrix : (4, 4) float
Homogeneous transform to rotate from `a` to `b`
angle : float
If `return_angle` angle in radians between `a` and `b`
"""
a = np.array(a, dtype=np.float64)
b = np.array(b, dtype=np.float64)
if a.shape != (3,) or b.shape != (3,):
raise ValueError("vectors must be (3,)!")
# find the SVD of the two vectors
au = np.linalg.svd(a.reshape((-1, 1)))[0]
bu = np.linalg.svd(b.reshape((-1, 1)))[0]
if np.linalg.det(au) < 0:
au[:, -1] *= -1.0
if np.linalg.det(bu) < 0:
bu[:, -1] *= -1.0
# put rotation into homogeneous transformation
matrix = np.eye(4)
matrix[:3, :3] = bu.dot(au.T)
if return_angle:
# projection of a onto b
# first row of SVD result is normalized source vector
dot = np.dot(au[0], bu[0])
# clip to avoid floating point error
angle = np.arccos(np.clip(dot, -1.0, 1.0))
if dot < -1e-5:
angle += np.pi
return matrix, angle
return matrix
def faces_to_edges(faces, return_index=False):
"""
Given a list of faces (n,3), return a list of edges (n*3,2)
Parameters
-----------
faces : (n, 3) int
Vertex indices representing faces
Returns
-----------
edges : (n*3, 2) int
Vertex indices representing edges
"""
faces = np.asanyarray(faces, np.int64)
# each face has three edges
edges = faces[:, [0, 1, 1, 2, 2, 0]].reshape((-1, 2))
if return_index:
# edges are in order of faces due to reshape
face_index = np.tile(np.arange(len(faces)), (3, 1)).T.reshape(-1)
return edges, face_index
return edges
def vector_angle(pairs):
"""
Find the angles between pairs of unit vectors.
Parameters
----------
pairs : (n, 2, 3) float
Unit vector pairs
Returns
----------
angles : (n,) float
Angles between vectors in radians
"""
pairs = np.asanyarray(pairs, dtype=np.float64)
if len(pairs) == 0:
return np.array([])
elif util.is_shape(pairs, (2, 3)):
pairs = pairs.reshape((-1, 2, 3))
elif not util.is_shape(pairs, (-1, 2, (2, 3))):
raise ValueError("pairs must be (n,2,(2|3))!")
# do the dot product between vectors
dots = util.diagonal_dot(pairs[:, 0], pairs[:, 1])
# clip for floating point error
dots = np.clip(dots, -1.0, 1.0)
# do cos and remove arbitrary sign
angles = np.abs(np.arccos(dots))
return angles
def triangulate_quads(quads, dtype=np.int64) -> NDArray:
"""
Given an array of quad faces return them as triangle faces,
also handles pure triangles and mixed triangles and quads.
Parameters
-----------
quads: (n, 4) int
Vertex indices of quad faces.
Returns
-----------
faces : (m, 3) int
Vertex indices of triangular faces.c
"""
if len(quads) == 0:
return np.zeros(0, dtype=dtype)
try:
# this will fail in newer versions of numpy
# if there are mixed quads and tris
quads = np.array(quads, dtype=dtype)
if len(quads.shape) == 2 and quads.shape[1] == 3:
# if they are just triangles return immediately
return quads.astype(dtype)
if len(quads.shape) == 2 and quads.shape[1] == 4:
# if they are just quads stack and return
return np.vstack((quads[:, [0, 1, 2]], quads[:, [2, 3, 0]])).astype(dtype)
except ValueError:
# new numpy raises an error for sequences
pass
# we made it here so we have mixed tris/quads/polygons
# filter into the three cases
tri = np.array([i for i in quads if len(i) == 3])
quad = np.array([i for i in quads if len(i) == 4])
# triangulate arbitrary polygons as triangle fans
# this isn't guaranteed to be sane if the polygons
# aren't convex but that would require a real maniac
poly = [
[[f[0], f[i + 1], f[i + 2]] for i in range(len(f) - 2)]
for f in quads
if len(f) > 4
]
if len(quad) == 0 and len(poly) == 0:
return tri.astype(dtype)
if len(poly) > 0:
poly = np.vstack(poly)
if len(quad) > 0:
quad = np.vstack((quad[:, [0, 1, 2]], quad[:, [2, 3, 0]]))
# combine triangulated quads with triangles
return util.vstack_empty([tri, quad, poly]).astype(dtype)
def vertex_face_indices(vertex_count, faces, faces_sparse):
"""
Find vertex face indices from the faces array of vertices
Parameters
-----------
vertex_count : int
The number of vertices faces refer to
faces : (n, 3) int
List of vertex indices
faces_sparse : scipy.sparse.COO
Sparse matrix
Returns
-----------
vertex_faces : (vertex_count, ) int
Face indices for every vertex
Array padded with -1 in each row for all vertices with fewer
face indices than the max number of face indices.
"""
# Create 2D array with row for each vertex and
# length of max number of faces for a vertex
try:
counts = np.bincount(faces.flatten(), minlength=vertex_count)
except TypeError:
# casting failed on 32 bit Windows
log.warning("casting failed, falling back!")
# fall back to np.unique (usually ~35x slower than bincount)
counts = np.unique(faces.flatten(), return_counts=True)[1]
assert len(counts) == vertex_count
assert faces.max() < vertex_count
# start cumulative sum at zero and clip off the last value
starts = np.append(0, np.cumsum(counts)[:-1])
# pack incrementing array into final shape
pack = np.arange(counts.max()) + starts[:, None]
# pad each row with -1 to pad to the max length
padded = -(pack >= (starts + counts)[:, None]).astype(np.int64)
try:
# do most of the work with a sparse dot product
identity = scipy.sparse.identity(len(faces), dtype=int)
sorted_faces = faces_sparse.dot(identity).nonzero()[1]
# this will fail if any face was degenerate
# TODO
# figure out how to filter out degenerate faces from sparse
# result if sorted_faces.size != faces.size
padded[padded == 0] = sorted_faces
except BaseException:
# fall back to a slow loop
log.warning(
"vertex_faces falling back to slow loop! "
+ "mesh probably has degenerate faces",
exc_info=True,
)
sort = np.zeros(faces.size, dtype=np.int64)
flat = faces.flatten()
for v in range(vertex_count):
# assign the data in order
sort[starts[v] : starts[v] + counts[v]] = (np.where(flat == v)[0] // 3)[::-1]
padded[padded == 0] = sort
return padded
def mean_vertex_normals(vertex_count, faces, face_normals, sparse=None, **kwargs):
"""
Find vertex normals from the mean of the faces that contain
that vertex.
Parameters
-----------
vertex_count : int
The number of vertices faces refer to
faces : (n, 3) int
List of vertex indices
face_normals : (n, 3) float
Normal vector for each face
Returns
-----------
vertex_normals : (vertex_count, 3) float
Normals for every vertex
Vertices unreferenced by faces will be zero.
"""
def summed_sparse():
# use a sparse matrix of which face contains each vertex to
# figure out the summed normal at each vertex
# allow cached sparse matrix to be passed
if sparse is None:
matrix = index_sparse(vertex_count, faces)
else:
matrix = sparse
summed = matrix.dot(face_normals)
return summed
def summed_loop():
# loop through every face, in tests was ~50x slower than
# doing this with a sparse matrix
summed = np.zeros((vertex_count, 3))
for face, normal in zip(faces, face_normals):
summed[face] += normal
return summed
try:
summed = summed_sparse()
except BaseException:
log.warning("unable to use sparse matrix, falling back!", exc_info=True)
summed = summed_loop()
# invalid normals will be returned as zero
vertex_normals = util.unitize(summed)
return vertex_normals
def weighted_vertex_normals(
vertex_count, faces, face_normals, face_angles, use_loop=False
):
"""
Compute vertex normals from the faces that contain that vertex.
The contribution of a face's normal to a vertex normal is the
ratio of the corner-angle in which the vertex is, with respect
to the sum of all corner-angles surrounding the vertex.
Grit Thuerrner & Charles A. Wuethrich (1998)
Computing Vertex Normals from Polygonal Facets,
Journal of Graphics Tools, 3:1, 43-46
Parameters
-----------
vertex_count : int
The number of vertices faces refer to
faces : (n, 3) int
List of vertex indices
face_normals : (n, 3) float
Normal vector for each face
face_angles : (n, 3) float
Angles at each vertex in the face
Returns
-----------
vertex_normals : (vertex_count, 3) float
Normals for every vertex
Vertices unreferenced by faces will be zero.
"""
def summed_sparse():
# use a sparse matrix of which face contains each vertex to
# figure out the summed normal at each vertex
# allow cached sparse matrix to be passed
# fill the matrix with vertex-corner angles as weights
matrix = index_sparse(vertex_count, faces, data=face_angles.ravel())
return matrix.dot(face_normals)
def summed_loop():
summed = np.zeros((vertex_count, 3), np.float64)
for vertex_idx in np.arange(vertex_count):
# loop over all vertices
# compute normal contributions from surrounding faces
# obviously slower than with the sparse matrix
face_idxs, inface_idxs = np.where(faces == vertex_idx)
surrounding_angles = face_angles[face_idxs, inface_idxs]
summed[vertex_idx] = np.dot(
surrounding_angles / surrounding_angles.sum(), face_normals[face_idxs]
)
return summed
# normals should be unit vectors
face_ok = (face_normals**2).sum(axis=1) > 0.5
# don't consider faces with invalid normals
faces = faces[face_ok]
face_normals = face_normals[face_ok]
face_angles = face_angles[face_ok]
if not use_loop:
try:
return util.unitize(summed_sparse())
except BaseException:
log.warning("unable to use sparse matrix, falling back!", exc_info=True)
# we either crashed or were asked to loop
return util.unitize(summed_loop())
def index_sparse(columns, indices, data=None, dtype=None):
"""
Return a sparse matrix for which vertices are contained in which faces.
A data vector can be passed which is then used instead of booleans
Parameters
------------
columns : int
Number of columns, usually number of vertices
indices : (m, d) int
Usually mesh.faces
Returns
---------
sparse: scipy.sparse.coo_matrix of shape (columns, len(faces))
dtype is boolean
Examples
----------
In [1]: sparse = faces_sparse(len(mesh.vertices), mesh.faces)
In [2]: sparse.shape
Out[2]: (12, 20)
In [3]: mesh.faces.shape
Out[3]: (20, 3)
In [4]: mesh.vertices.shape
Out[4]: (12, 3)
In [5]: dense = sparse.toarray().astype(int)
In [6]: dense
Out[6]:
array([[1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1],
[1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0],
[0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1]])
In [7]: dense.sum(axis=0)
Out[7]: array([3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
"""
indices = np.asanyarray(indices)
columns = int(columns)
# flattened list
row = indices.reshape(-1)
col = np.tile(
np.arange(len(indices)).reshape((-1, 1)), (1, indices.shape[1])
).reshape(-1)
shape = (columns, len(indices))
if data is None:
data = np.ones(len(col), dtype=bool)
elif len(data) != len(col):
raise ValueError("data incorrect length")
if dtype is not None:
data = data.astype(dtype)
# assemble into sparse matrix
return scipy.sparse.coo_matrix((data, (row, col)), shape=shape, dtype=data.dtype)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,866 @@
"""
grouping.py
-------------
Functions for grouping values and rows.
"""
import numpy as np
from . import util
from .constants import log, tol
from .typed import ArrayLike, Integer, NDArray, Optional
try:
from scipy.spatial import cKDTree
except BaseException as E:
# wrapping just ImportError fails in some cases
# will raise the error when someone tries to use KDtree
from . import exceptions
cKDTree = exceptions.ExceptionWrapper(E)
def merge_vertices(
mesh,
merge_tex: Optional[bool] = None,
merge_norm: Optional[bool] = None,
digits_vertex: Optional[Integer] = None,
digits_norm: Optional[Integer] = None,
digits_uv: Optional[Integer] = None,
):
"""
Removes duplicate vertices, grouped by position and
optionally texture coordinate and normal.
Parameters
-------------
mesh : Trimesh object
Mesh to merge vertices on
merge_tex : bool
If True textured meshes with UV coordinates will
have vertices merged regardless of UV coordinates
merge_norm : bool
If True, meshes with vertex normals will have
vertices merged ignoring different normals
digits_vertex : None or int
Number of digits to consider for vertex position
digits_norm : int
Number of digits to consider for unit normals
digits_uv : int
Number of digits to consider for UV coordinates
"""
# no vertices so exit early
if len(mesh.vertices) == 0:
return
if merge_tex is None:
merge_tex = False
if merge_norm is None:
merge_norm = False
if digits_norm is None:
digits_norm = 2
if digits_uv is None:
digits_uv = 4
if digits_vertex is None:
# use tol.merge if digit precision not passed
digits_vertex = util.decimal_to_digits(tol.merge)
# if we have a ton of unreferenced vertices it will
# make the unique_rows call super slow so cull first
if hasattr(mesh, "faces") and len(mesh.faces) > 0:
referenced = np.zeros(len(mesh.vertices), dtype=bool)
referenced[mesh.faces] = True
else:
# this is used for geometry without faces
referenced = np.ones(len(mesh.vertices), dtype=bool)
# collect vertex attributes into sequence we can stack
stacked = [mesh.vertices * (10**digits_vertex)]
# UV texture visuals require us to update the
# vertices and normals differently
if (
not merge_tex
and mesh.visual.defined
and mesh.visual.kind == "texture"
and mesh.visual.uv is not None
and len(mesh.visual.uv) == len(mesh.vertices)
):
# get an array with vertices and UV coordinates
# converted to integers at requested precision
stacked.append(mesh.visual.uv * (10**digits_uv))
# check to see if we have vertex normals
normals = mesh._cache["vertex_normals"]
if not merge_norm and np.shape(normals) == mesh.vertices.shape:
stacked.append(normals * (10**digits_norm))
# stack collected vertex properties and round to integer
stacked = np.column_stack(stacked).round().astype(np.int64)
# check unique rows of referenced vertices
u, i = unique_rows(stacked[referenced], keep_order=True)
# construct an inverse using the subset
inverse = np.zeros(len(mesh.vertices), dtype=np.int64)
inverse[referenced] = i
# get the vertex mask
mask = np.nonzero(referenced)[0][u]
# run the update including normals and UV coordinates
mesh.update_vertices(mask=mask, inverse=inverse)
def group(values, min_len: Optional[Integer] = None, max_len: Optional[Integer] = None):
"""
Return the indices of values that are identical
Parameters
----------
values : (n,) int
Values to group
min_len : int
The shortest group allowed
All groups will have len >= min_length
max_len : int
The longest group allowed
All groups will have len <= max_length
Returns
----------
groups : sequence
Contains indices to form groups
IE [0,1,0,1] returns [[0,2], [1,3]]
"""
original = np.asanyarray(values)
# save the sorted order and then apply it
order = original.argsort()
values = original[order]
# find the indexes which are duplicates
if values.dtype.kind == "f":
# for floats in a sorted array, neighbors are not duplicates
# if the difference between them is greater than approximate zero
nondupe = np.greater(np.abs(np.diff(values)), tol.zero)
else:
# for ints and strings we can check exact non- equality
# for all other types this will only work if they defined
# an __eq__
nondupe = values[1:] != values[:-1]
dupe_idx = np.append(0, np.nonzero(nondupe)[0] + 1)
# start with a mask that marks everything as ok
dupe_ok = np.ones(len(dupe_idx), dtype=bool)
# calculate the length of each group from their index
dupe_len = np.diff(np.concatenate((dupe_idx, [len(values)])))
# cull by length if requested
if min_len is not None or max_len is not None:
if min_len is not None:
dupe_ok &= dupe_len >= min_len
if max_len is not None:
dupe_ok &= dupe_len <= max_len
groups = [order[i : (i + j)] for i, j in zip(dupe_idx[dupe_ok], dupe_len[dupe_ok])]
return groups
def hashable_rows(
data: ArrayLike, digits: Optional[Integer] = None, allow_int: bool = True
) -> NDArray:
"""
We turn our array into integers based on the precision
given by digits and then put them in a hashable format.
Parameters
---------
data : (n, m) array
Input data
digits : int or None
How many digits to add to hash if data is floating point
If None, tol.merge will be used
Returns
---------
hashable : (n,)
May return as a `np.void` or a `np.uint64`
"""
# if there is no data return immediately
if len(data) == 0:
return np.array([], dtype=np.uint64)
# get array as integer to precision we care about
as_int = float_to_int(data, digits=digits)
# if it is flat integers already return
if len(as_int.shape) == 1:
return as_int
# if array is 2D and smallish, we can try bitbanging
# this is significantly faster than the custom dtype
if allow_int and len(as_int.shape) == 2 and as_int.shape[1] <= 4:
# can we pack the whole row into a single 64 bit integer
precision = int(np.floor(64 / as_int.shape[1]))
# get the extreme values of the data set
d_min, d_max = as_int.min(), as_int.max()
# since we are quantizing the data down we need every value
# to fit in a partial integer so we have to check against extrema
threshold = (2 ** (precision - 1)) - 1
# if the data is within the range of our precision threshold
if d_max < threshold and d_min > -threshold:
# the resulting package
hashable = np.zeros(len(as_int), dtype=np.uint64)
# offset to the middle of the unsigned integer range
# this array should contain only positive values
bitbang = (as_int.T + (threshold + 1)).astype(np.uint64)
# loop through each column and bitwise xor to combine
# make sure as_int is int64 otherwise bit offset won't work
for offset, column in enumerate(bitbang):
# will modify hashable in place
np.bitwise_xor(hashable, column << (offset * precision), out=hashable)
return hashable
# reshape array into magical data type that is weird but works with unique
dtype = np.dtype((np.void, as_int.dtype.itemsize * as_int.shape[1]))
# make sure result is contiguous and flat
result = np.ascontiguousarray(as_int).view(dtype).reshape(-1)
result.flags["WRITEABLE"] = False
return result
def float_to_int(data, digits: Optional[Integer] = None) -> NDArray[np.int64]:
"""
Given a numpy array of float/bool/int, return as integers.
Parameters
-------------
data : (n, d) float, int, or bool
Input data
digits : float or int
Precision for float conversion
Returns
-------------
as_int : (n, d) int
Data as integers
"""
# convert to any numpy array
data = np.asanyarray(data)
# we can early-exit if we've been passed data that is already
# an integer, unsigned integer, boolean, or empty
if data.dtype == np.int64:
return data
elif data.dtype.kind in "iub" or data.size == 0:
return data.astype(np.int64)
elif data.dtype.kind != "f":
# if it's not a floating point try to make it one
data = data.astype(np.float64)
if digits is None:
# get digits from `tol.merge`
digits = util.decimal_to_digits(tol.merge)
elif not isinstance(digits, (int, np.integer)):
raise TypeError(f"Digits must be `None` or `int`, not `{type(digits)}`")
# multiply by requested power of ten
# then subtract small epsilon to avoid "go either way" rounding
# then do the rounding and convert to integer
return np.round((data * 10**digits) - 1e-6).astype(np.int64)
def unique_ordered(
data: ArrayLike, return_index: bool = False, return_inverse: bool = False
):
"""
Returns the same as np.unique, but ordered as per the
first occurrence of the unique value in data.
Examples
---------
In [1]: a = [0, 3, 3, 4, 1, 3, 0, 3, 2, 1]
In [2]: np.unique(a)
Out[2]: array([0, 1, 2, 3, 4])
In [3]: trimesh.grouping.unique_ordered(a)
Out[3]: array([0, 3, 4, 1, 2])
"""
# uniques are the values, sorted
# index is the value in the original `data`
# i.e. `data[index] == unique`
# inverse is how to re-construct `data` from `unique`
# i.e. `unique[inverse] == data`
unique, index, inverse = np.unique(data, return_index=True, return_inverse=True)
# we want to maintain the original index order
order = index.argsort()
if not return_index and not return_inverse:
return unique[order]
# collect return values
# start with the unique values in original order
result = [unique[order]]
# the new index values
if return_index:
# re-order the index in the original array
result.append(index[order])
if return_inverse:
# create the new inverse from the order of the order
result.append(order.argsort()[inverse])
return result
def unique_bincount(
values: ArrayLike,
minlength: Integer = 0,
return_inverse: bool = False,
return_counts: bool = False,
):
"""
For arrays of integers find unique values using bin counting.
Roughly 10x faster for correct input than np.unique
Parameters
--------------
values : (n,) int
Values to find unique members of
minlength : int
Maximum value that will occur in values (values.max())
return_inverse : bool
If True, return an inverse such that unique[inverse] == values
return_counts : bool
If True, also return the number of times each
unique item appears in values
Returns
------------
unique : (m,) int
Unique values in original array
inverse : (n,) int, optional
An array such that unique[inverse] == values
Only returned if return_inverse is True
counts : (m,) int, optional
An array holding the counts of each unique item in values
Only returned if return_counts is True
"""
values = np.asanyarray(values)
if len(values.shape) != 1 or values.dtype.kind != "i":
raise ValueError("input must be 1D integers!")
try:
# count the number of occurrences of each value
counts = np.bincount(values, minlength=minlength)
except TypeError:
# casting failed on 32 bit windows
log.warning("casting failed, falling back!")
# fall back to numpy unique
return np.unique(
values, return_inverse=return_inverse, return_counts=return_counts
)
# which bins are occupied at all
# counts are integers so this works
unique_bin = counts.astype(bool)
# which values are unique
# indexes correspond to original values
unique = np.where(unique_bin)[0]
ret = (unique,)
if return_inverse:
# find the inverse to reconstruct original
inverse = (np.cumsum(unique_bin) - 1)[values]
ret += (inverse,)
if return_counts:
unique_counts = counts[unique]
ret += (unique_counts,)
if len(ret) == 1:
return ret[0]
return ret
def merge_runs(data: ArrayLike, digits: Optional[Integer] = None):
"""
Merge duplicate sequential values. This differs from unique_ordered
in that values can occur in multiple places in the sequence, but
only consecutive repeats are removed
Parameters
-----------
data: (n,) float or int
Returns
--------
merged: (m,) float or int
Examples
---------
In [1]: a
Out[1]:
array([-1, -1, -1, 0, 0, 1, 1, 2, 0,
3, 3, 4, 4, 5, 5, 6, 6, 7,
7, 8, 8, 9, 9, 9])
In [2]: trimesh.grouping.merge_runs(a)
Out[2]: array([-1, 0, 1, 2, 0, 3, 4, 5, 6, 7, 8, 9])
"""
if digits is None:
epsilon = tol.merge
else:
epsilon = 10 ** (-digits)
data = np.asanyarray(data)
mask = np.zeros(len(data), dtype=bool)
mask[0] = True
mask[1:] = np.abs(data[1:] - data[:-1]) > epsilon
return data[mask]
def unique_float(
data,
return_index: bool = False,
return_inverse: bool = False,
digits: Optional[Integer] = None,
):
"""
Identical to the numpy.unique command, except evaluates floating point
numbers, using a specified number of digits.
If digits isn't specified, the library default TOL_MERGE will be used.
"""
data = np.asanyarray(data)
as_int = float_to_int(data, digits)
_junk, unique, inverse = np.unique(as_int, return_index=True, return_inverse=True)
if (not return_index) and (not return_inverse):
return data[unique]
result = [data[unique]]
if return_index:
result.append(unique)
if return_inverse:
result.append(inverse)
return tuple(result)
def unique_rows(data, digits=None, keep_order=False):
"""
Returns indices of unique rows. It will return the
first occurrence of a row that is duplicated:
[[1,2], [3,4], [1,2]] will return [0,1]
Parameters
---------
data : (n, m) array
Floating point data
digits : int or None
How many digits to consider
Returns
--------
unique : (j,) int
Index in data which is a unique row
inverse : (n,) int
Array to reconstruct original
Example: data[unique][inverse] == data
"""
# get rows hashable so we can run unique function on it
rows = hashable_rows(data, digits=digits)
# we are throwing away the first value which is the
# garbage row-hash and only returning index and inverse
if keep_order:
# keeps order of original occurrence
return unique_ordered(rows, return_index=True, return_inverse=True)[1:]
# returns values sorted by row-hash but since our row-hash
# were pretty much garbage the sort order isn't meaningful
return np.unique(rows, return_index=True, return_inverse=True)[1:]
def unique_value_in_row(data, unique=None):
"""
For a 2D array of integers find the position of a
value in each row which only occurs once.
If there are more than one value per row which
occur once, the last one is returned.
Parameters
----------
data : (n, d) int
Data to check values
unique : (m,) int
List of unique values contained in data.
Generated from np.unique if not passed
Returns
---------
result : (n, d) bool
With one or zero True values per row.
Examples
-------------------------------------
In [0]: r = np.array([[-1, 1, 1],
[-1, 1, -1],
[-1, 1, 1],
[-1, 1, -1],
[-1, 1, -1]], dtype=np.int8)
In [1]: unique_value_in_row(r)
Out[1]:
array([[ True, False, False],
[False, True, False],
[ True, False, False],
[False, True, False],
[False, True, False]], dtype=bool)
In [2]: unique_value_in_row(r).sum(axis=1)
Out[2]: array([1, 1, 1, 1, 1])
In [3]: r[unique_value_in_row(r)]
Out[3]: array([-1, 1, -1, 1, 1], dtype=int8)
"""
if unique is None:
unique = np.unique(data)
data = np.asanyarray(data)
result = np.zeros_like(data, dtype=bool, subok=False)
for value in unique:
test = np.equal(data, value)
test_ok = test.sum(axis=1) == 1
result[test_ok] = test[test_ok]
return result
def group_rows(data, require_count=None, digits=None):
"""
Returns index groups of duplicate rows, for example:
[[1,2], [3,4], [1,2]] will return [[0,2], [1]]
Note that using require_count allows numpy advanced
indexing to be used in place of looping and
checking hashes and is ~10x faster.
Parameters
----------
data : (n, m) array
Data to group
require_count : None or int
Only return groups of a specified length, eg:
require_count = 2
[[1,2], [3,4], [1,2]] will return [[0,2]]
digits : None or int
If data is floating point how many decimals
to consider, or calculated from tol.merge
Returns
----------
groups : sequence (*,) int
Indices from in indicating identical rows.
"""
# start with getting a sortable format
hashable = hashable_rows(data, digits=digits)
# if there isn't a constant column size use more complex logic
if require_count is None:
return group(hashable)
# record the order of the rows so we can get the original indices back
order = hashable.argsort()
# but for now, we want our hashes sorted
hashable = hashable[order]
# this is checking each neighbour for equality, example:
# example: hashable = [1, 1, 1]; dupe = [0, 0]
dupe = hashable[1:] != hashable[:-1]
# we want the first index of a group, so we can slice from that location
# example: hashable = [0 1 1]; dupe = [1,0]; dupe_idx = [0,1]
dupe_idx = np.append(0, np.nonzero(dupe)[0] + 1)
# if you wanted to use this one function to deal with non- regular groups
# you could use: np.array_split(dupe_idx)
# this is roughly 3x slower than using the group_dict method above.
start_ok = np.diff(np.concatenate((dupe_idx, [len(hashable)]))) == require_count
groups = np.tile(dupe_idx[start_ok].reshape((-1, 1)), require_count) + np.arange(
require_count
)
groups_idx = order[groups]
if require_count == 1:
return groups_idx.reshape(-1)
return groups_idx
def boolean_rows(a, b, operation=np.intersect1d):
"""
Find the rows in two arrays which occur in both rows.
Parameters
---------
a: (n, d) int
Array with row vectors
b: (m, d) int
Array with row vectors
operation : function
Numpy boolean set operation function:
-np.intersect1d
-np.setdiff1d
Returns
--------
shared: (p, d) array containing rows in both a and b
"""
a = np.asanyarray(a, dtype=np.int64)
b = np.asanyarray(b, dtype=np.int64)
av = a.view([("", a.dtype)] * a.shape[1]).ravel()
bv = b.view([("", b.dtype)] * b.shape[1]).ravel()
return operation(av, bv).view(a.dtype).reshape(-1, a.shape[1])
def group_vectors(vectors, angle=1e-4, include_negative=False):
"""
Group vectors based on an angle tolerance, with the option to
include negative vectors.
Parameters
-----------
vectors : (n,3) float
Direction vector
angle : float
Group vectors closer than this angle in radians
include_negative : bool
If True consider the same:
[0,0,1] and [0,0,-1]
Returns
------------
new_vectors : (m,3) float
Direction vector
groups : (m,) sequence of int
Indices of source vectors
"""
vectors = np.asanyarray(vectors, dtype=np.float64)
angle = float(angle)
if include_negative:
vectors = util.vector_hemisphere(vectors)
spherical = util.vector_to_spherical(vectors)
angles, groups = group_distance(spherical, angle)
new_vectors = util.spherical_to_vector(angles)
return new_vectors, groups
def group_distance(values, distance):
"""
Find groups of points which have neighbours closer than radius,
where no two points in a group are farther than distance apart.
Parameters
---------
points : (n, d) float
Points of dimension d
distance : float
Max distance between points in a cluster
Returns
----------
unique : (m, d) float
Median value of each group
groups : (m) sequence of int
Indexes of points that make up a group
"""
values = np.asanyarray(values, dtype=np.float64)
consumed = np.zeros(len(values), dtype=bool)
tree = cKDTree(values)
# (n, d) set of values that are unique
unique = []
# (n) sequence of indices in values
groups = []
for index, value in enumerate(values):
if consumed[index]:
continue
group = np.array(tree.query_ball_point(value, distance), dtype=np.int64)
consumed[group] = True
unique.append(np.median(values[group], axis=0))
groups.append(group)
return np.array(unique), groups
def clusters(points, radius):
"""
Find clusters of points which have neighbours closer than radius
Parameters
---------
points : (n, d) float
Points of dimension d
radius : float
Max distance between points in a cluster
Returns
----------
groups : (m,) sequence of int
Indices of points in a cluster
"""
from . import graph
tree = cKDTree(points)
# some versions return pairs as a set of tuples
pairs = tree.query_pairs(r=radius, output_type="ndarray")
# group connected components
groups = graph.connected_components(pairs)
return groups
def blocks(data, min_len=2, max_len=np.inf, wrap=False, digits=None, only_nonzero=False):
"""
Find the indices in an array of contiguous blocks
of equal values.
Parameters
------------
data : (n,) array
Data to find blocks on
min_len : int
The minimum length group to be returned
max_len : int
The maximum length group to be retuurned
wrap : bool
Combine blocks on both ends of 1D array
digits : None or int
If dealing with floats how many digits to consider
only_nonzero : bool
Only return blocks of non- zero values
Returns
---------
blocks : (m) sequence of (*,) int
Indices referencing data
"""
data = float_to_int(data, digits=digits)
# keep an integer range around so we can slice
arange = np.arange(len(data))
arange.flags["WRITEABLE"] = False
nonzero = arange[1:][data[1:] != data[:-1]]
infl = np.zeros(len(nonzero) + 2, dtype=int)
infl[-1] = len(data)
infl[1:-1] = nonzero
# the length of each chunk
infl_len = infl[1:] - infl[:-1]
# check the length of each group
infl_ok = np.logical_and(infl_len >= min_len, infl_len <= max_len)
if only_nonzero:
# check to make sure the values of each contiguous block
# are True by checking the first value of each block
infl_ok = np.logical_and(infl_ok, data[infl[:-1]])
# inflate start/end indexes into full ranges of values
blocks = [arange[infl[i] : infl[i + 1]] for i, ok in enumerate(infl_ok) if ok]
if wrap:
# wrap only matters if first and last points are the same
if data[0] != data[-1]:
return blocks
# if we are only grouping nonzero things and
# the first and last point are zero we can exit
if only_nonzero and not bool(data[0]):
return blocks
# if all values are True or False we can exit
if len(blocks) == 1 and len(blocks[0]) == len(data):
return blocks
# so now first point equals last point, so the cases are:
# - first and last point are in a block: combine two blocks
# - first OR last point are in block: add other point to block
# - neither are in a block: check if combined is eligible block
# first point is in a block
first = len(blocks) > 0 and blocks[0][0] == 0
# last point is in a block
last = len(blocks) > 0 and blocks[-1][-1] == (len(data) - 1)
# CASE: first and last point are BOTH in block: combine blocks
if first and last:
blocks[0] = np.append(blocks[-1], blocks[0])
blocks.pop()
else:
# combined length
combined = infl_len[0] + infl_len[-1]
# exit if lengths aren't OK
if combined < min_len or combined > max_len:
return blocks
# new block combines both ends
new_block = np.append(
np.arange(infl[-2], infl[-1]), np.arange(infl[0], infl[1])
)
# we are in a first OR last situation now
if first:
# first was already in a block so replace it with combined
blocks[0] = new_block
elif last:
# last was already in a block so replace with superset
blocks[-1] = new_block
else:
# both are false
# combined length generated new block
blocks.append(new_block)
return blocks
def group_min(groups, data):
"""
Given a list of groups find the minimum element of data
within each group
Parameters
-----------
groups : (n,) sequence of (q,) int
Indexes of each group corresponding to each element in data
data : (m,)
The data that groups indexes reference
Returns
-----------
minimums : (n,)
Minimum value of data per group
"""
# sort with major key groups, minor key data
order = np.lexsort((data, groups))
groups = groups[order] # this is only needed if groups is unsorted
data = data[order]
# construct an index which marks borders between groups
index = np.zeros(len(groups), "bool")
index[0] = True
index[1:] = groups[1:] != groups[:-1]
return data[index]
@@ -0,0 +1,363 @@
"""
inertia.py
-------------
Functions for dealing with inertia tensors.
Results validated against known geometries and checked for
internal consistency.
"""
import numpy as np
from .typed import ArrayLike, NDArray, Number, Optional, Union, float64
from .util import multi_dot
def cylinder_inertia(
mass: Number, radius: Number, height: Number, transform: Optional[ArrayLike] = None
) -> NDArray[float64]:
"""
Return the inertia tensor of a cylinder.
Parameters
------------
mass : float
Mass of cylinder
radius : float
Radius of cylinder
height : float
Height of cylinder
transform : (4, 4) float
Transformation of cylinder
Returns
------------
inertia : (3, 3) float
Inertia tensor
"""
h2, r2 = height**2, radius**2
diagonal = np.array(
[
((mass * h2) / 12) + ((mass * r2) / 4),
((mass * h2) / 12) + ((mass * r2) / 4),
(mass * r2) / 2,
]
)
inertia = diagonal * np.eye(3)
if transform is not None:
inertia = transform_inertia(transform, inertia)
return inertia
def sphere_inertia(mass: Number, radius: Number) -> NDArray[float64]:
"""
Return the inertia tensor of a sphere.
Parameters
------------
mass : float
Mass of sphere
radius : float
Radius of sphere
Returns
------------
inertia : (3, 3) float
Inertia tensor
"""
return (2.0 / 5.0) * (radius**2) * mass * np.eye(3)
def points_inertia(
points: ArrayLike,
weights: Union[None, ArrayLike, Number] = None,
at_center_mass: bool = True,
) -> NDArray[float64]:
"""
Calculate an inertia tensor for an array of point masses
at the center of mass.
Parameters
----------
points : (n, 3)
Points in space.
weights : (n,) or number
Per-point weight to use.
at_center_mass
Calculate at the center of mass of the points, or if False
at the original origin.
Returns
-----------
tensor : (3, 3)
Inertia tensor for point masses.
"""
if weights is None:
# by default make the total weight 1.0 to match
# the default mass in other functions, and so that
# if a user didn't specify anything it doesn't blow
# up the scale depending on the number of points
weights = np.full(len(points), 1.0 / float(len(points)), dtype=np.float64)
elif isinstance(weights, (float, np.integer, int)):
# "is it a number" check
weights = np.full(len(points), float(weights), dtype=np.float64)
else:
weights = np.array(weights)
if len(weights) != len(points):
raise ValueError(
f"Weights must correspond to points! {len(weights)} != {len(points)}"
)
# make sure the points are an array of correct shape
points = np.asanyarray(points, dtype=np.float64)
if len(points.shape) != 2 or points.shape[1] != 3:
raise ValueError(f"Points must be `(n, 3)` not {points.shape}")
if at_center_mass:
# get the center of mass of the points
center_mass = np.average(points, weights=weights, axis=0)
# get the points with the origin at their center of mass
points_com = points - center_mass
else:
# calculate at original origin
points_com = points
# expand into shorthand for the expressions
x, y, z = points_com.T
x2, y2, z2 = (points_com**2).T
# calculate tensors per-point in a flattened (9, n) array
# from physics.stackexchange.com/questions/614094
tensors = np.array(
[y2 + z2, -x * y, -x * z, -x * y, x2 + z2, -y * z, -x * z, -y * z, x2 + y2],
dtype=np.float64,
)
# combine the weighted tensors and reshape
tensor = (tensors * weights).sum(axis=1).reshape((3, 3))
return tensor
def principal_axis(inertia: ArrayLike):
"""
Find the principal components and principal axis
of inertia from the inertia tensor.
Parameters
------------
inertia : (3, 3) float
Inertia tensor
Returns
------------
components : (3,) float
Principal components of inertia
vectors : (3, 3) float
Row vectors pointing along the
principal axes of inertia
"""
inertia = np.asanyarray(inertia, dtype=np.float64)
if inertia.shape != (3, 3):
raise ValueError("inertia tensor must be (3, 3)!")
# you could any of the following to calculate this:
# np.linalg.svd, np.linalg.eig, np.linalg.eigh
# moment of inertia is square symmetric matrix
# eigh has the best precision in tests
components, vectors = np.linalg.eigh(inertia)
# eigh returns them as column vectors, change them to row vectors
vectors = vectors.T
return components, vectors
def transform_inertia(
transform: ArrayLike,
inertia_tensor: ArrayLike,
parallel_axis: bool = False,
mass: Optional[Number] = None,
):
"""
Transform an inertia tensor to a new frame.
Note that in trimesh `mesh.moment_inertia` is *axis aligned*
and at `mesh.center_mass`.
So to transform to a new frame and get the moment of inertia at
the center of mass the translation should be ignored and only
rotation applied.
If parallel axis is enabled it will compute the inertia
about a new location.
More details in the MIT OpenCourseWare PDF:
` MIT16_07F09_Lec26.pdf`
Parameters
------------
transform : (3, 3) or (4, 4) float
Transformation matrix
inertia_tensor : (3, 3) float
Inertia tensor.
parallel_axis : bool
Apply the parallel axis theorum or not.
If the passed inertia tensor is at the center of mass
and you want the new post-transform tensor also at the
center of mass you DON'T want this enabled as you *only*
want to apply the rotation. Use this to get moment of
inertia at an arbitrary frame that isn't the center of mass.
Returns
------------
transformed : (3, 3) float
Inertia tensor in new frame.
"""
# check inputs and extract rotation
transform = np.asanyarray(transform, dtype=np.float64)
if transform.shape == (4, 4):
rotation = transform[:3, :3]
elif transform.shape == (3, 3):
rotation = transform
else:
raise ValueError("transform must be (3, 3) or (4, 4)!")
inertia_tensor = np.asanyarray(inertia_tensor, dtype=np.float64)
if inertia_tensor.shape != (3, 3):
raise ValueError("inertia_tensor must be (3, 3)!")
if parallel_axis:
if transform.shape == (3, 3):
# shorthand for "translation"
a = np.zeros(3, dtype=np.float64)
else:
# get the translation
a = transform[:3, 3]
# First the changed origin of the new transform is taken into
# account. To calculate the inertia tensor
# the parallel axis theorem is used
M = np.array(
[
[a[1] ** 2 + a[2] ** 2, -a[0] * a[1], -a[0] * a[2]],
[-a[0] * a[1], a[0] ** 2 + a[2] ** 2, -a[1] * a[2]],
[-a[0] * a[2], -a[1] * a[2], a[0] ** 2 + a[1] ** 2],
]
)
aligned_inertia = inertia_tensor + mass * M
return multi_dot([rotation.T, aligned_inertia, rotation])
return multi_dot([rotation, inertia_tensor, rotation.T])
def radial_symmetry(mesh):
"""
Check whether a mesh has radial symmetry.
Returns
-----------
symmetry : None or str
None No rotational symmetry
'radial' Symmetric around an axis
'spherical' Symmetric around a point
axis : None or (3,) float
Rotation axis or point
section : None or (3, 2) float
If radial symmetry provide vectors
to get cross section
"""
# shortcuts to avoid typing and hitting cache
scalar = mesh.principal_inertia_components.copy()
# exit early if inertia components are all zero
if (scalar < 1e-30).any():
return None, None, None
# normalize the PCI so we can compare them
scalar = scalar / np.linalg.norm(scalar)
vector = mesh.principal_inertia_vectors
# the sorted order of the principal components
order = scalar.argsort()
# we are checking if a geometry has radial symmetry
# if 2 of the PCI are equal, it is a revolved 2D profile
# if 3 of the PCI (all of them) are equal it is a sphere
diff = np.abs(np.diff(scalar[order]))
# diffs that are within tol of zero
diff_zero = diff < 1e-4
if diff_zero.all():
# this is the case where all 3 PCI are identical
# this means that the geometry is symmetric about a point
# examples of this are a sphere, icosahedron, etc
axis = vector[0]
section = vector[1:]
return "spherical", axis, section
elif diff_zero.any():
# this is the case for 2/3 PCI are identical
# this means the geometry is symmetric about an axis
# probably a revolved 2D profile
# we know that only 1/2 of the diff values are True
# if the first diff is 0, it means if we take the first element
# in the ordered PCI we will have one of the non- revolve axis
# if the second diff is 0, we take the last element of
# the ordered PCI for the section axis
# if we wanted the revolve axis we would just switch [0,-1] to
# [-1,0]
# since two vectors are the same, we know the middle
# one is one of those two
section_index = order[np.array([[0, 1], [1, -1]])[diff_zero]].flatten()
section = vector[section_index]
# we know the rotation axis is the sole unique value
# and is either first or last of the sorted values
axis_index = order[np.array([-1, 0])[diff_zero]][0]
axis = vector[axis_index]
return "radial", axis, section
return None, None, None
def scene_inertia(scene, transform: Optional[ArrayLike] = None) -> NDArray[float64]:
"""
Calculate the inertia of a scene about a specific frame.
Parameters
------------
scene : trimesh.Scene
Scene with geometry.
transform : None or (4, 4) float
Homogeneous transform to compute inertia at.
Returns
----------
moment : (3, 3)
Inertia tensor about requested frame
"""
# shortcuts for tight loop
graph = scene.graph
geoms = scene.geometry
# get the matrix ang geometry name for
nodes = [graph[n] for n in graph.nodes_geometry]
# get the moment of inertia with the mesh moved to a location
moments = np.array(
[
geoms[g].moment_inertia_frame(np.dot(np.linalg.inv(mat), transform))
for mat, g in nodes
if hasattr(geoms[g], "moment_inertia_frame")
],
dtype=np.float64,
)
return moments.sum(axis=0)
@@ -0,0 +1,3 @@
from . import blender
__all__ = ["blender"]
@@ -0,0 +1,142 @@
import os
import platform
from .. import util
from ..constants import log
from ..resources import get_string
from ..typed import BooleanOperationType, Iterable
from .generic import MeshScript
if platform.system() == "Windows":
# try to find Blender install on Windows
# split existing path by delimiter
_search_path = [i for i in os.environ.get("PATH", "").split(";") if len(i) > 0]
for pf in [r"C:\Program Files", r"C:\Program Files (x86)"]:
pf = os.path.join(pf, "Blender Foundation")
if os.path.exists(pf):
for p in os.listdir(pf):
if "Blender" in p:
_search_path.append(os.path.join(pf, p))
_search_path = ";".join(set(_search_path))
log.debug("searching for blender in: %s", _search_path)
elif platform.system() == "Darwin":
# try to find Blender on Mac OSX
_search_path = [i for i in os.environ.get("PATH", "").split(":") if len(i) > 0]
_search_path.extend(
[
"/Applications/blender.app/Contents/MacOS",
"/Applications/Blender.app/Contents/MacOS",
"/Applications/Blender/blender.app/Contents/MacOS",
]
)
_search_path = ":".join(set(_search_path))
log.debug("searching for blender in: %s", _search_path)
else:
_search_path = os.environ.get("PATH", "")
_blender_executable = util.which("blender", path=_search_path)
exists = _blender_executable is not None
# a map that translates:
# `trimesh.BooleanOperationType` -> blender value
_blender_bool = {
"union": "UNION",
"difference": "DIFFERENCE",
"intersection": "INTERSECT",
}
def boolean(
meshes: Iterable,
operation: BooleanOperationType = "difference",
use_exact: bool = True,
use_self: bool = False,
debug: bool = False,
check_volume: bool = True,
):
"""
Run a boolean operation with multiple meshes using Blender.
Parameters
-----------
meshes
List of mesh objects to be operated on
operation
Type of boolean operation ("difference", "union", "intersect").
use_exact
Use the "exact" mode as opposed to the "fast" mode.
use_self
Whether to consider self-intersections.
debug
Provide additional output for troubleshooting.
check_volume
Raise an error if not all meshes are watertight
positive volumes. Advanced users may want to ignore
this check as it is expensive.
Returns
----------
result
The result of the boolean operation on the provided meshes.
"""
if not exists:
raise ValueError("No blender available!")
if check_volume and not all(m.is_volume for m in meshes):
raise ValueError("Not all meshes are volumes!")
# conversions from the trimesh `BooleanOperationType` to the blender option
key = operation.lower().strip()
if key not in _blender_bool:
raise ValueError(
f"`{operation}` is not a valid boolean: `{_blender_bool.keys()}`"
)
if use_exact:
solver_options = "EXACT"
else:
solver_options = "FAST"
# get the template from our resources folder
template = get_string("templates/blender_boolean.py.tmpl")
# use string substitutions rather than `string.Template` as we aren't going
# to be filling in all the values here, `MeshScript` is going to be
# the source of `$MESH_PRE`, etc.
script = (
template.replace("$OPERATION", _blender_bool[key])
.replace("$SOLVER_OPTIONS", solver_options)
.replace("$USE_SELF", f"{use_self}")
)
with MeshScript(meshes=meshes, script=script, debug=debug) as blend:
result = blend.run(_blender_executable + " --background --python $SCRIPT")
result = util.make_sequence(result)
for m in result:
# blender returns actively incorrect face normals
m.face_normals = None
return util.concatenate(result)
def unwrap(
mesh, angle_limit: float = 66.0, island_margin: float = 0.0, debug: bool = False
):
"""
Run an unwrap operation using blender.
"""
if not exists:
raise ValueError("No blender available!")
# get the template from our resources folder
template = get_string("templates/blender_unwrap.py.template")
script = template.replace("$ANGLE_LIMIT", f"{angle_limit:.6f}").replace(
"$ISLAND_MARGIN", f"{island_margin:.6f}"
)
with MeshScript(meshes=[mesh], script=script, exchange="obj", debug=debug) as blend:
result = blend.run(_blender_executable + " --background --python $SCRIPT")
for m in util.make_sequence(result):
# blender returns actively incorrect face normals
m.face_normals = None
return result
@@ -0,0 +1,97 @@
import os
import platform
import subprocess
from string import Template
from subprocess import CalledProcessError, check_output
from tempfile import NamedTemporaryFile
from .. import exchange
from ..util import log
class MeshScript:
def __init__(self, meshes, script, exchange="stl", debug=False, **kwargs):
self.debug = debug
self.kwargs = kwargs
self.meshes = meshes
self.script = script
self.exchange = exchange
def __enter__(self):
# windows has problems with multiple programs using open files so we close
# them at the end of the enter call, and delete them ourselves at exit
# Blender sorts its objects alphabetically
# so prefix the mesh number on the file name
digit_count = len(str(len(self.meshes)))
self.mesh_pre = [
NamedTemporaryFile(
suffix=f".{self.exchange}",
prefix=f"{str(i).zfill(digit_count)}_",
mode="wb",
delete=False,
)
for i in range(len(self.meshes))
]
self.mesh_post = NamedTemporaryFile(
suffix=f".{self.exchange}", mode="rb", delete=False
)
self.script_out = NamedTemporaryFile(mode="wb", delete=False)
# export the meshes to a temporary STL container
for mesh, file_obj in zip(self.meshes, self.mesh_pre):
mesh.export(file_obj=file_obj.name)
self.replacement = {"MESH_" + str(i): m.name for i, m in enumerate(self.mesh_pre)}
self.replacement["MESH_PRE"] = str([i.name for i in self.mesh_pre])
self.replacement["MESH_POST"] = self.mesh_post.name
self.replacement["SCRIPT"] = self.script_out.name
script_text = Template(self.script).substitute(self.replacement)
if platform.system() == "Windows":
script_text = script_text.replace("\\", "\\\\")
self.script_out.write(script_text.encode("utf-8"))
# close all temporary files
self.script_out.close()
self.mesh_post.close()
for file_obj in self.mesh_pre:
file_obj.close()
return self
def run(self, command):
command_run = Template(command).substitute(self.replacement).split()
# run the binary
startupinfo = None
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
if self.debug:
log.info("executing: {}".format(" ".join(command_run)))
try:
output = check_output(
command_run, stderr=subprocess.STDOUT, startupinfo=startupinfo
)
except CalledProcessError as E:
# raise with the output from the process
raise RuntimeError(E.output.decode())
if self.debug:
log.info(output.decode())
# bring the binaries result back as a set of Trimesh kwargs
mesh_results = exchange.load.load_mesh(self.mesh_post.name, **self.kwargs)
return mesh_results
def __exit__(self, *args, **kwargs):
if self.debug:
log.info(f"MeshScript.debug: not deleting {self.script_out.name}")
return
# delete all the temporary files by name
# they are closed but their names are still available
os.remove(self.script_out.name)
for file_obj in self.mesh_pre:
os.remove(file_obj.name)
os.remove(self.mesh_post.name)
@@ -0,0 +1,805 @@
"""
intersections.py
------------------
Primarily mesh-plane intersections (slicing).
"""
import numpy as np
from . import geometry, grouping, util
from . import transformations as tf
from . import triangles as tm
from .constants import tol
from .triangles import points_to_barycentric
def mesh_plane(
mesh,
plane_normal,
plane_origin,
return_faces=False,
local_faces=None,
cached_dots=None,
):
"""
Find a the intersections between a mesh and a plane,
returning a set of line segments on that plane.
Parameters
---------
mesh : Trimesh object
Source mesh to slice
plane_normal : (3,) float
Normal vector of plane to intersect with mesh
plane_origin : (3,) float
Point on plane to intersect with mesh
return_faces : bool
If True return face index each line is from
local_faces : None or (m,) int
Limit section to just these faces.
cached_dots : (n, 3) float
If an external function has stored dot
products pass them here to avoid recomputing.
Returns
----------
lines : (m, 2, 3) float
List of 3D line segments in space.
face_index : (m,) int
Index of mesh.faces for each line
Only returned if return_faces was True
"""
def triangle_cases(signs):
"""
Figure out which faces correspond to which intersection
case from the signs of the dot product of each vertex.
Does this by bitbang each row of signs into an 8 bit
integer.
code : signs : intersects
0 : [-1 -1 -1] : No
2 : [-1 -1 0] : No
4 : [-1 -1 1] : Yes; 2 on one side, 1 on the other
6 : [-1 0 0] : Yes; one edge fully on plane
8 : [-1 0 1] : Yes; one vertex on plane 2 on different sides
12 : [-1 1 1] : Yes; 2 on one side, 1 on the other
14 : [0 0 0] : No (on plane fully)
16 : [0 0 1] : Yes; one edge fully on plane
20 : [0 1 1] : No
28 : [1 1 1] : No
Parameters
----------
signs: (n,3) int, all values are -1,0, or 1
Each row contains the dot product of all three vertices
in a face with respect to the plane
Returns
---------
basic : (n,) bool
Which faces are in the basic intersection case
one_vertex : (n,) bool
Which faces are in the one vertex case
one_edge : (n,) bool
Which faces are in the one edge case
"""
signs_sorted = np.sort(signs, axis=1)
coded = np.zeros(len(signs_sorted), dtype=np.int8) + 14
for i in range(3):
coded += signs_sorted[:, i] << 3 - i
# one edge fully on the plane
# note that we are only accepting *one* of the on- edge cases,
# where the other vertex has a positive dot product (16) instead
# of both on- edge cases ([6, 16])
# this is so that for regions that are co-planar with the the section plane
# we don't end up with an invalid boundary
key = np.zeros(29, dtype=bool)
key[16] = True
one_edge = key[coded]
# one vertex on plane, other two on different sides
key[:] = False
key[8] = True
one_vertex = key[coded]
# one vertex on one side of the plane, two on the other
key[:] = False
key[[4, 12]] = True
basic = key[coded]
return basic, one_vertex, one_edge
def handle_on_vertex(signs, faces, vertices):
# case where one vertex is on plane
# and two are on different sides
vertex_plane = faces[signs == 0]
edge_thru = faces[signs != 0].reshape((-1, 2))
point_intersect, valid = plane_lines(
plane_origin, plane_normal, vertices[edge_thru.T], line_segments=False
)
lines = np.column_stack((vertices[vertex_plane[valid]], point_intersect)).reshape(
(-1, 2, 3)
)
return lines
def handle_on_edge(signs, faces, vertices):
# case where two vertices are on the plane and one is off
edges = faces[signs == 0].reshape((-1, 2))
points = vertices[edges]
return points
def handle_basic(signs, faces, vertices):
# case where one vertex is on one side and two are on the other
unique_element = grouping.unique_value_in_row(signs, unique=[-1, 1])
edges = np.column_stack(
(
faces[unique_element],
faces[np.roll(unique_element, 1, axis=1)],
faces[unique_element],
faces[np.roll(unique_element, 2, axis=1)],
)
).reshape((-1, 2))
intersections, valid = plane_lines(
plane_origin, plane_normal, vertices[edges.T], line_segments=False
)
# since the data has been pre- culled, any invalid intersections at all
# means the culling was done incorrectly and thus things are broken
assert valid.all()
return intersections.reshape((-1, 2, 3))
# check input plane
plane_normal = np.asanyarray(plane_normal, dtype=np.float64)
plane_origin = np.asanyarray(plane_origin, dtype=np.float64)
if plane_origin.shape != (3,) or plane_normal.shape != (3,):
raise ValueError("Plane origin and normal must be (3,)!")
if local_faces is None:
# do a cross section against all faces
faces = mesh.faces
else:
local_faces = np.asanyarray(local_faces, dtype=np.int64)
# only take the subset of faces if passed
faces = mesh.faces[local_faces]
if cached_dots is not None:
dots = cached_dots
else:
# dot product of each vertex with the plane normal indexed by face
# so for each face the dot product of each vertex is a row
# shape is the same as mesh.faces (n,3)
dots = np.dot(mesh.vertices - plane_origin, plane_normal)
# sign of the dot product is -1, 0, or 1
# shape is the same as mesh.faces (n,3)
signs = np.zeros(len(mesh.vertices), dtype=np.int8)
signs[dots < -tol.merge] = -1
signs[dots > tol.merge] = 1
signs = signs[faces]
# figure out which triangles are in the cross section,
# and which of the three intersection cases they are in
cases = triangle_cases(signs)
# handlers for each case
handlers = (handle_basic, handle_on_vertex, handle_on_edge)
# the (m, 2, 3) line segments
lines = np.vstack(
[h(signs[c], faces[c], mesh.vertices) for c, h in zip(cases, handlers)]
)
if return_faces:
# everything that hit something
index = np.hstack([np.nonzero(c)[0] for c in cases])
assert index.dtype.kind == "i"
if local_faces is None:
return lines, index
# we are considering a subset of faces
# so we need to take the indexes from original
return lines, local_faces[index]
return lines
def mesh_multiplane(mesh, plane_origin, plane_normal, heights):
"""
A utility function for slicing a mesh by multiple
parallel planes which caches the dot product operation.
Parameters
-------------
mesh : trimesh.Trimesh
Geometry to be sliced by planes
plane_origin : (3,) float
Point on a plane
plane_normal : (3,) float
Normal vector of plane
heights : (m,) float
Offset distances from plane to slice at:
at `height=0` it will be exactly on the passed plane.
Returns
--------------
lines : (m,) sequence of (n, 2, 2) float
Lines in space for m planes
to_3D : (m, 4, 4) float
Transform to move each section back to 3D
face_index : (m,) sequence of (n,) int
Indexes of mesh.faces for each segment
"""
# check input plane
plane_normal = util.unitize(plane_normal)
plane_origin = np.asanyarray(plane_origin, dtype=np.float64)
heights = np.asanyarray(heights, dtype=np.float64)
# dot product of every vertex with plane
vertex_dots = np.dot(plane_normal, (mesh.vertices - plane_origin).T)
# reconstruct transforms for each 2D section
base_transform = geometry.plane_transform(origin=plane_origin, normal=plane_normal)
base_transform = np.linalg.inv(base_transform)
# alter translation Z inside loop
translation = np.eye(4)
# store results
transforms = []
face_index = []
segments = []
# loop through user specified heights
for height in heights:
# offset the origin by the height
new_origin = plane_origin + (plane_normal * height)
# offset the dot products by height and index by faces
new_dots = vertex_dots - height
# run the intersection with the cached dot products
lines, index = mesh_plane(
mesh=mesh,
plane_origin=new_origin,
plane_normal=plane_normal,
return_faces=True,
cached_dots=new_dots,
)
# get the transforms to 3D space and back
translation[2, 3] = height
to_3D = np.dot(base_transform, translation)
to_2D = np.linalg.inv(to_3D)
transforms.append(to_3D)
# transform points to 2D frame
lines_2D = tf.transform_points(lines.reshape((-1, 3)), to_2D)
# if we didn't screw up the transform all
# of the Z values should be zero
# assert np.allclose(lines_2D[:, 2], 0.0)
# reshape back in to lines and discard Z
lines_2D = lines_2D[:, :2].reshape((-1, 2, 2))
# store (n, 2, 2) float lines
segments.append(lines_2D)
# store (n,) int indexes of mesh.faces
face_index.append(index)
# (n, 4, 4) transforms from 2D to 3D
transforms = np.array(transforms, dtype=np.float64)
return segments, transforms, face_index
def plane_lines(plane_origin, plane_normal, endpoints, line_segments=True):
"""
Calculate plane-line intersections
Parameters
---------
plane_origin : (3,) float
Point on plane
plane_normal : (3,) float
Plane normal vector
endpoints : (2, n, 3) float
Points defining lines to be tested
line_segments : bool
If True, only returns intersections as valid if
vertices from endpoints are on different sides
of the plane.
Returns
---------
intersections : (m, 3) float
Cartesian intersection points
valid : (n, 3) bool
Indicate whether a valid intersection exists
for each input line segment
"""
endpoints = np.asanyarray(endpoints)
plane_origin = np.asanyarray(plane_origin).reshape(3)
line_dir = util.unitize(endpoints[1] - endpoints[0])
plane_normal = util.unitize(np.asanyarray(plane_normal).reshape(3))
t = np.dot(plane_normal, (plane_origin - endpoints[0]).T)
b = np.dot(plane_normal, line_dir.T)
# If the plane normal and line direction are perpendicular, it means
# the vector is 'on plane', and there isn't a valid intersection.
# We discard on-plane vectors by checking that the dot product is nonzero
valid = np.abs(b) > tol.zero
if line_segments:
test = np.dot(plane_normal, np.transpose(plane_origin - endpoints[1]))
different_sides = np.sign(t) != np.sign(test)
nonzero = np.logical_or(np.abs(t) > tol.zero, np.abs(test) > tol.zero)
valid = np.logical_and(valid, different_sides)
valid = np.logical_and(valid, nonzero)
d = np.divide(t[valid], b[valid])
intersection = endpoints[0][valid]
intersection = intersection + np.reshape(d, (-1, 1)) * line_dir[valid]
return intersection, valid
def planes_lines(
plane_origins,
plane_normals,
line_origins,
line_directions,
return_distance=False,
return_denom=False,
):
"""
Given one line per plane find the intersection points.
Parameters
-----------
plane_origins : (n,3) float
Point on each plane
plane_normals : (n,3) float
Normal vector of each plane
line_origins : (n,3) float
Point at origin of each line
line_directions : (n,3) float
Direction vector of each line
return_distance : bool
Return distance from origin to point also
return_denom : bool
Return denominator, so you can check for small values
Returns
----------
on_plane : (n,3) float
Points on specified planes
valid : (n,) bool
Did plane intersect line or not
distance : (n,) float
[OPTIONAL] Distance from point
denom : (n,) float
[OPTIONAL] Denominator
"""
# check input types
plane_origins = np.asanyarray(plane_origins, dtype=np.float64)
plane_normals = np.asanyarray(plane_normals, dtype=np.float64)
line_origins = np.asanyarray(line_origins, dtype=np.float64)
line_directions = np.asanyarray(line_directions, dtype=np.float64)
# vector from line to plane
origin_vectors = plane_origins - line_origins
projection_ori = util.diagonal_dot(origin_vectors, plane_normals)
projection_dir = util.diagonal_dot(line_directions, plane_normals)
valid = np.abs(projection_dir) > 1e-5
distance = np.divide(projection_ori[valid], projection_dir[valid])
on_plane = line_directions[valid] * distance.reshape((-1, 1))
on_plane += line_origins[valid]
result = [on_plane, valid]
if return_distance:
result.append(distance)
if return_denom:
result.append(projection_dir)
return result
def slice_faces_plane(
vertices,
faces,
plane_normal,
plane_origin,
uv=None,
face_index=None,
cached_dots=None,
):
"""
Slice a mesh (given as a set of faces and vertices) with a plane, returning a
new mesh (again as a set of faces and vertices) that is the
portion of the original mesh to the positive normal side of the plane.
Parameters
---------
vertices : (n, 3) float
Vertices of source mesh to slice
faces : (n, 3) int
Faces of source mesh to slice
plane_normal : (3,) float
Normal vector of plane to intersect with mesh
plane_origin : (3,) float
Point on plane to intersect with mesh
uv : (n, 2) float, optional
UV coordinates of source mesh to slice
face_index : ((m,) int)
Indexes of faces to slice. When no mask is provided, the
default is to slice all faces.
cached_dots : (n, 3) float
If an external function has stored dot
products pass them here to avoid recomputing
Returns
----------
new_vertices : (n, 3) float
Vertices of sliced mesh
new_faces : (n, 3) int
Faces of sliced mesh
new_uv : (n, 2) int or None
UV coordinates of sliced mesh
"""
if len(vertices) == 0:
return vertices, faces, uv
have_uv = uv is not None
# Construct a mask for the faces to slice.
if face_index is not None:
faces = faces[face_index]
if cached_dots is not None:
dots = cached_dots
else:
# dot product of each vertex with the plane normal indexed by face
# so for each face the dot product of each vertex is a row
# shape is the same as faces (n,3)
dots = np.dot(vertices - plane_origin, plane_normal)
# Find vertex orientations w.r.t. faces for all triangles:
# -1 -> vertex "inside" plane (positive normal direction)
# 0 -> vertex on plane
# 1 -> vertex "outside" plane (negative normal direction)
signs = np.zeros(len(vertices), dtype=np.int8)
signs[dots < -tol.merge] = 1
signs[dots > tol.merge] = -1
signs = signs[faces]
# Find all triangles that intersect this plane
# onedge <- indices of all triangles intersecting the plane
# inside <- indices of all triangles "inside" the plane (positive normal)
signs_sum = signs.sum(axis=1, dtype=np.int8)
signs_asum = np.abs(signs).sum(axis=1, dtype=np.int8)
# Cases:
# (0,0,0), (-1,0,0), (-1,-1,0), (-1,-1,-1) <- inside
# (1,0,0), (1,1,0), (1,1,1) <- outside
# (1,0,-1), (1,-1,-1), (1,1,-1) <- onedge
onedge = np.logical_and(signs_asum >= 2, np.abs(signs_sum) <= 1)
inside = signs_sum == -signs_asum
# for any faces that lie exactly on-the-plane
# we want to only include them if their normal
# is backwards from the slicing normal
on_plane = signs_asum == 0
if on_plane.any():
# compute the normals and whether
# face is degenerate here
check, valid = tm.normals(vertices[faces[on_plane]])
# only include faces back from normal
dot_check = np.dot(check, plane_normal)
# exclude any degenerate faces from the result
inside[on_plane] = valid
# exclude the degenerate face from our mask
on_plane[on_plane] = valid
# apply results for this subset
inside[on_plane] = dot_check < 0.0
# Automatically include all faces that are "inside"
new_faces = faces[inside]
# Separate faces on the edge into two cases: those which will become
# quads (two vertices inside plane) and those which will become triangles
# (one vertex inside plane)
triangles = vertices[faces]
cut_triangles = triangles[onedge]
cut_faces_quad = faces[np.logical_and(onedge, signs_sum < 0)]
cut_faces_tri = faces[np.logical_and(onedge, signs_sum >= 0)]
cut_signs_quad = signs[np.logical_and(onedge, signs_sum < 0)]
cut_signs_tri = signs[np.logical_and(onedge, signs_sum >= 0)]
# If no faces to cut, the surface is not in contact with this plane.
# Thus, return a mesh with only the inside faces
if len(cut_faces_quad) + len(cut_faces_tri) == 0:
if len(new_faces) == 0:
# if no new faces at all return empty arrays
empty = (
np.zeros((0, 3), dtype=np.float64),
np.zeros((0, 3), dtype=np.int64),
np.zeros((0, 2), dtype=np.float64) if have_uv else None,
)
return empty
# find the unique indices in the new faces
# using an integer-only unique function
unique, inverse = grouping.unique_bincount(
new_faces.reshape(-1), minlength=len(vertices), return_inverse=True
)
# use the unique indices for our final vertices and faces
final_vert = vertices[unique]
final_face = inverse.reshape((-1, 3))
final_uv = uv[unique] if have_uv else None
return final_vert, final_face, final_uv
# Extract the intersections of each triangle's edges with the plane
o = cut_triangles # origins
d = np.roll(o, -1, axis=1) - o # directions
num = (plane_origin - o).dot(plane_normal) # compute num/denom
denom = np.dot(d, plane_normal)
denom[denom == 0.0] = 1e-12 # prevent division by zero
dist = np.divide(num, denom)
# intersection points for each segment
int_points = np.einsum("ij,ijk->ijk", dist, d) + o
# Initialize the array of new vertices with the current vertices
new_vertices = vertices
new_quad_vertices = np.zeros((0, 3))
new_tri_vertices = np.zeros((0, 3))
# Handle the case where a new quad is formed by the intersection
# First, extract the intersection points belonging to a new quad
quad_int_points = int_points[(signs_sum < 0)[onedge], :, :]
num_quads = len(quad_int_points)
if num_quads > 0:
# Extract the vertex on the outside of the plane, then get the vertices
# (in CCW order of the inside vertices)
quad_int_inds = np.where(cut_signs_quad == 1)[1]
quad_int_verts = cut_faces_quad[
np.stack((range(num_quads), range(num_quads)), axis=1),
np.stack(((quad_int_inds + 1) % 3, (quad_int_inds + 2) % 3), axis=1),
]
# Fill out new quad faces with the intersection points as vertices
new_quad_faces = np.append(
quad_int_verts,
np.arange(len(new_vertices), len(new_vertices) + 2 * num_quads).reshape(
num_quads, 2
),
axis=1,
)
# Extract correct intersection points from int_points and order them in
# the same way as they were added to faces
new_quad_vertices = quad_int_points[
np.stack((range(num_quads), range(num_quads)), axis=1),
np.stack((((quad_int_inds + 2) % 3).T, quad_int_inds.T), axis=1),
:,
].reshape(2 * num_quads, 3)
# Add new vertices to existing vertices, triangulate quads, and add the
# resulting triangles to the new faces
new_vertices = np.append(new_vertices, new_quad_vertices, axis=0)
new_tri_faces_from_quads = geometry.triangulate_quads(new_quad_faces)
new_faces = np.append(new_faces, new_tri_faces_from_quads, axis=0)
# Handle the case where a new triangle is formed by the intersection
# First, extract the intersection points belonging to a new triangle
tri_int_points = int_points[(signs_sum >= 0)[onedge], :, :]
num_tris = len(tri_int_points)
if num_tris > 0:
# Extract the single vertex for each triangle inside the plane and get the
# inside vertices (CCW order)
tri_int_inds = np.where(cut_signs_tri == -1)[1]
tri_int_verts = cut_faces_tri[range(num_tris), tri_int_inds].reshape(num_tris, 1)
# Fill out new triangles with the intersection points as vertices
new_tri_faces = np.append(
tri_int_verts,
np.arange(len(new_vertices), len(new_vertices) + 2 * num_tris).reshape(
num_tris, 2
),
axis=1,
)
# Extract correct intersection points and order them in the same way as
# the vertices were added to the faces
new_tri_vertices = tri_int_points[
np.stack((range(num_tris), range(num_tris)), axis=1),
np.stack((tri_int_inds.T, ((tri_int_inds + 2) % 3).T), axis=1),
:,
].reshape(2 * num_tris, 3)
# Append new vertices and new faces
new_vertices = np.append(new_vertices, new_tri_vertices, axis=0)
new_faces = np.append(new_faces, new_tri_faces, axis=0)
# find the unique indices in the new faces
# using an integer-only unique function
unique, inverse = grouping.unique_bincount(
new_faces.reshape(-1), minlength=len(new_vertices), return_inverse=True
)
# use the unique indexes for our final vertex and faces
final_vert = new_vertices[unique]
final_face = inverse.reshape((-1, 3))
final_uv = None
if have_uv:
# Generate barycentric coordinates for intersection vertices
quad_barycentrics = points_to_barycentric(
np.repeat(vertices[cut_faces_quad], 2, axis=0), new_quad_vertices
)
tri_barycentrics = points_to_barycentric(
np.repeat(vertices[cut_faces_tri], 2, axis=0), new_tri_vertices
)
all_barycentrics = np.concatenate([quad_barycentrics, tri_barycentrics])
# Interpolate UVs
cut_uv = np.concatenate([uv[cut_faces_quad], uv[cut_faces_tri]])
new_uv = np.einsum("ijk,ij->ik", np.repeat(cut_uv, 2, axis=0), all_barycentrics)
final_uv = np.concatenate([uv, new_uv])[unique]
return final_vert, final_face, final_uv
def slice_mesh_plane(
mesh,
plane_normal,
plane_origin,
face_index=None,
cap=False,
engine=None,
**kwargs,
):
"""
Slice a mesh with a plane returning a new mesh that is the
portion of the original mesh to the positive normal side
of the plane.
Parameters
---------
mesh : Trimesh object
Source mesh to slice
plane_normal : (3,) float
Normal vector of plane to intersect with mesh
plane_origin : (3,) float
Point on plane to intersect with mesh
cap : bool
If True, cap the result with a triangulated polygon
face_index : ((m,) int)
Indexes of mesh.faces to slice. When no mask is provided, the
default is to slice all faces.
cached_dots : (n, 3) float
If an external function has stored dot
products pass them here to avoid recomputing
engine : None or str
Triangulation engine passed to `triangulate_polygon`
kwargs : dict
Passed to the newly created sliced mesh
Returns
----------
new_mesh : Trimesh object
Sliced mesh
"""
# check input for none
if mesh is None:
return None
# avoid circular import
from scipy.spatial import cKDTree
from .base import Trimesh
from .creation import triangulate_polygon
from .path import polygons
from .visual import TextureVisuals
# check input plane
plane_normal = np.asanyarray(plane_normal, dtype=np.float64)
plane_origin = np.asanyarray(plane_origin, dtype=np.float64)
# check to make sure origins and normals have acceptable shape
shape_ok = (
(plane_origin.shape == (3,) or util.is_shape(plane_origin, (-1, 3)))
and (plane_normal.shape == (3,) or util.is_shape(plane_normal, (-1, 3)))
and plane_origin.shape == plane_normal.shape
)
if not shape_ok:
raise ValueError("plane origins and normals must be (n, 3)!")
# start with copy of original mesh, faces, and vertices
vertices = mesh.vertices.copy()
faces = mesh.faces.copy()
# We copy the UV coordinates if available
has_uv = (
hasattr(mesh.visual, "uv") and np.shape(mesh.visual.uv) == (len(mesh.vertices), 2)
) and not cap
uv = mesh.visual.uv.copy() if has_uv else None
if "process" not in kwargs:
kwargs["process"] = False
# slice away specified planes
for origin, normal in zip(
plane_origin.reshape((-1, 3)), plane_normal.reshape((-1, 3))
):
# save the new vertices and faces
vertices, faces, uv = slice_faces_plane(
vertices=vertices,
faces=faces,
uv=uv,
plane_normal=normal,
plane_origin=origin,
face_index=face_index,
)
# check if cap arg specified
if cap:
if face_index:
# This hasn't been implemented yet.
raise NotImplementedError("face_index and cap can't be used together")
# start by deduplicating vertices again
unique, inverse = grouping.unique_rows(vertices)
vertices = vertices[unique]
# will collect additional faces
f = inverse[faces]
# remove degenerate faces by checking to make sure
# that each face has three unique indices
f = f[(f[:, :1] != f[:, 1:]).all(axis=1)]
# transform to the cap plane
to_2D = geometry.plane_transform(origin=origin, normal=-normal)
to_3D = np.linalg.inv(to_2D)
vertices_2D = tf.transform_points(vertices, to_2D)
edges = geometry.faces_to_edges(f)
edges.sort(axis=1)
on_plane = np.abs(vertices_2D[:, 2]) < 1e-8
edges = edges[on_plane[edges].all(axis=1)]
edges = edges[edges[:, 0] != edges[:, 1]]
unique_edge = grouping.group_rows(edges, require_count=1)
if len(unique) < 3:
continue
tree = cKDTree(vertices)
# collect new faces
faces = [f]
for p in polygons.edges_to_polygons(edges[unique_edge], vertices_2D[:, :2]):
# triangulate cap and raise an error if any new vertices were inserted
vn, fn = triangulate_polygon(p, engine=engine, force_vertices=True)
# collect the original index for the new vertices
vn3 = tf.transform_points(util.stack_3D(vn), to_3D)
distance, vid = tree.query(vn3)
if distance.max() > 1e-8:
util.log.debug("triangulate may have inserted vertex!")
# triangulation should not have inserted vertices
nf = vid[fn]
# hmm but it may have returned faces that are now degenerate
nf_ok = (nf[:, 1:] != nf[:, :1]).all(axis=1) & (nf[:, 1] != nf[:, 2])
faces.append(nf[nf_ok])
faces = np.vstack(faces)
visual = (
TextureVisuals(uv=uv, material=mesh.visual.material.copy()) if has_uv else None
)
# return the sliced mesh
return Trimesh(vertices=vertices, faces=faces, visual=visual, **kwargs)
@@ -0,0 +1,106 @@
"""
interval.py
--------------
Deal with 1D intervals which are defined by:
[start position, end position]
"""
import numpy as np
from .typed import ArrayLike, NDArray, float64
def intersection(a: ArrayLike, b: NDArray[float64]) -> NDArray[float64]:
"""
Given pairs of ranges merge them in to
one range if they overlap.
Parameters
--------------
a : (2, ) or (n, 2)
Start and end of a 1D interval
b : (2, ) float
Start and end of a 1D interval
Returns
--------------
inter : (2, ) or (2, 2) float
The unioned range from the two inputs,
if not np.ptp(`inter, axis=1)` will be zero.
"""
a = np.array(a, dtype=np.float64)
b = np.array(b, dtype=np.float64)
# convert to vectorized form
is_1D = a.shape == (2,)
a = a.reshape((-1, 2))
b = b.reshape((-1, 2))
# make sure they're min-max
a.sort(axis=1)
b.sort(axis=1)
a_low, a_high = a.T
b_low, b_high = b.T
# do the checks
check = np.logical_not(np.logical_or(b_low >= a_high, a_low >= b_high))
overlap = np.zeros(a.shape, dtype=np.float64)
overlap[check] = np.column_stack(
(
np.array([a_low[check], b_low[check]]).max(axis=0),
np.array([a_high[check], b_high[check]]).min(axis=0),
)
)
if is_1D:
return overlap[0]
return overlap
def union(intervals: ArrayLike, sort: bool = True) -> NDArray[float64]:
"""
For array of multiple intervals union them all into
the subset of intervals.
For example:
`intervals = [[1,2], [2,3]] -> [[1, 3]]`
`intervals = [[1,2], [2.5,3]] -> [[1, 2], [2.5, 3]]`
Parameters
------------
intervals : (n, 2)
Pairs of `(min, max)` values.
sort
If the array is already ordered into (min, max) pairs
and then pairs sorted by minimum value you can skip the
sorting in this function.
Returns
----------
unioned : (m, 2)
New intervals where `m <= n`
"""
if len(intervals) == 0:
return np.zeros(0)
# if the intervals have not been pre-sorted we should apply our sorting logic
# you would only skip this if you are subsetting a larger list elsewhere.
if sort:
# copy inputs and make sure they are (min, max) pairs
intervals = np.sort(intervals, axis=1)
# order them by lowest starting point
intervals = intervals[intervals[:, 0].argsort()]
# we know we will have at least one interval
unions = [intervals[0].tolist()]
for begin, end in intervals[1:]:
if unions[-1][1] >= begin:
unions[-1][1] = max(unions[-1][1], end)
else:
unions.append([begin, end])
return np.array(unions)
@@ -0,0 +1,130 @@
from math import log2
from .typed import Any, Callable, Iterable, List, NDArray, Sequence, Union
def reduce_cascade(operation: Callable, items: Union[Sequence, NDArray]):
"""
Call an operation function in a cascaded pairwise way against a
flat list of items.
This should produce the same result as `functools.reduce`
if `operation` is commutable like addition or multiplication.
This may be faster for an `operation` that runs with a speed
proportional to its largest input, which mesh booleans appear to.
The union of a large number of small meshes appears to be
"much faster" using this method.
This only differs from `functools.reduce` for commutative `operation`
in that it returns `None` on empty inputs rather than `functools.reduce`
which raises a `TypeError`.
For example on `a b c d e f g` this function would run and return:
a b
c d
e f
ab cd
ef g
abcd efg
-> abcdefg
Where `functools.reduce` would run and return:
a b
ab c
abc d
abcd e
abcde f
abcdef g
-> abcdefg
Parameters
----------
operation
The function to call on pairs of items.
items
The flat list of items to apply operation against.
"""
if len(items) == 0:
return None
elif len(items) == 1:
# skip the loop overhead for a single item
return items[0]
elif len(items) == 2:
# skip the loop overhead for a single pair
return operation(items[0], items[1])
for _ in range(int(1 + log2(len(items)))):
results = []
# loop over pairs of items.
items_mod = len(items) % 2
for i in range(0, len(items) - items_mod, 2):
results.append(operation(items[i], items[i + 1]))
# if we had a non-even number of items it will have been
# skipped by the loop so append it to our list
if items_mod != 0:
results.append(items[-1])
items = results
# logic should have reduced to a single item
assert len(results) == 1
return results[0]
def chain(*args: Union[Iterable[Any], Any, None]) -> List[Any]:
"""
A less principled version of `list(itertools.chain(*args))` that
accepts non-iterable values, filters `None`, and returns a list
rather than yielding values.
If all passed values are iterables this will return identical
results to `list(itertools.chain(*args))`.
Examples
----------
In [1]: list(itertools.chain([1,2], [3]))
Out[1]: [1, 2, 3]
In [2]: trimesh.util.chain([1,2], [3])
Out[2]: [1, 2, 3]
In [3]: trimesh.util.chain([1,2], [3], 4)
Out[3]: [1, 2, 3, 4]
In [4]: list(itertools.chain([1,2], [3], 4))
----> 1 list(itertools.chain([1,2], [3], 4))
TypeError: 'int' object is not iterable
In [5]: trimesh.util.chain([1,2], None, 3, None, [4], [], [], 5, [])
Out[5]: [1, 2, 3, 4, 5]
Parameters
-----------
args
Will be individually checked to see if they're iterable
before either being appended or extended to a flat list.
Returns
----------
chained
The values in a flat list.
"""
# collect values to a flat list
chained = []
# extend if it's a sequence, otherwise append
[
chained.extend(a)
if (hasattr(a, "__iter__") and not isinstance(a, (str, bytes)))
else chained.append(a)
for a in args
if a is not None
]
return chained
@@ -0,0 +1,192 @@
"""
nsphere.py
--------------
Functions for fitting and minimizing nspheres:
circles, spheres, hyperspheres, etc.
"""
import numpy as np
from . import convex, util
from .constants import log, tol
try:
# scipy is a soft dependency
from scipy import spatial
from scipy.optimize import leastsq
except BaseException as E:
# raise the exception when someone tries to use it
from . import exceptions
leastsq = exceptions.ExceptionWrapper(E)
spatial = exceptions.ExceptionWrapper(E)
try:
import psutil
def _MAX_MEMORY():
# if we have psutil check actual free memory when called
return psutil.virtual_memory().free / 2.0
except BaseException:
def _MAX_MEMORY():
# use a hardcoded best guess estimate
return 1e9
def minimum_nsphere(obj):
"""
Compute the minimum n- sphere for a mesh or a set of points.
Uses the fact that the minimum n- sphere will be centered at one of
the vertices of the furthest site voronoi diagram, which is n*log(n)
but should be pretty fast due to using the scipy/qhull implementations
of convex hulls and voronoi diagrams.
Parameters
----------
obj : (n, d) float or trimesh.Trimesh
Points or mesh to find minimum bounding nsphere
Returns
----------
center : (d,) float
Center of fitted n- sphere
radius : float
Radius of fitted n-sphere
"""
# reduce the input points or mesh to the vertices of the convex hull
# since we are computing the furthest site voronoi diagram this reduces
# the input complexity substantially and returns the same value
points = convex.hull_points(obj)
# we are scaling the mesh to a unit cube
# this used to pass qhull_options 'QbB' to Voronoi however this had a bug somewhere
# to avoid this we scale to a unit cube ourselves inside this function
points_origin = points.min(axis=0)
points_scale = np.ptp(points, axis=0).min()
points = (points - points_origin) / points_scale
# if all of the points are on an n-sphere already the voronoi
# method will fail so we check a least squares fit before
# bothering to compute the voronoi diagram
fit_C, fit_R, fit_E = fit_nsphere(points)
# return fit radius and center to global scale
fit_R = (((points - fit_C) ** 2).sum(axis=1).max() ** 0.5) * points_scale
fit_C = (fit_C * points_scale) + points_origin
if fit_E < 1e-6:
# points were on an n-sphere so just return fit
return fit_C, fit_R
# calculate a furthest site voronoi diagram
# this will fail if the points are ALL on the surface of
# the n-sphere but hopefully the least squares check caught those cases
# , qhull_options='QbB Pp')
voronoi = spatial.Voronoi(points, furthest_site=True)
# find the maximum radius^2 point for each of the voronoi vertices
# this is worst case quite expensive but we have taken
# convex hull to reduce n for this operation
# we are doing comparisons on the radius squared then rooting once
try:
# cdist is massivly faster than looping or tiling methods
# although it does create a very large intermediate array
# first, get an order of magnitude memory size estimate
# a float64 would be 8 bytes per entry plus overhead
memory_estimate = len(voronoi.vertices) * len(points) * 9
if memory_estimate > _MAX_MEMORY():
raise MemoryError
radii_2 = spatial.distance.cdist(
voronoi.vertices, points, metric="sqeuclidean"
).max(axis=1)
except MemoryError:
# log the MemoryError
log.warning("MemoryError: falling back to slower check!")
# fall back to a potentially very slow list comprehension
radii_2 = np.array(
[((points - v) ** 2).sum(axis=1).max() for v in voronoi.vertices]
)
# we want the smallest sphere so take the min of the radii
radii_idx = radii_2.argmin()
# return voronoi radius and center to global scale
radius_v = np.sqrt(radii_2[radii_idx]) * points_scale
center_v = (voronoi.vertices[radii_idx] * points_scale) + points_origin
if radius_v > fit_R:
return fit_C, fit_R
return center_v, radius_v
def fit_nsphere(points, prior=None):
"""
Fit an n-sphere to a set of points using least squares.
Parameters
------------
points : (n, d) float
Points in space
prior : (d,) float
Best guess for center of nsphere
Returns
---------
center : (d,) float
Location of center
radius : float
Mean radius across circle
error : float
Peak to peak value of deviation from mean radius
"""
# make sure points are numpy array
points = np.asanyarray(points, dtype=np.float64)
# create ones so we can dot instead of using slower sum
ones = np.ones(points.shape[1])
def residuals(center):
# do the axis sum with a dot
# this gets called a LOT so worth optimizing
radii_sq = np.dot((points - center) ** 2, ones)
# residuals are difference between mean
# use our sum mean vs .mean() as it is slightly faster
return radii_sq - (radii_sq.sum() / len(radii_sq))
if prior is None:
guess = points.mean(axis=0)
else:
guess = np.asanyarray(prior)
center_result, return_code = leastsq(residuals, guess, xtol=1e-8)
if return_code not in [1, 2, 3, 4]:
raise ValueError("Least square fit failed!")
radii = util.row_norm(points - center_result)
radius = radii.mean()
error = np.ptp(radii)
return center_result, radius, error
def is_nsphere(points):
"""
Check if a list of points is an nsphere.
Parameters
-----------
points : (n, dimension) float
Points in space
Returns
-----------
check : bool
True if input points are on an nsphere
"""
_center, _radius, error = fit_nsphere(points)
check = error < tol.merge
return check
@@ -0,0 +1,418 @@
"""
parent.py
-------------
The base class for Trimesh, PointCloud, and Scene objects
"""
import abc
import os
from dataclasses import dataclass
import numpy as np
from . import bounds, caching
from . import transformations as tf
from .caching import cache_decorator
from .constants import tol
from .resolvers import ResolverLike
from .typed import Any, ArrayLike, Dict, NDArray, Optional, float64
from .util import ABC
@dataclass
class LoadSource:
"""
Save information about where a particular object was loaded from.
"""
# a file-like object that can be accessed
file_obj: Optional[Any] = None
# a cleaned file type string, i.e. "stl"
file_type: Optional[str] = None
# if this was originally loaded from a file path
# save it here so we can check it later.
file_path: Optional[str] = None
# did we open `file_obj` ourselves?
was_opened: bool = False
# a resolver for loading assets next to the file
resolver: Optional[ResolverLike] = None
@property
def file_name(self) -> Optional[str]:
"""
Get just the file name from the path if available.
Returns
---------
file_name
Just the file name, i.e. for file_path="/a/b/c.stl" -> "c.stl"
"""
if self.file_path is None:
return None
return os.path.basename(self.file_path)
def __getstate__(self) -> Dict:
# this overrides the `pickle.dump` behavior for this class
# we cannot pickle a file object so return `file_obj: None` for pickles
return {k: v if k != "file_obj" else None for k, v in self.__dict__.items()}
def __deepcopy__(self, *args):
return LoadSource(**self.__getstate__())
class Geometry(ABC):
"""
`Geometry` is the parent class for all geometry.
By decorating a method with `abc.abstractmethod` it means
the objects that inherit from `Geometry` MUST implement
those methods.
"""
# geometry should have a dict to store loose metadata
metadata: Dict
@property
def source(self) -> LoadSource:
"""
Where and what was this current geometry loaded from?
Returns
--------
source
If loaded from a file, has the path, type, etc.
"""
# this should have been tacked on by the loader
# but we want to *always* be able to access
# a value like `mesh.source.file_type` so add a default
current = getattr(self, "_source", None)
if current is not None:
return current
self._source = LoadSource()
return self._source
@property
@abc.abstractmethod
def identifier_hash(self) -> str:
pass
@property
@abc.abstractmethod
def bounds(self) -> NDArray[np.float64]:
pass
@property
@abc.abstractmethod
def extents(self) -> NDArray[np.float64]:
pass
@abc.abstractmethod
def apply_transform(self, matrix: ArrayLike) -> Any:
pass
@property
@abc.abstractmethod
def is_empty(self) -> bool:
pass
def __hash__(self):
"""
Get a hash of the current geometry.
Returns
---------
hash
Hash of current graph and geometry.
"""
return self._data.__hash__() # type: ignore
@abc.abstractmethod
def copy(self):
pass
@abc.abstractmethod
def show(self):
pass
@abc.abstractmethod
def __add__(self, other):
pass
@abc.abstractmethod
def export(self, file_obj, file_type=None):
pass
def __repr__(self) -> str:
"""
Print quick summary of the current geometry without
computing properties.
Returns
-----------
repr : str
Human readable quick look at the geometry.
"""
elements = []
if hasattr(self, "vertices"):
# for Trimesh and PointCloud
elements.append(f"vertices.shape={self.vertices.shape}")
if hasattr(self, "faces"):
# for Trimesh
elements.append(f"faces.shape={self.faces.shape}")
if hasattr(self, "geometry") and isinstance(self.geometry, dict):
# for Scene
elements.append(f"len(geometry)={len(self.geometry)}")
if "Voxel" in type(self).__name__:
# for VoxelGrid objects
elements.append(str(self.shape)[1:-1])
if "file_name" in self.metadata:
display = self.metadata["file_name"]
elements.append(f"name=`{display}`")
return "<trimesh.{}({})>".format(type(self).__name__, ", ".join(elements))
def apply_translation(self, translation: ArrayLike):
"""
Translate the current mesh.
Parameters
----------
translation : (3,) float
Translation in XYZ
"""
translation = np.asanyarray(translation, dtype=np.float64)
if translation.shape == (2,):
# create a planar matrix if we were passed a 2D offset
return self.apply_transform(tf.planar_matrix(offset=translation))
elif translation.shape != (3,):
raise ValueError("Translation must be (3,) or (2,)!")
# manually create a translation matrix
matrix = np.eye(4)
matrix[:3, 3] = translation
return self.apply_transform(matrix)
def apply_scale(self, scaling):
"""
Scale the mesh.
Parameters
----------
scaling : float or (3,) float
Scale factor to apply to the mesh
"""
matrix = tf.scale_and_translate(scale=scaling)
# apply_transform will work nicely even on negative scales
return self.apply_transform(matrix)
def __radd__(self, other):
"""
Concatenate the geometry allowing concatenation with
built in `sum()` function:
`sum(Iterable[trimesh.Trimesh])`
Parameters
------------
other : Geometry
Geometry or 0
Returns
----------
concat : Geometry
Geometry of combined result
"""
if other == 0:
# adding 0 to a geometry never makes sense
return self
# otherwise just use the regular add function
return self.__add__(type(self)(other))
@cache_decorator
def scale(self) -> float:
"""
A loosely specified "order of magnitude scale" for the
geometry which always returns a value and can be used
to make code more robust to large scaling differences.
It returns the diagonal of the axis aligned bounding box
or if anything is invalid or undefined, `1.0`.
Returns
----------
scale : float
Approximate order of magnitude scale of the geometry.
"""
# if geometry is empty return 1.0
if self.extents is None:
return 1.0
# get the length of the AABB diagonal
scale = float((self.extents**2).sum() ** 0.5)
if scale < tol.zero:
return 1.0
return scale
@property
def units(self) -> Optional[str]:
"""
Definition of units for the mesh.
Returns
----------
units : str
Unit system mesh is in, or None if not defined
"""
return self.metadata.get("units", None)
@units.setter
def units(self, value: str) -> None:
"""
Define the units of the current mesh.
"""
self.metadata["units"] = str(value).lower().strip()
class Geometry3D(Geometry):
"""
The `Geometry3D` object is the parent object of geometry objects
which are three dimensional, including Trimesh, PointCloud,
and Scene objects.
"""
@caching.cache_decorator
def bounding_box(self):
"""
An axis aligned bounding box for the current mesh.
Returns
----------
aabb : trimesh.primitives.Box
Box object with transform and extents defined
representing the axis aligned bounding box of the mesh
"""
from . import primitives
transform = np.eye(4)
# translate to center of axis aligned bounds
transform[:3, 3] = self.bounds.mean(axis=0)
return primitives.Box(transform=transform, extents=self.extents, mutable=False)
@caching.cache_decorator
def bounding_box_oriented(self):
"""
An oriented bounding box for the current mesh.
Returns
---------
obb : trimesh.primitives.Box
Box object with transform and extents defined
representing the minimum volume oriented
bounding box of the mesh
"""
from . import bounds, primitives
to_origin, extents = bounds.oriented_bounds(self)
return primitives.Box(
transform=np.linalg.inv(to_origin), extents=extents, mutable=False
)
@caching.cache_decorator
def bounding_sphere(self):
"""
A minimum volume bounding sphere for the current mesh.
Note that the Sphere primitive returned has an unpadded
exact `sphere_radius` so while the distance of every vertex
of the current mesh from sphere_center will be less than
sphere_radius, the faceted sphere primitive may not
contain every vertex.
Returns
--------
minball : trimesh.primitives.Sphere
Sphere primitive containing current mesh
"""
from . import nsphere, primitives
center, radius = nsphere.minimum_nsphere(self)
return primitives.Sphere(center=center, radius=radius, mutable=False)
@caching.cache_decorator
def bounding_cylinder(self):
"""
A minimum volume bounding cylinder for the current mesh.
Returns
--------
mincyl : trimesh.primitives.Cylinder
Cylinder primitive containing current mesh
"""
from . import bounds, primitives
kwargs = bounds.minimum_cylinder(self)
return primitives.Cylinder(mutable=False, **kwargs)
@caching.cache_decorator
def bounding_primitive(self):
"""
The minimum volume primitive (box, sphere, or cylinder) that
bounds the mesh.
Returns
---------
bounding_primitive : object
Smallest primitive which bounds the mesh:
trimesh.primitives.Sphere
trimesh.primitives.Box
trimesh.primitives.Cylinder
"""
options = [
self.bounding_box_oriented,
self.bounding_sphere,
self.bounding_cylinder,
]
volume_min = np.argmin([i.volume for i in options])
return options[volume_min]
def apply_obb(self, **kwargs) -> NDArray[float64]:
"""
Apply the oriented bounding box transform to the current mesh.
This will result in a mesh with an AABB centered at the
origin and the same dimensions as the OBB.
Parameters
------------
kwargs
Passed through to `bounds.oriented_bounds`
Returns
----------
matrix : (4, 4) float
Transformation matrix that was applied
to mesh to move it into OBB frame
"""
# save the pre-transform volume
if tol.strict and hasattr(self, "volume"):
volume = self.volume
# calculate the OBB passing keyword arguments through
matrix, extents = bounds.oriented_bounds(self, **kwargs)
# apply the transform
self.apply_transform(matrix)
if tol.strict:
# obb transform should not have changed volume
if hasattr(self, "volume") and getattr(self, "is_watertight", False):
assert np.isclose(self.volume, volume)
# overall extents should match what we expected
assert np.allclose(self.extents, extents)
return matrix
@@ -0,0 +1,18 @@
"""
trimesh.path
-------------
Handle 2D and 3D vector paths such as those contained in an
SVG or DXF file.
"""
try:
from .path import Path2D, Path3D
except BaseException as E:
from .. import exceptions
Path2D = exceptions.ExceptionWrapper(E)
Path3D = exceptions.ExceptionWrapper(E)
# explicitly add objects to all as per pep8
__all__ = ["Path2D", "Path3D"]
@@ -0,0 +1,259 @@
from dataclasses import dataclass
import numpy as np
from .. import util
from ..constants import log
from ..constants import res_path as res
from ..constants import tol_path as tol
from ..typed import ArrayLike, NDArray, Number, Optional, float64
# floating point zero
_TOL_ZERO = 1e-12
@dataclass
class ArcInfo:
# What is the radius of the circular arc?
radius: float
# what is the center of the circular arc
# it is either 2D or 3D depending on input.
center: NDArray[float64]
# what is the 3D normal vector of the plane the arc lies on
normal: Optional[NDArray[float64]] = None
# what is the starting and ending angle of the arc.
angles: Optional[NDArray[float64]] = None
# what is the angular span of this circular arc.
span: Optional[Number] = None
def __getitem__(self, item):
# add for backwards compatibility
return getattr(self, item)
def arc_center(
points: ArrayLike, return_normal: bool = True, return_angle: bool = True
) -> ArcInfo:
"""
Given three points on a 2D or 3D arc find the center,
radius, normal, and angular span.
Parameters
---------
points : (3, dimension) float
Points in space, where dimension is either 2 or 3
return_normal : bool
If True calculate the 3D normal unit vector
return_angle : bool
If True calculate the start and stop angle and span
Returns
---------
info
Arc center, radius, and other information.
"""
points = np.asanyarray(points, dtype=np.float64)
# get the non-unit vectors of the three points
vectors = points[[2, 0, 1]] - points[[1, 2, 0]]
# we need both the squared row sum and the non-squared
abc2 = np.dot(vectors**2, [1] * points.shape[1])
# same as np.linalg.norm(vectors, axis=1)
abc = np.sqrt(abc2)
# perform radius calculation scaled to shortest edge
# to avoid precision issues with small or large arcs
scale = abc.min()
# get the edge lengths scaled to the smallest
edges = abc / scale
# half the total length of the edges
half = edges.sum() / 2.0
# check the denominator for the radius calculation
denom = half * np.prod(half - edges)
if denom < tol.merge:
raise ValueError("arc is colinear!")
# find the radius and scale back after the operation
radius = scale * ((np.prod(edges) / 4.0) / np.sqrt(denom))
# use a barycentric approach to get the center
ba2 = (abc2[[1, 2, 0, 0, 2, 1, 0, 1, 2]] * [1, 1, -1, 1, 1, -1, 1, 1, -1]).reshape(
(3, 3)
).sum(axis=1) * abc2
center = points.T.dot(ba2) / ba2.sum()
if tol.strict:
# all points should be at the calculated radius from center
assert util.allclose(np.linalg.norm(points - center, axis=1), radius)
# start with initial results
result = {"center": center, "radius": radius}
if return_normal:
if points.shape == (3, 2):
# for 2D arcs still use the cross product so that
# the sign of the normal vector is consistent
result["normal"] = util.unitize(
np.cross(np.append(-vectors[1], 0), np.append(vectors[2], 0))
)
else:
# otherwise just take the cross product
result["normal"] = util.unitize(np.cross(-vectors[1], vectors[2]))
if return_angle:
# vectors from points on arc to center point
vector = util.unitize(points - center)
edge_direction = np.diff(points, axis=0)
# find the angle between the first and last vector
dot = np.dot(*vector[[0, 2]])
if dot < (_TOL_ZERO - 1):
angle = np.pi
elif dot > 1 - _TOL_ZERO:
angle = 0.0
else:
angle = np.arccos(dot)
# if the angle is nonzero and vectors are opposite direction
# it means we have a long arc rather than the short path
if abs(angle) > _TOL_ZERO and np.dot(*edge_direction) < 0.0:
angle = (np.pi * 2) - angle
# convoluted angle logic
angles = np.arctan2(*vector[:, :2].T[::-1]) + np.pi * 2
angles_sorted = np.sort(angles[[0, 2]])
reverse = angles_sorted[0] < angles[1] < angles_sorted[1]
angles_sorted = angles_sorted[:: (1 - int(not reverse) * 2)]
result["angles"] = angles_sorted
result["span"] = angle
return ArcInfo(**result)
def discretize_arc(points, close=False, scale=1.0):
"""
Returns a version of a three point arc consisting of
line segments.
Parameters
---------
points : (3, d) float
Points on the arc where d in [2,3]
close : boolean
If True close the arc into a circle
scale : float
What is the approximate overall drawing scale
Used to establish order of magnitude for precision
Returns
---------
discrete : (m, d) float
Connected points in space
"""
# make sure points are (n, 3)
points, is_2D = util.stack_3D(points, return_2D=True)
# find the center of the points
try:
# try to find the center from the arc points
center_info = arc_center(points)
except BaseException:
# if we hit an exception return a very bad but
# technically correct discretization of the arc
if is_2D:
return points[:, :2]
return points
center, R, N, angle = (
center_info.center,
center_info.radius,
center_info.normal,
center_info.span,
)
# if requested, close arc into a circle
if close:
angle = np.pi * 2
# the number of facets, based on the angle criteria
count_a = angle / res.seg_angle
count_l = (R * angle) / (res.seg_frac * scale)
# figure out the number of line segments
count = np.max([count_a, count_l])
# force at LEAST 4 points for the arc
# otherwise the endpoints will diverge
count = np.clip(count, 4, np.inf)
count = int(np.ceil(count))
V1 = util.unitize(points[0] - center)
V2 = util.unitize(np.cross(-N, V1))
t = np.linspace(0, angle, count)
discrete = np.tile(center, (count, 1))
discrete += R * np.cos(t).reshape((-1, 1)) * V1
discrete += R * np.sin(t).reshape((-1, 1)) * V2
# do an in-process check to make sure result endpoints
# match the endpoints of the source arc
if not close:
if tol.strict:
arc_dist = util.row_norm(points[[0, -1]] - discrete[[0, -1]])
arc_ok = (arc_dist < tol.merge).all()
if not arc_ok:
log.warning(
"failed to discretize arc (endpoint_distance=%s R=%s)",
str(arc_dist),
R,
)
log.warning("Failed arc points: %s", str(points))
raise ValueError("Arc endpoints diverging!")
# snap the discrete result to exact control points
discrete[[0, -1]] = points[[0, -1]]
# clip to the dimension of input
discrete = discrete[:, : (3 - is_2D)]
return discrete
def to_threepoint(center, radius, angles=None):
"""
For 2D arcs, given a center and radius convert them to three
points on the arc.
Parameters
-----------
center : (2,) float
Center point on the plane
radius : float
Radius of arc
angles : (2,) float
Angles in radians for start and end angle
if not specified, will default to (0.0, pi)
Returns
----------
three : (3, 2) float
Arc control points
"""
# if no angles provided assume we want a half circle
if angles is None:
angles = [0.0, np.pi]
# force angles to float64
angles = np.asanyarray(angles, dtype=np.float64)
if angles.shape != (2,):
raise ValueError("angles must be (2,)!")
# provide the wrap around
if angles[1] < angles[0]:
angles[1] += np.pi * 2
center = np.asanyarray(center, dtype=np.float64)
if center.shape != (2,):
raise ValueError("only valid on 2D arcs!")
# turn the angles of [start, end]
# into [start, middle, end]
angles = np.array([angles[0], angles.mean(), angles[1]], dtype=np.float64)
# turn angles into (3, 2) points
three = (np.column_stack((np.cos(angles), np.sin(angles))) * radius) + center
return three
@@ -0,0 +1,294 @@
import numpy as np
from .. import transformations, util
from ..geometry import plane_transform
from . import arc
from .entities import Arc, Line
def circle_pattern(
pattern_radius, circle_radius, count, center=None, angle=None, **kwargs
):
"""
Create a Path2D representing a circle pattern.
Parameters
------------
pattern_radius : float
Radius of circle centers
circle_radius : float
The radius of each circle
count : int
Number of circles in the pattern
center : (2,) float
Center of pattern
angle : float
If defined pattern will span this angle
If None, pattern will be evenly spaced
Returns
-------------
pattern : trimesh.path.Path2D
Path containing circular pattern
"""
from .path import Path2D
if angle is None:
angles = np.linspace(0.0, np.pi * 2.0, count + 1)[:-1]
elif isinstance(angle, float) or isinstance(angle, int):
angles = np.linspace(0.0, angle, count)
else:
raise ValueError("angle must be float or int!")
if center is None:
center = [0.0, 0.0]
# centers of circles
centers = np.column_stack((np.cos(angles), np.sin(angles))) * pattern_radius
vert = []
ents = []
for circle_center in centers:
# (3,3) center points of arc
three = arc.to_threepoint(
angles=[0, np.pi], center=circle_center, radius=circle_radius
)
# add a single circle entity
ents.append(Arc(points=np.arange(3) + len(vert), closed=True))
# keep flat array by extend instead of append
vert.extend(three)
# translate vertices to pattern center
vert = np.array(vert) + center
pattern = Path2D(entities=ents, vertices=vert, **kwargs)
return pattern
def circle(radius, center=None, **kwargs):
"""
Create a Path2D containing circle with the specified
radius.
Parameters
--------------
radius : float
The radius of the circle
center : None or (2,) float
Center of the circle, origin by default
** kwargs : dict
Passed to trimesh.path.Path2D constructor
Returns
-------------
circle : Path2D
Path containing specified circle
"""
from .path import Path2D
if center is None:
center = [0.0, 0.0]
else:
center = np.asanyarray(center, dtype=np.float64)
# make sure radius is a float
radius = float(radius)
# (3, 2) float, points on arc
three = arc.to_threepoint(angles=[0, np.pi], center=center, radius=radius)
# generate the path object
result = Path2D(
entities=[Arc(points=np.arange(3), closed=True)], vertices=three, **kwargs
)
return result
def rectangle(bounds, **kwargs):
"""
Create a Path2D containing a single or multiple rectangles
with the specified bounds.
Parameters
--------------
bounds : (2, 2) float, or (m, 2, 2) float
Minimum XY, Maximum XY
Returns
-------------
rect : Path2D
Path containing specified rectangles
"""
from .path import Path2D
# data should be float
bounds = np.asanyarray(bounds, dtype=np.float64)
# bounds are extents, re- shape to origin- centered rectangle
if bounds.shape == (2,):
half = np.abs(bounds) / 2.0
bounds = np.array([-half, half])
# should have one bounds or multiple bounds
if not (util.is_shape(bounds, (2, 2)) or util.is_shape(bounds, (-1, 2, 2))):
raise ValueError("bounds must be (m, 2, 2) or (2, 2)")
# hold Line objects
lines = []
# hold (n, 2) cartesian points
vertices = []
# loop through each rectangle
for lower, upper in bounds.reshape((-1, 2, 2)):
lines.append(Line((np.arange(5) % 4) + len(vertices)))
vertices.extend([lower, [upper[0], lower[1]], upper, [lower[0], upper[1]]])
# create the Path2D with specified rectangles
rect = Path2D(entities=lines, vertices=vertices, **kwargs)
return rect
def box_outline(extents=None, transform=None, **kwargs):
"""
Return a cuboid.
Parameters
------------
extents : float, or (3,) float
Edge lengths
transform: (4, 4) float
Transformation matrix
**kwargs:
passed to Trimesh to create box
Returns
------------
geometry : trimesh.Path3D
Path outline of a cuboid geometry
"""
from .exchange.load import load_path
# create vertices for the box
vertices = [0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1]
vertices = np.array(vertices, order="C", dtype=np.float64).reshape((-1, 3))
vertices -= 0.5
# resize the vertices based on passed size
if extents is not None:
extents = np.asanyarray(extents, dtype=np.float64)
if extents.shape != (3,):
raise ValueError("Extents must be (3,)!")
vertices *= extents
# apply transform if passed
if transform is not None:
vertices = transformations.transform_points(vertices, transform)
# vertex indices
indices = [0, 1, 3, 2, 0, 4, 5, 7, 6, 4, 0, 2, 6, 7, 3, 1, 5]
outline = load_path(vertices[indices])
return outline
def grid(
side,
count=5,
transform=None,
plane_origin=None,
plane_normal=None,
include_circle=True,
sections_circle=32,
):
"""
Create a Path3D for a grid visualization of a plane.
Parameters
-----------
side : float
Length of half of a grid side
count : int
Number of grid lines per grid half
transform : None or (4, 4) float
Transformation matrix to move grid location.
Takes precedence over plane_origin if both are passed.
plane_origin : None or (3,) float
Plane origin
plane_normal : None or (3,) float
Unit normal vector
include_circle : bool
Include a circular pattern inside the grid
sections_circle : int
How many sections should the smallest circle have
Returns
----------
grid : trimesh.path.Path3D
Path containing grid plane visualization
"""
from .path import Path3D
# change full side length to half-side
side = float(side)
# make sure count is an integer
count = int(count)
# get a spaced sequence of radius
radii = np.linspace(0.0, side, count + 1)[1:]
# what's the maximum radius
rmax = radii[-1]
# keep a count of the current vertex count
current = 0
# collect vertices and entities
vertices = []
entities = []
for r in radii:
if include_circle:
# scale the section count by radius
circle_res = int((r / radii[0]) * sections_circle)
# generate a circule pattern
theta = np.linspace(0.0, np.pi * 2, circle_res)
circle = np.column_stack((np.cos(theta), np.sin(theta))) * r
# append the circle pattern
vertices.append(circle)
entities.append(Line(points=np.arange(len(circle)) + current))
# keep the vertex count correct
current += len(circle)
# generate a series of grid lines
vertices.append(
[
[-rmax, r],
[rmax, r],
[-rmax, -r],
[rmax, -r],
[r, -rmax],
[r, rmax],
[-r, -rmax],
[-r, rmax],
]
)
# append an entity per grid line
for i in [0, 2, 4, 6]:
entities.append(Line(points=np.arange(2) + current + i))
current += len(vertices[-1])
# add the middle lines which were skipped
vertices.append([[0, rmax], [0, -rmax], [-rmax, 0], [rmax, 0]])
entities.append(Line(points=np.arange(2) + current))
entities.append(Line(points=np.arange(2) + current + 2))
# stack vertices into clean (n, 3) float
vertices = np.vstack(vertices)
# if plane was passed instead of transform create the matrix here
if transform is None and plane_origin is not None and plane_normal is not None:
transform = np.linalg.inv(
plane_transform(origin=plane_origin, normal=plane_normal)
)
# stack vertices to 3D
vertices = np.column_stack((vertices, np.zeros(len(vertices))))
# apply transform if passed
if transform is not None:
vertices = transformations.transform_points(vertices, matrix=transform)
# combine result into a Path3D object
grid_path = Path3D(entities=entities, vertices=vertices)
return grid_path
@@ -0,0 +1,136 @@
import numpy as np
from ..constants import res_path as res
from ..constants import tol_path as tol
from ..typed import Integer, List
def discretize_bezier(points, count=None, scale=1.0):
"""
Parameters
----------
points : (order, dimension) float
Control points of the bezier curve
For a 2D cubic bezier, order=3, dimension=2
count : int, or None
Number of segments
scale : float
Scale of curve
Returns
----------
discrete: (n, dimension) float
Points forming a a polyline representation
"""
# make sure we have a numpy array
points = np.asanyarray(points, dtype=np.float64)
if count is None:
# how much distance does a small percentage of the curve take
# this is so we can figure out how finely we have to sample t
norm = np.linalg.norm(np.diff(points, axis=0), axis=1).sum()
count = np.ceil(norm / (res.seg_frac * scale))
count = int(
np.clip(count, res.min_sections * len(points), res.max_sections * len(points))
)
count = int(count)
# parameterize incrementing 0.0 - 1.0
t = np.linspace(0.0, 1.0, count)
# decrementing 1.0-0.0
t_d = 1.0 - t
n = len(points) - 1
# binomial coefficients, i, and each point
iterable = zip(binomial(n), np.arange(len(points)), points)
# run the actual interpolation
stacked = [
((t**i) * (t_d ** (n - i))).reshape((-1, 1)) * p * c for c, i, p in iterable
]
result = np.sum(stacked, axis=0)
# a bezier curve always starts and ends on control points
if tol.strict:
# test to make sure end points are correct
test = np.sum((result[[0, -1]] - points[[0, -1]]) ** 2, axis=1)
assert (test < tol.merge).all()
assert len(result) >= 2
# snap the first and last points to the exact control point
result[[0, -1]] = points[[0, -1]]
return result
def discretize_bspline(control, knots, count=None, scale=1.0):
"""
Given a B-Splines control points and knot vector, return
a sampled version of the curve.
Parameters
----------
control : (o, d) float
Control points of the b- spline
knots : (j,) float
B-spline knots
count : int
Number of line segments to discretize the spline
If not specified will be calculated as something reasonable
Returns
----------
discrete : (count, dimension) float
Points on a polyline version of the B-spline
"""
# evaluate the b-spline using scipy/fitpack
from scipy.interpolate import splev
# (n, d) control points where d is the dimension of vertices
control = np.asanyarray(control, dtype=np.float64)
degree = len(knots) - len(control) - 1
if count is None:
norm = np.linalg.norm(np.diff(control, axis=0), axis=1).sum()
count = int(
np.clip(
norm / (res.seg_frac * scale),
res.min_sections * len(control),
res.max_sections * len(control),
)
)
ipl = np.linspace(knots[0], knots[-1], count)
discrete = splev(ipl, [knots, control.T, degree])
discrete = np.column_stack(discrete)
return discrete
def binomial(n: Integer) -> List:
"""
Return all binomial coefficients for a given order.
For n > 5, scipy.special.binom is used, below we hardcode.
Parameters
--------------
n : int
Order of binomial
Returns
---------------
binom : (n + 1,) int
Binomial coefficients of a given order
"""
if n == 1:
return [1, 1]
elif n == 2:
return [1, 2, 1]
elif n == 3:
return [1, 3, 3, 1]
elif n == 4:
return [1, 4, 6, 4, 1]
elif n == 5:
return [1, 5, 10, 10, 5, 1]
else:
from scipy.special import binom
return binom(n, np.arange(n + 1))
@@ -0,0 +1,821 @@
"""
entities.py
--------------
Basic geometric primitives which only store references to
vertex indices rather than vertices themselves.
"""
from copy import deepcopy
import numpy as np
from .. import util
from ..util import ABC
from .arc import arc_center, discretize_arc
from .curve import discretize_bezier, discretize_bspline
class Entity(ABC):
def __init__(
self, points, closed=None, layer=None, metadata=None, color=None, **kwargs
):
# points always reference vertex indices and are int
self.points = np.asanyarray(points, dtype=np.int64)
# save explicit closed
if closed is not None:
self.closed = closed
# save the passed layer
if layer is not None:
self.layer = layer
if metadata is not None:
self.metadata.update(metadata)
self._cache = {}
# save the passed color
self.color = color
# save any other kwargs for general use
self.kwargs = kwargs
@property
def metadata(self):
"""
Get any metadata about the entity.
Returns
---------
metadata : dict
Bag of properties.
"""
if not hasattr(self, "_metadata"):
self._metadata = {}
# note that we don't let a new dict be assigned
return self._metadata
@property
def layer(self):
"""
Set the layer the entity resides on as a shortcut
to putting it in the entity metadata.
Returns
----------
layer : any
Hashable layer identifier.
"""
return self.metadata.get("layer")
@layer.setter
def layer(self, value):
"""
Set the current layer of the entity.
Returns
----------
layer : any
Hashable layer indicator
"""
self.metadata["layer"] = value
def to_dict(self) -> dict:
"""
Returns a dictionary with all of the information
about the entity.
Returns
-----------
as_dict : dict
Has keys 'type', 'points', 'closed'
"""
return {
"type": self.__class__.__name__,
"points": self.points.tolist(),
"closed": self.closed,
}
@property
def closed(self):
"""
If the first point is the same as the end point
the entity is closed
Returns
-----------
closed : bool
Is the entity closed or not?
"""
closed = len(self.points) > 2 and self.points[0] == self.points[-1]
return closed
@property
def nodes(self):
"""
Returns an (n,2) list of nodes, or vertices on the path.
Note that this generic class function assumes that all of the
reference points are on the path which is true for lines and
three point arcs.
If you were to define another class where that wasn't the case
(for example, the control points of a bezier curve),
you would need to implement an entity- specific version of this
function.
The purpose of having a list of nodes is so that they can then be
added as edges to a graph so we can use functions to check
connectivity, extract paths, etc.
The slicing on this function is essentially just tiling points
so the first and last vertices aren't repeated. Example:
self.points = [0,1,2]
returns: [[0,1], [1,2]]
"""
return (
np.column_stack((self.points, self.points)).reshape(-1)[1:-1].reshape((-1, 2))
)
@property
def end_points(self):
"""
Returns the first and last points. Also note that if you
define a new entity class where the first and last vertices
in self.points aren't the endpoints of the curve you need to
implement this function for your class.
Returns
-------------
ends : (2,) int
Indices of the two end points of the entity
"""
return self.points[[0, -1]]
@property
def is_valid(self):
"""
Is the current entity valid.
Returns
-----------
valid : bool
Is the current entity well formed
"""
return True
def reverse(self, direction=-1):
"""
Reverse the current entity in place.
Parameters
----------------
direction : int
If positive will not touch direction
If negative will reverse self.points
"""
if direction < 0:
self._direction = -1
else:
self._direction = 1
def _orient(self, curve):
"""
Reverse a curve if a flag is set.
Parameters
--------------
curve : (n, dimension) float
Curve made up of line segments in space
Returns
------------
orient : (n, dimension) float
Original curve, but possibly reversed
"""
if hasattr(self, "_direction") and self._direction < 0:
return curve[::-1]
return curve
def bounds(self, vertices):
"""
Return the AABB of the current entity.
Parameters
-----------
vertices : (n, dimension) float
Vertices in space
Returns
-----------
bounds : (2, dimension) float
Coordinates of AABB, in (min, max) form
"""
bounds = np.array(
[vertices[self.points].min(axis=0), vertices[self.points].max(axis=0)]
)
return bounds
def length(self, vertices):
"""
Return the total length of the entity.
Parameters
--------------
vertices : (n, dimension) float
Vertices in space
Returns
---------
length : float
Total length of entity
"""
diff = np.diff(self.discrete(vertices), axis=0) ** 2
length = (np.dot(diff, [1] * vertices.shape[1]) ** 0.5).sum()
return length
def explode(self):
"""
Split the entity into multiple entities.
Returns
------------
explode : list of Entity
Current entity split into multiple entities.
"""
return [self.copy()]
def copy(self):
"""
Return a copy of the current entity.
Returns
------------
copied : Entity
Copy of current entity
"""
copied = deepcopy(self)
# only copy metadata if set
if hasattr(self, "_metadata"):
copied._metadata = deepcopy(self._metadata)
# check for very annoying subtle copy failures
assert id(copied._metadata) != id(self._metadata)
assert id(copied.points) != id(self.points)
return copied
def __hash__(self):
"""
Return a hash that represents the current entity.
Returns
----------
hashed : int
Hash of current class name, points, and closed
"""
return hash(self._bytes())
def _bytes(self):
"""
Get hashable bytes that define the current entity.
Returns
------------
data : bytes
Hashable data defining the current entity
"""
# give consistent ordering of points for hash
if self.points[0] > self.points[-1]:
return self.__class__.__name__.encode("utf-8") + self.points.tobytes()
else:
return self.__class__.__name__.encode("utf-8") + self.points[::-1].tobytes()
class Text(Entity):
"""
Text to annotate a 2D or 3D path.
"""
def __init__(
self,
origin,
text,
height=None,
vector=None,
normal=None,
align=None,
layer=None,
color=None,
metadata=None,
):
"""
An entity for text labels.
Parameters
--------------
origin : int
Index of a single vertex for text origin
text : str
The text to label
height : float or None
The height of text
vector : int or None
An vertex index for which direction text
is written along unitized: vector - origin
normal : int or None
A vertex index for the plane normal:
vector is along unitized: normal - origin
align : (2,) str or None
Where to draw from for [horizontal, vertical]:
'center', 'left', 'right'
"""
# where is text placed
self.origin = origin
# what direction is the text pointing
self.vector = vector
# what is the normal of the text plane
self.normal = normal
# how high is the text entity
self.height = height
# what layer is the entity on
if layer is not None:
self.layer = layer
if metadata is not None:
self.metadata.update(metadata)
# what color is the entity
self.color = color
# None or (2,) str
if align is None:
# if not set make everything centered
align = ["center", "center"]
elif isinstance(align, str):
# if only one is passed set for both
# horizontal and vertical
align = [align, align]
elif len(align) != 2:
# otherwise raise rror
raise ValueError("align must be (2,) str")
self.align = align
# make sure text is a string
if hasattr(text, "decode"):
self.text = text.decode("utf-8")
else:
self.text = str(text)
@property
def origin(self):
"""
The origin point of the text.
Returns
-----------
origin : int
Index of vertices
"""
return self.points[0]
@origin.setter
def origin(self, value):
value = int(value)
if not hasattr(self, "points") or np.ptp(self.points) == 0:
self.points = np.ones(3, dtype=np.int64) * value
else:
self.points[0] = value
@property
def vector(self):
"""
A point representing the text direction
along the vector: vertices[vector] - vertices[origin]
Returns
----------
vector : int
Index of vertex
"""
return self.points[1]
@vector.setter
def vector(self, value):
if value is None:
return
self.points[1] = int(value)
@property
def normal(self):
"""
A point representing the plane normal along the
vector: vertices[normal] - vertices[origin]
Returns
------------
normal : int
Index of vertex
"""
return self.points[2]
@normal.setter
def normal(self, value):
if value is None:
return
self.points[2] = int(value)
def plot(self, vertices, show=False):
"""
Plot the text using matplotlib.
Parameters
--------------
vertices : (n, 2) float
Vertices in space
show : bool
If True, call plt.show()
"""
if vertices.shape[1] != 2:
raise ValueError("only for 2D points!")
import matplotlib.pyplot as plt
# get rotation angle in degrees
angle = np.degrees(self.angle(vertices))
# TODO: handle text size better
plt.text(
*vertices[self.origin],
s=self.text,
rotation=angle,
ha=self.align[0],
va=self.align[1],
size=18,
)
if show:
plt.show()
def angle(self, vertices):
"""
If Text is 2D, get the rotation angle in radians.
Parameters
-----------
vertices : (n, 2) float
Vertices in space referenced by self.points
Returns
---------
angle : float
Rotation angle in radians
"""
if vertices.shape[1] != 2:
raise ValueError("angle only valid for 2D points!")
# get the vector from origin
direction = vertices[self.vector] - vertices[self.origin]
# get the rotation angle in radians
angle = np.arctan2(*direction[::-1])
return angle
def length(self, vertices):
return 0.0
def discrete(self, *args, **kwargs):
return np.array([])
@property
def closed(self):
return False
@property
def is_valid(self):
return True
@property
def nodes(self):
return np.array([])
@property
def end_points(self):
return np.array([])
def _bytes(self):
data = b"".join([b"Text", self.points.tobytes(), self.text.encode("utf-8")])
return data
class Line(Entity):
"""
A line or poly-line entity
"""
def discrete(self, vertices, scale=1.0):
"""
Discretize into a world- space path.
Parameters
------------
vertices: (n, dimension) float
Points in space
scale : float
Size of overall scene for numerical comparisons
Returns
-------------
discrete: (m, dimension) float
Path in space composed of line segments
"""
return self._orient(vertices[self.points])
@property
def is_valid(self):
"""
Is the current entity valid.
Returns
-----------
valid : bool
Is the current entity well formed
"""
valid = np.any((self.points - self.points[0]) != 0)
return valid
def explode(self):
"""
If the current Line entity consists of multiple line
break it up into n Line entities.
Returns
----------
exploded: (n,) Line entities
"""
# copy over the current layer
layer = self.layer
points = (
np.column_stack((self.points, self.points)).ravel()[1:-1].reshape((-1, 2))
)
exploded = [Line(i, layer=layer) for i in points]
return exploded
def _bytes(self):
# give consistent ordering of points for hash
if self.points[0] > self.points[-1]:
return b"Line" + self.points.tobytes()
else:
return b"Line" + self.points[::-1].tobytes()
def to_dict(self) -> dict:
"""
Returns a dictionary with all of the information
about the Line. `closed` is not additional information
for a Line like it is for Arc where the value determines
if it is a partial or complete circle. Rather it is a check
which indicates the first and last points are identical,
and thus should not be included in the export
Returns
-----------
as_dict
Has keys 'type', 'points'
"""
return {
"type": self.__class__.__name__,
"points": self.points.tolist(),
}
class Arc(Entity):
@property
def closed(self):
"""
A boolean flag for whether the arc is closed (a circle) or not.
Returns
----------
closed : bool
If set True, Arc will be a closed circle
"""
return getattr(self, "_closed", False)
@closed.setter
def closed(self, value):
"""
Set the Arc to be closed or not, without
changing the control points
Parameters
------------
value : bool
Should this Arc be a closed circle or not
"""
self._closed = bool(value)
@property
def is_valid(self):
"""
Is the current Arc entity valid.
Returns
-----------
valid : bool
Does the current Arc have exactly 3 control points
"""
return len(np.unique(self.points)) == 3
def _bytes(self):
# give consistent ordering of points for hash
order = int(self.points[0] > self.points[-1]) * 2 - 1
return b"Arc" + bytes(self.closed) + self.points[::order].tobytes()
def length(self, vertices):
"""
Return the arc length of the 3-point arc.
Parameter
----------
vertices : (n, d) float
Vertices for overall drawing.
Returns
-----------
length : float
Length of arc.
"""
# find the actual radius and angle span
if self.closed:
# we don't need the angular span as
# it's indicated as a closed circle
fit = self.center(vertices, return_normal=False, return_angle=False)
return np.pi * fit.radius * 4
# get the angular span of the circular arc
fit = self.center(vertices, return_normal=False, return_angle=True)
return fit.span * fit.radius * 2
def discrete(self, vertices, scale=1.0):
"""
Discretize the arc entity into line sections.
Parameters
------------
vertices : (n, dimension) float
Points in space
scale : float
Size of overall scene for numerical comparisons
Returns
-------------
discrete : (m, dimension) float
Path in space made up of line segments
"""
return self._orient(
discretize_arc(vertices[self.points], close=self.closed, scale=scale)
)
def center(self, vertices, **kwargs):
"""
Return the center information about the arc entity.
Parameters
-------------
vertices : (n, dimension) float
Vertices in space
Returns
-------------
info : dict
With keys: 'radius', 'center'
"""
return arc_center(vertices[self.points], **kwargs)
def bounds(self, vertices):
"""
Return the AABB of the arc entity.
Parameters
-----------
vertices: (n, dimension) float
Vertices in space
Returns
-----------
bounds : (2, dimension) float
Coordinates of AABB in (min, max) form
"""
if util.is_shape(vertices, (-1, 2)) and self.closed:
# if we have a closed arc (a circle), we can return the actual bounds
# this only works in two dimensions, otherwise this would return the
# AABB of an sphere
info = self.center(vertices, return_normal=False, return_angle=False)
bounds = np.array(
[info.center - info.radius, info.center + info.radius], dtype=np.float64
)
else:
# since the AABB of a partial arc is hard, approximate
# the bounds by just looking at the discrete values
discrete = self.discrete(vertices)
bounds = np.array(
[discrete.min(axis=0), discrete.max(axis=0)], dtype=np.float64
)
return bounds
class Curve(Entity):
"""
The parent class for all wild curves in space.
"""
@property
def nodes(self):
# a point midway through the curve
mid = self.points[len(self.points) // 2]
return [[self.points[0], mid], [mid, self.points[-1]]]
class Bezier(Curve):
"""
An open or closed Bezier curve
"""
def discrete(self, vertices, scale=1.0, count=None):
"""
Discretize the Bezier curve.
Parameters
-------------
vertices : (n, 2) or (n, 3) float
Points in space
scale : float
Scale of overall drawings (for precision)
count : int
Number of segments to return
Returns
-------------
discrete : (m, 2) or (m, 3) float
Curve as line segments
"""
return self._orient(
discretize_bezier(vertices[self.points], count=count, scale=scale)
)
class BSpline(Curve):
"""
An open or closed B- Spline.
"""
def __init__(self, points, knots, layer=None, metadata=None, color=None, **kwargs):
self.points = np.asanyarray(points, dtype=np.int64)
self.knots = np.asanyarray(knots, dtype=np.float64)
if layer is not None:
self.layer = layer
if metadata is not None:
self.metadata.update(metadata)
self._cache = {}
self.kwargs = kwargs
self.color = color
def discrete(self, vertices, count=None, scale=1.0):
"""
Discretize the B-Spline curve.
Parameters
-------------
vertices : (n, 2) or (n, 3) float
Points in space
scale : float
Scale of overall drawings (for precision)
count : int
Number of segments to return
Returns
-------------
discrete : (m, 2) or (m, 3) float
Curve as line segments
"""
discrete = discretize_bspline(
control=vertices[self.points], knots=self.knots, count=count, scale=scale
)
return self._orient(discrete)
def _bytes(self):
# give consistent ordering of points for hash
if self.points[0] > self.points[-1]:
return b"BSpline" + self.knots.tobytes() + self.points.tobytes()
else:
return b"BSpline" + self.knots[::-1].tobytes() + self.points[::-1].tobytes()
def to_dict(self) -> dict:
"""
Returns a dictionary with all of the information
about the entity.
"""
return {
"type": self.__class__.__name__,
"points": self.points.tolist(),
"knots": self.knots.tolist(),
"closed": self.closed,
}
@@ -0,0 +1,964 @@
from collections import defaultdict
import numpy as np
from ... import grouping, resources, util
from ... import transformations as tf
from ...constants import log
from ...constants import tol_path as tol
from ...util import multi_dict
from ..arc import to_threepoint
from ..entities import Arc, BSpline, Line, Text
# unit codes
_DXF_UNITS = {
1: "inches",
2: "feet",
3: "miles",
4: "millimeters",
5: "centimeters",
6: "meters",
7: "kilometers",
8: "microinches",
9: "mils",
10: "yards",
11: "angstroms",
12: "nanometers",
13: "microns",
14: "decimeters",
15: "decameters",
16: "hectometers",
17: "gigameters",
18: "AU",
19: "light years",
20: "parsecs",
}
# backwards, for reference
_UNITS_TO_DXF = {v: k for k, v in _DXF_UNITS.items()}
# a string which we will replace spaces with temporarily
_SAFESPACE = "|<^>|"
# save metadata to a DXF Xrecord starting here
# Valid values are 1-369 (except 5 and 105)
XRECORD_METADATA = 134
# the sentinel string for trimesh metadata
# this should be seen at XRECORD_METADATA
XRECORD_SENTINEL = "TRIMESH_METADATA:"
# the maximum line length before we split lines
XRECORD_MAX_LINE = 200
# the maximum index of XRECORDS
XRECORD_MAX_INDEX = 368
def load_dxf(file_obj, **kwargs):
"""
Load a DXF file to a dictionary containing vertices and
entities.
Parameters
----------
file_obj: file or file- like object (has object.read method)
Returns
----------
result: dict, keys are entities, vertices and metadata
"""
# in a DXF file, lines come in pairs,
# a group code then the next line is the value
# we are removing all whitespace then splitting with the
# splitlines function which uses the universal newline method
raw = file_obj.read()
# if we've been passed bytes
if hasattr(raw, "decode"):
# search for the sentinel string indicating binary DXF
# do it by encoding sentinel to bytes and subset searching
if raw[:22].find(b"AutoCAD Binary DXF") != -1:
# no converter to ASCII DXF available
raise NotImplementedError("Binary DXF is not supported!")
else:
# we've been passed bytes that don't have the
# header for binary DXF so try decoding as UTF-8
raw = raw.decode("utf-8", errors="ignore")
# remove trailing whitespace
raw = str(raw).strip()
# without any spaces and in upper case
cleaned = raw.replace(" ", "").strip().upper()
# blob with spaces and original case
blob_raw = np.array(str.splitlines(raw)).reshape((-1, 2))
# if this reshape fails, it means the DXF is malformed
blob = np.array(str.splitlines(cleaned)).reshape((-1, 2))
# get the section which contains the header in the DXF file
endsec = np.nonzero(blob[:, 1] == "ENDSEC")[0]
# store metadata
metadata = {}
# try reading the header, which may be malformed
header_start = np.nonzero(blob[:, 1] == "HEADER")[0]
if len(header_start) > 0:
header_end = endsec[np.searchsorted(endsec, header_start[0])]
header_blob = blob[header_start[0] : header_end]
# store some properties from the DXF header
metadata["DXF_HEADER"] = {}
for key, group in [
("$ACADVER", "1"),
("$DIMSCALE", "40"),
("$DIMALT", "70"),
("$DIMALTF", "40"),
("$DIMUNIT", "70"),
("$INSUNITS", "70"),
("$LUNITS", "70"),
]:
value = get_key(header_blob, key, group)
if value is not None:
metadata["DXF_HEADER"][key] = value
# store unit data pulled from the header of the DXF
# prefer LUNITS over INSUNITS
# I couldn't find a table for LUNITS values but they
# look like they are 0- indexed versions of
# the INSUNITS keys, so for now offset the key value
for offset, key in [(-1, "$LUNITS"), (0, "$INSUNITS")]:
# get the key from the header blob
units = get_key(header_blob, key, "70")
# if it exists add the offset
if units is None:
continue
metadata[key] = units
units += offset
# if the key is in our list of units store it
if units in _DXF_UNITS:
metadata["units"] = _DXF_UNITS[units]
# warn on drawings with no units
if "units" not in metadata:
log.debug("DXF doesn't have units specified!")
# get the section which contains entities in the DXF file
entity_start = np.nonzero(blob[:, 1] == "ENTITIES")[0][0]
entity_end = endsec[np.searchsorted(endsec, entity_start)]
blocks = None
check_entity = blob[entity_start:entity_end][:, 1]
# only load blocks if an entity references them via an INSERT
if "INSERT" in check_entity or "BLOCK" in check_entity:
try:
# which part of the raw file contains blocks
block_start = np.nonzero(blob[:, 1] == "BLOCKS")[0][0]
block_end = endsec[np.searchsorted(endsec, block_start)]
blob_block = blob[block_start:block_end]
blob_block_raw = blob_raw[block_start:block_end]
block_infl = np.nonzero((blob_block == ["0", "BLOCK"]).all(axis=1))[0]
# collect blocks by name
blocks = {}
for index in np.array_split(np.arange(len(blob_block)), block_infl):
try:
v, e, name = convert_entities(
blob_block[index], blob_block_raw[index], return_name=True
)
if len(e) > 0:
blocks[name] = (v, e)
except BaseException:
pass
except BaseException:
log.error("failed to parse blocks!", exc_info=True)
# actually load referenced entities
vertices, entities = convert_entities(
blob[entity_start:entity_end], blob_raw[entity_start:entity_end], blocks=blocks
)
# return result as kwargs for trimesh.path.Path2D constructor
result = {"vertices": vertices, "entities": entities, "metadata": metadata}
return result
def convert_entities(blob, blob_raw=None, blocks=None, return_name=False):
"""
Convert a chunk of entities into trimesh entities.
Parameters
------------
blob : (n, 2) str
Blob of entities uppercased
blob_raw : (n, 2) str
Blob of entities not uppercased
blocks : None or dict
Blocks referenced by INSERT entities
return_name : bool
If True return the first '2' value
Returns
----------
"""
if blob_raw is None:
blob_raw = blob
def info(e):
"""
Pull metadata based on group code, and return as a dict.
"""
# which keys should we extract from the entity data
# DXF group code : our metadata key
get = {"8": "layer", "2": "name"}
# replace group codes with names and only
# take info from the entity dict if it is in cand
renamed = {get[k]: util.make_sequence(v)[0] for k, v in e.items() if k in get}
return renamed
def convert_line(e):
"""
Convert DXF LINE entities into trimesh Line entities.
"""
# create a single Line entity
entities.append(Line(points=len(vertices) + np.arange(2), **info(e)))
# add the vertices to our collection
vertices.extend(
np.array([[e["10"], e["20"]], [e["11"], e["21"]]], dtype=np.float64)
)
def convert_circle(e):
"""
Convert DXF CIRCLE entities into trimesh Circle entities
"""
R = float(e["40"])
C = np.array([e["10"], e["20"]]).astype(np.float64)
points = to_threepoint(center=C[:2], radius=R)
entities.append(
Arc(points=(len(vertices) + np.arange(3)), closed=True, **info(e))
)
vertices.extend(points)
def convert_arc(e):
"""
Convert DXF ARC entities into into trimesh Arc entities.
"""
# the radius of the circle
R = float(e["40"])
# the center point of the circle
C = np.array([e["10"], e["20"]], dtype=np.float64)
# the start and end angle of the arc, in degrees
# this may depend on an AUNITS header data
A = np.radians(np.array([e["50"], e["51"]], dtype=np.float64))
# convert center/radius/angle representation
# to three points on the arc representation
points = to_threepoint(center=C[:2], radius=R, angles=A)
# add a single Arc entity
entities.append(Arc(points=len(vertices) + np.arange(3), closed=False, **info(e)))
# add the three vertices
vertices.extend(points)
def convert_polyline(e):
"""
Convert DXF LWPOLYLINE entities into trimesh Line entities.
"""
# load the points in the line
lines = np.column_stack((e["10"], e["20"])).astype(np.float64)
# save entity info so we don't have to recompute
polyinfo = info(e)
# 70 is the closed flag for polylines
# if the closed flag is set make sure to close
is_closed = "70" in e and int(e["70"][0]) & 1
if is_closed:
lines = np.vstack((lines, lines[:1]))
# 42 is the vertex bulge flag for LWPOLYLINE entities
# "bulge" is autocad for "add a stupid arc using flags
# in my otherwise normal polygon", it's like SVG arc
# flags but somehow even more annoying
if "42" in e:
# get the actual bulge float values
bulge = np.array(e["42"], dtype=np.float64)
# what position were vertices stored at
vid = np.nonzero(chunk[:, 0] == "10")[0]
# what position were bulges stored at in the chunk
bid = np.nonzero(chunk[:, 0] == "42")[0]
# filter out endpoint bulge if we're not closed
if not is_closed:
bid_ok = bid < vid.max()
bid = bid[bid_ok]
bulge = bulge[bid_ok]
# which vertex index is bulge value associated with
bulge_idx = np.searchsorted(vid, bid)
# convert stupid bulge to Line/Arc entities
v, e = bulge_to_arcs(
lines=lines, bulge=bulge, bulge_idx=bulge_idx, is_closed=is_closed
)
for i in e:
# offset added entities by current vertices length
i.points += len(vertices)
vertices.extend(v)
entities.extend(e)
# done with this polyline
return
# we have a normal polyline so just add it
# as single line entity and vertices
entities.append(Line(points=np.arange(len(lines)) + len(vertices), **polyinfo))
vertices.extend(lines)
def convert_bspline(e):
"""
Convert DXF Spline entities into trimesh BSpline entities.
"""
# in the DXF there are n points and n ordered fields
# with the same group code
points = np.column_stack((e["10"], e["20"])).astype(np.float64)
knots = np.array(e["40"]).astype(np.float64)
# if there are only two points, save it as a line
if len(points) == 2:
# create a single Line entity
entities.append(Line(points=len(vertices) + np.arange(2), **info(e)))
# add the vertices to our collection
vertices.extend(points)
return
# check bit coded flag for closed
# closed = bool(int(e['70'][0]) & 1)
# check euclidean distance to see if closed
closed = np.linalg.norm(points[0] - points[-1]) < tol.merge
# create a BSpline entity
entities.append(
BSpline(
points=np.arange(len(points)) + len(vertices),
knots=knots,
closed=closed,
**info(e),
)
)
# add the vertices
vertices.extend(points)
def convert_text(e):
"""
Convert a DXF TEXT entity into a native text entity.
"""
# text with leading and trailing whitespace removed
text = e["1"].strip()
# try getting optional height of text
try:
height = float(e["40"])
except BaseException:
height = None
try:
# rotation angle converted to radians
angle = np.radians(float(e["50"]))
except BaseException:
# otherwise no rotation
angle = 0.0
# origin point
origin = np.array([e["10"], e["20"]], dtype=np.float64)
# an origin-relative point (so transforms work)
vector = origin + [np.cos(angle), np.sin(angle)]
# try to extract a (horizontal, vertical) text alignment
align = ["center", "center"]
try:
align[0] = ["left", "center", "right"][int(e["72"])]
except BaseException:
pass
# append the entity
entities.append(
Text(
origin=len(vertices),
vector=len(vertices) + 1,
height=height,
text=text,
align=align,
)
)
# append the text origin and direction
vertices.append(origin)
vertices.append(vector)
def convert_insert(e):
"""
Convert an INSERT entity, which inserts a named group of
entities (i.e. a "BLOCK") at a specific location.
"""
if blocks is None:
return
# name of block to insert
name = e["2"]
# if we haven't loaded the block skip
if name not in blocks:
return
# angle to rotate the block by
angle = float(e.get("50", 0.0))
# the insertion point of the block
offset = np.array([e.get("10", 0.0), e.get("20", 0.0)], dtype=np.float64)
# what to scale the block by
scale = np.array([e.get("41", 1.0), e.get("42", 1.0)], dtype=np.float64)
# the current entities and vertices of the referenced block.
cv, ce = blocks[name]
for i in ce:
# copy the referenced entity as it may be included multiple times
entities.append(i.copy())
# offset its vertices to the current index
entities[-1].points += len(vertices)
# transform the block's vertices based on the entity settings
vertices.extend(
tf.transform_points(
cv, tf.planar_matrix(offset=offset, theta=np.radians(angle), scale=scale)
)
)
# find the start points of entities
# DXF object to trimesh object converters
loaders = {
"LINE": (dict, convert_line),
"LWPOLYLINE": (multi_dict, convert_polyline),
"ARC": (dict, convert_arc),
"CIRCLE": (dict, convert_circle),
"SPLINE": (multi_dict, convert_bspline),
"INSERT": (dict, convert_insert),
"BLOCK": (dict, convert_insert),
}
# store loaded vertices
vertices = []
# store loaded entities
entities = []
# an old-style polyline entity strings its data across
# multiple vertex entities like a real asshole
polyline = None
# chunks of entities are divided by group-code-0
inflection = np.nonzero(blob[:, 0] == "0")[0]
unsupported = defaultdict(lambda: 0)
# loop through chunks of entity information
for index in np.array_split(np.arange(len(blob)), inflection):
# if there is only a header continue
if len(index) < 1:
continue
# chunk will be an (n, 2) array of (group code, data) pairs
chunk = blob[index]
# the string representing entity type
entity_type = chunk[0][1]
# if we are referencing a block or insert by name make
# sure the name key is in the original case vs upper-case
if entity_type in ("BLOCK", "INSERT"):
try:
index_name = next(i for i, v in enumerate(chunk) if v[0] == "2")
chunk[index_name][1] = blob_raw[index][index_name][1]
except StopIteration:
pass
# special case old- style polyline entities
if entity_type == "POLYLINE":
polyline = [dict(chunk)]
# if we are collecting vertex entities
elif polyline is not None and entity_type == "VERTEX":
polyline.append(dict(chunk))
# the end of a polyline
elif polyline is not None and entity_type == "SEQEND":
# pull the geometry information for the entity
lines = np.array([[i["10"], i["20"]] for i in polyline[1:]], dtype=np.float64)
is_closed = False
# check for a closed flag on the polyline
if "70" in polyline[0]:
# flag is bit- coded integer
flag = int(polyline[0]["70"])
# first bit represents closed
is_closed = bool(flag & 1)
if is_closed:
lines = np.vstack((lines, lines[:1]))
# get the index of each bulged vertices
bulge_idx = np.array(
[i for i, e in enumerate(polyline) if "42" in e], dtype=np.int64
)
# get the actual bulge value
bulge = np.array(
[float(e["42"]) for i, e in enumerate(polyline) if "42" in e],
dtype=np.float64,
)
# convert bulge to new entities
cv, ce = bulge_to_arcs(
lines=lines, bulge=bulge, bulge_idx=bulge_idx, is_closed=is_closed
)
for i in ce:
# offset entities by existing vertices
i.points += len(vertices)
vertices.extend(cv)
entities.extend(ce)
# we no longer have an active polyline
polyline = None
elif entity_type == "TEXT":
# text entities need spaces preserved so take
# group codes from clean representation (0- column)
# and data from the raw representation (1- column)
chunk_raw = blob_raw[index]
# if we didn't use clean group codes we wouldn't
# be able to access them by key as whitespace
# is random and crazy, like: ' 1 '
chunk_raw[:, 0] = blob[index][:, 0]
try:
convert_text(dict(chunk_raw))
except BaseException:
log.debug("failed to load text entity!", exc_info=True)
# if the entity contains all relevant data we can
# cleanly load it from inside a single function
elif entity_type in loaders:
# the chunker converts an (n,2) list into a dict
chunker, loader = loaders[entity_type]
# convert data to dict
entity_data = chunker(chunk)
# append data to the lists we're collecting
loader(entity_data)
elif entity_type != "ENTITIES":
unsupported[entity_type] += 1
if len(unsupported) > 0:
log.debug(
"skipping dxf entities: {}".format(
", ".join(f"{k}: {v}" for k, v in unsupported.items())
)
)
# stack vertices into single array
vertices = util.vstack_empty(vertices).astype(np.float64)
if return_name:
name = blob_raw[blob[:, 0] == "2"][0][1]
return vertices, entities, name
return vertices, entities
def export_dxf(path, only_layers=None):
"""
Export a 2D path object to a DXF file.
Parameters
----------
path : trimesh.path.path.Path2D
Input geometry to export
only_layers : None or set
If passed only export the layers specified
Returns
----------
export : str
Path formatted as a DXF file
"""
# get the template for exporting DXF files
template = resources.get_json("templates/dxf.json")
def format_points(points, as_2D=False, increment=True):
"""
Format points into DXF- style point string.
Parameters
-----------
points : (n,2) or (n,3) float
Points in space
as_2D : bool
If True only output 2 points per vertex
increment : bool
If True increment group code per point
Example:
[[X0, Y0, Z0], [X1, Y1, Z1]]
Result, new lines replaced with spaces:
True -> 10 X0 20 Y0 30 Z0 11 X1 21 Y1 31 Z1
False -> 10 X0 20 Y0 30 Z0 10 X1 20 Y1 30 Z1
Returns
-----------
packed : str
Points formatted with group code
"""
points = np.asanyarray(points, dtype=np.float64)
# get points in 3D
three = util.stack_3D(points)
if increment:
group = np.tile(
np.arange(len(three), dtype=np.int64).reshape((-1, 1)), (1, 3)
)
else:
group = np.zeros((len(three), 3), dtype=np.int64)
group += [10, 20, 30]
if as_2D:
group = group[:, :2]
three = three[:, :2]
# join into result string
packed = "\n".join(
f"{g:d}\n{v:.12g}" for g, v in zip(group.reshape(-1), three.reshape(-1))
)
return packed
def entity_info(entity):
"""
Pull layer, color, and name information about an entity
Parameters
-----------
entity : entity object
Source entity to pull metadata
Returns
----------
subs : dict
Has keys 'COLOR', 'LAYER', 'NAME'
"""
# TODO : convert RGBA entity.color to index
subs = {
"COLOR": 255, # default is ByLayer
"LAYER": 0,
"NAME": str(id(entity))[:16],
}
if hasattr(entity, "layer"):
# make sure layer name is forced into ASCII
subs["LAYER"] = util.to_ascii(entity.layer)
return subs
def convert_line(line, vertices):
"""
Convert an entity to a discrete polyline
Parameters
-------------
line : entity
Entity which has 'e.discrete' method
vertices : (n, 2) float
Vertices in space
Returns
-----------
as_dxf : str
Entity exported as a DXF
"""
# get a discrete representation of entity
points = line.discrete(vertices)
# if one or fewer points return nothing
if len(points) <= 1:
return ""
# generate a substitution dictionary for template
subs = entity_info(line)
subs["POINTS"] = format_points(points, as_2D=True, increment=False)
subs["TYPE"] = "LWPOLYLINE"
subs["VCOUNT"] = len(points)
# 1 is closed
# 0 is default (open)
subs["FLAG"] = int(bool(line.closed))
result = template["line"].format(**subs)
return result
def convert_arc(arc, vertices):
# get the center of arc and include span angles
info = arc.center(vertices, return_angle=True, return_normal=False)
subs = entity_info(arc)
center = info.center
if len(center) == 2:
center = np.append(center, 0.0)
data = "10\n{:.12g}\n20\n{:.12g}\n30\n{:.12g}".format(*center)
data += f"\n40\n{info.radius:.12g}"
if arc.closed:
subs["TYPE"] = "CIRCLE"
else:
subs["TYPE"] = "ARC"
# an arc is the same as a circle, with an added start
# and end angle field
data += "\n100\nAcDbArc"
data += "\n50\n{:.12g}\n51\n{:.12g}".format(*np.degrees(info.angles))
subs["DATA"] = data
result = template["arc"].format(**subs)
return result
def convert_bspline(spline, vertices):
# points formatted with group code
points = format_points(vertices[spline.points], increment=False)
# (n,) float knots, formatted with group code
knots = ("40\n{:.12g}\n" * len(spline.knots)).format(*spline.knots)[:-1]
# bit coded
flags = {"closed": 1, "periodic": 2, "rational": 4, "planar": 8, "linear": 16}
flag = flags["planar"]
if spline.closed:
flag = flag | flags["closed"]
normal = [0.0, 0.0, 1.0]
n_code = [210, 220, 230]
n_str = "\n".join(f"{i:d}\n{j:.12g}" for i, j in zip(n_code, normal))
subs = entity_info(spline)
subs.update(
{
"TYPE": "SPLINE",
"POINTS": points,
"KNOTS": knots,
"NORMAL": n_str,
"DEGREE": 3,
"FLAG": flag,
"FCOUNT": 0,
"KCOUNT": len(spline.knots),
"PCOUNT": len(spline.points),
}
)
# format into string template
result = template["bspline"].format(**subs)
return result
def convert_text(txt, vertices):
"""
Convert a Text entity to DXF string.
"""
# start with layer info
sub = entity_info(txt)
# get the origin point of the text
sub["ORIGIN"] = format_points(vertices[[txt.origin]], increment=False)
# rotation angle in degrees
sub["ANGLE"] = np.degrees(txt.angle(vertices))
# actual string of text with spaces escaped
# force into ASCII to avoid weird encoding issues
sub["TEXT"] = (
txt.text.replace(" ", _SAFESPACE)
.encode("ascii", errors="ignore")
.decode("ascii")
)
# height of text
sub["HEIGHT"] = txt.height
result = template["text"].format(**sub)
return result
def convert_generic(entity, vertices):
"""
For entities we don't know how to handle, return their
discrete form as a polyline
"""
return convert_line(entity, vertices)
# make sure we're not losing a ton of
# precision in the string conversion
np.set_printoptions(precision=12)
# trimesh entity to DXF entity converters
conversions = {
"Line": convert_line,
"Text": convert_text,
"Arc": convert_arc,
"Bezier": convert_generic,
"BSpline": convert_bspline,
}
collected = []
for e, layer in zip(path.entities, path.layers):
name = type(e).__name__
# only export specified layers
if only_layers is not None and layer not in only_layers:
continue
if name in conversions:
converted = conversions[name](e, path.vertices).strip()
if len(converted) > 0:
# only save if we converted something
collected.append(converted)
else:
log.debug("Entity type %s not exported!", name)
# join all entities into one string
entities_str = "\n".join(collected)
# add in the extents of the document as explicit XYZ lines
hsub = {f"EXTMIN_{k}": v for k, v in zip("XYZ", np.append(path.bounds[0], 0.0))}
hsub.update({f"EXTMAX_{k}": v for k, v in zip("XYZ", np.append(path.bounds[1], 0.0))})
# apply a units flag defaulting to `1`
hsub["LUNITS"] = _UNITS_TO_DXF.get(path.units, 1)
# run the format for the header
sections = [template["header"].format(**hsub).strip()]
# do the same for entities
sections.append(template["entities"].format(ENTITIES=entities_str).strip())
# and the footer
sections.append(template["footer"].strip())
# filter out empty sections
# random whitespace causes AutoCAD to fail to load
# although Draftsight, LibreCAD, and Inkscape don't care
# what a giant legacy piece of shit
# create the joined string blob
blob = "\n".join(sections).replace(_SAFESPACE, " ")
# run additional self- checks
if tol.strict:
# check that every line pair is (group code, value)
lines = str.splitlines(str(blob))
# should be even number of lines
assert (len(lines) % 2) == 0
# group codes should all be convertible to int and positive
assert all(int(i) >= 0 for i in lines[::2])
# make sure we didn't slip any unicode in there
blob.encode("ascii")
return blob
def bulge_to_arcs(lines, bulge, bulge_idx, is_closed=False, metadata=None):
"""
Polylines can have "vertex bulge" which means the polyline
has an arc tangent to segments, rather than meeting at a
vertex.
From Autodesk reference:
The bulge is the tangent of one fourth the included
angle for an arc segment, made negative if the arc
goes clockwise from the start point to the endpoint.
A bulge of 0 indicates a straight segment, and a
bulge of 1 is a semicircle.
Parameters
----------------
lines : (n, 2) float
Polyline vertices in order
bulge : (m,) float
Vertex bulge value
bulge_idx : (m,) float
Which index of lines is bulge associated with
is_closed : bool
Is segment closed
metadata : None, or dict
Entity metadata to add
Returns
---------------
vertices : (a, 2) float
New vertices for poly-arc
entities : (b,) entities.Entity
New entities, either line or arc
"""
# make sure lines are 2D array
lines = np.asanyarray(lines, dtype=np.float64)
# make sure inputs are numpy arrays
bulge = np.asanyarray(bulge, dtype=np.float64)
bulge_idx = np.asanyarray(bulge_idx, dtype=np.int64)
# filter out zero- bulged polylines
ok = np.abs(bulge) > 1e-5
bulge = bulge[ok]
bulge_idx = bulge_idx[ok]
# metadata to apply to new entities
if metadata is None:
metadata = {}
# if there's no bulge, just return the input curve
if len(bulge) == 0:
index = np.arange(len(lines))
# add a single line entity and vertices
entities = [Line(index, **metadata)]
return lines, entities
# use bulge to calculate included angle of the arc
angle = np.arctan(bulge) * 4.0
# the indexes making up a bulged segment
tid = np.column_stack((bulge_idx, bulge_idx - 1))
# if it's a closed segment modulus to start vertex
if is_closed:
tid %= len(lines)
# the vector connecting the two ends of the arc
vector = lines[tid[:, 0]] - lines[tid[:, 1]]
# the length of the connector segment
length = np.linalg.norm(vector, axis=1)
# perpendicular vectors by crossing vector with Z
perp = np.cross(
np.column_stack((vector, np.zeros(len(vector)))),
np.ones((len(vector), 3)) * [0, 0, 1],
)
# strip the zero Z
perp = util.unitize(perp[:, :2])
# midpoint of each line
midpoint = lines[tid].mean(axis=1)
# calculate the signed radius of each arc segment
radius = (length / 2.0) / np.sin(angle / 2.0)
# offset magnitude to point on arc
offset = radius - np.cos(angle / 2) * radius
# convert each arc to three points:
# start, any point on arc, end
three = np.column_stack(
(lines[tid[:, 0]], midpoint + perp * offset.reshape((-1, 1)), lines[tid[:, 1]])
).reshape((-1, 3, 2))
# if we're in strict mode make sure our arcs
# have the same magnitude as the input data
if tol.strict:
from ..arc import arc_center
check_angle = [arc_center(i).span for i in three]
assert np.allclose(np.abs(angle), np.abs(check_angle))
check_radii = [arc_center(i).radius for i in three]
assert np.allclose(check_radii, np.abs(radius))
# collect new entities and vertices
entities, vertices = [], []
# add the entities for each new arc
for arc_points in three:
entities.append(Arc(points=np.arange(3) + len(vertices), **metadata))
vertices.extend(arc_points)
# if there are unconsumed line
# segments add them to drawing
if (len(lines) - 1) > len(bulge):
# indexes of line segments
existing = util.stack_lines(np.arange(len(lines)))
# remove line segments replaced with arcs
for line_idx in grouping.boolean_rows(
existing, np.sort(tid, axis=1), np.setdiff1d
):
# add a single line entity and vertices
entities.append(Line(points=np.arange(2) + len(vertices), **metadata))
vertices.extend(lines[line_idx].copy())
# make sure vertices are clean numpy array
vertices = np.array(vertices, dtype=np.float64)
return vertices, entities
def get_key(blob, field, code):
"""
Given a loaded (n, 2) blob and a field name
get a value by code.
"""
try:
line = blob[np.nonzero(blob[:, 1] == field)[0][0] + 1]
except IndexError:
return None
if line[0] == code:
try:
return int(line[1])
except ValueError:
return line[1]
else:
return None
# store the loaders we have available
_dxf_loaders = {"dxf": load_dxf}
@@ -0,0 +1,82 @@
import os
from ... import util
from ...exchange import ply
from . import dxf, svg_io
def export_path(path, file_type=None, file_obj=None, **kwargs):
"""
Export a Path object to a file- like object, or to a filename
Parameters
---------
file_obj: None, str, or file object
A filename string or a file-like object
file_type: None or str
File type, e.g.: 'svg', 'dxf'
kwargs : passed to loader
Returns
---------
exported : str or bytes
Data exported
"""
# if file object is a string it is probably a file path
# so we can split the extension to set the file type
if isinstance(file_obj, str):
file_type = util.split_extension(file_obj)
# run the export
export = _path_exporters[file_type](path, **kwargs)
# if we've been passed files write the data
_write_export(export=export, file_obj=file_obj)
return export
def export_dict(path):
"""
Export a path as a dict of kwargs for the Path constructor.
"""
export_entities = [e.to_dict() for e in path.entities]
export_object = {"entities": export_entities, "vertices": path.vertices.tolist()}
return export_object
def _write_export(export, file_obj=None):
"""
Write a string to a file.
If file_obj isn't specified, return the string
Parameters
---------
export: a string of the export data
file_obj: a file-like object or a filename
"""
if file_obj is None:
return export
if hasattr(file_obj, "write"):
out_file = file_obj
else:
# expand user and relative paths
file_path = os.path.abspath(os.path.expanduser(file_obj))
out_file = open(file_path, "wb")
try:
out_file.write(export)
except TypeError:
out_file.write(export.encode("utf-8"))
out_file.close()
return export
_path_exporters = {
"dxf": dxf.export_dxf,
"svg": svg_io.export_svg,
"ply": ply.export_ply,
"dict": export_dict,
}
@@ -0,0 +1,92 @@
from ... import util
from ...exceptions import ExceptionWrapper
from ...exchange.ply import load_ply
from ...typed import Optional, Set
from ..path import Path
from . import misc
from .dxf import _dxf_loaders
from .svg_io import _svg_loaders
def load_path(file_obj, file_type: Optional[str] = None, **kwargs):
"""
Load a file to a Path file_object.
Parameters
-----------
file_obj
Accepts many types:
- Path, Path2D, or Path3D file_objects
- open file file_object (dxf or svg)
- file name (dxf or svg)
- shapely.geometry.Polygon
- shapely.geometry.MultiLineString
- dict with kwargs for Path constructor
- `(n, 2, (2|3)) float` line segments
file_type
Type of file is required if file
object is passed.
Returns
---------
path : Path, Path2D, Path3D file_object
Data as a native trimesh Path file_object
"""
# avoid a circular import
from ...exchange.load import _load_kwargs, _parse_file_args
arg = _parse_file_args(file_obj=file_obj, file_type=file_type, **kwargs)
if isinstance(file_obj, Path):
# we have been passed a file object that is already a loaded
# trimesh.path.Path object so do nothing and return
return file_obj
elif util.is_file(arg.file_obj):
if arg.file_type in path_loaders:
kwargs.update(
path_loaders[arg.file_type](
file_obj=arg.file_obj, file_type=arg.file_type
)
)
elif arg.file_type == "ply":
# we cannot register this exporter to path_loaders since
# this is already reserved by Trimesh in ply format in trimesh.load()
kwargs.update(load_ply(file_obj=arg.file_obj, file_type=arg.file_type))
elif util.is_instance_named(file_obj, ["Polygon", "MultiPolygon"]):
# convert from shapely polygons to Path2D
kwargs.update(misc.polygon_to_path(file_obj))
elif util.is_instance_named(file_obj, "MultiLineString"):
# convert from shapely LineStrings to Path2D
kwargs.update(misc.linestrings_to_path(file_obj))
elif isinstance(file_obj, dict):
# load as kwargs
kwargs = file_obj
elif util.is_sequence(file_obj):
# load as lines in space
kwargs.update(misc.lines_to_path(file_obj))
else:
raise ValueError("Not a supported object type!")
# actually load
result = _load_kwargs(kwargs)
result._source = arg
return result
def path_formats() -> Set[str]:
"""
Get a list of supported path formats.
Returns
------------
loaders
Extensions of loadable formats, i.e. {'svg', 'dxf'}
"""
return {k for k, v in path_loaders.items() if not isinstance(v, ExceptionWrapper)}
path_loaders = {}
path_loaders.update(_svg_loaders)
path_loaders.update(_dxf_loaders)
@@ -0,0 +1,221 @@
import numpy as np
from ... import graph, grouping, util
from ...constants import tol_path
from ...typed import ArrayLike, Dict, NDArray, Optional
from ..entities import Arc, Line
def dict_to_path(as_dict):
"""
Turn a pure dict into a dict containing entity objects that
can be sent directly to a Path constructor.
Parameters
------------
as_dict : dict
Has keys: 'vertices', 'entities'
Returns
------------
kwargs : dict
Has keys: 'vertices', 'entities'
"""
# start kwargs with initial value
result = as_dict.copy()
# map of constructors
loaders = {"Arc": Arc, "Line": Line}
# pre- allocate entity array
entities = [None] * len(as_dict["entities"])
# run constructor for dict kwargs
for entity_index, entity in enumerate(as_dict["entities"]):
if entity["type"] == "Line":
entities[entity_index] = loaders[entity["type"]](points=entity["points"])
else:
entities[entity_index] = loaders[entity["type"]](
points=entity["points"], closed=entity["closed"]
)
result["entities"] = entities
return result
def lines_to_path(lines: ArrayLike, index: Optional[NDArray[np.int64]] = None) -> Dict:
"""
Turn line segments into argument to be used for a Path2D or Path3D.
Parameters
------------
lines : (n, 2, dimension) or (n, dimension) float
Line segments or connected polyline curve in 2D or 3D
index : (n,) int64
If passed save an index for each line segment.
Returns
-----------
kwargs : Dict
kwargs for Path constructor
"""
lines = np.asanyarray(lines, dtype=np.float64)
if index is not None:
index = np.asanyarray(index, dtype=np.int64)
if util.is_shape(lines, (-1, (2, 3))):
# the case where we have a list of points
# we are going to assume they are connected
result = {"entities": np.array([Line(np.arange(len(lines)))]), "vertices": lines}
return result
elif util.is_shape(lines, (-1, 2, (2, 3))):
# case where we have line segments in 2D or 3D
dimension = lines.shape[-1]
# convert lines to even number of (n, dimension) points
lines = lines.reshape((-1, dimension))
# merge duplicate vertices
unique, inverse = grouping.unique_rows(lines, digits=tol_path.merge_digits)
# use scipy edges_to_path to skip creating
# a bajillion individual line entities which
# will be super slow vs. fewer polyline entities
return edges_to_path(edges=inverse.reshape((-1, 2)), vertices=lines[unique])
else:
raise ValueError("Lines must be (n,(2|3)) or (n,2,(2|3))")
return result
def polygon_to_path(polygon):
"""
Load shapely Polygon objects into a trimesh.path.Path2D object
Parameters
-------------
polygon : shapely.geometry.Polygon
Input geometry
Returns
-----------
kwargs : dict
Keyword arguments for Path2D constructor
"""
# start with a single polyline for the exterior
entities = []
# start vertices
vertices = []
if hasattr(polygon.boundary, "geoms"):
boundaries = polygon.boundary.geoms
else:
boundaries = [polygon.boundary]
# append interiors as single Line objects
current = 0
for boundary in boundaries:
entities.append(Line(np.arange(len(boundary.coords)) + current))
current += len(boundary.coords)
# append the new vertex array
vertices.append(np.array(boundary.coords))
# make sure result arrays are numpy
kwargs = {
"entities": entities,
"vertices": np.vstack(vertices) if len(vertices) > 0 else vertices,
}
return kwargs
def linestrings_to_path(multi) -> Dict:
"""
Load shapely LineString objects into arguments to create a Path2D or Path3D.
Parameters
-------------
multi : shapely.geometry.LineString or MultiLineString
Input 2D or 3D geometry
Returns
-------------
kwargs : Dict
Keyword arguments for Path2D or Path3D constructor
"""
import shapely
# append to result as we go
entities = []
vertices = []
if isinstance(multi, shapely.MultiLineString):
multi = list(multi.geoms)
else:
multi = [multi]
for line in multi:
# only append geometry with points
if hasattr(line, "coords"):
coords = np.array(line.coords)
if len(coords) < 2:
continue
entities.append(Line(np.arange(len(coords)) + len(vertices)))
vertices.extend(coords)
kwargs = {"entities": np.array(entities), "vertices": np.array(vertices)}
return kwargs
def faces_to_path(mesh, face_ids=None, **kwargs):
"""
Given a mesh and face indices find the outline edges and
turn them into a Path3D.
Parameters
------------
mesh : trimesh.Trimesh
Triangulated surface in 3D
face_ids : (n,) int
Indexes referencing mesh.faces
Returns
---------
kwargs : dict
Kwargs for Path3D constructor
"""
if face_ids is None:
edges = mesh.edges_sorted
else:
# take advantage of edge ordering to index as single row
edges = mesh.edges_sorted.reshape((-1, 6))[face_ids].reshape((-1, 2))
# an edge which occurs onely once is on the boundary
unique_edges = grouping.group_rows(edges, require_count=1)
# add edges and vertices to kwargs
kwargs.update(edges_to_path(edges=edges[unique_edges], vertices=mesh.vertices))
return kwargs
def edges_to_path(edges: ArrayLike, vertices: ArrayLike, **kwargs) -> Dict:
"""
Given an edge list of indices and associated vertices
representing lines, generate kwargs for a Path object.
Parameters
-----------
edges : (n, 2) int
Vertex indices of line segments
vertices : (m, dimension) float
Vertex positions where dimension is 2 or 3
Returns
----------
kwargs : dict
Kwargs for Path constructor
"""
# sequence of ordered traversals
dfs = graph.traversals(edges, mode="dfs")
# make sure every consecutive index in DFS
# traversal is an edge in the source edge list
dfs_connected = graph.fill_traversals(dfs, edges=edges)
# kwargs for Path constructor
# turn traversals into Line objects
lines = [Line(d) for d in dfs_connected]
kwargs.update({"entities": lines, "vertices": vertices, "process": False})
return kwargs
@@ -0,0 +1,804 @@
import base64
import json
from collections import defaultdict, deque
from copy import deepcopy
import numpy as np
from ... import exceptions, grouping, resources, util
from ...constants import log, tol
from ...transformations import planar_matrix, transform_points
from ...typed import Dict, Iterable, Mapping, NDArray, Number
from ...util import jsonify
from ..arc import arc_center, to_threepoint
from ..entities import Arc, Bezier, Line
# store any additional properties using a trimesh namespace
_ns_name = "trimesh"
_ns_url = "https://github.com/mikedh/trimesh"
_ns = f"{{{_ns_url}}}"
_IDENTITY = np.eye(3)
_IDENTITY.flags["WRITEABLE"] = False
def svg_to_path(file_obj=None, file_type=None, path_string=None):
"""
Load an SVG file into a Path2D object.
Parameters
-----------
file_obj : open file object
Contains SVG data
file_type: None
Not used
path_string : None or str
If passed, parse a single path string and ignore `file_obj`.
Returns
-----------
loaded : dict
With kwargs for Path2D constructor
"""
force = None
tree = None
paths = []
shapes = []
if file_obj is not None:
# first parse the XML
tree = etree.fromstring(file_obj.read())
# store paths and transforms as
# (path string, 3x3 matrix)
for element in tree.iter("{*}path"):
# store every path element attributes and transform
paths.append((element.attrib, element_transform(element)))
# now try converting shapes
for shape in tree.iter(
("{*}circle", "{*}rect", "{*}line", "{*}polyline", "{*}polygon")
):
shapes.append(
(shape.tag.rsplit("}", 1)[-1], shape.attrib, element_transform(shape))
)
try:
# see if the SVG should be reproduced as a scene
force = tree.attrib[_ns + "class"]
except BaseException:
pass
elif path_string is not None:
# parse a single SVG path string
paths.append(({"d": path_string}, _IDENTITY))
else:
raise ValueError("`file_obj` or `pathstring` required")
result = _svg_path_convert(paths=paths, shapes=shapes, force=force)
try:
if tree is not None:
# get overall metadata from JSON string if it exists
result["metadata"] = _decode(tree.attrib[_ns + "metadata"])
except KeyError:
# not in the trimesh ns
pass
except BaseException:
# no metadata stored with trimesh ns
log.debug("failed metadata", exc_info=True)
# if the result is a scene try to get the metadata
# for each subgeometry here
if "geometry" in result:
try:
# get per-geometry metadata if available
bag = _decode(tree.attrib[_ns + "metadata_geometry"])
for name, meta in bag.items():
if name in result["geometry"]:
# assign this metadata to the geometry
result["geometry"][name]["metadata"] = meta
except KeyError:
# no stored geometry metadata so ignore
pass
except BaseException:
# failed to load existing metadata
log.debug("failed metadata", exc_info=True)
return result
def _attrib_metadata(attrib: Mapping) -> Dict:
try:
# try to retrieve any trimesh attributes as metadata
return {
k.lstrip(_ns): _decode(v)
for k, v in attrib.items()
if k[1:].startswith(_ns_url)
}
except BaseException:
return {}
def element_transform(element, max_depth=10):
"""
Find a transformation matrix for an XML element.
Parameters
--------------
e : lxml.etree.Element
Element to search upwards from.
max_depth : int
Maximum depth to search for transforms.
"""
matrices = deque()
# start at the passed element
current = element
for _ in range(max_depth):
# get the transforms from a particular element
if "transform" in current.attrib:
matrices.extendleft(transform_to_matrices(current.attrib["transform"])[::-1])
current = current.getparent()
if current is None:
break
if len(matrices) == 0:
# no transforms is an identity matrix
return _IDENTITY
elif len(matrices) == 1:
return matrices[0]
else:
# evaluate the transforms in the order they were passed
# as this is what the SVG spec says you should do
return util.multi_dot(matrices)
def transform_to_matrices(transform: str) -> NDArray[np.float64]:
"""
Convert an SVG transform string to an array of matrices.
i.e. "rotate(-10 50 100)
translate(-36 45.5)
skewX(40)
scale(1 0.5)"
Parameters
-----------
transform : str
Contains transformation information in SVG form
Returns
-----------
matrices : (n, 3, 3) float
Multiple transformation matrices from input transform string
"""
# split the transform string in to components of:
# (operation, args) i.e. (translate, '-1.0, 2.0')
components = [
[j.strip() for j in i.strip().split("(") if len(j) > 0]
for i in transform.lower().split(")")
if len(i) > 0
]
# store each matrix without dotting
matrices = []
for line in components:
if len(line) == 0:
continue
elif len(line) != 2:
raise ValueError("should always have two components!")
key, args = line
# convert string args to array of floats
# support either comma or space delimiter
values = np.array([float(i) for i in args.replace(",", " ").split()])
if key == "translate":
# convert translation to a (3, 3) homogeneous matrix
matrices.append(_IDENTITY.copy())
matrices[-1][:2, 2] = values
elif key == "matrix":
# [a b c d e f] ->
# [[a c e],
# [b d f],
# [0 0 1]]
matrices.append(np.vstack((values.reshape((3, 2)).T, [0, 0, 1])))
elif key == "rotate":
# SVG rotations are in degrees
angle = np.degrees(values[0])
# if there are three values rotate around point
if len(values) == 3:
point = values[1:]
else:
point = None
matrices.append(planar_matrix(theta=angle, point=point))
elif key == "scale":
# supports (x_scale, y_scale) or (scale)
mat = _IDENTITY.copy()
mat[:2, :2] *= values
matrices.append(mat)
else:
log.debug(f"unknown SVG transform: {key}")
return np.array(matrices, dtype=np.float64)
def _svg_path_convert(paths: Iterable, shapes: Iterable, force=None):
"""
Convert an SVG path string into a Path2D object
Parameters
-------------
paths: list of tuples
Containing (path string, (3, 3) matrix, metadata)
Returns
-------------
drawing : dict
Kwargs for Path2D constructor
"""
def complex_to_float(values):
return np.array([[i.real, i.imag] for i in values], dtype=np.float64)
def load_multi(multi):
# load a previously parsed multiline
# start the count where indicated
start = counts[name]
# end at the block of our new points
end = start + len(multi.points)
return (Line(points=np.arange(start, end)), multi.points)
def load_arc(svg_arc):
# load an SVG arc into a trimesh arc
points = complex_to_float([svg_arc.start, svg_arc.point(0.5), svg_arc.end])
# create an arc from the now numpy points
arc = Arc(
points=np.arange(3) + counts[name],
# we may have monkey-patched the entity to
# indicate that it is a closed circle
closed=getattr(svg_arc, "closed", False),
)
return arc, points
def load_quadratic(svg_quadratic):
# load a quadratic bezier spline
points = complex_to_float(
[svg_quadratic.start, svg_quadratic.control, svg_quadratic.end]
)
return Bezier(points=np.arange(3) + counts[name]), points
def load_cubic(svg_cubic):
# load a cubic bezier spline
points = complex_to_float(
[svg_cubic.start, svg_cubic.control1, svg_cubic.control2, svg_cubic.end]
)
return Bezier(np.arange(4) + counts[name]), points
class MultiLine:
# An object to hold one or multiple Line entities.
def __init__(self, lines):
if tol.strict:
# in unit tests make sure we only have lines
assert all(type(L).__name__ in ("Line", "Close") for L in lines)
# get the starting point of every line
points = [L.start for L in lines]
# append the endpoint
points.append(lines[-1].end)
# convert to (n, 2) float points
self.points = np.array([[i.real, i.imag] for i in points], dtype=np.float64)
# load functions for each entity
loaders = {
"Arc": load_arc,
"MultiLine": load_multi,
"CubicBezier": load_cubic,
"QuadraticBezier": load_quadratic,
}
entities = defaultdict(list)
vertices = defaultdict(list)
counts = defaultdict(lambda: 0)
for attrib, matrix in paths:
# the path string is stored under `d`
path_string = attrib.get("d", "")
if len(path_string) == 0:
log.debug("empty path string!")
continue
# get the name of the geometry if trimesh specified it
# note that the get will by default return `None`
name = _decode(attrib.get(_ns + "name"))
# get parsed entities from svg.path
raw = np.array(list(parse_path(path_string)))
# if there is no path string exit
if len(raw) == 0:
continue
# create an integer code for entities we can combine
kinds_lookup = {"Line": 1, "Close": 1, "Arc": 2}
# get a code for each entity we parsed
kinds = np.array([kinds_lookup.get(type(i).__name__, 0) for i in raw], dtype=int)
# find groups of consecutive entities so we can combine
blocks = grouping.blocks(kinds, min_len=1, only_nonzero=False)
if tol.strict:
# in unit tests make sure we didn't lose any entities
assert util.allclose(np.hstack(blocks), np.arange(len(raw)))
# Combine consecutive entities that can be represented
# more concisely as a single trimesh entity.
parsed = []
for b in blocks:
chunk = raw[b]
current = type(raw[b[0]]).__name__
if current in ("Line", "Close"):
# if entity consists of lines add a multiline
parsed.append(MultiLine(chunk))
elif len(b) > 1 and current == "Arc":
# if we have multiple arcs check to see if they
# actually represent a single closed circle
# get a single array with the relevant arc points
verts = np.array(
[
[
a.start.real,
a.start.imag,
a.end.real,
a.end.imag,
a.center.real,
a.center.imag,
a.radius.real,
a.radius.imag,
a.rotation,
]
for a in chunk
],
dtype=np.float64,
)
# all arcs share the same center radius and rotation
closed = False
if np.ptp(verts[:, 4:], axis=0).mean() < 1e-3:
start, end = verts[:, :2], verts[:, 2:4]
# if every end point matches the start point of a new
# arc that means this is really a closed circle made
# up of multiple arc segments
closed = util.allclose(start, np.roll(end, 1, axis=0))
if closed:
# hot-patch a closed arc flag
chunk[0].closed = True
# all arcs in this block are now represented by one entity
parsed.append(chunk[0])
else:
# we don't have a closed circle so add each
# arc entity individually without combining
parsed.extend(chunk)
else:
# otherwise just add the entities
parsed.extend(chunk)
entity_meta = _attrib_metadata(attrib=attrib)
# loop through parsed entity objects
for svg_entity in parsed:
# keyed by entity class name
type_name = type(svg_entity).__name__
if type_name in loaders:
# get new entities and vertices
e, v = loaders[type_name](svg_entity)
e.metadata.update(entity_meta)
# append them to the result
entities[name].append(e)
# transform the vertices by the matrix and append
vertices[name].append(transform_points(v, matrix))
counts[name] += len(v)
# load simple shape geometry
for kind, attrib, matrix in shapes:
# get the geometry name (defaults to None)
name = _decode(attrib.get(_ns + "name"))
if kind == "circle":
points = to_threepoint(
[float(attrib["cx"]), float(attrib["cy"])], float(attrib["r"])
)
entity = Arc(points=np.arange(3) + counts[name], closed=True)
elif kind == "rect":
# todo : support rounded rectangle
origin = np.array([attrib["x"], attrib["y"]], dtype=np.float64)
w, h = np.array([attrib["width"], attrib["height"]], dtype=np.float64)
points = np.array(
[origin, origin + (w, 0), origin + (w, h), origin + (0, h), origin],
dtype=np.float64,
)
entity = Line(points=np.arange(len(points)) + counts[name])
elif kind == "polyline":
points = np.fromstring(
attrib["points"].strip().replace(",", " "), sep=" ", dtype=np.float64
).reshape((-1, 2))
entity = Line(points=np.arange(len(points)) + counts[name])
elif kind == "polygon":
points = np.fromstring(
attrib["points"].strip().replace(",", " "), sep=" ", dtype=np.float64
).reshape((-1, 2))
# polygon implies forced-closed so check to see if it
# is already closed and if not add the closing index
if (points[0] == points[-1]).all():
index = np.arange(len(points)) + counts[name]
else:
index = np.arange(len(points) + 1) + counts[name]
index[-1] = index[0]
entity = Line(points=index)
elif kind == "line":
points = np.array(
[attrib["x1"], attrib["y1"], attrib["x2"], attrib["y2"]], dtype=np.float64
).reshape((2, 2))
entity = Line(points=np.arange(len(points)) + counts[name])
else:
log.debug(f"unsupported SVG shape: `{kind}`")
continue
entities[name].append(entity)
vertices[name].append(transform_points(points, matrix))
counts[name] += len(points)
if len(vertices) == 0:
return {"vertices": [], "entities": []}
geoms = {
name: {"vertices": np.vstack(v), "entities": entities[name]}
for name, v in vertices.items()
}
if len(geoms) > 1 or force == "Scene":
kwargs = {"geometry": geoms}
else:
# return a single Path2D
kwargs = next(iter(geoms.values()))
return kwargs
def _entities_to_str(entities, vertices, name=None, digits=None, only_layers=None):
"""
Convert the entities of a path to path strings.
Parameters
------------
entities : (n,) list
Entity objects
vertices : (m, 2) float
Vertices entities reference
name : any
Trimesh namespace name to assign to entity
digits : int
Number of digits to format exports into
only_layers : set
Only export these layers if passed
"""
if digits is None:
digits = 13
points = vertices.copy()
# generate a format string with the requested digits
temp_digits = f"0.{int(digits)}f"
# generate a format string for circles as two arc segments
temp_circle = (
"M {x:DI},{y:DI}a{r:DI},{r:DI},0,1,0,{d:DI}," + "0a{r:DI},{r:DI},0,1,0,-{d:DI},0Z"
).replace("DI", temp_digits)
# generate a format string for an absolute move-to command
temp_move = "M{:DI},{:DI}".replace("DI", temp_digits)
# generate a format string for an absolute-line command
temp_line = "L{:DI},{:DI}".replace("DI", temp_digits)
# generate a format string for a single arc
temp_arc = "M{SX:DI} {SY:DI}A{R},{R} 0 {L:d},{S:d} {EX:DI},{EY:DI}".replace(
"DI", temp_digits
)
def _cross_2d(a: NDArray, b: NDArray) -> Number:
"""
Numpy 2.0 depreciated cross products of 2D arrays.
"""
return a[0] * b[1] - a[1] * b[0]
def svg_arc(arc):
"""
arc string: (rx ry x-axis-rotation large-arc-flag sweep-flag x y)+
large-arc-flag: greater than 180 degrees
sweep flag: direction (cw/ccw)
"""
vertices = points[arc.points]
info = arc_center(vertices, return_normal=False, return_angle=True)
C, R, angle = info.center, info.radius, info.span
if arc.closed:
return temp_circle.format(x=C[0] - R, y=C[1], r=R, d=2.0 * R)
vertex_start, vertex_mid, vertex_end = vertices
large_flag = int(angle > np.pi)
sweep_flag = int(
_cross_2d(vertex_mid - vertex_start, vertex_end - vertex_start) > 0.0
)
return temp_arc.format(
SX=vertex_start[0],
SY=vertex_start[1],
L=large_flag,
S=sweep_flag,
EX=vertex_end[0],
EY=vertex_end[1],
R=R,
)
def svg_discrete(entity):
"""
Use an entities discrete representation to export a
curve as a polyline
"""
discrete = entity.discrete(points)
# if entity contains no geometry return
if len(discrete) == 0:
return ""
# the format string for the SVG path
return (temp_move + (temp_line * (len(discrete) - 1))).format(
*discrete.reshape(-1)
)
# tuples of (metadata, path string)
pairs = []
for entity in entities:
if only_layers is not None and entity.layer not in only_layers:
continue
# check the class name of the entity
if entity.__class__.__name__ == "Arc":
# export the exact version of the entity
path_string = svg_arc(entity)
else:
# just export the polyline version of the entity
path_string = svg_discrete(entity)
meta = deepcopy(entity.metadata)
if name is not None:
meta["name"] = name
pairs.append((meta, path_string))
return pairs
def export_svg(drawing, return_path=False, only_layers=None, digits=None, **kwargs):
"""
Export a Path2D object into an SVG file.
Parameters
-----------
drawing : Path2D
Source geometry
return_path : bool
If True return only path string not wrapped in XML
only_layers : None or set
If passed only export the specified layers
digits : None or int
Number of digits for floating point values
Returns
-----------
as_svg : str
XML formatted SVG, or path string
"""
# collect custom attributes for the overall export
attribs = {"class": type(drawing).__name__}
if util.is_instance_named(drawing, "Scene"):
pairs = []
geom_meta = {}
for name, geom in drawing.geometry.items():
if not util.is_instance_named(geom, "Path2D"):
continue
geom_meta[name] = geom.metadata
# a pair of (metadata, path string)
pairs.extend(
_entities_to_str(
entities=geom.entities,
vertices=geom.vertices,
name=name,
digits=digits,
only_layers=only_layers,
)
)
if len(geom_meta) > 0:
# encode the whole metadata bundle here to avoid
# polluting the file with a ton of loose attribs
attribs["metadata_geometry"] = _encode(geom_meta)
elif util.is_instance_named(drawing, "Path2D"):
pairs = _entities_to_str(
entities=drawing.entities,
vertices=drawing.vertices,
digits=digits,
only_layers=only_layers,
)
else:
raise ValueError("drawing must be Scene or Path2D object!")
# return path string without XML wrapping
if return_path:
return " ".join(v[1] for v in pairs)
# fetch the export template for the base SVG file
template_svg = resources.get_string("templates/base.svg")
elements = []
for meta, path_string in pairs:
# create a simple path element
elements.append(f'<path d="{path_string}" {_format_attrib(meta)}/>')
# format as XML
if "stroke_width" in kwargs:
stroke_width = float(kwargs["stroke_width"])
else:
# set stroke to something OK looking
stroke_width = drawing.extents.max() / 800.0
try:
# store metadata in XML as JSON -_-
attribs["metadata"] = _encode(drawing.metadata)
except BaseException:
# log failed metadata encoding
log.debug("failed to encode", exc_info=True)
subs = {
"elements": "\n".join(elements),
"min_x": drawing.bounds[0][0],
"min_y": drawing.bounds[0][1],
"width": drawing.extents[0],
"height": drawing.extents[1],
"stroke_width": stroke_width,
"attribs": _format_attrib(attribs),
}
return template_svg.format(**subs)
def _format_attrib(attrib):
"""
Format attribs into the trimesh namespace.
Parameters
-----------
attrib : dict
Bag of keys and values.
"""
bag = {k: _encode(v) for k, v in attrib.items()}
return "\n".join(
f'{_ns_name}:{k}="{v}"'
for k, v in bag.items()
if len(k) > 0 and v is not None and len(v) > 0
)
def _encode(stuff):
"""
Wangle things into a string.
Parameters
-----------
stuff : dict, str
Thing to pack
Returns
------------
encoded : str
Packaged into url-safe b64 string
"""
if isinstance(stuff, str) and '"' not in stuff:
return stuff
pack = base64.urlsafe_b64encode(
jsonify(
{k: v for k, v in stuff.items() if not k.startswith("_")},
separators=(",", ":"),
).encode("utf-8")
)
result = "base64," + util.decode_text(pack)
if tol.strict:
# make sure we haven't broken the things
_deep_same(stuff, _decode(result))
return result
def _deep_same(original, other):
"""
Do a recursive comparison of two items to check
our encoding scheme in unit tests.
Parameters
-----------
original : str, bytes, list, dict
Original item
other : str, bytes, list, dict
Item that should be identical
Raises
------------
AssertionError
If items are not the same.
"""
# ndarrays will be converted to lists
# but otherwise types should be identical
if isinstance(original, np.ndarray):
assert isinstance(other, (list, np.ndarray))
elif isinstance(original, str):
assert isinstance(other, str)
else:
# otherwise they should be the same type
assert isinstance(original, type(other))
if isinstance(original, (str, bytes)):
# string and bytes should just be identical
assert original == other
return
elif isinstance(original, (float, int, np.ndarray)):
# for Number classes use numpy magic comparison
# which includes an epsilon for floating point
assert np.allclose(original, other)
return
elif isinstance(original, list):
# lengths should match
assert len(original) == len(other)
# every element should be identical
for a, b in zip(original, other):
_deep_same(a, b)
return
# we should have special-cased everything else by here
assert isinstance(original, dict)
# all keys should match
assert set(original.keys()) == set(other.keys())
# do a recursive comparison of the values
for k in original.keys():
_deep_same(original[k], other[k])
def _decode(bag):
"""
Decode a base64 bag of stuff.
Parameters
------------
bag : str
Starts with `base64,`
Returns
-------------
loaded : dict
Loaded bag of stuff
"""
if bag is None:
return
text = util.decode_text(bag)
if text.startswith("base64,"):
return json.loads(
base64.urlsafe_b64decode(text[7:].encode("utf-8")).decode("utf-8")
)
return text
_svg_loaders = {"svg": svg_to_path}
try:
# pip install svg.path
from svg.path import parse_path
except BaseException as E:
# will re-raise the import exception when
# someone tries to call `parse_path`
parse_path = exceptions.ExceptionWrapper(E)
_svg_loaders["svg"] = parse_path
try:
from lxml import etree
except BaseException as E:
# will re-raise the import exception when
# someone actually tries to use the module
etree = exceptions.ExceptionWrapper(E)
_svg_loaders["svg"] = etree
@@ -0,0 +1,73 @@
import numpy as np
from .. import util
from ..constants import tol_path as tol
def line_line(origins, directions, plane_normal=None):
"""
Find the intersection between two lines.
Uses terminology from:
http://geomalgorithms.com/a05-_intersect-1.html
line 1: P(s) = p_0 + sU
line 2: Q(t) = q_0 + tV
Parameters
---------
origins : (2, d) float
Points on lines (d in [2,3])
directions : (2, d) float
Direction vectors
plane_normal : (3, ) float
If not passed computed from cross
Returns
---------
intersects : bool
Whether the lines intersect.
In 2D, false if the lines are parallel
In 3D, false if lines are not coplanar
intersection : (d,) float or None
Point of intersection
"""
# check so we can accept 2D or 3D points
origins, is_2D = util.stack_3D(origins, return_2D=True)
directions, is_2D = util.stack_3D(directions, return_2D=True)
# unitize direction vectors
directions /= util.row_norm(directions).reshape((-1, 1))
# exit if values are parallel
if np.sum(np.abs(np.diff(directions, axis=0))) < tol.zero:
return False, None
# using notation from docstring
q_0, p_0 = origins
v, u = directions
w = p_0 - q_0
# recompute plane normal if not passed
if plane_normal is None:
# the normal of the plane given by the two direction vectors
plane_normal = np.cross(u, v)
plane_normal /= np.linalg.norm(plane_normal)
# vectors perpendicular to the two lines
v_perp = np.cross(v, plane_normal)
v_perp /= np.linalg.norm(v_perp)
# if the vector from origin to origin is on the plane given by
# the direction vector, the dot product with the plane normal
# should be within floating point error of zero
w_norm = np.linalg.norm(w)
if w_norm > tol.zero and abs(np.dot(plane_normal, w / w_norm)) > tol.zero:
# not coplanar
return False, None
# value of parameter s where intersection occurs
s_I = np.dot(-v_perp, w) / np.dot(v_perp, u)
# plug back into the equation of the line to find the point
intersection = p_0 + s_I * u
return True, intersection[: (3 - is_2D)]
@@ -0,0 +1,820 @@
"""
packing.py
------------
Pack rectangular regions onto larger rectangular regions.
"""
import numpy as np
from ..constants import log, tol
from ..typed import ArrayLike, Integer, NDArray, Number, Optional, float64
from ..util import allclose, bounds_tree
# floating point zero
_TOL_ZERO = 1e-12
class RectangleBin:
"""
An N-dimensional binary space partition tree for packing
hyper-rectangles. Split logic is pure `numpy` but behaves
similarly to `scipy.spatial.Rectangle`.
Mostly useful for packing 2D textures and 3D boxes and
has not been tested outside of 2 and 3 dimensions.
Original article about using this for packing textures:
http://www.blackpawn.com/texts/lightmaps/
"""
def __init__(self, bounds):
"""
Create a rectangular bin.
Parameters
------------
bounds : (2, dimension *) float
Bounds array are `[mins, maxes]`
"""
# this is a *binary* tree so regardless of the dimensionality
# of the rectangles each node has exactly two children
self.child = []
# is this node occupied.
self.occupied = False
# assume bounds are a list
self.bounds = np.array(bounds, dtype=np.float64)
@property
def extents(self):
"""
Bounding box size.
Returns
----------
extents : (dimension,) float
Edge lengths of bounding box
"""
bounds = self.bounds
return bounds[1] - bounds[0]
def insert(self, size, rotate=True):
"""
Insert a rectangle into the bin.
Parameters
-------------
size : (dimension,) float
Size of rectangle to insert/
Returns
----------
inserted : (2,) float or None
Position of insertion in the tree or None
if the insertion was unsuccessful.
"""
for child in self.child:
# try inserting into child cells
attempt = child.insert(size=size, rotate=rotate)
if attempt is not None:
return attempt
# can't insert into occupied cells
if self.occupied:
return None
# shortcut for our bounds
bounds = self.bounds.copy()
extents = bounds[1] - bounds[0]
if rotate:
# we are allowed to rotate the rectangle
for roll in range(len(size)):
size_test = extents - _roll(size, roll)
fits = (size_test > -_TOL_ZERO).all()
if fits:
size = _roll(size, roll)
break
# we tried rotating and none of the directions fit
if not fits:
return None
else:
# compare the bin size to the insertion candidate size
# manually compute extents here to avoid function call
size_test = extents - size
if (size_test < -_TOL_ZERO).any():
return None
# since the cell is big enough for the current rectangle, either it
# is going to be inserted here, or the cell is going to be split
# either way the cell is now occupied.
self.occupied = True
# this means the inserted rectangle fits perfectly
# since we already checked to see if it was negative
# no abs is needed
if (size_test < _TOL_ZERO).all():
return bounds
# pick the axis to split along
axis = size_test.argmax()
# split hyper-rectangle along axis
# note that split is *absolute* distance not offset
# so we have to add the current min to the size
splits = np.vstack((bounds, bounds))
splits[1:3, axis] = bounds[0][axis] + size[axis]
# assign two children
self.child[:] = RectangleBin(splits[:2]), RectangleBin(splits[2:])
# insert the requested item into the first child
return self.child[0].insert(size, rotate=rotate)
def _roll(a, count):
"""
A speedup for `numpy.roll` that only works
on flat arrays and is fast on 2D and 3D and
reverts to `numpy.roll` for other cases.
Parameters
-----------
a : (n,) any
Array to roll
count : int
Number of places to shift array
Returns
---------
rolled : (n,) any
Input array shifted by requested amount
"""
# a lookup table for roll in 2 and 3 dimensions
lookup = [[[0, 1], [1, 0]], [[0, 1, 2], [2, 0, 1], [1, 2, 0]]]
try:
# roll the array using advanced indexing and a lookup table
return a[lookup[len(a) - 2][count]]
except IndexError:
# failing that return the results using concat
return np.concatenate([a[-count:], a[:-count]])
def rectangles_single(extents, size=None, shuffle=False, rotate=True, random=None):
"""
Execute a single insertion order of smaller rectangles onto
a larger rectangle using a binary space partition tree.
Parameters
----------
extents : (n, dimension) float
The size of the hyper-rectangles to pack.
size : None or (dim,) float
Maximum size of container to pack onto.
If not passed it will re-root the tree when items
larger than any available node are inserted.
shuffle : bool
Whether or not to shuffle the insert order of the
smaller rectangles, as the final packing density depends
on insertion order.
rotate : bool
If True, allow integer-roll rotation.
Returns
---------
bounds : (m, 2, dim) float
Axis aligned resulting bounds in space
transforms : (m, dim + 1, dim + 1) float
Homogeneous transformation including rotation.
consume : (n,) bool
Which of the original rectangles were packed,
i.e. `consume.sum() == m`
"""
extents = np.asanyarray(extents, dtype=np.float64)
dimension = extents.shape[1]
# the return arrays
offset = np.zeros((len(extents), 2, dimension))
consume = np.zeros(len(extents), dtype=bool)
# start by ordering them by maximum length
order = np.argsort(extents.max(axis=1))[::-1]
if shuffle:
if random is not None:
order = random.permutation(order)
else:
# reorder with permutations
order = np.random.permutation(order)
if size is None:
# if no bounds are passed start it with the size of a large
# rectangle exactly which will require re-rooting for
# subsequent insertions
root_bounds = [[0.0] * dimension, extents[np.ptp(extents, axis=1).argmax()]]
else:
# restrict the bounds to passed size and disallow re-rooting
root_bounds = [[0.0] * dimension, size]
# the current root node to insert each rectangle
root = RectangleBin(bounds=root_bounds)
for index in order:
# the current rectangle to be inserted
rectangle = extents[index]
# try to insert the hyper-rectangle into children
inserted = root.insert(rectangle, rotate=rotate)
if inserted is None and size is None:
# we failed to insert into children
# so we need to create a new parent
# get the size of the current root node
bounds = root.bounds
# current extents
current = np.ptp(bounds, axis=0)
# pick the direction which has the least hyper-volume.
best = np.inf
for roll in range(len(current)):
stack = np.array([current, _roll(rectangle, roll)])
# we are going to combine two hyper-rect
# so we have `dim` choices on ways to split
# choose the split that minimizes the new hyper-volume
# the new AABB is going to be the `max` of the lengths
# on every dim except one which will be the `sum`
ch = np.tile(stack.max(axis=0), (len(current), 1))
np.fill_diagonal(ch, stack.sum(axis=0))
# choose the new AABB by which one minimizes hyper-volume
choice_prod = np.prod(ch, axis=1)
if choice_prod.min() < best:
choices = ch
choices_idx = choice_prod.argmin()
best = choice_prod[choices_idx]
if not rotate:
break
# we now know the full extent of the AABB
new_max = bounds[0] + choices[choices_idx]
# offset the new bounding box corner
new_min = bounds[0].copy()
new_min[choices_idx] += current[choices_idx]
# original bounds may be stretched
new_ori_max = np.vstack((bounds[1], new_max)).max(axis=0)
new_ori_max[choices_idx] = bounds[1][choices_idx]
assert (new_ori_max >= bounds[1]).all()
# the bounds containing the original sheet
bounds_ori = np.array([bounds[0], new_ori_max])
# the bounds containing the location to insert
# the new rectangle
bounds_ins = np.array([new_min, new_max])
# generate the new root node
new_root = RectangleBin([bounds[0], new_max])
# this node has children so it is occupied
new_root.occupied = True
# create a bin for both bounds
new_root.child = [RectangleBin(bounds_ori), RectangleBin(bounds_ins)]
# insert the original sheet into the new tree
root_offset = new_root.child[0].insert(np.ptp(bounds, axis=0), rotate=rotate)
# we sized the cells so original tree would fit
assert root_offset is not None
# existing inserts need to be moved
if not allclose(root_offset[0][0], 0.0):
offset[consume] += root_offset[0][0]
# insert the child that didn't fit before into the other child
child = new_root.child[1].insert(rectangle, rotate=rotate)
# since we re-sized the cells to fit insertion should always work
assert child is not None
offset[index] = child
consume[index] = True
# subsume the existing tree into a new root
root = new_root
elif inserted is not None:
# we successfully inserted
offset[index] = inserted
consume[index] = True
if tol.strict:
# in tests make sure we've never returned overlapping bounds
assert not bounds_overlap(offset[consume])
return offset[consume], consume
def paths(paths, **kwargs):
"""
Pack a list of Path2D objects into a rectangle.
Parameters
------------
paths: (n,) Path2D
Geometry to be packed
Returns
------------
packed : trimesh.path.Path2D
All paths packed into a single path object.
transforms : (m, 3, 3) float
Homogeneous transforms to move paths from their
original position to the new one.
consume : (n,) bool
Which of the original paths were inserted,
i.e. `consume.sum() == m`
"""
from .util import concatenate
# pack using exterior polygon which will have the
# oriented bounding box calculated before packing
packable = []
original = []
for index, path in enumerate(paths):
quantity = path.metadata.get("quantity", 1)
original.extend([index] * quantity)
packable.extend([path.polygons_closed[path.root[0]]] * quantity)
# pack the polygons using rectangular bin packing
transforms, consume = polygons(polygons=packable, **kwargs)
positioned = []
for index, matrix in zip(np.nonzero(consume)[0], transforms):
current = paths[original[index]].copy()
current.apply_transform(matrix)
positioned.append(current)
# append all packed paths into a single Path object
packed = concatenate(positioned)
return packed, transforms, consume
def polygons(polygons, **kwargs):
"""
Pack polygons into a rectangle by taking each Polygon's OBB
and then packing that as a rectangle.
Parameters
------------
polygons : (n,) shapely.geometry.Polygon
Source geometry
**kwargs : dict
Passed through to `packing.rectangles`.
Returns
-------------
transforms : (m, 3, 3) float
Homogeonous transforms from original frame to
packed frame.
consume : (n,) bool
Which of the original polygons was packed,
i.e. `consume.sum() == m`
"""
from .polygons import polygon_bounds, polygons_obb
# find the oriented bounding box of the polygons
obb, extents = polygons_obb(polygons)
# run packing for a number of iterations
bounds, consume = rectangles(extents=extents, **kwargs)
log.debug("%i/%i parts were packed successfully", consume.sum(), len(polygons))
# transformations to packed positions
roll = roll_transform(bounds=bounds, extents=extents[consume])
transforms = np.array([np.dot(b, a) for a, b in zip(obb[consume], roll)])
if tol.strict:
# original bounds should not overlap
assert not bounds_overlap(bounds)
# confirm transfor
check_bound = np.array(
[
polygon_bounds(polygons[index], matrix=m)
for index, m in zip(np.nonzero(consume)[0], transforms)
]
)
assert not bounds_overlap(check_bound)
return transforms, consume
def rectangles(
extents,
size=None,
density_escape=0.99,
spacing=None,
iterations=50,
rotate=True,
quanta=None,
seed=None,
):
"""
Run multiple iterations of rectangle packing, this is the
core function for all rectangular packing.
Parameters
------------
extents : (n, dimension) float
Size of hyper-rectangle to be packed
size : None or (dimension,) float
Size of sheet to pack onto. If not passed tree will be allowed
to create new volume-minimizing parent nodes.
density_escape : float
Exit early if rectangular density is above this threshold.
spacing : float
Distance to allow between rectangles
iterations : int
Number of iterations to run
rotate : bool
Allow right angle rotations or not.
quanta : None or float
Discrete "snap" interval.
seed
If deterministic results are needed seed the RNG here.
Returns
---------
bounds : (m, 2, dimension) float
Axis aligned bounding boxes of inserted hyper-rectangle.
inserted : (n,) bool
Which of the original rect were packed.
"""
# copy extents and make sure they are floats
extents = np.array(extents, dtype=np.float64)
dim = extents.shape[1]
if spacing is not None:
# add on any requested spacing
extents += spacing * 2.0
# hyper-volume: area in 2D, volume in 3D, party in 4D
area = np.prod(extents, axis=1)
# best density percentage in 0.0 - 1.0
best_density = 0.0
# how many rect were inserted
best_count = 0
if seed is None:
random = None
else:
random = np.random.default_rng(seed=seed)
for i in range(iterations):
# run a single insertion order
# don't shuffle the first run, shuffle subsequent runs
bounds, insert = rectangles_single(
extents=extents, size=size, shuffle=(i != 0), rotate=rotate, random=random
)
count = insert.sum()
extents_all = np.ptp(bounds.reshape((-1, dim)), axis=0)
if quanta is not None:
# compute the density using an upsized quanta
extents = np.ceil(extents_all / quanta) * quanta
# calculate the packing density
density = area[insert].sum() / np.prod(extents_all)
# compare this packing density against our best
if density > best_density or count > best_count:
best_density = density
best_count = count
# save the result
result = [bounds, insert]
# exit early if everything is inserted and
# we have exceeded our target density
if density > density_escape and insert.all():
break
if spacing is not None:
# shrink the bounds by spacing
result[0] += [[[spacing], [-spacing]]]
log.debug(f"{iterations} iterations packed with density {best_density:0.3f}")
return result
def images(
images,
power_resize: bool = False,
deduplicate: bool = False,
iterations: Optional[Integer] = 50,
seed: Optional[Integer] = None,
spacing: Optional[Number] = None,
mode: Optional[str] = None,
):
"""
Pack a list of images and return result and offsets.
Parameters
------------
images : (n,) PIL.Image
Images to be packed
power_resize : bool
Should the result image be upsized to the nearest
power of two? Not every GPU supports materials that
aren't a power of two size.
deduplicate
Should images that have identical hashes be inserted
more than once?
mode
If passed return an output image with the
requested mode, otherwise will be picked
from the input images.
Returns
-----------
packed : PIL.Image
Multiple images packed into result
offsets : (n, 2) int
Offsets for original image to pack
"""
from PIL import Image
if deduplicate:
# only pack duplicate images once
_, index, inverse = np.unique(
[hash(i.tobytes()) for i in images], return_index=True, return_inverse=True
)
# use the number of pixels as the rectangle size
bounds, insert = rectangles(
extents=[images[i].size for i in index],
rotate=False,
iterations=iterations,
seed=seed,
spacing=spacing,
)
# really should have inserted all the rect
assert insert.all()
# re-index bounds back to original indexes
bounds = bounds[inverse]
assert np.allclose(np.ptp(bounds, axis=1), [i.size for i in images])
else:
# use the number of pixels as the rectangle size
bounds, insert = rectangles(
extents=[i.size for i in images],
rotate=False,
iterations=iterations,
seed=seed,
spacing=spacing,
)
# really should have inserted all the rect
assert insert.all()
if spacing is None:
spacing = 0
else:
spacing = int(spacing)
# offsets should be integer multiple of pizels
offset = bounds[:, 0].round().astype(int)
extents = np.ptp(bounds.reshape((-1, 2)), axis=0) + (spacing * 2)
size = extents.round().astype(int)
if power_resize:
# round up all dimensions to powers of 2
size = (2 ** np.ceil(np.log2(size))).astype(np.int64)
if mode is None:
# get the mode of every input image
modes = list({i.mode for i in images})
# pick the longest mode as a simple heuristic
# which prefers "RGBA" over "RGB"
mode = modes[np.argmax([len(m) for m in modes])]
# create the image in the mode of the first image
result = Image.new(mode, tuple(size))
done = set()
# paste each image into the result
for img, off in zip(images, offset):
if tuple(off) not in done:
# box is upper left corner
corner = (off[0], size[1] - img.size[1] - off[1])
result.paste(img, box=corner)
else:
done.add(tuple(off))
return result, offset
def meshes(meshes, **kwargs):
"""
Pack 3D meshes into a rectangular volume using box packing.
Parameters
------------
meshes : (n,) trimesh.Trimesh
Input geometry to pack
**kwargs : dict
Passed to `packing.rectangles`
Returns
------------
placed : (m,) trimesh.Trimesh
Meshes moved into the rectangular volume.
transforms : (m, 4, 4) float
Homogeneous transform moving mesh from original
position to being packed in a rectangular volume.
consume : (n,) bool
Which of the original meshes were inserted,
i.e. `consume.sum() == m`
"""
# pack meshes relative to their oriented bounding boxes
obbs = [i.bounding_box_oriented for i in meshes]
obb_extent = np.array([i.primitive.extents for i in obbs])
obb_transform = np.array([o.primitive.transform for o in obbs])
# run packing
bounds, consume = rectangles(obb_extent, **kwargs)
# generate the transforms from an origin centered AABB
# to the final placed and rotated AABB
transforms = np.array(
[
np.dot(r, np.linalg.inv(o))
for o, r in zip(
obb_transform[consume],
roll_transform(bounds=bounds, extents=obb_extent[consume]),
)
],
dtype=np.float64,
)
# copy the meshes and move into position
placed = [
meshes[index].copy().apply_transform(T)
for index, T in zip(np.nonzero(consume)[0], transforms)
]
return placed, transforms, consume
def visualize(extents, bounds):
"""
Visualize a 3D box packing.
Parameters
------------
extents : (n, 3) float
AABB size before packing.
bounds : (n, 2, 3) float
AABB location after packing.
Returns
------------
scene : trimesh.Scene
Scene with boxes at requested locations.
"""
from ..creation import box
from ..scene import Scene
from ..visual import random_color
# use a roll transform to verify extents
transforms = roll_transform(bounds=bounds, extents=extents)
meshes = [box(extents=e) for e in extents]
for m, matrix, check in zip(meshes, transforms, bounds):
m.apply_transform(matrix)
assert np.allclose(m.bounds, check)
m.visual.face_colors = random_color()
return Scene(meshes)
def roll_transform(bounds: ArrayLike, extents: ArrayLike) -> NDArray[float64]:
"""
Packing returns rotations with integer "roll" which
needs to be converted into a homogeneous rotation matrix.
Currently supports `dimension=2` and `dimension=3`.
Parameters
--------------
bounds : (n, 2, dimension) float
Axis aligned bounding boxes of packed position
extents : (n, dimension) float
Original pre-rolled extents will be used
to determine rotation to move to `bounds`.
Returns
----------
transforms : (n, dimension + 1, dimension + 1) float
Homogeneous transformation to move cuboid at the origin
into the position determined by `bounds`.
"""
if len(bounds) != len(extents):
raise ValueError("`bounds` must match `extents`")
if len(extents) == 0:
return []
# find the size of the AABB of the passed bounds
passed = np.ptp(bounds, axis=1)
# zeroth index is 2D, `1` is 3D
dimension = passed.shape[1]
# store the resulting transformation matrices
result = np.tile(np.eye(dimension + 1), (len(bounds), 1, 1))
# a lookup table for rotations for rolling cuboiods
# as `lookup[dimension - 2][roll]`
# implemented for 2D and 3D
lookup = [
np.array(
[np.eye(3), np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])]
),
np.array(
[
np.eye(4),
[
[-0.0, -0.0, -1.0, -0.0],
[-1.0, -0.0, -0.0, -0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
[
[-0.0, -1.0, -0.0, -0.0],
[0.0, 0.0, 1.0, 0.0],
[-1.0, -0.0, -0.0, -0.0],
[0.0, 0.0, 0.0, 1.0],
],
]
),
]
# rectangular rotation involves rolling
for roll in range(extents.shape[1]):
# find all the passed bounding boxes represented by
# rolling the original extents by this amount
rolled = np.roll(extents, roll, axis=1)
# check to see if the rolled original extents
# match the requested bounding box
ok = np.ptp((passed - rolled), axis=1) < _TOL_ZERO
if not ok.any():
continue
# the base rotation for this
mat = lookup[dimension - 2][roll]
# the lower corner of the AABB plus the rolled extent
offset = np.tile(np.eye(dimension + 1), (ok.sum(), 1, 1))
offset[:, :dimension, dimension] = bounds[:, 0][ok] + rolled[ok] / 2.0
result[ok] = [np.dot(o, mat) for o in offset]
if tol.strict:
if dimension == 3:
# make sure bounds match inputs
from ..creation import box
assert all(
allclose(box(extents=e).apply_transform(m).bounds, b)
for b, e, m in zip(bounds, extents, result)
)
elif dimension == 2:
# in 2D check with a rectangle
from .creation import rectangle
assert all(
allclose(rectangle(bounds=[-e / 2, e / 2]).apply_transform(m).bounds, b)
for b, e, m in zip(bounds, extents, result)
)
else:
raise ValueError("unsupported dimension")
return result
def bounds_overlap(bounds, epsilon=1e-8):
"""
Check to see if multiple axis-aligned bounding boxes
contains overlaps using `rtree`.
Parameters
------------
bounds : (n, 2, dimension) float
Axis aligned bounding boxes
epsilon : float
Amount to shrink AABB to avoid spurious floating
point hits.
Returns
--------------
overlap : bool
True if any bound intersects any other bound.
"""
# pad AABB by epsilon for deterministic intersections
padded = np.array(bounds) + np.reshape([epsilon, -epsilon], (1, 2, 1))
tree = bounds_tree(padded)
# every returned AABB should not overlap with any other AABB
return any(
set(tree.intersection(current.ravel())) != {i} for i, current in enumerate(bounds)
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,968 @@
import numpy as np
from shapely import ops
from shapely.geometry import Polygon
from .. import bounds, geometry, graph, grouping
from ..constants import log
from ..constants import tol_path as tol
from ..iteration import reduce_cascade
from ..transformations import transform_points
from ..typed import ArrayLike, Iterable, NDArray, Number, Optional, Union, float64, int64
from .simplify import fit_circle_check
from .traversal import resample_path
try:
import networkx as nx
except BaseException as E:
# create a dummy module which will raise the ImportError
# or other exception only when someone tries to use networkx
from ..exceptions import ExceptionWrapper
nx = ExceptionWrapper(E)
try:
from rtree.index import Index
except BaseException as E:
# create a dummy module which will raise the ImportError
from ..exceptions import ExceptionWrapper
Index = ExceptionWrapper(E)
def enclosure_tree(polygons):
"""
Given a list of shapely polygons with only exteriors,
find which curves represent the exterior shell or root curve
and which represent holes which penetrate the exterior.
This is done with an R-tree for rough overlap detection,
and then exact polygon queries for a final result.
Parameters
-----------
polygons : (n,) shapely.geometry.Polygon
Polygons which only have exteriors and may overlap
Returns
-----------
roots : (m,) int
Index of polygons which are root
contains : networkx.DiGraph
Edges indicate a polygon is
contained by another polygon
"""
# nodes are indexes in polygons
contains = nx.DiGraph()
if len(polygons) == 0:
return np.array([], dtype=np.int64), contains
elif len(polygons) == 1:
# add an early exit for only a single polygon
contains.add_node(0)
return np.array([0], dtype=np.int64), contains
# get the bounds for every valid polygon
bounds = {
k: v
for k, v in {
i: getattr(polygon, "bounds", []) for i, polygon in enumerate(polygons)
}.items()
if len(v) == 4
}
# make sure we don't have orphaned polygon
contains.add_nodes_from(bounds.keys())
if len(bounds) > 0:
# if there are no valid bounds tree creation will fail
# and we won't be calling `tree.intersection` anywhere
# we could return here but having multiple return paths
# seems more dangerous than iterating through an empty graph
tree = Index(zip(bounds.keys(), bounds.values(), [None] * len(bounds)))
# loop through every polygon
for i, b in bounds.items():
# we first query for bounding box intersections from the R-tree
for j in tree.intersection(b):
# if we are checking a polygon against itself continue
if i == j:
continue
# do a more accurate polygon in polygon test
# for the enclosure tree information
if polygons[i].contains(polygons[j]):
contains.add_edge(i, j)
elif polygons[j].contains(polygons[i]):
contains.add_edge(j, i)
# a root or exterior curve has an even number of parents
# wrap in dict call to avoid networkx view
degree = dict(contains.in_degree())
# convert keys and values to numpy arrays
indexes = np.array(list(degree.keys()))
degrees = np.array(list(degree.values()))
# roots are curves with an even inward degree (parent count)
roots = indexes[(degrees % 2) == 0]
# if there are multiple nested polygons split the graph
# so the contains logic returns the individual polygons
if len(degrees) > 0 and degrees.max() > 1:
# collect new edges for graph
edges = []
# order the roots so they are sorted by degree
roots = roots[np.argsort([degree[r] for r in roots])]
# find edges of subgraph for each root and children
for root in roots:
children = indexes[degrees == degree[root] + 1]
edges.extend(contains.subgraph(np.append(children, root)).edges())
# stack edges into new directed graph
contains = nx.from_edgelist(edges, nx.DiGraph())
# if roots have no children add them anyway
contains.add_nodes_from(roots)
return roots, contains
def edges_to_polygons(edges: NDArray[int64], vertices: NDArray[float64]):
"""
Given an edge list of indices and associated vertices
representing lines, generate a list of polygons.
Parameters
-----------
edges : (n, 2)
Indexes of vertices which represent lines
vertices : (m, 2)
Vertices in 2D space.
Returns
----------
polygons : (p,) shapely.geometry.Polygon
Polygon objects with interiors
"""
assert isinstance(vertices, np.ndarray)
# create closed polygon objects
polygons = []
# loop through a sequence of ordered traversals
for dfs in graph.traversals(edges, mode="dfs"):
try:
# try to recover polygons before they are more complicated
repaired = repair_invalid(Polygon(vertices[dfs]))
# if it returned a multipolygon extend into a flat list
if hasattr(repaired, "geoms"):
polygons.extend(repaired.geoms)
else:
polygons.append(repaired)
except ValueError:
continue
# if there is only one polygon, just return it
if len(polygons) == 1:
return polygons
# find which polygons contain which other polygons
roots, tree = enclosure_tree(polygons)
# generate polygons with proper interiors
return [
Polygon(
shell=polygons[root].exterior,
holes=[polygons[i].exterior for i in tree[root].keys()],
)
for root in roots
]
def polygons_obb(polygons: Union[Iterable[Polygon], ArrayLike]):
"""
Find the OBBs for a list of shapely.geometry.Polygons
"""
rectangles = [None] * len(polygons)
transforms = [None] * len(polygons)
for i, p in enumerate(polygons):
transforms[i], rectangles[i] = polygon_obb(p)
return np.array(transforms), np.array(rectangles)
def polygon_obb(polygon: Union[Polygon, NDArray]):
"""
Find the oriented bounding box of a Shapely polygon.
The OBB is always aligned with an edge of the convex hull of the polygon.
Parameters
-------------
polygons : shapely.geometry.Polygon
Input geometry
Returns
-------------
transform : (3, 3) float
Transformation matrix
which will move input polygon from its original position
to the first quadrant where the AABB is the OBB
extents : (2,) float
Extents of transformed polygon
"""
if hasattr(polygon, "exterior"):
points = np.asanyarray(polygon.exterior.coords)
elif isinstance(polygon, np.ndarray):
points = polygon
else:
raise ValueError("polygon or points must be provided")
transform, extents = bounds.oriented_bounds_2D(points)
if tol.strict:
moved = transform_points(points=points, matrix=transform)
assert np.allclose(-extents / 2.0, moved.min(axis=0))
assert np.allclose(extents / 2.0, moved.max(axis=0))
return transform, extents
def transform_polygon(polygon, matrix):
"""
Transform a polygon by a a 2D homogeneous transform.
Parameters
-------------
polygon : shapely.geometry.Polygon
2D polygon to be transformed.
matrix : (3, 3) float
2D homogeneous transformation.
Returns
--------------
result : shapely.geometry.Polygon
Polygon transformed by matrix.
"""
matrix = np.asanyarray(matrix, dtype=np.float64)
if hasattr(polygon, "geoms"):
result = [transform_polygon(p, t) for p, t in zip(polygon, matrix)]
return result
# transform the outer shell
shell = transform_points(np.array(polygon.exterior.coords), matrix)[:, :2]
# transform the interiors
holes = [
transform_points(np.array(i.coords), matrix)[:, :2] for i in polygon.interiors
]
# create a new polygon with the result
result = Polygon(shell=shell, holes=holes)
return result
def polygon_bounds(polygon, matrix=None):
"""
Get the transformed axis aligned bounding box of a
shapely Polygon object.
Parameters
------------
polygon : shapely.geometry.Polygon
Polygon pre-transform
matrix : (3, 3) float or None.
Homogeneous transform moving polygon in space
Returns
------------
bounds : (2, 2) float
Axis aligned bounding box of transformed polygon.
"""
if matrix is not None:
assert matrix.shape == (3, 3)
points = transform_points(points=np.array(polygon.exterior.coords), matrix=matrix)
else:
points = np.array(polygon.exterior.coords)
bounds = np.array([points.min(axis=0), points.max(axis=0)])
assert bounds.shape == (2, 2)
return bounds
def plot(polygon=None, show=True, axes=None, **kwargs):
"""
Plot a shapely polygon using matplotlib.
Parameters
------------
polygon : shapely.geometry.Polygon
Polygon to be plotted
show : bool
If True will display immediately
**kwargs
Passed to plt.plot
"""
import matplotlib.pyplot as plt
def plot_single(single):
axes.plot(*single.exterior.xy, **kwargs)
for interior in single.interiors:
axes.plot(*interior.xy, **kwargs)
# make aspect ratio non-stupid
if axes is None:
axes = plt.axes()
axes.set_aspect("equal", "datalim")
if polygon.__class__.__name__ == "MultiPolygon":
[plot_single(i) for i in polygon.geoms]
elif hasattr(polygon, "__iter__"):
[plot_single(i) for i in polygon]
elif polygon is not None:
plot_single(polygon)
if show:
plt.show()
return axes
def resample_boundaries(polygon: Polygon, resolution: float, clip=None):
"""
Return a version of a polygon with boundaries re-sampled
to a specified resolution.
Parameters
-------------
polygon : shapely.geometry.Polygon
Source geometry
resolution : float
Desired distance between points on boundary
clip : (2,) int
Upper and lower bounds to clip
number of samples to avoid exploding count
Returns
------------
kwargs : dict
Keyword args for a Polygon constructor `Polygon(**kwargs)`
"""
def resample_boundary(boundary):
# add a polygon.exterior or polygon.interior to
# the deque after resampling based on our resolution
count = boundary.length / resolution
count = int(np.clip(count, *clip))
return resample_path(boundary.coords, count=count)
if clip is None:
clip = [8, 200]
# create a sequence of [(n,2)] points
kwargs = {"shell": resample_boundary(polygon.exterior), "holes": []}
for interior in polygon.interiors:
kwargs["holes"].append(resample_boundary(interior))
return kwargs
def stack_boundaries(boundaries):
"""
Stack the boundaries of a polygon into a single
(n, 2) list of vertices.
Parameters
------------
boundaries : dict
With keys 'shell', 'holes'
Returns
------------
stacked : (n, 2) float
Stacked vertices
"""
if len(boundaries["holes"]) == 0:
return boundaries["shell"]
return np.vstack((boundaries["shell"], np.vstack(boundaries["holes"])))
def medial_axis(polygon: Polygon, resolution: Optional[Number] = None, clip=None):
"""
Given a shapely polygon, find the approximate medial axis
using a voronoi diagram of evenly spaced points on the
boundary of the polygon.
Parameters
----------
polygon : shapely.geometry.Polygon
The source geometry
resolution : float
Distance between each sample on the polygon boundary
clip : None, or (2,) int
Clip sample count to min of clip[0] and max of clip[1]
Returns
----------
edges : (n, 2) int
Vertex indices representing line segments
on the polygon's medial axis
vertices : (m, 2) float
Vertex positions in space
"""
# a circle will have a single point medial axis
if len(polygon.interiors) == 0:
# what is the approximate scale of the polygon
scale = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).max()
# a (center, radius, error) tuple
fit = fit_circle_check(polygon.exterior.coords, scale=scale)
# is this polygon in fact a circle
if fit is not None:
# return an edge that has the center as the midpoint
epsilon = np.clip(fit["radius"] / 500, 1e-5, np.inf)
vertices = np.array(
[fit["center"] + [0, epsilon], fit["center"] - [0, epsilon]],
dtype=np.float64,
)
# return a single edge to avoid consumers needing to special case
edges = np.array([[0, 1]], dtype=np.int64)
return edges, vertices
from scipy.spatial import Voronoi
from shapely import vectorized
if resolution is None:
resolution = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).max() / 100
# get evenly spaced points on the polygons boundaries
samples = resample_boundaries(polygon=polygon, resolution=resolution, clip=clip)
# stack the boundary into a (m,2) float array
samples = stack_boundaries(samples)
# create the voronoi diagram on 2D points
voronoi = Voronoi(samples)
# which voronoi vertices are contained inside the polygon
contains = vectorized.contains(polygon, *voronoi.vertices.T)
# ridge vertices of -1 are outside, make sure they are False
contains = np.append(contains, False)
# make sure ridge vertices is numpy array
ridge = np.asanyarray(voronoi.ridge_vertices, dtype=np.int64)
# only take ridges where every vertex is contained
edges = ridge[contains[ridge].all(axis=1)]
# now we need to remove uncontained vertices
contained = np.unique(edges)
mask = np.zeros(len(voronoi.vertices), dtype=np.int64)
mask[contained] = np.arange(len(contained))
# mask voronoi vertices
vertices = voronoi.vertices[contained]
# re-index edges
edges_final = mask[edges]
if tol.strict:
# make sure we didn't screw up indexes
assert np.ptp(vertices[edges_final] - voronoi.vertices[edges]) < 1e-5
return edges_final, vertices
def identifier(polygon: Polygon) -> NDArray[float64]:
"""
Return a vector containing values representative of
a particular polygon.
Parameters
---------
polygon : shapely.geometry.Polygon
Input geometry
Returns
---------
identifier : (8,) float
Values which should be unique for this polygon.
"""
result = [
len(polygon.interiors),
polygon.convex_hull.area,
polygon.convex_hull.length,
polygon.area,
polygon.length,
polygon.exterior.length,
]
# include the principal second moments of inertia of the polygon
# this is invariant to rotation and translation
_, principal, _, _ = second_moments(polygon, return_centered=True)
result.extend(principal)
return np.array(result, dtype=np.float64)
def random_polygon(segments=8, radius=1.0):
"""
Generate a random polygon with a maximum number of sides and approximate radius.
Parameters
---------
segments : int
The maximum number of sides the random polygon will have
radius : float
The approximate radius of the polygon desired
Returns
---------
polygon : shapely.geometry.Polygon
Geometry object with random exterior and no interiors.
"""
angles = np.sort(np.cumsum(np.random.random(segments) * np.pi * 2) % (np.pi * 2))
radii = np.random.random(segments) * radius
points = np.column_stack((np.cos(angles), np.sin(angles))) * radii.reshape((-1, 1))
points = np.vstack((points, points[0]))
polygon = Polygon(points).buffer(0.0)
if hasattr(polygon, "geoms"):
return polygon.geoms[0]
return polygon
def polygon_scale(polygon):
"""
For a Polygon object return the diagonal length of the AABB.
Parameters
------------
polygon : shapely.geometry.Polygon
Source geometry
Returns
------------
scale : float
Length of AABB diagonal
"""
extents = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0)
scale = (extents**2).sum() ** 0.5
return scale
def paths_to_polygons(paths, scale=None):
"""
Given a sequence of connected points turn them into
valid shapely Polygon objects.
Parameters
-----------
paths : (n,) sequence
Of (m, 2) float closed paths
scale : float
Approximate scale of drawing for precision
Returns
-----------
polys : (p,) list
Filled with Polygon or None
"""
polygons = [None] * len(paths)
for i, path in enumerate(paths):
if len(path) < 4:
# since the first and last vertices are identical in
# a closed loop a 4 vertex path is the minimum for
# non-zero area
continue
try:
polygon = Polygon(path)
if polygon.is_valid:
polygons[i] = polygon
else:
polygons[i] = repair_invalid(polygon, scale)
except ValueError:
# raised if a polygon is unrecoverable
continue
except BaseException:
log.error("unrecoverable polygon", exc_info=True)
polygons = np.array(polygons)
return polygons
def sample(polygon, count, factor=1.5, max_iter=10):
"""
Use rejection sampling to generate random points inside a
polygon. Note that this function may return fewer or no
points, in particular if the polygon as very little area
compared to the area of the axis-aligned bounding box.
Parameters
-----------
polygon : shapely.geometry.Polygon
Polygon that will contain points
count : int
Number of points to return
factor : float
How many points to test per loop
max_iter : int
Maximum number of intersection checks is:
> count * factor * max_iter
Returns
-----------
hit : (n, 2) float
Random points inside polygon
where n <= count
"""
# do batch point-in-polygon queries
from shapely import vectorized
# TODO : this should probably have some option to
# sample from the *oriented* bounding box which would
# make certain cases much, much more efficient.
# get size of bounding box
bounds = np.reshape(polygon.bounds, (2, 2))
extents = np.ptp(bounds, axis=0)
# how many points to check per loop iteration
per_loop = int(count * factor)
# start with some rejection sampling
points = bounds[0] + extents * np.random.random((per_loop, 2))
# do the point in polygon test and append resulting hits
mask = vectorized.contains(polygon, *points.T)
hit = [points[mask]]
hit_count = len(hit[0])
# if our first non-looping check got enough samples exit
if hit_count >= count:
return hit[0][:count]
# if we have to do iterations loop here slowly
for _ in range(max_iter):
# generate points inside polygons AABB
points = (np.random.random((per_loop, 2)) * extents) + bounds[0]
# do the point in polygon test and append resulting hits
mask = vectorized.contains(polygon, *points.T)
hit.append(points[mask])
# keep track of how many points we've collected
hit_count += len(hit[-1])
# if we have enough points exit the loop
if hit_count > count:
break
# stack the hits into an (n,2) array and truncate
hit = np.vstack(hit)[:count]
return hit
def repair_invalid(polygon, scale=None, rtol=0.5):
"""
Given a shapely.geometry.Polygon, attempt to return a
valid version of the polygon through buffering tricks.
Parameters
-----------
polygon : shapely.geometry.Polygon
Source geometry
rtol : float
How close does a perimeter have to be
scale : float or None
For numerical precision reference
Returns
----------
repaired : shapely.geometry.Polygon
Repaired polygon
Raises
----------
ValueError
If polygon can't be repaired
"""
if hasattr(polygon, "is_valid") and polygon.is_valid:
return polygon
# basic repair involves buffering the polygon outwards
# this will fix a subset of problems.
basic = polygon.buffer(tol.zero)
# if it returned multiple polygons check the largest
if hasattr(basic, "geoms"):
basic = basic.geoms[np.argmax([i.area for i in basic.geoms])]
# check perimeter of result against original perimeter
if basic.is_valid and np.isclose(basic.length, polygon.length, rtol=rtol):
return basic
if scale is None:
distance = 0.002 * np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).mean()
else:
distance = 0.002 * scale
# if there are no interiors, we can work with just the exterior
# ring, which is often more reliable
if len(polygon.interiors) == 0:
# try buffering the exterior of the polygon
# the interior will be offset by -tol.buffer
rings = polygon.exterior.buffer(distance).interiors
if len(rings) == 1:
# reconstruct a single polygon from the interior ring
recon = Polygon(shell=rings[0]).buffer(distance)
# check perimeter of result against original perimeter
if recon.is_valid and np.isclose(recon.length, polygon.length, rtol=rtol):
return recon
# try de-deuplicating the outside ring
points = np.array(polygon.exterior.coords)
# remove any segments shorter than tol.merge
# this is a little risky as if it was discretized more
# finely than 1-e8 it may remove detail
unique = np.append(True, (np.diff(points, axis=0) ** 2).sum(axis=1) ** 0.5 > 1e-8)
# make a new polygon with result
dedupe = Polygon(shell=points[unique])
# check result
if dedupe.is_valid and np.isclose(dedupe.length, polygon.length, rtol=rtol):
return dedupe
# buffer and unbuffer the whole polygon
buffered = polygon.buffer(distance).buffer(-distance)
# if it returned multiple polygons check the largest
if hasattr(buffered, "geoms"):
areas = np.array([b.area for b in buffered.geoms])
return buffered.geoms[areas.argmax()]
# check perimeter of result against original perimeter
if buffered.is_valid and np.isclose(buffered.length, polygon.length, rtol=rtol):
log.debug("Recovered invalid polygon through double buffering")
return buffered
raise ValueError("unable to recover polygon!")
def projected(
mesh,
normal,
origin=None,
ignore_sign=True,
rpad=1e-5,
apad=None,
tol_dot=1e-10,
precise: bool = False,
):
"""
Project a mesh onto a plane and then extract the polygon
that outlines the mesh projection on that plane.
Note that this will ignore back-faces, which is only
relevant if the source mesh isn't watertight.
Also padding: this generates a result by unioning the
polygons of multiple connected regions, which requires
the polygons be padded by a distance so that a polygon
union produces a single coherent result. This distance
is calculated as: `apad + (rpad * scale)`
Parameters
----------
mesh : trimesh.Trimesh
Source geometry
check : bool
If True make sure is flat
normal : (3,) float
Normal to extract flat pattern along
origin : None or (3,) float
Origin of plane to project mesh onto
ignore_sign : bool
Allow a projection from the normal vector in
either direction: this provides a substantial speedup
on watertight meshes where the direction is irrelevant
but if you have a triangle soup and want to discard
backfaces you should set this to False.
rpad : float
Proportion to pad polygons by before unioning
and then de-padding result by to avoid zero-width gaps.
apad : float
Absolute padding to pad polygons by before unioning
and then de-padding result by to avoid zero-width gaps.
tol_dot : float
Tolerance for discarding on-edge triangles.
max_regions : int
Raise an exception if the mesh has more than this
number of disconnected regions to fail quickly before
unioning.
Returns
----------
projected : shapely.geometry.Polygon or None
Outline of source mesh
Raises
---------
ValueError
If max_regions is exceeded
"""
# make sure normal is a unitized copy
normal = np.array(normal, dtype=np.float64)
normal /= np.linalg.norm(normal)
# the projection of each face normal onto facet normal
dot_face = np.dot(normal, mesh.face_normals.T)
if ignore_sign:
# for watertight mesh speed up projection by handling side with less faces
# check if face lies on front or back of normal
front = dot_face > tol_dot
back = dot_face < -tol_dot
# divide the mesh into front facing section and back facing parts
# and discard the faces perpendicular to the axis.
# since we are doing a unary_union later we can use the front *or*
# the back so we use which ever one has fewer triangles
# we want the largest nonzero group
count = np.array([front.sum(), back.sum()])
if count.min() == 0:
# if one of the sides has zero faces we need the other
pick = count.argmax()
else:
# otherwise use the normal direction with the fewest faces
pick = count.argmin()
# use the picked side
side = [front, back][pick]
else:
# if explicitly asked to care about the sign
# only handle the front side of normal
side = dot_face > tol_dot
# subset the adjacency pairs to ones which have both faces included
# on the side we are currently looking at
adjacency_check = side[mesh.face_adjacency].all(axis=1)
adjacency = mesh.face_adjacency[adjacency_check]
# transform from the mesh frame in 3D to the XY plane
to_2D = geometry.plane_transform(origin=origin, normal=normal)
# transform mesh vertices to 2D and clip the zero Z
vertices_2D = transform_points(mesh.vertices, to_2D)[:, :2]
if precise:
eps = 1e-10
faces = mesh.faces[side]
# just union all the polygons
return (
ops.unary_union(
[Polygon(f) for f in vertices_2D[np.column_stack((faces, faces[:, :1]))]]
)
.buffer(eps)
.buffer(-eps)
)
# a sequence of face indexes that are connected
face_groups = graph.connected_components(adjacency, nodes=np.nonzero(side)[0])
# reshape edges into shape length of faces for indexing
edges = mesh.edges_sorted.reshape((-1, 6))
polygons = []
for faces in face_groups:
# index edges by face then shape back to individual edges
edge = edges[faces].reshape((-1, 2))
# edges that occur only once are on the boundary
group = grouping.group_rows(edge, require_count=1)
# turn each region into polygons
polygons.extend(edges_to_polygons(edges=edge[group], vertices=vertices_2D))
padding = 0.0
if apad is not None:
# set padding by absolute value
padding += float(apad)
if rpad is not None:
# get the 2D scale as the longest side of the AABB
scale = np.ptp(vertices_2D, axis=0).max()
# apply the scale-relative padding
padding += float(rpad) * scale
# if there is only one region we don't need to run a union
elif len(polygons) == 1:
return polygons[0]
elif len(polygons) == 0:
return None
# in my tests this was substantially faster than `shapely.ops.unary_union`
reduced = reduce_cascade(lambda a, b: a.union(b), polygons)
# can be None
if reduced is not None:
return reduced.buffer(padding).buffer(-padding)
def second_moments(polygon: Polygon, return_centered=False):
"""
Calculate the second moments of area of a polygon
from the boundary.
Parameters
------------
polygon : shapely.geometry.Polygon
Closed polygon.
return_centered : bool
Get second moments for a frame with origin at the centroid
and perform a principal axis transformation.
Returns
----------
moments : (3,) float
The values of `[Ixx, Iyy, Ixy]`
principal_moments : (2,) float
Principal second moments of inertia: `[Imax, Imin]`
Only returned if `centered`.
alpha : float
Angle by which the polygon needs to be rotated, so the
principal axis align with the X and Y axis.
Only returned if `centered`.
transform : (3, 3) float
Transformation matrix which rotates the polygon by alpha.
Only returned if `centered`.
"""
transform = np.eye(3)
if return_centered:
# calculate centroid and move polygon
transform[:2, 2] = -np.array(polygon.centroid.coords)
polygon = transform_polygon(polygon, transform)
# start with the exterior
coords = np.array(polygon.exterior.coords)
# shorthand the coordinates
x1, y1 = np.vstack((coords[-1], coords[:-1])).T
x2, y2 = coords.T
# do vectorized operations
v = x1 * y2 - x2 * y1
Ixx = np.sum(v * (y1 * y1 + y1 * y2 + y2 * y2)) / 12.0
Iyy = np.sum(v * (x1 * x1 + x1 * x2 + x2 * x2)) / 12.0
Ixy = np.sum(v * (x1 * y2 + 2 * x1 * y1 + 2 * x2 * y2 + x2 * y1)) / 24.0
for interior in polygon.interiors:
coords = np.array(interior.coords)
# shorthand the coordinates
x1, y1 = np.vstack((coords[-1], coords[:-1])).T
x2, y2 = coords.T
# do vectorized operations
v = x1 * y2 - x2 * y1
Ixx -= np.sum(v * (y1 * y1 + y1 * y2 + y2 * y2)) / 12.0
Iyy -= np.sum(v * (x1 * x1 + x1 * x2 + x2 * x2)) / 12.0
Ixy -= np.sum(v * (x1 * y2 + 2 * x1 * y1 + 2 * x2 * y2 + x2 * y1)) / 24.0
moments = [Ixx, Iyy, Ixy]
if not return_centered:
return moments
# get the principal moments
root = np.sqrt(((Iyy - Ixx) / 2.0) ** 2 + Ixy**2)
Imax = (Ixx + Iyy) / 2.0 + root
Imin = (Ixx + Iyy) / 2.0 - root
principal_moments = [Imax, Imin]
# do the principal axis transform
if np.isclose(Ixy, 0.0, atol=1e-12):
alpha = 0
elif np.isclose(Ixx, Iyy):
# prevent division by 0
alpha = 0.25 * np.pi
else:
alpha = 0.5 * np.arctan(2.0 * Ixy / (Ixx - Iyy))
# construct transformation matrix
cos_alpha = np.cos(alpha)
sin_alpha = np.sin(alpha)
transform[0, 0] = cos_alpha
transform[1, 1] = cos_alpha
transform[0, 1] = -sin_alpha
transform[1, 0] = sin_alpha
return moments, principal_moments, alpha, transform
@@ -0,0 +1,111 @@
"""
raster.py
------------
Turn 2D vector paths into raster images using `pillow`
"""
import numpy as np
try:
# keep pillow as a soft dependency
from PIL import Image, ImageChops, ImageDraw
except BaseException as E:
from .. import exceptions
# re-raise the useful exception when called
_handle = exceptions.ExceptionWrapper(E)
Image = _handle
ImageDraw = _handle
ImageChops = _handle
from ..typed import ArrayLike, Floating, Optional, Union
def rasterize(
path: "trimesh.path.Path2D", # noqa
pitch: Union[Floating, ArrayLike, None] = None,
origin: Optional[ArrayLike] = None,
resolution=None,
fill=True,
width=None,
):
"""
Rasterize a Path2D object into a boolean image ("mode 1").
Parameters
------------
path : Path2D
Original geometry
pitch : float or (2,) float
Length(s) in model space of pixel edges
origin : (2,) float
Origin position in model space
resolution : (2,) int
Resolution in pixel space
fill : bool
If True will return closed regions as filled
width : int
If not None will draw outline this wide in pixels
Returns
------------
raster : PIL.Image
Rasterized version of input as `mode 1` image
"""
if pitch is None:
if resolution is not None:
resolution = np.array(resolution, dtype=np.int64)
# establish pitch from passed resolution
pitch = (path.extents / (resolution + 2)).max()
else:
pitch = path.extents.max() / 2048
if origin is None:
origin = path.bounds[0] - (pitch * 2.0)
# check inputs
pitch = np.asanyarray(pitch, dtype=np.float64)
origin = np.asanyarray(origin, dtype=np.float64)
# if resolution is None make it larger than path
if resolution is None:
span = np.ptp(np.vstack((path.bounds, origin)), axis=0)
resolution = np.ceil(span / pitch) + 2
# get resolution as a (2,) int tuple
resolution = np.asanyarray(resolution, dtype=np.int64)
resolution = tuple(resolution.tolist())
# convert all discrete paths to pixel space
discrete = [((i - origin) / pitch).round().astype(np.int64) for i in path.discrete]
# the path indexes that are exteriors
# needed to know what to fill/empty but expensive
roots = path.root
enclosure = path.enclosure_directed
# draw the exteriors
result = Image.new(mode="1", size=resolution)
draw = ImageDraw.Draw(result)
# if a width is specified draw the outline
if width is not None:
width = int(width)
for coords in discrete:
draw.line(coords.flatten().tolist(), fill=1, width=width)
# if we are not filling the polygon exit
if not fill:
return result
# roots are ordered by degree
# so we draw the outermost one first
# and then go in as we progress
for root in roots:
# draw the exterior
draw.polygon(discrete[root].flatten().tolist(), fill=1)
# draw the interior children
for child in enclosure[root]:
draw.polygon(discrete[child].flatten().tolist(), fill=0)
return result
@@ -0,0 +1,103 @@
"""
repair.py
--------------
Try to fix problems with closed regions.
"""
import numpy as np
from scipy.spatial import cKDTree
from .. import util
from . import segments
def fill_gaps(path, distance=0.025):
"""
Find vertices without degree 2 and try to connect to
other vertices. Operations are done in-place.
Parameters
------------
segments : trimesh.path.Path2D
Line segments defined by start and end points
"""
# find any vertex without degree 2 (connected to two things)
broken = np.array([k for k, d in dict(path.vertex_graph.degree()).items() if d != 2])
# if all vertices have correct connectivity, exit
if len(broken) == 0:
return
# first find broken vertices with distance
tree = cKDTree(path.vertices[broken])
pairs = tree.query_pairs(r=distance, output_type="ndarray")
connect_seg = []
if len(pairs) > 0:
end_points = {tuple(sorted(e.end_points)) for e in path.entities}
pair_set = {tuple(i) for i in np.sort(broken[pairs], axis=1)}
# we don't want to connect entities to themselves so do a set
# difference
mask = np.array(list(pair_set.difference(end_points)))
if len(mask) > 0:
connect_seg = path.vertices[mask]
# a set of values we can query intersections with quickly
broken_set = set(broken)
# query end points set vs path.dangling to avoid having
# to compute every single path and discrete curve
dangle = [
i
for i, e in enumerate(path.entities)
if len(broken_set.intersection(e.end_points)) > 0
]
segs = []
# mask for which entities to keep
keep = np.ones(len(path.entities), dtype=bool)
# save a reference to the line class to avoid circular import
line_class = None
for entity_index in dangle:
# only consider line entities
if path.entities[entity_index].__class__.__name__ != "Line":
continue
if line_class is None:
line_class = path.entities[entity_index].__class__
# get discrete version of entity
points = path.entities[entity_index].discrete(path.vertices)
# turn connected curve into segments
seg_idx = util.stack_lines(np.arange(len(points)))
# append the segments to our collection
segs.append(points[seg_idx])
# remove this entity and replace with segments
keep[entity_index] = False
# combine segments with connection segments
all_segs = util.vstack_empty((util.vstack_empty(segs), connect_seg))
# go home early
if len(all_segs) == 0:
return
# split segments at broken vertices so topology can happen
split = segments.split(all_segs, path.vertices[broken])
# merge duplicate segments
final_seg = segments.unique(split)
# add line segments in as line entities
entities = []
for i in range(len(final_seg)):
entities.append(line_class(points=np.arange(2) + (i * 2) + len(path.vertices)))
# replace entities with new entities
path.entities = np.append(path.entities[keep], entities)
path.vertices = np.vstack((path.vertices, np.vstack(final_seg)))
path._cache.clear()
path.process()
@@ -0,0 +1,524 @@
"""
segments.py
--------------
Deal with (n, 2, 3) line segments.
"""
import numpy as np
from .. import geometry, transformations, util
from ..constants import tol
from ..grouping import group_rows, unique_rows
from ..interval import union
from ..typed import ArrayLike, NDArray, float64
def segments_to_parameters(segments: ArrayLike):
"""
For 3D line segments defined by two points, turn
them in to an origin defined as the closest point along
the line to the zero origin as well as a direction vector
and start and end parameter.
Parameters
------------
segments : (n, 2, 3) float
Line segments defined by start and end points
Returns
--------------
origins : (n, 3) float
Point on line closest to [0, 0, 0]
vectors : (n, 3) float
Unit line directions
parameters : (n, 2) float
Start and end distance pairs for each line
"""
segments = np.asanyarray(segments, dtype=np.float64)
if not util.is_shape(segments, (-1, 2, (2, 3))):
raise ValueError("incorrect segment shape!", segments.shape)
# make the initial origin one of the end points
endpoint = segments[:, 0]
vectors = segments[:, 1] - endpoint
vectors_norm = util.row_norm(vectors)
vectors /= vectors_norm.reshape((-1, 1))
# find the point along the line nearest the origin
offset = util.diagonal_dot(endpoint, vectors)
# points nearest [0, 0, 0] will be our new origin
origins = endpoint + (offset.reshape((-1, 1)) * -vectors)
# parametric start and end of line segment
parameters = np.column_stack((offset, offset + vectors_norm))
# make sure signs are consistent
vectors, signs = util.vector_hemisphere(vectors, return_sign=True)
parameters *= signs.reshape((-1, 1))
return origins, vectors, parameters
def parameters_to_segments(
origins: NDArray[float64], vectors: ArrayLike, parameters: NDArray[float64]
):
"""
Convert a parametric line segment representation to
a two point line segment representation
Parameters
------------
origins : (n, 3) float
Line origin point
vectors : (n, 3) float
Unit line directions
parameters : (n, 2) float
Start and end distance pairs for each line
Returns
--------------
segments : (n, 2, 3) float
Line segments defined by start and end points
"""
# don't copy input
origins = np.asanyarray(origins, dtype=np.float64)
vectors = np.asanyarray(vectors, dtype=np.float64)
parameters = np.asanyarray(parameters, dtype=np.float64)
# turn the segments into a reshapable 2D array
segments = np.hstack(
(origins + vectors * parameters[:, :1], origins + vectors * parameters[:, 1:])
)
return segments.reshape((-1, 2, origins.shape[1]))
def colinear_pairs(segments, radius=0.01, angle=0.01, length=None):
"""
Find pairs of segments which are colinear.
Parameters
-------------
segments : (n, 2, (2, 3)) float
Two or three dimensional line segments
radius : float
Maximum radius line origins can differ
and be considered colinear
angle : float
Maximum angle in radians segments can
differ and still be considered colinear
length : None or float
If specified, will additionally require
that pairs have a *vertex* within this distance.
Returns
------------
pairs : (m, 2) int
Indexes of segments which are colinear
"""
from scipy import spatial
# convert segments to parameterized origins
# which are the closest point on the line to
# the actual zero- origin
origins, vectors, _param = segments_to_parameters(segments)
# create a kdtree for origins
tree = spatial.cKDTree(origins)
# find origins closer than specified radius
pairs = tree.query_pairs(r=radius, output_type="ndarray")
# calculate angles between pairs
angles = geometry.vector_angle(vectors[pairs])
# angles can be within tolerance of 180 degrees or 0.0 degrees
angle_ok = np.logical_or(
util.isclose(angles, np.pi, atol=angle), util.isclose(angles, 0.0, atol=angle)
)
# apply angle threshold
colinear = pairs[angle_ok]
# if length is specified check endpoint proximity
if length is not None:
# `segments` index of colinear pairs
a, b = colinear.T
# we want the minimum distance of any of these pairs:
# a[0] - b[0]
# a[1] - b[0]
# a[0] - b[1]
# a[1] - b[1]
# do it in the most confusing possible vectorized way
min_vertex = np.linalg.norm(
segments[a][:, [0, 1, 0, 1], :] - segments[b][:, [0, 0, 1, 1], :], axis=2
).min(axis=1)
# remove pairs that don't meet the distance metric
colinear = colinear[min_vertex < length]
return colinear
def clean(segments: ArrayLike, digits: int = 10) -> NDArray[float64]:
"""
Clean up line segments by unioning the ranges of colinear segments.
Parameters
------------
segments : (n, 2, 2) or (n, 2, 3)
Line segments in space.
digits
How many digits to consider.
Returns
-----------
cleaned : (m, 2, 2) or (m, 2, 3)
Where `m <= n`
"""
# convert segments to parameterized origins
# which are the closest point on the line to
# the actual zero- origin
origins, vectors, param = segments_to_parameters(segments)
# make sure parameters are in min-max order
param.sort(axis=1)
# find the groups of values with identical origins and vectors
groups = group_rows(np.column_stack((origins, vectors)), digits=digits)
# get the union of every interval range for colinear segments
unions = [union(param[g][param[g][:, 0].argsort()], sort=False) for g in groups]
# reconstruct indexes for the origins and vectors
indexes = np.concatenate([g[: len(u)] for g, u in zip(groups, unions)])
# convert parametric form back into vertex-segment form
return parameters_to_segments(
origins=origins[indexes], vectors=vectors[indexes], parameters=np.vstack(unions)
)
def split(segments, points, atol=1e-5):
"""
Find any points that lie on a segment (not an endpoint)
and then split that segment into two segments.
We are basically going to find the distance between
point and both segment vertex, and see if it is with
tolerance of the segment length.
Parameters
--------------
segments : (n, 2, (2, 3) float
Line segments in space
points : (n, (2, 3)) float
Points in space
atol : float
Absolute tolerance for distances
Returns
-------------
split : (n, 2, (3 | 3) float
Line segments in space, split at vertices
"""
points = np.asanyarray(points, dtype=np.float64)
segments = np.asanyarray(segments, dtype=np.float64)
# reshape to a flat 2D (n, dimension) array
seg_flat = segments.reshape((-1, segments.shape[2]))
# find the length of every segment
length = ((segments[:, 0, :] - segments[:, 1, :]) ** 2).sum(axis=1) ** 0.5
# a mask to remove segments we split at the end
keep = np.ones(len(segments), dtype=bool)
# append new segments to a list
new_seg = []
# loop through every point
for p in points:
# note that you could probably get a speedup
# by using scipy.spatial.distance.cdist here
# find the distance from point to every segment endpoint
pair = ((seg_flat - p) ** 2).sum(axis=1).reshape((-1, 2)) ** 0.5
# point is on a segment if it is not on a vertex
# and the sum length is equal to the actual segment length
on_seg = np.logical_and(
util.isclose(length, pair.sum(axis=1), atol=atol),
~util.isclose(pair, 0.0, atol=atol).any(axis=1),
)
# if we have any points on the segment split it in twain
if on_seg.any():
# remove the original segment
keep = np.logical_and(keep, ~on_seg)
# split every segment that this point lies on
for seg in segments[on_seg]:
new_seg.append([p, seg[0]])
new_seg.append([p, seg[1]])
if len(new_seg) > 0:
return np.vstack((segments[keep], new_seg))
else:
return segments
def unique(segments, digits=5):
"""
Find unique non-zero line segments.
Parameters
------------
segments : (n, 2, (2|3)) float
Line segments in space
digits : int
How many digits to consider when merging vertices
Returns
-----------
unique : (m, 2, (2|3)) float
Segments with duplicates merged
"""
segments = np.asanyarray(segments, dtype=np.float64)
# find segments as unique indexes so we can find duplicates
inverse = unique_rows(segments.reshape((-1, segments.shape[2])), digits=digits)[
1
].reshape((-1, 2))
# make sure rows are sorted
inverse.sort(axis=1)
# remove segments where both indexes are the same
mask = np.zeros(len(segments), dtype=bool)
# only include the first occurrence of a segment
mask[unique_rows(inverse)[0]] = True
# remove segments that are zero-length
mask[inverse[:, 0] == inverse[:, 1]] = False
# apply the unique mask
unique = segments[mask]
return unique
def extrude(segments, height, double_sided=False):
"""
Extrude 2D line segments into 3D triangles.
Parameters
-------------
segments : (n, 2, 2) float
2D line segments
height : float
Distance to extrude along Z
double_sided : bool
If true, return 4 triangles per segment
Returns
-------------
vertices : (n, 3) float
Vertices in space
faces : (n, 3) int
Indices of vertices forming triangles
"""
segments = np.asanyarray(segments, dtype=np.float64)
if not util.is_shape(segments, (-1, 2, 2)):
raise ValueError("segments shape incorrect")
# we are creating two vertices triangles for every 2D line segment
# on the segments of the 2D triangulation
vertices = np.column_stack(
(
np.tile(segments.reshape((-1, 2)), 2).reshape((-1, 2)),
np.tile([0, height, 0, height], len(segments)),
)
)
faces = (
np.tile([3, 1, 2, 2, 1, 0], (len(segments), 1))
+ np.arange(len(segments)).reshape((-1, 1)) * 4
).reshape((-1, 3))
if double_sided:
# stack so they will render from the back
faces = np.vstack((faces, np.fliplr(faces)))
return vertices, faces
def length(segments, summed=True):
"""
Extrude 2D line segments into 3D triangles.
Parameters
-------------
segments : (n, 2, 2) float
2D line segments
height : float
Distance to extrude along Z
double_sided : bool
If true, return 4 triangles per segment
Returns
-------------
vertices : (n, 3) float
Vertices in space
faces : (n, 3) int
Indices of vertices forming triangles
"""
segments = np.asanyarray(segments)
norms = util.row_norm(segments[:, 0, :] - segments[:, 1, :])
if summed:
return norms.sum()
return norms
def resample(segments, maxlen, return_index=False, return_count=False):
"""
Resample line segments until no segment
is longer than maxlen.
Parameters
-------------
segments : (n, 2, 2|3) float
2D line segments
maxlen : float
The maximum length of a line segment
return_index : bool
Return the index of the source segment
return_count : bool
Return how many segments each original was split into
Returns
-------------
resampled : (m, 2, 2|3) float
Line segments where no segment is longer than maxlen
index : (m,) int
[OPTIONAL] The index of segments resampled came from
count : (n,) int
[OPTIONAL] The count of the original segments
"""
# check arguments
maxlen = float(maxlen)
segments = np.array(segments, dtype=np.float64)
if len(segments.shape) != 3:
raise ValueError(f"{segments.shape} != (n, 2, 2|3)")
dimension = segments.shape[2]
# shortcut for endpoints
pt1 = segments[:, 0]
pt2 = segments[:, 1]
# vector between endpoints
vec = pt2 - pt1
# the integer number of times a segment needs to be split
splits = np.ceil(util.row_norm(vec) / maxlen).astype(np.int64)
# save resulting segments
result = []
# save index of original segment
index = []
tile = np.tile
# generate the line indexes ahead of time
stacks = util.stack_lines(np.arange(splits.max() + 1))
# loop through each count of unique splits needed
for split in np.unique(splits):
# get a mask of which segments need to be split
mask = splits == split
# the vector for each incremental length
increment = vec[mask] / split
# stack the increment vector into the shape needed
v = tile(increment, split + 1).reshape((-1, dimension)) * tile(
np.arange(split + 1), len(increment)
).reshape((-1, 1))
# stack the origin points correctly
o = tile(pt1[mask], split + 1).reshape((-1, dimension))
# now get each segment as an (split, 3) polyline
poly = (o + v).reshape((-1, split + 1, dimension))
# save the resulting segments
# magical slicing is equivalent to:
# > [p[stack] for p in poly]
result.extend(poly[:, stacks[:split]])
if return_index:
# get the original index from the mask
index_original = np.nonzero(mask)[0].reshape((-1, 1))
# save one entry per split segment
index.append(
(np.ones((len(poly), split), dtype=np.int64) * index_original).ravel()
)
if tol.strict:
# check to make sure every start and end point
# from the reconstructed result corresponds
for original, recon in zip(segments[mask], poly):
assert np.allclose(original[0], recon[0])
assert np.allclose(original[-1], recon[-1])
# make sure stack slicing was OK
assert np.allclose(util.stack_lines(np.arange(split + 1)), stacks[:split])
# stack into (n, 2, 3) segments
result = [np.concatenate(result)]
if tol.strict:
# make sure resampled segments have the same length as input
assert np.isclose(length(segments), length(result[0]), atol=1e-3)
# stack additional return options
if return_index:
# stack original indexes
index = np.concatenate(index)
if tol.strict:
# index should correspond to result
assert len(index) == len(result[0])
# every segment should be represented
assert set(index) == set(range(len(segments)))
result.append(index)
if return_count:
result.append(splits)
if len(result) == 1:
return result[0]
return result
def to_svg(segments, digits=4, matrix=None, merge=True):
"""
Convert (n, 2, 2) line segments to an SVG path string.
Parameters
------------
segments : (n, 2, 2) float
Line segments to convert
digits : int
Number of digits to include in SVG string
matrix : None or (3, 3) float
Homogeneous 2D transformation to apply before export
Returns
-----------
path : str
SVG path string with one line per segment
IE: 'M 0.1 0.2 L 10 12'
"""
segments = np.array(segments, copy=True)
if not util.is_shape(segments, (-1, 2, 2)):
raise ValueError("only for (n, 2, 2) segments!")
# create the array to export
# apply 2D transformation if passed
if matrix is not None:
segments = transformations.transform_points(
segments.reshape((-1, 2)), matrix=matrix
).reshape((-1, 2, 2))
if merge:
# remove duplicate and zero-length segments
segments = unique(segments, digits=digits)
# create the format string for a single line segment
base = "M_ _L_ _".replace("_", "{:0." + str(int(digits)) + "f}")
# create one large format string then apply points
result = (base * len(segments)).format(*segments.ravel())
return result
@@ -0,0 +1,426 @@
import collections
import copy
import numpy as np
from .. import util
from ..constants import log
from ..constants import tol_path as tol
from ..nsphere import fit_nsphere
from . import arc, entities
def fit_circle_check(points, scale, prior=None, final=False, verbose=False):
"""
Fit a circle, and reject the fit if:
* the radius is larger than tol.radius_min*scale or tol.radius_max*scale
* any segment spans more than tol.seg_angle
* any segment is longer than tol.seg_frac*scale
* the fit deviates by more than tol.radius_frac*radius
* the segments on the ends deviate from tangent by more than tol.tangent
Parameters
---------
points : (n, d)
List of points which represent a path
prior : (center, radius) tuple
Best guess or None if unknown
scale : float
What is the overall scale of the set of points
verbose : bool
Output log.debug messages for the reasons
for fit rejection only suggested for manual debugging
Returns
-----------
if fit is acceptable:
(center, radius) tuple
else:
None
"""
# an arc needs at least three points
if len(points) < 3:
return None
# make sure our points are a numpy array
points = np.asanyarray(points, dtype=np.float64)
# do a least squares fit on the points
C, R, r_deviation = fit_nsphere(points, prior=prior)
# check to make sure radius is between min and max allowed
if not tol.radius_min < (R / scale) < tol.radius_max:
if verbose:
log.debug("circle fit error: R %f", R / scale)
return None
# check point radius error
r_error = r_deviation / R
if r_error > tol.radius_frac:
if verbose:
log.debug("circle fit error: fit %s", str(r_error))
return None
vectors = np.diff(points, axis=0)
segment = util.row_norm(vectors)
# approximate angle in radians, segments are linear length
# not arc length but this is close and avoids a cosine
angle = segment / R
if (angle > tol.seg_angle).any():
if verbose:
log.debug("circle fit error: angle %s", str(angle))
return None
if final and (angle > tol.seg_angle_min).sum() < 3:
log.debug("final: angle %s", str(angle))
return None
# check segment length as a fraction of drawing scale
scaled = segment / scale
if (scaled > tol.seg_frac).any():
if verbose:
log.debug("circle fit error: segment %s", str(scaled))
return None
# check to make sure the line segments on the ends are actually
# tangent with the candidate circle fit
mid_pt = points[[0, -2]] + (vectors[[0, -1]] * 0.5)
radial = util.unitize(mid_pt - C)
ends = util.unitize(vectors[[0, -1]])
tangent = np.abs(np.arccos(util.diagonal_dot(radial, ends)))
tangent = np.abs(tangent - np.pi / 2).max()
if tangent > tol.tangent:
if verbose:
log.debug("circle fit error: tangent %f", np.degrees(tangent))
return None
result = {"center": C, "radius": R}
return result
def is_circle(points, scale, verbose=False):
"""
Given a set of points, quickly determine if they represent
a circle or not.
Parameters
-------------
points : (n,2 ) float
Points in space
scale : float
Scale of overall drawing
verbose : bool
Print all fit messages or not
Returns
-------------
control: (3,2) float, points in space, OR
None, if not a circle
"""
# make sure input is a numpy array
points = np.asanyarray(points)
scale = float(scale)
# can only be a circle if the first and last point are the
# same (AKA is a closed path)
if np.linalg.norm(points[0] - points[-1]) > tol.merge:
return None
box = np.ptp(points, axis=0)
# the bounding box size of the points
# check aspect ratio as an early exit if the path is not a circle
aspect = np.divide(*box)
if np.abs(aspect - 1.0) > tol.aspect_frac:
return None
# fit a circle with tolerance checks
CR = fit_circle_check(points, scale=scale)
if CR is None:
return None
# return the circle as three control points
control = arc.to_threepoint(**CR)
return control
def merge_colinear(points, scale):
"""
Given a set of points representing a path in space,
merge points which are colinear.
Parameters
----------
points : (n, dimension) float
Points in space
scale : float
Scale of drawing for precision
Returns
----------
merged : (j, d) float
Points with colinear and duplicate
points merged, where (j < n)
"""
points = np.asanyarray(points, dtype=np.float64)
scale = float(scale)
if len(points.shape) != 2 or points.shape[1] != 2:
raise ValueError("only for 2D points!")
# if there's less than 3 points nothing to merge
if len(points) < 3:
return points.copy()
# the vector from one point to the next
direction = points[1:] - points[:-1]
# the length of the direction vector
direction_norm = util.row_norm(direction)
# make sure points don't have zero length
direction_ok = direction_norm > tol.merge
# remove duplicate points
points = np.vstack((points[0], points[1:][direction_ok]))
direction = direction[direction_ok]
direction_norm = direction_norm[direction_ok]
# create a vector between every other point, then turn it perpendicular
# if we have points A B C D
# and direction vectors A-B, B-C, etc
# these will be perpendicular to the vectors A-C, B-D, etc
perp = (points[2:] - points[:-2]).T[::-1].T
perp[:, 0] *= -1
perp_norm = util.row_norm(perp)
perp_nonzero = perp_norm > tol.merge
perp[perp_nonzero] /= perp_norm[perp_nonzero].reshape((-1, 1))
# find the projection of each direction vector
# onto the perpendicular vector
projection = np.abs(util.diagonal_dot(perp, direction[:-1]))
projection_ratio = np.max(
(projection / direction_norm[1:], projection / direction_norm[:-1]), axis=0
)
mask = np.ones(len(points), dtype=bool)
# since we took diff, we need to offset by one
mask[1:-1][projection_ratio < 1e-4 * scale] = False
merged = points[mask]
return merged
def resample_spline(points, smooth=0.001, count=None, degree=3):
"""
Resample a path in space, smoothing along a b-spline.
Parameters
-----------
points : (n, dimension) float
Points in space
smooth : float
Smoothing distance
count : int or None
Number of samples desired in output
degree : int
Degree of spline polynomial
Returns
---------
resampled : (count, dimension) float
Points in space
"""
from scipy.interpolate import splev, splprep
if count is None:
count = len(points)
points = np.asanyarray(points)
closed = np.linalg.norm(points[0] - points[-1]) < tol.merge
tpl = splprep(points.T, s=smooth, k=degree)[0]
i = np.linspace(0.0, 1.0, count)
resampled = np.column_stack(splev(i, tpl))
if closed:
shared = resampled[[0, -1]].mean(axis=0)
resampled[0] = shared
resampled[-1] = shared
return resampled
def points_to_spline_entity(points, smooth=None, count=None):
"""
Create a spline entity from a curve in space
Parameters
-----------
points : (n, dimension) float
Points in space
smooth : float
Smoothing distance
count : int or None
Number of samples desired in result
Returns
---------
entity : entities.BSpline
Entity object with points indexed at zero
control : (m, dimension) float
New vertices for entity
"""
from scipy.interpolate import splprep
if count is None:
count = len(points)
if smooth is None:
smooth = 0.002
points = np.asanyarray(points, dtype=np.float64)
closed = np.linalg.norm(points[0] - points[-1]) < tol.merge
knots, control, _degree = splprep(points.T, s=smooth)[0]
control = np.transpose(control)
index = np.arange(len(control))
if closed:
control[0] = control[[0, -1]].mean(axis=0)
control = control[:-1]
index[-1] = index[0]
entity = entities.BSpline(points=index, knots=knots, closed=closed)
return entity, control
def simplify_basic(drawing, process=False, **kwargs):
"""
Merge colinear segments and fit circles.
Parameters
-----------
drawing : Path2D
Source geometry, will not be modified
Returns
-----------
simplified : Path2D
Original path but with some closed line-loops converted to circles
"""
if any(entity.__class__.__name__ != "Line" for entity in drawing.entities):
log.debug("Skipping path containing entities other than `Line`")
return drawing
# we are going to do a bookkeeping to avoid having
# to recompute literally everything when simplification is ran
cache = copy.deepcopy(drawing._cache)
# store new values
vertices_new = collections.deque()
entities_new = collections.deque()
# avoid thrashing cache in loop
scale = drawing.scale
# loop through (n, 2) closed paths
for discrete in drawing.discrete:
# check to see if the closed entity is a circle
circle = is_circle(discrete, scale=scale)
if circle is not None:
# the points are circular enough for our high standards
# so replace them with a closed Arc entity
entities_new.append(
entities.Arc(points=np.arange(3) + len(vertices_new), closed=True)
)
vertices_new.extend(circle)
else:
# not a circle, so clean up colinear segments
# then save it as a single line entity
points = merge_colinear(discrete, scale=scale)
# references for new vertices
indexes = np.arange(len(points)) + len(vertices_new)
# discrete curves are always closed
indexes[-1] = indexes[0]
# append new vertices and entity
entities_new.append(entities.Line(points=indexes))
vertices_new.extend(points)
# create the new drawing object
simplified = type(drawing)(
entities=entities_new,
vertices=vertices_new,
metadata=copy.deepcopy(drawing.metadata),
process=process,
)
# we have changed every path to a single closed entity
# either a closed arc, or a closed line
# so all closed paths are now represented by a single entity
cache.cache.update(
{
"paths": np.arange(len(entities_new)).reshape((-1, 1)),
"path_valid": np.ones(len(entities_new), dtype=bool),
"dangling": np.array([]),
}
)
# force recompute of exact bounds
if "bounds" in cache.cache:
cache.cache.pop("bounds")
simplified._cache = cache
# set the cache ID so it won't dump when a value is requested
simplified._cache.id_set()
return simplified
def simplify_spline(path, smooth=None, verbose=False):
"""
Replace discrete curves with b-spline or Arc and
return the result as a new Path2D object.
Parameters
------------
path : trimesh.path.Path2D
Input geometry
smooth : float
Distance to smooth
Returns
------------
simplified : Path2D
Consists of Arc and BSpline entities
"""
new_vertices = []
new_entities = []
scale = path.scale
for discrete in path.discrete:
circle = is_circle(discrete, scale=scale, verbose=verbose)
if circle is not None:
# the points are circular enough for our high standards
# so replace them with a closed Arc entity
new_entities.append(
entities.Arc(points=np.arange(3) + len(new_vertices), closed=True)
)
new_vertices.extend(circle)
continue
# entities for this path
entity, vertices = points_to_spline_entity(discrete, smooth=smooth)
# reindex returned control points
entity.points += len(new_vertices)
# save entity and vertices
new_vertices.extend(vertices)
new_entities.append(entity)
# create the Path2D object for the result
simplified = type(path)(entities=new_entities, vertices=new_vertices)
return simplified
@@ -0,0 +1,493 @@
import copy
import numpy as np
from .. import constants, grouping, util
from ..typed import ArrayLike, Integer, NDArray, Number, Optional
from .util import is_ccw
try:
import networkx as nx
except BaseException as E:
# create a dummy module which will raise the ImportError
# or other exception only when someone tries to use networkx
from ..exceptions import ExceptionWrapper
nx = ExceptionWrapper(E)
def vertex_graph(entities):
"""
Given a set of entity objects generate a networkx.Graph
that represents their vertex nodes.
Parameters
--------------
entities : list
Objects with 'closed' and 'nodes' attributes
Returns
-------------
graph : networkx.Graph
Graph where node indexes represent vertices
closed : (n,) int
Indexes of entities which are 'closed'
"""
graph = nx.Graph()
closed = []
for index, entity in enumerate(entities):
if entity.closed:
closed.append(index)
else:
# or `entity.end_points`
graph.add_edges_from(entity.nodes, entity_index=index)
return graph, np.array(closed)
def vertex_to_entity_path(vertex_path, graph, entities, vertices=None):
"""
Convert a path of vertex indices to a path of entity indices.
Parameters
----------
vertex_path : (n,) int
Ordered list of vertex indices representing a path
graph : nx.Graph
Vertex connectivity
entities : (m,) list
Entity objects
vertices : (p, dimension) float
Vertex points in space
Returns
----------
entity_path : (q,) int
Entity indices which make up vertex_path
"""
def edge_direction(a, b):
"""
Given two edges, figure out if the first needs to be
reversed to keep the progression forward.
[1,0] [1,2] -1 1
[1,0] [2,1] -1 -1
[0,1] [1,2] 1 1
[0,1] [2,1] 1 -1
Parameters
------------
a : (2,) int
b : (2,) int
Returns
------------
a_direction : int
b_direction : int
"""
if a[0] == b[0]:
return -1, 1
elif a[0] == b[1]:
return -1, -1
elif a[1] == b[0]:
return 1, 1
elif a[1] == b[1]:
return 1, -1
else:
constants.log.debug(
"\n".join(
[
"edges not connected!",
"vertex path %s",
"entity path: %s",
"entity[a]: %s,",
"entity[b]: %s",
]
),
vertex_path,
entity_path,
entities[ea].points,
entities[eb].points,
)
return None, None
if vertices is None or vertices.shape[1] != 2:
ccw_direction = 1
else:
ccw_check = is_ccw(vertices[np.append(vertex_path, vertex_path[0])])
ccw_direction = (ccw_check * 2) - 1
# make sure vertex path is correct type
vertex_path = np.asanyarray(vertex_path, dtype=np.int64)
# we will be saving entity indexes
entity_path = []
# loop through pairs of vertices
for i in np.arange(len(vertex_path) + 1):
# get two wrapped vertex positions
vertex_path_pos = np.mod(np.arange(2) + i, len(vertex_path))
vertex_index = vertex_path[vertex_path_pos]
entity_index = graph.get_edge_data(*vertex_index)["entity_index"]
entity_path.append(entity_index)
# remove duplicate entities and order CCW
entity_path = grouping.unique_ordered(entity_path)[::ccw_direction]
# check to make sure there is more than one entity
if len(entity_path) == 1:
# apply CCW reverse in place if necessary
if ccw_direction < 0:
index = entity_path[0]
entities[index].reverse()
return entity_path
# traverse the entity path and reverse entities in place to
# align with this path ordering
round_trip = np.append(entity_path, entity_path[0])
round_trip = zip(round_trip[:-1], round_trip[1:])
for ea, eb in round_trip:
da, db = edge_direction(entities[ea].end_points, entities[eb].end_points)
if da is not None:
entities[ea].reverse(direction=da)
entities[eb].reverse(direction=db)
entity_path = np.array(entity_path)
return entity_path
def closed_paths(entities, vertices):
"""
Paths are lists of entity indices.
We first generate vertex paths using graph cycle algorithms,
and then convert them to entity paths.
This will also change the ordering of entity.points in place
so a path may be traversed without having to reverse the entity.
Parameters
-------------
entities : (n,) entity objects
Entity objects
vertices : (m, dimension) float
Vertex points in space
Returns
-------------
entity_paths : sequence of (n,) int
Ordered traversals of entities
"""
# get a networkx graph of entities
graph, closed = vertex_graph(entities)
# add entities that are closed as single- entity paths
entity_paths = np.reshape(closed, (-1, 1)).tolist()
# look for cycles in the graph, or closed loops
vertex_paths = nx.cycles.cycle_basis(graph)
# loop through every vertex cycle
for vertex_path in vertex_paths:
# a path has no length if it has fewer than 2 vertices
if len(vertex_path) < 2:
continue
# convert vertex indices to entity indices
entity_paths.append(vertex_to_entity_path(vertex_path, graph, entities, vertices))
return entity_paths
def discretize_path(entities, vertices, path, scale=1.0):
"""
Turn a list of entity indices into a path of connected points.
Parameters
-----------
entities : (j,) entity objects
Objects like 'Line', 'Arc', etc.
vertices: (n, dimension) float
Vertex points in space.
path : (m,) int
Indexes of entities
scale : float
Overall scale of drawing used for
Number tolerances in certain cases
Returns
-----------
discrete : (p, dimension) float
Connected points in space that lie on the
path and can be connected with line segments.
"""
# make sure vertices are numpy array
vertices = np.asanyarray(vertices)
path_len = len(path)
if path_len == 0:
raise ValueError("Cannot discretize empty path!")
if path_len == 1:
# case where we only have one entity
discrete = np.asanyarray(entities[path[0]].discrete(vertices, scale=scale))
else:
# run through path appending each entity
discrete = []
for i, entity_id in enumerate(path):
# the current (n, dimension) discrete curve of an entity
current = entities[entity_id].discrete(vertices, scale=scale)
# check if we are on the final entity
if i >= (path_len - 1):
# if we are on the last entity include the last point
discrete.append(current)
else:
# slice off the last point so we don't get duplicate
# points from the end of one entity and the start of another
discrete.append(current[:-1])
# stack all curves to one nice (n, dimension) curve
discrete = np.vstack(discrete)
# make sure 2D curves are are counterclockwise
if vertices.shape[1] == 2 and not is_ccw(discrete):
# reversing will make array non c- contiguous
discrete = np.ascontiguousarray(discrete[::-1])
return discrete
class PathSample:
def __init__(self, points: ArrayLike):
# make sure input array is numpy
self._points = np.array(points)
# find the direction of each segment
self._vectors = np.diff(self._points, axis=0)
# find the length of each segment
self._norms = util.row_norm(self._vectors)
# unit vectors for each segment
nonzero = self._norms > constants.tol_path.zero
self._unit_vec = self._vectors.copy()
self._unit_vec[nonzero] /= self._norms[nonzero].reshape((-1, 1))
# total distance in the path
self.length = self._norms.sum()
# cumulative sum of section length
# note that this is sorted
self._cum_norm = np.cumsum(self._norms)
def sample(
self, distances: ArrayLike, include_original: bool = False
) -> NDArray[np.float64]:
"""
Return points at the distances along the path requested.
Parameters
----------
distances
Distances along the path to sample at.
include_original
Include the original vertices even if they are not
specified in `distance`. Useful as this will return
a result with identical area and length, however
indexes of `distance` will not correspond with result.
Returns
--------
samples : (n, dimension)
Samples requested.
`n==len(distances)` if not `include_original`
"""
# return the indices in cum_norm that each sample would
# need to be inserted at to maintain the sorted property
positions = np.searchsorted(self._cum_norm, distances)
positions = np.clip(positions, 0, len(self._unit_vec) - 1)
offsets = np.append(0, self._cum_norm)[positions]
# the distance past the reference vertex we need to travel
projection = distances - offsets
# find out which direction we need to project
direction = self._unit_vec[positions]
# find out which vertex we're offset from
origin = self._points[positions]
# just the parametric equation for a line
resampled = origin + (direction * projection.reshape((-1, 1)))
if include_original:
# find the original positions that were not inserted
# note that this checks *exact float equal*
uninserted = ~np.isin(np.append(self._cum_norm, 0.0), projection)
if uninserted.any():
# find the index of the uninserted original points in the new sampling
index = np.searchsorted(positions, np.nonzero(uninserted)[0])
# insert the original points at the index
resampled = np.insert(resampled, index, self._points[uninserted], axis=0)
return resampled
def truncate(self, distance: Number) -> NDArray[np.float64]:
"""
Return a truncated version of the path.
Only one vertex (at the endpoint) will be added.
Parameters
----------
distance
Distance along the path to truncate at.
Returns
----------
path
Path clipped to `distance` requested.
"""
position = np.searchsorted(self._cum_norm, distance)
offset = distance - self._cum_norm[position - 1]
if offset < constants.tol_path.merge:
truncated = self._points[: position + 1]
else:
vector = util.unitize(
np.diff(self._points[np.arange(2) + position], axis=0).reshape(-1)
)
vector *= offset
endpoint = self._points[position] + vector
truncated = np.vstack((self._points[: position + 1], endpoint))
assert (
util.row_norm(np.diff(truncated, axis=0)).sum() - distance
) < constants.tol_path.merge
return truncated
def resample_path(
points: ArrayLike,
count: Optional[Integer] = None,
step: Optional[Number] = None,
step_round: bool = True,
include_original: bool = False,
) -> NDArray[np.float64]:
"""
Given a path along (n,d) points, resample them such that the
distance traversed along the path is constant in between each
of the resampled points. Note that this can produce clipping at
corners, as the original vertices are NOT guaranteed to be in the
new, resampled path.
ONLY ONE of count or step can be specified
Result can be uniformly distributed (np.linspace) by specifying count
Result can have a specific distance (np.arange) by specifying step
Parameters
----------
points: (n, d) float
Points in space
count : int,
Number of points to sample evenly (aka np.linspace)
step : float
Distance each step should take along the path (aka np.arange)
step_round
Alter `step` to the nearest integer division of overall length.
include_original
Include the exact original points in the output.
Returns
----------
resampled : (j,d) float
Points on the path
"""
points = np.array(points, dtype=np.float64)
# generate samples along the perimeter from kwarg count or step
if (count is not None) and (step is not None):
raise ValueError("Only step OR count can be specified")
if (count is None) and (step is None):
raise ValueError("Either step or count must be specified")
sampler = PathSample(points)
if step is not None and step_round:
if step >= sampler.length:
return points[[0, -1]]
count = int(np.ceil(sampler.length / step))
if count is not None:
samples = np.linspace(0, sampler.length, count)
elif step is not None:
samples = np.arange(0, sampler.length, step)
resampled = sampler.sample(samples, include_original=include_original)
if constants.tol.strict:
check = util.row_norm(points[[0, -1]] - resampled[[0, -1]])
assert check[0] < constants.tol_path.merge
if count is not None:
assert check[1] < constants.tol_path.merge
return resampled
def split(path):
"""
Split a Path2D into multiple Path2D objects where each
one has exactly one root curve.
Parameters
--------------
path : trimesh.path.Path2D
Input geometry
Returns
-------------
split : list of trimesh.path.Path2D
Original geometry as separate paths
"""
# avoid a circular import by referencing class of path
Path2D = type(path)
# save the results of the split to an array
split = []
# get objects from cache to avoid a bajillion
# cache checks inside the tight loop
paths = path.paths
discrete = path.discrete
polygons_closed = path.polygons_closed
enclosure_directed = path.enclosure_directed
for root_index, root in enumerate(path.root):
# get a list of the root curve's children
connected = list(enclosure_directed[root].keys())
# add the root node to the list
connected.append(root)
# store new paths and entities
new_paths = []
new_entities = []
for index in connected:
nodes = paths[index]
# add a path which is just sequential indexes
new_paths.append(np.arange(len(nodes)) + len(new_entities))
# save the entity indexes
new_entities.extend(nodes)
# store the root index from the original drawing
metadata = copy.deepcopy(path.metadata)
metadata["split_2D"] = root_index
# we made the root path the last index of connected
new_root = np.array([len(new_paths) - 1])
# prevents the copying from nuking our cache
with path._cache:
# create the Path2D
split.append(
Path2D(
entities=copy.deepcopy(path.entities[new_entities]),
vertices=copy.deepcopy(path.vertices),
metadata=metadata,
)
)
# add back expensive things to the cache
split[-1]._cache.update(
{
"paths": new_paths,
"polygons_closed": polygons_closed[connected],
"discrete": [discrete[c] for c in connected],
"root": new_root,
}
)
# set the cache ID
split[-1]._cache.id_set()
return np.array(split)
@@ -0,0 +1,59 @@
import numpy as np
from ..util import is_ccw # NOQA
def concatenate(paths, **kwargs):
"""
Concatenate multiple paths into a single path.
Parameters
-------------
paths : (n,) Path
Path objects to concatenate
kwargs
Passed through to the path constructor
Returns
-------------
concat : Path, Path2D, or Path3D
Concatenated result
"""
# if only one path object just return copy
if len(paths) == 1:
return paths[0].copy()
# upgrade to 3D if we have mixed 2D and 3D paths
dimensions = {i.vertices.shape[1] for i in paths}
if len(dimensions) > 1:
paths = [i.to_3D() if hasattr(i, "to_3D") else i for i in paths]
# length of vertex arrays
vert_len = np.array([len(i.vertices) for i in paths])
# how much to offset each paths vertex indices by
offsets = np.append(0.0, np.cumsum(vert_len))[:-1].astype(np.int64)
# resulting entities
entities = []
# resulting vertices
vertices = []
# resulting metadata
metadata = {}
for path, offset in zip(paths, offsets):
# update metadata
metadata.update(path.metadata)
# copy vertices, we will stack later
vertices.append(path.vertices.copy())
# copy entity then reindex points
for entity in path.entities:
# cleanly copy the entity into a new object
copied = entity.copy()
# offset the indexes
copied.points += offset
entities.append(copied)
# generate the single new concatenated path
# use input types so we don't have circular imports
concat = type(path)(
metadata=metadata, entities=entities, vertices=np.vstack(vertices), **kwargs
)
return concat
@@ -0,0 +1,149 @@
"""
permutate.py
-------------
Randomly deform meshes in different ways.
"""
import numpy as np
from . import transformations, util
from . import triangles as triangles_module
from .typed import Number
def transform(mesh, translation_scale: Number = 1000.0):
"""
Return a permutated variant of a mesh by randomly reordering faces
and rotatating + translating a mesh by a random matrix.
Parameters
----------
mesh : trimesh.Trimesh
Mesh, will not be altered by this function
Returns
----------
permutated : trimesh.Trimesh
Mesh with same faces as input mesh but reordered
and rigidly transformed in space.
"""
# rotate and translate randomly
matrix = transformations.random_rotation_matrix(translate=translation_scale)
# randomly re-order triangles
triangles = np.random.permutation(mesh.triangles).reshape((-1, 3))
# apply rigid transform
triangles = transformations.transform_points(triangles, matrix)
# extract the class from the input object
mesh_type = util.type_named(mesh, "Trimesh")
# generate a new mesh from the permutated data
permutated = mesh_type(**triangles_module.to_kwargs(triangles.reshape((-1, 3, 3))))
return permutated
def noise(mesh, magnitude=None):
"""
Add gaussian noise to every vertex of a mesh, making
no effort to maintain topology or sanity.
Parameters
----------
mesh : trimesh.Trimesh
Input geometry, will not be altered
magnitude : float
What is the maximum distance per axis we can displace a vertex.
If None, value defaults to (mesh.scale / 100.0)
Returns
----------
permutated : trimesh.Trimesh
Input mesh with noise applied
"""
if magnitude is None:
magnitude = mesh.scale / 100.0
random = (np.random.random(mesh.vertices.shape) - 0.5) * magnitude
vertices_noise = mesh.vertices.copy() + random
# make sure we've re- ordered faces randomly
triangles = np.random.permutation(vertices_noise[mesh.faces])
mesh_type = util.type_named(mesh, "Trimesh")
permutated = mesh_type(**triangles_module.to_kwargs(triangles))
return permutated
def tessellation(mesh):
"""
Subdivide each face of a mesh into three faces with the new vertex
randomly placed inside the old face.
This produces a mesh with exactly the same surface area and volume
but with different tessellation.
Parameters
------------
mesh : trimesh.Trimesh
Input geometry
Returns
----------
permutated : trimesh.Trimesh
Mesh with remeshed facets
"""
# create random barycentric coordinates for each face
# pad all coordinates by a small amount to bias new vertex towards center
barycentric = np.random.random(mesh.faces.shape) + 0.05
barycentric /= barycentric.sum(axis=1).reshape((-1, 1))
# create one new vertex somewhere in a face
vertex_face = (barycentric.reshape((-1, 3, 1)) * mesh.triangles).sum(axis=1)
vertex_face_id = np.arange(len(vertex_face)) + len(mesh.vertices)
# new vertices are the old vertices stacked on the vertices in the faces
vertices = np.vstack((mesh.vertices, vertex_face))
# there are three new faces per old face, and we maintain correct winding
faces = np.vstack(
(
np.column_stack((mesh.faces[:, [0, 1]], vertex_face_id)),
np.column_stack((mesh.faces[:, [1, 2]], vertex_face_id)),
np.column_stack((mesh.faces[:, [2, 0]], vertex_face_id)),
)
)
# make sure the order of the faces is permutated
faces = np.random.permutation(faces)
mesh_type = util.type_named(mesh, "Trimesh")
permutated = mesh_type(vertices=vertices, faces=faces)
return permutated
class Permutator:
def __init__(self, mesh):
"""
A convenience object to get permutated versions of a mesh.
"""
self._mesh = mesh
def transform(self, translation_scale=1000):
return transform(self._mesh, translation_scale=translation_scale)
def noise(self, magnitude=None):
return noise(self._mesh, magnitude)
def tessellation(self):
return tessellation(self._mesh)
try:
# copy the function docstrings to the helper object
Permutator.noise.__doc__ = noise.__doc__
Permutator.transform.__doc__ = transform.__doc__
Permutator.tessellation.__doc__ = tessellation.__doc__
except AttributeError:
# no docstrings in Python2
pass
@@ -0,0 +1,772 @@
"""
points.py
-------------
Functions dealing with (n, d) points.
"""
import copy
from hashlib import sha256
import numpy as np
from numpy import float64
from . import caching, grouping, transformations, util
from .constants import tol
from .geometry import plane_transform
from .inertia import points_inertia
from .parent import Geometry3D
from .typed import ArrayLike, NDArray
from .visual.color import VertexColor
def point_plane_distance(points, plane_normal, plane_origin=None):
"""
The minimum perpendicular distance of a point to a plane.
Parameters
-----------
points : (n, 3) float
Points in space
plane_normal : (3,) float
Unit normal vector
plane_origin : (3,) float
Plane origin in space
Returns
------------
distances : (n,) float
Distance from point to plane
"""
points = np.asanyarray(points, dtype=float64)
if plane_origin is None:
w = points
else:
w = points - plane_origin
distances = np.dot(plane_normal, w.T) / np.linalg.norm(plane_normal)
return distances
def major_axis(points):
"""
Returns an approximate vector representing the major
axis of the passed points.
Parameters
-------------
points : (n, dimension) float
Points in space
Returns
-------------
axis : (dimension,) float
Vector along approximate major axis
"""
_U, S, V = np.linalg.svd(points)
axis = util.unitize(np.dot(S, V))
return axis
def plane_fit(points):
"""
Fit a plane to points using SVD.
Parameters
---------
points : (n, 3) float or (p, n, 3,) float
3D points in space
Second option allows to simultaneously compute
p centroids and normals
Returns
---------
C : (3,) float or (p, 3,) float
Point on the plane
N : (3,) float or (p, 3,) float
Unit normal vector of plane
"""
# make sure input is numpy array
points = np.asanyarray(points, dtype=float64)
assert points.ndim == 2 or points.ndim == 3
# with only one point set, np.dot is faster
if points.ndim == 2:
# make the plane origin the mean of the points
C = points.mean(axis=0)
# points offset by the plane origin
x = points - C[None, :]
# create a (3, 3) matrix
M = np.dot(x.T, x)
else:
# make the plane origin the mean of the points
C = points.mean(axis=1)
# points offset by the plane origin
x = points - C[:, None, :]
# create a (p, 3, 3) matrix
M = np.einsum("pnd, pnm->pdm", x, x)
# run SVD
N = np.linalg.svd(M)[0][..., -1]
# return the centroid(s) and normal(s)
return C, N
def radial_sort(points, origin, normal, start=None):
"""
Sorts a set of points radially (by angle) around an
axis specified by origin and normal vector.
Parameters
--------------
points : (n, 3) float
Points in space
origin : (3,) float
Origin to sort around
normal : (3,) float
Vector to sort around
start : (3,) float
Vector to specify start position in counter-clockwise
order viewing in direction of normal, MUST not be
parallel with normal
Returns
--------------
ordered : (n, 3) float
Same as input points but reordered
"""
# create two axis perpendicular to each other and
# the normal and project the points onto them
if start is None:
axis0 = [normal[0], normal[2], -normal[1]]
axis1 = np.cross(normal, axis0)
else:
normal, start = util.unitize([normal, start])
if np.abs(1 - np.abs(np.dot(normal, start))) < tol.zero:
raise ValueError("start must not parallel with normal")
axis0 = np.cross(start, normal)
axis1 = np.cross(axis0, normal)
vectors = points - origin
# calculate the angles of the points on the axis
angles = np.arctan2(np.dot(vectors, axis0), np.dot(vectors, axis1))
# return the points sorted by angle
return points[angles.argsort()[::-1]]
def project_to_plane(
points,
plane_normal,
plane_origin,
transform=None,
return_transform=False,
return_planar=True,
):
"""
Project (n, 3) points onto a plane.
Parameters
-----------
points : (n, 3) float
Points in space.
plane_normal : (3,) float
Unit normal vector of plane
plane_origin : (3,)
Origin point of plane
transform : None or (4, 4) float
Homogeneous transform, if specified, normal+origin are overridden
return_transform : bool
Returns the (4, 4) matrix used or not
return_planar : bool
Return (n, 2) points rather than (n, 3) points
"""
if np.all(np.abs(plane_normal) < tol.zero):
raise NameError("Normal must be nonzero!")
if transform is None:
transform = plane_transform(plane_origin, plane_normal)
transformed = transformations.transform_points(points, transform)
transformed = transformed[:, 0 : (3 - int(return_planar))]
if return_transform:
polygon_to_3D = np.linalg.inv(transform)
return transformed, polygon_to_3D
return transformed
def remove_close(points, radius):
"""
Given an (n, m) array of points return a subset of
points where no point is closer than radius.
Parameters
------------
points : (n, dimension) float
Points in space
radius : float
Minimum radius between result points
Returns
------------
culled : (m, dimension) float
Points in space
mask : (n,) bool
Which points from the original points were returned
"""
from scipy.spatial import cKDTree
tree = cKDTree(points)
# get the index of every pair of points closer than our radius
pairs = tree.query_pairs(radius, output_type="ndarray")
# how often each vertex index appears in a pair
# this is essentially a cheaply computed "vertex degree"
# in the graph that we could construct for connected points
count = np.bincount(pairs.ravel(), minlength=len(points))
# for every pair we know we have to remove one of them
# which of the two options we pick can have a large impact
# on how much over-culling we end up doing
column = count[pairs].argmax(axis=1)
# take the value in each row with the highest degree
# there is probably better numpy slicing you could do here
highest = pairs.ravel()[column + 2 * np.arange(len(column))]
# mask the vertices by index
mask = np.ones(len(points), dtype=bool)
mask[highest] = False
if tol.strict:
# verify we actually did what we said we'd do
test = cKDTree(points[mask])
assert len(test.query_pairs(radius)) == 0
return points[mask], mask
def k_means(points, k, **kwargs):
"""
Find k centroids that attempt to minimize the k- means problem:
https://en.wikipedia.org/wiki/Metric_k-center
Parameters
----------
points: (n, d) float
Points in space
k : int
Number of centroids to compute
**kwargs : dict
Passed directly to scipy.cluster.vq.kmeans
Returns
----------
centroids : (k, d) float
Points in some space
labels: (n) int
Indexes for which points belong to which centroid
"""
from scipy.cluster.vq import kmeans
from scipy.spatial import cKDTree
points = np.asanyarray(points, dtype=float64)
points_std = points.std(axis=0)
points_std[points_std < tol.zero] = 1
whitened = points / points_std
centroids_whitened, _distortion = kmeans(whitened, k, **kwargs)
centroids = centroids_whitened * points_std
# find which centroid each point is closest to
tree = cKDTree(centroids)
labels = tree.query(points, k=1)[1]
return centroids, labels
def tsp(points, start=0):
"""
Find an ordering of points where each is visited and
the next point is the closest in euclidean distance,
and if there are multiple points with equal distance
go to an arbitrary one.
Assumes every point is visitable from every other point,
i.e. the travelling salesman problem on a fully connected
graph. It is not a MINIMUM traversal; rather it is a
"not totally goofy traversal, quickly." On random points
this traversal is often ~20x shorter than random ordering,
and executes on 1000 points in around 29ms on a 2014 i7.
Parameters
---------------
points : (n, dimension) float
ND points in space
start : int
The index of points we should start at
Returns
---------------
traversal : (n,) int
Ordered traversal visiting every point
distances : (n - 1,) float
The euclidean distance between points in traversal
"""
# points should be float
points = np.asanyarray(points, dtype=float64)
if len(points.shape) != 2:
raise ValueError("points must be (n, dimension)!")
# start should be an index
start = int(start)
# a mask of unvisited points by index
unvisited = np.ones(len(points), dtype=bool)
unvisited[start] = False
# traversal of points by index
traversal = np.zeros(len(points), dtype=np.int64) - 1
traversal[0] = start
# list of distances
distances = np.zeros(len(points) - 1, dtype=float64)
# a mask of indexes in order
index_mask = np.arange(len(points), dtype=np.int64)
# in the loop we want to call distances.sum(axis=1)
# a lot and it's actually kind of slow for "reasons"
# dot products with ones is equivalent and ~2x faster
sum_ones = np.ones(points.shape[1])
# loop through all points
for i in range(len(points) - 1):
# which point are we currently on
current = points[traversal[i]]
# do NlogN distance query
# use dot instead of .sum(axis=1) or np.linalg.norm
# as it is faster, also don't square root here
dist = np.dot((points[unvisited] - current) ** 2, sum_ones)
# minimum distance index
min_index = dist.argmin()
# successor is closest unvisited point
successor = index_mask[unvisited][min_index]
# update the mask
unvisited[successor] = False
# store the index to the traversal
traversal[i + 1] = successor
# store the distance
distances[i] = dist[min_index]
# we were comparing distance^2 so take square root
distances **= 0.5
return traversal, distances
def plot_points(points, show=True):
"""
Plot an (n, 3) list of points using matplotlib
Parameters
-------------
points : (n, 3) float
Points in space
show : bool
If False, will not show until plt.show() is called
"""
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # NOQA
points = np.asanyarray(points, dtype=float64)
if len(points.shape) != 2:
raise ValueError("Points must be (n, 2|3)!")
if points.shape[1] == 3:
fig = plt.figure()
ax = fig.add_subplot(111, projection="3d")
ax.scatter(*points.T)
elif points.shape[1] == 2:
plt.scatter(*points.T)
else:
raise ValueError(f"points not 2D/3D: {points.shape}")
if show:
plt.show()
class PointCloud(Geometry3D):
"""
Hold 3D points in an object which can be visualized
in a scene.
"""
def __init__(self, vertices, colors=None, metadata=None, **kwargs):
"""
Load an array of points into a PointCloud object.
Parameters
-------------
vertices : (n, 3) float
Points in space
colors : (n, 4) uint8 or None
RGBA colors for each point
metadata : dict or None
Metadata about points
"""
self._data = caching.DataStore()
self._cache = caching.Cache(self._data.__hash__)
self.metadata = {}
if metadata is not None:
self.metadata.update(metadata)
# load vertices
self.vertices = vertices
if "vertex_colors" in kwargs and colors is None:
colors = kwargs["vertex_colors"]
# save visual data to vertex color object
self.visual = VertexColor(colors=colors, obj=self)
def __setitem__(self, *args, **kwargs):
return self.vertices.__setitem__(*args, **kwargs)
def __getitem__(self, *args, **kwargs):
return self.vertices.__getitem__(*args, **kwargs)
@property
def shape(self):
"""
Get the shape of the pointcloud
Returns
----------
shape : (2,) int
Shape of vertex array
"""
return self.vertices.shape
@property
def is_empty(self):
"""
Are there any vertices defined or not.
Returns
----------
empty : bool
True if no vertices defined
"""
return len(self.vertices) == 0
def copy(self):
"""
Safely get a copy of the current point cloud.
Copied objects will have emptied caches to avoid memory
issues and so may be slow on initial operations until
caches are regenerated.
Current object will *not* have its cache cleared.
Returns
---------
copied : trimesh.PointCloud
Copy of current point cloud
"""
copied = PointCloud(vertices=None)
# copy vertex and face data
copied._data.data = copy.deepcopy(self._data.data)
# copy visual data
copied.visual = copy.deepcopy(self.visual)
# get metadata
copied.metadata = copy.deepcopy(self.metadata)
# make sure cache is set from here
copied._cache.clear()
return copied
def hash(self):
"""
Get a hash of the current vertices.
Returns
----------
hash : str
Hash of self.vertices
"""
return self._data.__hash__()
@property
def identifier(self) -> NDArray[float64]:
"""
Return a simple array representing this PointCloud
that can be used to identify identical arrays.
Returns
----------
identifier : (9,)
A flat array of data representing the cloud.
"""
return self.moment_inertia.ravel()
@property
def identifier_hash(self) -> str:
"""
A hash of the PointCloud's identifier that can be used
to detect duplicates.
"""
return sha256(
(self.identifier * 1e5).round().astype(np.int64).tobytes()
).hexdigest()
def merge_vertices(self):
"""
Merge vertices closer than tol.merge (default: 1e-8)
"""
# run unique rows
unique, inverse = grouping.unique_rows(self.vertices)
# apply unique mask to vertices
self.vertices = self.vertices[unique]
# apply unique mask to colors
if self.colors is not None and len(self.colors) == len(inverse):
self.colors = self.colors[unique]
def apply_transform(self, transform):
"""
Apply a homogeneous transformation to the PointCloud
object in- place.
Parameters
--------------
transform : (4, 4) float
Homogeneous transformation to apply to PointCloud
"""
self.vertices = transformations.transform_points(self.vertices, matrix=transform)
return self
@property
def bounds(self):
"""
The axis aligned bounds of the PointCloud
Returns
------------
bounds : (2, 3) float
Minimum, Maximum verteex
"""
return np.array([self.vertices.min(axis=0), self.vertices.max(axis=0)])
@property
def extents(self):
"""
The size of the axis aligned bounds
Returns
------------
extents : (3,) float
Edge length of axis aligned bounding box
"""
return np.ptp(self.bounds, axis=0)
@property
def centroid(self):
"""
The mean vertex position
Returns
------------
centroid : (3,) float
Mean vertex position
"""
return self.vertices.mean(axis=0)
@caching.cache_decorator
def moment_inertia(self) -> NDArray[float64]:
return points_inertia(points=self.vertices, weights=self.weights)
@property
def weights(self) -> NDArray[float64]:
"""
If each point has a specific weight assigned to it.
Returns
-----------
weights : (n,)
A per-vertex weight.
"""
current = self._data.get("weights")
if current is None:
ones = np.ones(len(self.vertices), dtype=np.float64)
self._data["weights"] = ones
return ones
return current
@weights.setter
def weights(self, values: ArrayLike):
"""
Assign a weight to each point for later computation.
Parameters
-----------
values : (n,)
Weights for each vertex.
"""
values = np.asanyarray(values, dtype=np.float64)
if values.shape != (self.shape[0],):
raise ValueError("Weights must match vertices!")
self._data["weights"] = values
@property
def vertices(self):
"""
Vertices of the PointCloud
Returns
------------
vertices : (n, 3) float
Points in the PointCloud
"""
return self._data.get("vertices", np.zeros(shape=(0, 3), dtype=float64))
@vertices.setter
def vertices(self, values):
"""
Assign vertex values to the point cloud.
Parameters
--------------
values : (n, 3) float
Points in space
"""
if values is None or len(values) == 0:
return self._data.data.pop("vertices", None)
self._data["vertices"] = np.asanyarray(values, order="C", dtype=float64)
@property
def colors(self):
"""
Stored per- point color
Returns
----------
colors : (len(self.vertices), 4) np.uint8
Per- point RGBA color
"""
return self.visual.vertex_colors
@colors.setter
def colors(self, data):
self.visual.vertex_colors = data
@caching.cache_decorator
def kdtree(self):
"""
Return a scipy.spatial.cKDTree of the vertices of the mesh.
Not cached as this lead to observed memory issues and segfaults.
Returns
---------
tree : scipy.spatial.cKDTree
Contains mesh.vertices
"""
from scipy.spatial import cKDTree
tree = cKDTree(self.vertices.view(np.ndarray))
return tree
@caching.cache_decorator
def convex_hull(self):
"""
A convex hull of every point.
Returns
-------------
convex_hull : trimesh.Trimesh
A watertight mesh of the hull of the points
"""
from . import convex
return convex.convex_hull(self.vertices)
def scene(self):
"""
A scene containing just the PointCloud
Returns
----------
scene : trimesh.Scene
Scene object containing this PointCloud
"""
from .scene.scene import Scene
return Scene(self)
def show(self, **kwargs):
"""
Open a viewer window displaying the current PointCloud
"""
self.scene().show(**kwargs)
def export(self, file_obj=None, file_type=None, **kwargs):
"""
Export the current pointcloud to a file object.
If file_obj is a filename, file will be written there.
Supported formats are xyz
Parameters
------------
file_obj: open writeable file object
str, file name where to save the pointcloud
None, if you would like this function to return the export blob
file_type: str
Which file type to export as.
If file name is passed this is not required
"""
from .exchange.export import export_mesh
return export_mesh(self, file_obj=file_obj, file_type=file_type, **kwargs)
def query(self, input_points, **kwargs):
"""
Find the the closest points and associated attributes from this PointCloud.
Parameters
------------
input_points : (n, 3) float
Input query points
kwargs : dict
Arguments for proximity.query_from_points
result : proximity.NearestQueryResult
Result of the query.
"""
from .proximity import query_from_points
return query_from_points(self.vertices, input_points, self.kdtree, **kwargs)
def __add__(self, other):
if len(other.colors) == len(self.colors) == 0:
colors = None
else:
# preserve colors
# if one point cloud has no color property use black
other_colors = (
[[0, 0, 0, 255]] * len(other.vertices)
if len(other.colors) == 0
else other.colors
)
self_colors = (
[[0, 0, 0, 255]] * len(self.vertices)
if len(self.colors) == 0
else self.colors
)
colors = np.vstack((self_colors, other_colors))
return PointCloud(
vertices=np.vstack((self.vertices, other.vertices)), colors=colors
)
@@ -0,0 +1,321 @@
"""
poses.py
-----------
Find stable orientations of meshes.
"""
import numpy as np
from .triangles import points_to_barycentric
try:
import networkx as nx
except BaseException as E:
# create a dummy module which will raise the ImportError
# or other exception only when someone tries to use networkx
from .exceptions import ExceptionWrapper
nx = ExceptionWrapper(E)
def compute_stable_poses(mesh, center_mass=None, sigma=0.0, n_samples=1, threshold=0.0):
"""
Computes stable orientations of a mesh and their quasi-static probabilities.
This method samples the location of the center of mass from a multivariate
gaussian with the mean at the center of mass, and a covariance
equal to and identity matrix times sigma, over n_samples.
For each sample, it computes the stable resting poses of the mesh on a
a planar workspace and evaluates the probabilities of landing in
each pose if the object is dropped onto the table randomly.
This method returns the 4x4 homogeneous transform matrices that place
the shape against the planar surface with the z-axis pointing upwards
and a list of the probabilities for each pose.
The transforms and probabilities that are returned are sorted, with the
most probable pose first.
Parameters
----------
mesh : trimesh.Trimesh
The target mesh
com : (3,) float
Rhe object center of mass. If None, this method
assumes uniform density and watertightness and
computes a center of mass explicitly
sigma : float
Rhe covariance for the multivariate gaussian used
to sample center of mass locations
n_samples : int
The number of samples of the center of mass location
threshold : float
The probability value at which to threshold
returned stable poses
Returns
-------
transforms : (n, 4, 4) float
The homogeneous matrices that transform the
object to rest in a stable pose, with the
new z-axis pointing upwards from the table
and the object just touching the table.
probs : (n,) float
Probability in (0, 1) for each pose
"""
# save convex hull mesh to avoid a cache check
cvh = mesh.convex_hull
if center_mass is None:
center_mass = mesh.center_mass
# Sample center of mass, rejecting points outside of conv hull
sample_coms = []
while len(sample_coms) < n_samples:
remaining = n_samples - len(sample_coms)
coms = np.random.multivariate_normal(center_mass, sigma * np.eye(3), remaining)
for c in coms:
dots = np.einsum("ij,ij->i", c - cvh.triangles_center, cvh.face_normals)
if np.all(dots < 0):
sample_coms.append(c)
norms_to_probs = {} # Map from normal to probabilities
# For each sample, compute the stable poses
for sample_com in sample_coms:
# Create toppling digraph
dg = _create_topple_graph(cvh, sample_com)
# Propagate probabilities to sink nodes with a breadth-first traversal
nodes = [n for n in dg.nodes() if dg.in_degree(n) == 0]
n_iters = 0
while len(nodes) > 0 and n_iters <= len(mesh.faces):
new_nodes = []
for node in nodes:
if dg.out_degree(node) == 0:
continue
successor = next(iter(dg.successors(node)))
dg.nodes[successor]["prob"] += dg.nodes[node]["prob"]
dg.nodes[node]["prob"] = 0.0
new_nodes.append(successor)
nodes = new_nodes
n_iters += 1
# Collect stable poses
for node in dg.nodes():
if dg.nodes[node]["prob"] > 0.0:
normal = cvh.face_normals[node]
prob = dg.nodes[node]["prob"]
key = tuple(np.around(normal, decimals=3))
if key in norms_to_probs:
norms_to_probs[key]["prob"] += 1.0 / n_samples * prob
else:
norms_to_probs[key] = {
"prob": 1.0 / n_samples * prob,
"normal": normal,
}
transforms = []
probs = []
# Filter stable poses
for key in norms_to_probs:
prob = norms_to_probs[key]["prob"]
if prob > threshold:
tf = np.eye(4)
# Compute a rotation matrix for this stable pose
z = -1.0 * norms_to_probs[key]["normal"]
x = np.array([-z[1], z[0], 0])
if np.linalg.norm(x) == 0.0:
x = np.array([1, 0, 0])
else:
x = x / np.linalg.norm(x)
y = np.cross(z, x)
y = y / np.linalg.norm(y)
tf[:3, :3] = np.array([x, y, z])
# Compute the necessary translation for this stable pose
m = cvh.copy()
m.apply_transform(tf)
z = -m.bounds[0][2]
tf[:3, 3] = np.array([0, 0, z])
transforms.append(tf)
probs.append(prob)
# Sort the results
transforms = np.array(transforms)
probs = np.array(probs)
inds = np.argsort(-probs)
return transforms[inds], probs[inds]
def _orient3dfast(plane, pd):
"""
Performs a fast 3D orientation test.
Parameters
----------
plane: (3,3) float, three points in space that define a plane
pd: (3,) float, a single point
Returns
-------
result: float, if greater than zero then pd is above the plane through
the given three points, if less than zero then pd is below
the given plane, and if equal to zero then pd is on the
given plane.
"""
pa, pb, pc = plane
adx = pa[0] - pd[0]
bdx = pb[0] - pd[0]
cdx = pc[0] - pd[0]
ady = pa[1] - pd[1]
bdy = pb[1] - pd[1]
cdy = pc[1] - pd[1]
adz = pa[2] - pd[2]
bdz = pb[2] - pd[2]
cdz = pc[2] - pd[2]
return (
adx * (bdy * cdz - bdz * cdy)
+ bdx * (cdy * adz - cdz * ady)
+ cdx * (ady * bdz - adz * bdy)
)
def _compute_static_prob(tri, com):
"""
For an object with the given center of mass, compute
the probability that the given tri would be the first to hit the
ground if the object were dropped with a pose chosen uniformly at random.
Parameters
----------
tri: (3,3) float, the vertices of a triangle
cm: (3,) float, the center of mass of the object
Returns
-------
prob: float, the probability in [0,1] for the given triangle
"""
sv = [(v - com) / np.linalg.norm(v - com) for v in tri]
# Use L'Huilier's Formula to compute spherical area
a = np.arccos(min(1, max(-1, np.dot(sv[0], sv[1]))))
b = np.arccos(min(1, max(-1, np.dot(sv[1], sv[2]))))
c = np.arccos(min(1, max(-1, np.dot(sv[2], sv[0]))))
s = (a + b + c) / 2.0
# Prevents weirdness with arctan
try:
return (
1.0
/ np.pi
* np.arctan(
np.sqrt(
np.tan(s / 2)
* np.tan((s - a) / 2)
* np.tan((s - b) / 2)
* np.tan((s - c) / 2)
)
)
)
except BaseException:
s = s + 1e-8
return (
1.0
/ np.pi
* np.arctan(
np.sqrt(
np.tan(s / 2)
* np.tan((s - a) / 2)
* np.tan((s - b) / 2)
* np.tan((s - c) / 2)
)
)
)
def _create_topple_graph(cvh_mesh, com):
"""
Constructs a toppling digraph for the given convex hull mesh and
center of mass.
Each node n_i in the digraph corresponds to a face f_i of the mesh and is
labelled with the probability that the mesh will land on f_i if dropped
randomly. Not all faces are stable, and node n_i has a directed edge to
node n_j if the object will quasi-statically topple from f_i to f_j if it
lands on f_i initially.
This computation is described in detail in
http://goldberg.berkeley.edu/pubs/eps.pdf.
Parameters
----------
cvh_mesh : trimesh.Trimesh
Rhe convex hull of the target shape
com : (3,) float
The 3D location of the target shape's center of mass
Returns
-------
graph : networkx.DiGraph
Graph representing static probabilities and toppling
order for the convex hull
"""
adj_graph = nx.Graph()
topple_graph = nx.DiGraph()
# Create face adjacency graph
face_pairs = cvh_mesh.face_adjacency
edges = cvh_mesh.face_adjacency_edges
graph_edges = []
for fp, e in zip(face_pairs, edges):
verts = cvh_mesh.vertices[e]
graph_edges.append([fp[0], fp[1], {"verts": verts}])
adj_graph.add_edges_from(graph_edges)
# Compute static probabilities of landing on each face
for i, tri in enumerate(cvh_mesh.triangles):
prob = _compute_static_prob(tri, com)
topple_graph.add_node(i, prob=prob)
# Compute COM projections onto planes of each triangle in cvh_mesh
proj_dists = np.einsum(
"ij,ij->i", cvh_mesh.face_normals, com - cvh_mesh.triangles[:, 0]
)
proj_coms = com - np.einsum("i,ij->ij", proj_dists, cvh_mesh.face_normals)
barys = points_to_barycentric(cvh_mesh.triangles, proj_coms)
unstable_face_indices = np.where(np.any(barys < 0, axis=1))[0]
# For each unstable face, compute the face it topples to
for fi in unstable_face_indices:
proj_com = proj_coms[fi]
centroid = cvh_mesh.triangles_center[fi]
norm = cvh_mesh.face_normals[fi]
for tfi in adj_graph[fi]:
v1, v2 = adj_graph[fi][tfi]["verts"]
if np.dot(np.cross(v1 - centroid, v2 - centroid), norm) < 0:
tmp = v2
v2 = v1
v1 = tmp
plane1 = [centroid, v1, v1 + norm]
plane2 = [centroid, v2 + norm, v2]
if (
_orient3dfast(plane1, proj_com) >= 0
and _orient3dfast(plane2, proj_com) >= 0
):
break
topple_graph.add_edge(fi, tfi)
return topple_graph
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,587 @@
"""
proximity.py
---------------
Query mesh- point proximity.
"""
import numpy as np
from . import util
from .constants import log_time, tol
from .grouping import group_min
from .triangles import closest_point as _corresponding
from .triangles import points_to_barycentric
try:
from scipy.spatial import cKDTree
except BaseException as E:
from .exceptions import ExceptionWrapper
cKDTree = ExceptionWrapper(E)
def nearby_faces(mesh, points):
"""
For each point find nearby faces relatively quickly.
The closest point on the mesh to the queried point is guaranteed to be
on one of the faces listed.
Does this by finding the nearest vertex on the mesh to each point, and
then returns all the faces that intersect the axis aligned bounding box
centered at the queried point and extending to the nearest vertex.
Parameters
----------
mesh : trimesh.Trimesh
Mesh to query.
points : (n, 3) float
Points in space
Returns
-----------
candidates : (points,) int
Sequence of indexes for mesh.faces
"""
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)!")
# an r-tree containing the axis aligned bounding box for every triangle
rtree = mesh.triangles_tree
# a kd-tree containing every vertex of the mesh
kdtree = cKDTree(mesh.vertices[mesh.referenced_vertices])
# query the distance to the nearest vertex to get AABB of a sphere
distance_vertex = kdtree.query(points)[0].reshape((-1, 1))
distance_vertex += tol.merge
# axis aligned bounds
bounds = np.column_stack((points - distance_vertex, points + distance_vertex))
# faces that intersect axis aligned bounding box
candidates = [list(rtree.intersection(b)) for b in bounds]
return candidates
def closest_point_naive(mesh, points):
"""
Given a mesh and a list of points find the closest point
on any triangle.
Does this by constructing a very large intermediate array and
comparing every point to every triangle.
Parameters
----------
mesh : Trimesh
Takes mesh to have same interfaces as `closest_point`
points : (m, 3) float
Points in space
Returns
----------
closest : (m, 3) float
Closest point on triangles for each point
distance : (m,) float
Distances between point and triangle
triangle_id : (m,) int
Index of triangle containing closest point
"""
# get triangles from mesh
triangles = mesh.triangles.view(np.ndarray)
# establish that input points are sane
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(triangles, (-1, 3, 3)):
raise ValueError("triangles shape incorrect")
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)")
# create a giant tiled array of each point tiled len(triangles) times
points_tiled = np.tile(points, (1, len(triangles)))
on_triangle = np.array(
[_corresponding(triangles, i.reshape((-1, 3))) for i in points_tiled]
)
# distance squared
distance_2 = [((i - q) ** 2).sum(axis=1) for i, q in zip(on_triangle, points)]
triangle_id = np.array([i.argmin() for i in distance_2])
# closest cartesian point
closest = np.array([g[i] for i, g in zip(triangle_id, on_triangle)])
distance = np.array([g[i] for i, g in zip(triangle_id, distance_2)]) ** 0.5
return closest, distance, triangle_id
def closest_point(mesh, points):
"""
Given a mesh and a list of points find the closest point
on any triangle.
Parameters
----------
mesh : trimesh.Trimesh
Mesh to query
points : (m, 3) float
Points in space
Returns
----------
closest : (m, 3) float
Closest point on triangles for each point
distance : (m,) float
Distance to mesh.
triangle_id : (m,) int
Index of triangle containing closest point
"""
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)!")
# do a tree- based query for faces near each point
candidates = nearby_faces(mesh, points)
# view triangles as an ndarray so we don't have to recompute
# the MD5 during all of the subsequent advanced indexing
triangles = mesh.triangles.view(np.ndarray)
# create the corresponding list of triangles
# and query points to send to the closest_point function
all_candidates = np.concatenate(candidates)
num_candidates = list(map(len, candidates))
tile_idxs = np.repeat(np.arange(len(points)), num_candidates)
query_point = points[tile_idxs, :]
query_tri = triangles[all_candidates]
# do the computation for closest point
query_close = _corresponding(query_tri, query_point)
query_group = np.cumsum(num_candidates)[:-1]
# vectors and distances for
# closest point to query point
query_vector = query_point - query_close
query_distance = util.diagonal_dot(query_vector, query_vector)
# get best two candidate indices by arg-sorting the per-query_distances
qds = np.array_split(query_distance, query_group)
idxs = np.int32([qd.argsort()[:2] if len(qd) > 1 else [0, 0] for qd in qds])
idxs[1:] += query_group.reshape(-1, 1)
# points, distances and triangle ids for best two candidates
two_points = query_close[idxs]
two_dists = query_distance[idxs]
two_candidates = all_candidates[idxs]
# the first candidate is the best result for unambiguous cases
result_close = query_close[idxs[:, 0]]
result_tid = two_candidates[:, 0]
result_distance = two_dists[:, 0]
# however: same closest point on two different faces
# find the best one and correct triangle ids if necessary
check_distance = np.ptp(two_dists, axis=1) < tol.merge
check_magnitude = np.all(np.abs(two_dists) > tol.merge, axis=1)
# mask results where corrections may be apply
c_mask = np.bitwise_and(check_distance, check_magnitude)
# get two face normals for the candidate points
normals = mesh.face_normals[two_candidates[c_mask]]
# compute normalized surface-point to query-point vectors
vectors = query_vector[idxs[c_mask]] / two_dists[c_mask].reshape(-1, 2, 1) ** 0.5
# compare enclosed angle for both face normals
dots = (normals * vectors).sum(axis=2)
# take the idx with the most positive angle
# allows for selecting the correct candidate triangle id
c_idxs = dots.argmax(axis=1)
# correct triangle ids where necessary
# closest point and distance remain valid
result_tid[c_mask] = two_candidates[c_mask, c_idxs]
result_distance[c_mask] = two_dists[c_mask, c_idxs]
result_close[c_mask] = two_points[c_mask, c_idxs]
# we were comparing the distance squared so
# now take the square root in one vectorized operation
result_distance **= 0.5
return result_close, result_distance, result_tid
def signed_distance(mesh, points):
"""
Find the signed distance from a mesh to a list of points.
* Points OUTSIDE the mesh will have NEGATIVE distance
* Points within tol.merge of the surface will have POSITIVE distance
* Points INSIDE the mesh will have POSITIVE distance
Parameters
-----------
mesh : trimesh.Trimesh
Mesh to query.
points : (n, 3) float
Points in space
Returns
----------
signed_distance : (n,) float
Signed distance from point to mesh
"""
# make sure we have a numpy array
points = np.asanyarray(points, dtype=np.float64)
# find the closest point on the mesh to the queried points
closest, distance, triangle_id = closest_point(mesh, points)
# we only care about nonzero distances
nonzero = distance > tol.merge
if not nonzero.any():
return distance
# For closest points that project directly in to the triangle, compute sign from
# triangle normal Project each point in to the closest triangle plane
nonzero = np.where(nonzero)[0]
normals = mesh.face_normals[triangle_id]
projection = (
points[nonzero]
- (
normals[nonzero].T
* np.einsum("ij,ij->i", points[nonzero] - closest[nonzero], normals[nonzero])
).T
)
# Determine if the projection lies within the closest triangle
barycentric = points_to_barycentric(mesh.triangles[triangle_id[nonzero]], projection)
ontriangle = ~(
((barycentric < -tol.merge) | (barycentric > 1 + tol.merge)).any(axis=1)
)
# Where projection does lie in the triangle, compare vector to projection to the
# triangle normal to compute sign
sign = np.sign(
np.einsum(
"ij,ij->i",
normals[nonzero[ontriangle]],
points[nonzero[ontriangle]] - projection[ontriangle],
)
)
distance[nonzero[ontriangle]] *= -1.0 * sign
# For all other triangles, resort to raycasting against the entire mesh
inside = mesh.ray.contains_points(points[nonzero[~ontriangle]])
sign = (inside.astype(int) * 2) - 1.0
# apply sign to previously computed distance
distance[nonzero[~ontriangle]] *= sign
return distance
class NearestQueryResult:
"""
Stores the nearest points and attributes for nearest points queries.
"""
def __init__(self):
self.nearest = None
self.distances = None
self.normals = None
self.triangle_indices = None
self.barycentric_coordinates = None
self.interpolated_normals = None
self.vertex_indices = None
def has_normals(self):
return self.normals is not None or self.interpolated_normals is not None
class ProximityQuery:
"""
Proximity queries for the current mesh.
"""
def __init__(self, mesh):
self._mesh = mesh
@log_time
def on_surface(self, points):
"""
Given list of points, for each point find the closest point
on any triangle of the mesh.
Parameters
----------
points : (m,3) float, points in space
Returns
----------
closest : (m, 3) float
Closest point on triangles for each point
distance : (m,) float
Distance to surface
triangle_id : (m,) int
Index of closest triangle for each point.
"""
return closest_point(mesh=self._mesh, points=points)
def vertex(self, points):
"""
Given a set of points, return the closest vertex index to each point
Parameters
----------
points : (n, 3) float
Points in space
Returns
----------
distance : (n,) float
Distance from source point to vertex.
vertex_id : (n,) int
Index of mesh.vertices for closest vertex.
"""
tree = self._mesh.kdtree
return tree.query(points)
def signed_distance(self, points):
"""
Find the signed distance from a mesh to a list of points.
* Points OUTSIDE the mesh will have NEGATIVE distance
* Points within tol.merge of the surface will have POSITIVE distance
* Points INSIDE the mesh will have POSITIVE distance
Parameters
-----------
points : (n, 3) float
Points in space
Returns
----------
signed_distance : (n,) float
Signed distance from point to mesh.
"""
return signed_distance(self._mesh, points)
def longest_ray(mesh, points, directions):
"""
Find the lengths of the longest rays which do not intersect the mesh
cast from a list of points in the provided directions.
Parameters
-----------
points : (n, 3) float
Points in space.
directions : (n, 3) float
Directions of rays.
Returns
----------
signed_distance : (n,) float
Length of rays.
"""
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)!")
directions = np.asanyarray(directions, dtype=np.float64)
if not util.is_shape(directions, (-1, 3)):
raise ValueError("directions must be (n,3)!")
if len(points) != len(directions):
raise ValueError("number of points must equal number of directions!")
_faces, rays, locations = mesh.ray.intersects_id(
points, directions, return_locations=True, multiple_hits=True
)
if len(rays) > 0:
distances = np.linalg.norm(locations - points[rays], axis=1)
else:
distances = np.array([])
# Reject intersections at distance less than tol.planar
rays = rays[distances > tol.planar]
distances = distances[distances > tol.planar]
# Add infinite length for those with no valid intersection
no_intersections = np.setdiff1d(np.arange(len(points)), rays)
rays = np.concatenate((rays, no_intersections))
distances = np.concatenate((distances, np.repeat(np.inf, len(no_intersections))))
return group_min(rays, distances)
def max_tangent_sphere(
mesh, points, inwards=True, normals=None, threshold=1e-6, max_iter=100
):
"""
Find the center and radius of the sphere which is tangent to
the mesh at the given point and at least one more point with no
non-tangential intersections with the mesh.
Masatomo Inui, Nobuyuki Umezu & Ryohei Shimane (2016)
Shrinking sphere:
A parallel algorithm for computing the thickness of 3D objects,
Computer-Aided Design and Applications, 13:2, 199-207,
DOI: 10.1080/16864360.2015.1084186
Parameters
----------
points : (n, 3) float
Points in space.
inwards : bool
Whether to have the sphere inside or outside the mesh.
normals : (n, 3) float or None
Normals of the mesh at the given points
if is None computed automatically.
Returns
----------
centers : (n,3) float
Centers of spheres
radii : (n,) float
Radii of spheres
"""
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)!")
if normals is not None:
normals = np.asanyarray(normals, dtype=np.float64)
if not util.is_shape(normals, (-1, 3)):
raise ValueError("normals must be (n,3)!")
if len(points) != len(normals):
raise ValueError("number of points must equal number of normals!")
else:
normals = mesh.face_normals[closest_point(mesh, points)[2]]
if inwards:
normals = -normals
# Find initial tangent spheres
distances = longest_ray(mesh, points, normals)
radii = distances * 0.5
not_converged = np.ones(len(points), dtype=bool) # boolean mask
# If ray is infinite, find the vertex which is furthest from our point
# when projected onto the ray. I.e. find v which maximises
# (v-p).n = v.n - p.n.
# We use a loop rather a vectorised approach to reduce memory cost
# it also seems to run faster.
for i in np.where(np.isinf(distances))[0]:
projections = np.dot(mesh.vertices - points[i], normals[i])
# If no points lie outside the tangent plane, then the radius is infinite
# otherwise we have a point outside the tangent plane, take the one with maximal
# projection
if projections.max() < tol.planar:
radii[i] = np.inf
not_converged[i] = False
else:
vertex = mesh.vertices[projections.argmax()]
radii[i] = np.dot(vertex - points[i], vertex - points[i]) / (
2 * np.dot(vertex - points[i], normals[i])
)
# Compute centers
centers = points + normals * np.nan_to_num(radii.reshape(-1, 1))
centers[np.isinf(radii)] = [np.nan, np.nan, np.nan]
# Our iterative process terminates when the difference in sphere
# radius is less than threshold*D
D = np.linalg.norm(mesh.bounds[1] - mesh.bounds[0])
convergence_threshold = threshold * D
n_iter = 0
while not_converged.sum() > 0 and n_iter < max_iter:
n_iter += 1
n_points, n_dists, _n_faces = mesh.nearest.on_surface(centers[not_converged])
# If the distance to the nearest point is the same as the distance
# to the start point then we are done.
done = (
np.abs(
n_dists
- np.linalg.norm(centers[not_converged] - points[not_converged], axis=1)
)
< tol.planar
)
not_converged[np.where(not_converged)[0][done]] = False
# Otherwise find the radius and center of the sphere tangent to the mesh
# at the point and the nearest point.
diff = n_points[~done] - points[not_converged]
old_radii = radii[not_converged].copy()
# np.einsum produces element wise dot product
radii[not_converged] = np.einsum("ij, ij->i", diff, diff) / (
2 * np.einsum("ij, ij->i", diff, normals[not_converged])
)
centers[not_converged] = points[not_converged] + normals[not_converged] * radii[
not_converged
].reshape(-1, 1)
# If change in radius is less than threshold we have converged
cvged = old_radii - radii[not_converged] < convergence_threshold
not_converged[np.where(not_converged)[0][cvged]] = False
return centers, radii
def thickness(mesh, points, exterior=False, normals=None, method="max_sphere"):
"""
Find the thickness of the mesh at the given points.
Parameters
----------
points : (n, 3) float
Points in space
exterior : bool
Whether to compute the exterior thickness
(a.k.a. reach)
normals : (n, 3) float
Normals of the mesh at the given points
If is None computed automatically.
method : string
One of 'max_sphere' or 'ray'
Returns
----------
thickness : (n,) float
Thickness at given points.
"""
points = np.asanyarray(points, dtype=np.float64)
if not util.is_shape(points, (-1, 3)):
raise ValueError("points must be (n,3)!")
if normals is not None:
normals = np.asanyarray(normals, dtype=np.float64)
if not util.is_shape(normals, (-1, 3)):
raise ValueError("normals must be (n,3)!")
if len(points) != len(normals):
raise ValueError("number of points must equal number of normals!")
else:
normals = mesh.face_normals[closest_point(mesh, points)[2]]
if method == "max_sphere":
_centers, radius = max_tangent_sphere(
mesh=mesh, points=points, inwards=not exterior, normals=normals
)
thickness = radius * 2
return thickness
elif method == "ray":
if exterior:
return longest_ray(mesh, points, normals)
else:
return longest_ray(mesh, points, -normals)
else:
raise ValueError('Invalid method, use "max_sphere" or "ray"')
@@ -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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
"""
remesh.py
-------------
Deal with re- triangulation of existing meshes.
"""
from itertools import zip_longest
import numpy as np
from . import graph, grouping, util
from .constants import tol
from .geometry import faces_to_edges
def subdivide(
vertices, faces, face_index=None, vertex_attributes=None, return_index=False
):
"""
Subdivide a mesh into smaller triangles.
Note that if `face_index` is passed, only those
faces will be subdivided and their neighbors won't
be modified making the mesh no longer "watertight."
Parameters
------------
vertices : (n, 3) float
Vertices in space
faces : (m, 3) int
Indexes of vertices which make up triangular faces
face_index : faces to subdivide.
if None: all faces of mesh will be subdivided
if (n,) int array of indices: only specified faces
vertex_attributes : dict
Contains (n, d) attribute data
return_index : bool
If True, return index of original face for new faces
Returns
----------
new_vertices : (q, 3) float
Vertices in space
new_faces : (p, 3) int
Remeshed faces
index_dict : dict
Only returned if `return_index`, {index of
original face : index of new faces}.
"""
if face_index is None:
face_mask = np.ones(len(faces), dtype=bool)
else:
face_mask = np.zeros(len(faces), dtype=bool)
face_mask[face_index] = True
# the (c, 3) int array of vertex indices
faces_subset = faces[face_mask]
# find the unique edges of our faces subset
edges = np.sort(faces_to_edges(faces_subset), axis=1)
unique, inverse = grouping.unique_rows(edges)
# then only produce one midpoint per unique edge
mid = vertices[edges[unique]].mean(axis=1)
mid_idx = inverse.reshape((-1, 3)) + len(vertices)
# the new faces_subset with correct winding
f = np.column_stack(
[
faces_subset[:, 0],
mid_idx[:, 0],
mid_idx[:, 2],
mid_idx[:, 0],
faces_subset[:, 1],
mid_idx[:, 1],
mid_idx[:, 2],
mid_idx[:, 1],
faces_subset[:, 2],
mid_idx[:, 0],
mid_idx[:, 1],
mid_idx[:, 2],
]
).reshape((-1, 3))
# add the 3 new faces_subset per old face all on the end
# by putting all the new faces after all the old faces
# it makes it easier to understand the indexes
new_faces = np.vstack((faces[~face_mask], f))
# stack the new midpoint vertices on the end
new_vertices = np.vstack((vertices, mid))
if vertex_attributes is not None:
new_attributes = {}
for key, values in vertex_attributes.items():
if len(values) != len(vertices):
continue
attr_mid = values[edges[unique]].mean(axis=1)
new_attributes[key] = np.vstack((values, attr_mid))
return new_vertices, new_faces, new_attributes
if return_index:
# turn the mask back into integer indexes
nonzero = np.nonzero(face_mask)[0]
# new faces start past the original faces
# but we've removed all the faces in face_mask
start = len(faces) - len(nonzero)
# indexes are just offset from start
stack = np.arange(start, start + len(f) * 4).reshape((-1, 4))
# reformat into a slightly silly dict for some reason
index_dict = dict(zip(nonzero, stack))
return new_vertices, new_faces, index_dict
return new_vertices, new_faces
def subdivide_to_size(vertices, faces, max_edge, max_iter=10, return_index=False):
"""
Subdivide a mesh until every edge is shorter than a
specified length.
Will return a triangle soup, not a nicely structured mesh.
Parameters
------------
vertices : (n, 3) float
Vertices in space
faces : (m, 3) int
Indices of vertices which make up triangles
max_edge : float
Maximum length of any edge in the result
max_iter : int
The maximum number of times to run subdivision
return_index : bool
If True, return index of original face for new faces
Returns
------------
vertices : (j, 3) float
Vertices in space
faces : (q, 3) int
Indices of vertices
index : (q, 3) int
Only returned if `return_index`, index of
original face for each new face.
"""
# store completed
done_face = []
done_vert = []
done_idx = []
# copy inputs and make sure dtype is correct
current_faces = np.array(faces, dtype=np.int64, copy=True)
current_vertices = np.array(vertices, dtype=np.float64, copy=True)
# store a map to the original face index
current_index = np.arange(len(faces))
# loop through iteration cap
for i in range(max_iter + 1):
# compute the length of every triangle edge
edge_length = (
np.diff(current_vertices[current_faces[:, [0, 1, 2, 0]], :3], axis=1) ** 2
).sum(axis=2) ** 0.5
# check edge length against maximum
too_long = (edge_length > max_edge).any(axis=1)
# faces that are OK
face_ok = ~too_long
# clean up the faces a little bit so we don't
# store a ton of unused vertices
unique, inverse = grouping.unique_bincount(
current_faces[face_ok].flatten(), return_inverse=True
)
# store vertices and faces meeting criteria
done_vert.append(current_vertices[unique])
done_face.append(inverse.reshape((-1, 3)))
if return_index:
done_idx.append(current_index[face_ok])
current_index = np.tile(current_index[too_long], (4, 1)).T.ravel()
# met our goals so exit
if not too_long.any():
break
# check max_iter before subdividing again
if i >= max_iter:
raise ValueError("max_iter exceeded!")
# run subdivision again
(current_vertices, current_faces) = subdivide(
current_vertices, current_faces[too_long]
)
# stack sequence into nice (n, 3) arrays
final_vertices, final_faces = util.append_faces(done_vert, done_face)
if return_index:
final_index = np.concatenate(done_idx)
assert len(final_index) == len(final_faces)
return final_vertices, final_faces, final_index
return final_vertices, final_faces
def subdivide_loop(vertices, faces, iterations=None):
"""
Subdivide a mesh by dividing each triangle into four triangles
and approximating their smoothed surface (loop subdivision).
This function is an array-based implementation of loop subdivision,
which avoids slow for loop and enables faster calculation.
Overall process:
1. Calculate odd vertices.
Assign a new odd vertex on each edge and
calculate the value for the boundary case and the interior case.
The value is calculated as follows.
v2
/ f0 \\ 0
v0--e--v1 / \\
\\f1 / v0--e--v1
v3
- interior case : 3:1 ratio of mean(v0,v1) and mean(v2,v3)
- boundary case : mean(v0,v1)
2. Calculate even vertices.
The new even vertices are calculated with the existing
vertices and their adjacent vertices.
1---2
/ \\/ \\ 0---1
0---v---3 / \\/ \\
\\ /\\/ b0---v---b1
k...4
- interior case : (1-kB):B ratio of v and k adjacencies
- boundary case : 3:1 ratio of v and mean(b0,b1)
3. Compose new faces with new vertices.
Parameters
------------
vertices : (n, 3) float
Vertices in space
faces : (m, 3) int
Indices of vertices which make up triangles
Returns
------------
vertices : (j, 3) float
Vertices in space
faces : (q, 3) int
Indices of vertices
iterations : int
Number of iterations to run subdivision
"""
if iterations is None:
iterations = 1
def _subdivide(vertices, faces):
# find the unique edges of our faces
edges, edges_face = faces_to_edges(faces, return_index=True)
edges.sort(axis=1)
unique, inverse = grouping.unique_rows(edges)
# set interior edges if there are two edges and boundary if there is
# one.
edge_inter = np.sort(grouping.group_rows(edges, require_count=2), axis=1)
edge_bound = grouping.group_rows(edges, require_count=1)
# make sure that one edge is shared by only one or two faces.
if not len(edge_inter) * 2 + len(edge_bound) == len(edges):
# we have multiple bodies it's a party!
# edges shared by 2 faces are "connected"
# so this connected components operation is
# essentially identical to `face_adjacency`
faces_group = graph.connected_components(edges_face[edge_inter])
if len(faces_group) == 1:
raise ValueError("Some edges are shared by more than 2 faces")
# collect a subdivided copy of each body
seq_verts = []
seq_faces = []
# keep track of vertex count as we go so
# we can do a single vstack at the end
count = 0
# loop through original face indexes
for f in faces_group:
# a lot of the complexity in this operation
# is computing vertex neighbors so we only
# want to pass forward the referenced vertices
# for this particular group of connected faces
unique, inverse = grouping.unique_bincount(
faces[f].reshape(-1), return_inverse=True
)
# subdivide this subset of faces
cur_verts, cur_faces = _subdivide(
vertices=vertices[unique], faces=inverse.reshape((-1, 3))
)
# increment the face references to match
# the vertices when we stack them later
cur_faces += count
# increment the total vertex count
count += len(cur_verts)
# append to the sequence
seq_verts.append(cur_verts)
seq_faces.append(cur_faces)
# return results as clean (n, 3) arrays
return np.vstack(seq_verts), np.vstack(seq_faces)
# set interior, boundary mask for unique edges
edge_bound_mask = np.zeros(len(edges), dtype=bool)
edge_bound_mask[edge_bound] = True
edge_bound_mask = edge_bound_mask[unique]
edge_inter_mask = ~edge_bound_mask
# find the opposite face for each edge
edge_pair = np.zeros(len(edges)).astype(int)
edge_pair[edge_inter[:, 0]] = edge_inter[:, 1]
edge_pair[edge_inter[:, 1]] = edge_inter[:, 0]
opposite_face1 = edges_face[unique]
opposite_face2 = edges_face[edge_pair[unique]]
# set odd vertices to the middle of each edge (default as boundary
# case).
odd = vertices[edges[unique]].mean(axis=1)
# modify the odd vertices for the interior case
e = edges[unique[edge_inter_mask]]
e_v0 = vertices[e][:, 0]
e_v1 = vertices[e][:, 1]
e_f0 = faces[opposite_face1[edge_inter_mask]]
e_f1 = faces[opposite_face2[edge_inter_mask]]
e_v2_idx = e_f0[~(e_f0[:, :, None] == e[:, None, :]).any(-1)]
e_v3_idx = e_f1[~(e_f1[:, :, None] == e[:, None, :]).any(-1)]
e_v2 = vertices[e_v2_idx]
e_v3 = vertices[e_v3_idx]
# simplified from:
# # 3 / 8 * (e_v0 + e_v1) + 1 / 8 * (e_v2 + e_v3)
odd[edge_inter_mask] = 0.375 * e_v0 + 0.375 * e_v1 + e_v2 / 8.0 + e_v3 / 8.0
# find vertex neighbors of each vertex
neighbors = graph.neighbors(edges=edges[unique], max_index=len(vertices))
# convert list type of array into a fixed-shaped numpy array (set -1 to
# empties)
neighbors = np.array(list(zip_longest(*neighbors, fillvalue=-1))).T
# if the neighbor has -1 index, its point is (0, 0, 0), so that
# it is not included in the summation of neighbors when calculating the
# even
vertices_ = np.vstack([vertices, [0.0, 0.0, 0.0]])
# number of neighbors
k = (neighbors + 1).astype(bool).sum(axis=1)
# calculate even vertices for the interior case
even = np.zeros_like(vertices)
# beta = 1 / k * (5 / 8 - (3 / 8 + 1 / 4 * np.cos(2 * np.pi / k)) ** 2)
# simplified with sympy.parse_expr('...').simplify()
beta = (40.0 - (2.0 * np.cos(2 * np.pi / k) + 3) ** 2) / (64 * k)
even = (
beta[:, None] * vertices_[neighbors].sum(1)
+ (1 - k[:, None] * beta[:, None]) * vertices
)
# calculate even vertices for the boundary case
if edge_bound_mask.any():
# boundary vertices from boundary edges
vrt_bound_mask = np.zeros(len(vertices), dtype=bool)
vrt_bound_mask[np.unique(edges[unique][~edge_inter_mask])] = True
# one boundary vertex has two neighbor boundary vertices (set
# others as -1)
boundary_neighbors = neighbors[vrt_bound_mask]
boundary_neighbors[~vrt_bound_mask[neighbors[vrt_bound_mask]]] = -1
even[vrt_bound_mask] = (
vertices_[boundary_neighbors].sum(axis=1) / 8.0
+ (3.0 / 4.0) * vertices[vrt_bound_mask]
)
# the new faces with odd vertices
odd_idx = inverse.reshape((-1, 3)) + len(vertices)
new_faces = np.column_stack(
[
faces[:, 0],
odd_idx[:, 0],
odd_idx[:, 2],
odd_idx[:, 0],
faces[:, 1],
odd_idx[:, 1],
odd_idx[:, 2],
odd_idx[:, 1],
faces[:, 2],
odd_idx[:, 0],
odd_idx[:, 1],
odd_idx[:, 2],
]
).reshape((-1, 3))
# stack the new even vertices and odd vertices
new_vertices = np.vstack((even, odd))
return new_vertices, new_faces
for _ in range(iterations):
vertices, faces = _subdivide(vertices, faces)
if tol.strict or True:
assert np.isfinite(vertices).all()
assert np.isfinite(faces).all()
# should raise if faces are malformed
assert np.isfinite(vertices[faces]).all()
# none of the faces returned should be degenerate
# i.e. every face should have 3 unique vertices
assert (faces[:, 1:] != faces[:, :1]).all()
return vertices, faces
@@ -0,0 +1,417 @@
"""
rendering.py
--------------
Functions to convert trimesh objects to pyglet/opengl objects.
"""
import numpy as np
from . import util
# avoid importing pyglet or pyglet.gl
# as pyglet does things on import
GL_POINTS, GL_LINES, GL_TRIANGLES = (0, 1, 4)
def convert_to_vertexlist(geometry, **kwargs):
"""
Try to convert various geometry objects to the constructor
args for a pyglet indexed vertex list.
Parameters
------------
obj : Trimesh, Path2D, Path3D, (n,2) float, (n,3) float
Object to render
Returns
------------
args : tuple
Args to be passed to pyglet indexed vertex list
constructor.
"""
if util.is_instance_named(geometry, "Trimesh"):
return mesh_to_vertexlist(geometry, **kwargs)
elif util.is_instance_named(geometry, "Path"):
# works for Path3D and Path2D
# both of which inherit from Path
return path_to_vertexlist(geometry, **kwargs)
elif util.is_instance_named(geometry, "PointCloud"):
# pointcloud objects contain colors
return points_to_vertexlist(geometry.vertices, colors=geometry.colors, **kwargs)
elif util.is_instance_named(geometry, "ndarray"):
# (n,2) or (n,3) points
return points_to_vertexlist(geometry, **kwargs)
elif util.is_instance_named(geometry, "VoxelGrid"):
# for voxels view them as a bunch of boxes
return mesh_to_vertexlist(geometry.as_boxes(**kwargs), **kwargs)
else:
raise ValueError("Geometry passed is not a viewable type!")
def mesh_to_vertexlist(mesh, group=None, smooth=True, smooth_threshold=60000):
"""
Convert a Trimesh object to arguments for an
indexed vertex list constructor.
Parameters
-------------
mesh : trimesh.Trimesh
Mesh to be rendered
group : str
Rendering group for the vertex list
smooth : bool
Should we try to smooth shade the mesh
smooth_threshold : int
Maximum number of faces to smooth shade
Returns
--------------
args : (7,) tuple
Args for vertex list constructor
"""
# nominally support 2D vertices
if len(mesh.vertices.shape) == 2 and mesh.vertices.shape[1] == 2:
vertices = np.column_stack((mesh.vertices, np.zeros(len(mesh.vertices))))
else:
vertices = mesh.vertices
if hasattr(mesh.visual, "uv"):
# if the mesh has texture defined pass it to pyglet
vertex_count = len(vertices)
normals = mesh.vertex_normals
faces = mesh.faces
# get the per-vertex UV coordinates
uv = mesh.visual.uv
# shortcut for the material
material = mesh.visual.material
if hasattr(material, "image"):
# does the material actually have an image specified
no_image = material.image is None
elif hasattr(material, "baseColorTexture"):
no_image = material.baseColorTexture is None
else:
no_image = True
# didn't get valid texture so skip it
if uv is None or no_image or len(uv) != vertex_count:
# if no UV coordinates on material, just set face colors
# to the diffuse color of the material
color_gl = colors_to_gl(material.main_color, vertex_count)
else:
# if someone passed (n, 3) UVR cut it off here
if uv.shape[1] > 2:
uv = uv[:, :2]
# texcoord as (2,) float
color_gl = ("t2f/static", uv.astype(np.float64).reshape(-1).tolist())
elif smooth and len(mesh.faces) < smooth_threshold:
# if we have a small number of faces and colors defined
# smooth the mesh by merging vertices of faces below
# the threshold angle
smooth = mesh.smooth_shaded
vertices = smooth.vertices
vertex_count = len(vertices)
normals = smooth.vertex_normals
faces = smooth.faces
color_gl = colors_to_gl(smooth.visual.vertex_colors, vertex_count)
else:
# we don't have textures or want to smooth so
# send a polygon soup of disconnected triangles to opengl
vertex_count = len(mesh.faces) * 3
normals = np.tile(mesh.face_normals, (1, 3))
vertices = vertices[mesh.faces]
faces = np.arange(vertex_count, dtype=np.int64)
colors = np.tile(mesh.visual.face_colors, (1, 3)).reshape((-1, 4))
color_gl = colors_to_gl(colors, vertex_count)
# create the ordered tuple for pyglet, use like:
# `batch.add_indexed(*args)`
args = (
vertex_count, # number of vertices
GL_TRIANGLES, # mode
group, # group
faces.reshape(-1).tolist(), # indices
("v3f/static", vertices.reshape(-1).tolist()),
("n3f/static", normals.reshape(-1).tolist()),
color_gl,
)
return args
def path_to_vertexlist(path, group=None, **kwargs):
"""
Convert a Path3D object to arguments for a
pyglet indexed vertex list constructor.
Parameters
-------------
path : trimesh.path.Path3D object
Mesh to be rendered
group : str
Rendering group for the vertex list
Returns
--------------
args : (7,) tuple
Args for vertex list constructor
"""
# avoid cache check inside tight loop
vertices = path.vertices
# get (n, 2, (2|3)) lines
stacked = [util.stack_lines(e.discrete(vertices)) for e in path.entities]
lines = util.vstack_empty(stacked)
count = len(lines)
# stack zeros for 2D lines
if util.is_shape(vertices, (-1, 2)):
lines = lines.reshape((-1, 2))
lines = np.column_stack((lines, np.zeros(len(lines))))
# index for GL is one per point
index = np.arange(count).tolist()
# convert from entity color to the color of
# each vertex in the line segments
colors = path.colors
if colors is not None:
colors = np.vstack(
[
(np.ones((len(s), 4)) * c).astype(np.uint8)
for s, c in zip(stacked, path.colors)
]
)
# convert to gl-friendly colors
gl_colors = colors_to_gl(colors, count=count)
# collect args for vertexlist constructor
args = (
count, # number of lines
GL_LINES, # mode
group, # group
index, # indices
("v3f/static", lines.reshape(-1)),
gl_colors,
)
return args
def points_to_vertexlist(points, colors=None, group=None, **kwargs):
"""
Convert a numpy array of 3D points to args for
a vertex list constructor.
Parameters
-------------
points : (n, 3) float
Points to be rendered
colors : (n, 3) or (n, 4) float
Colors for each point
group : str
Rendering group for the vertex list
Returns
--------------
args : (7,) tuple
Args for vertex list constructor
"""
points = np.asanyarray(points, dtype=np.float64)
if util.is_shape(points, (-1, 2)):
points = np.column_stack((points, np.zeros(len(points))))
elif not util.is_shape(points, (-1, 3)):
raise ValueError("Pointcloud must be (n,3)!")
index = np.arange(len(points)).tolist()
args = (
len(points), # number of vertices
GL_POINTS, # mode
group, # group
index, # indices
("v3f/static", points.reshape(-1)),
colors_to_gl(colors, len(points)),
)
return args
def colors_to_gl(colors, count):
"""
Given a list of colors (or None) return a GL-acceptable
list of colors.
Parameters
------------
colors: (count, (3 or 4)) float
Input colors as an array
Returns
---------
colors_type : str
Color type
colors_gl : (count,) list
Colors to pass to pyglet
"""
colors = np.asanyarray(colors)
count = int(count)
# get the GL kind of color we have
colors_dtypes = {"f": "f", "i": "B", "u": "B"}
if colors.dtype.kind in colors_dtypes:
dtype = colors_dtypes[colors.dtype.kind]
else:
dtype = None
if dtype is not None and util.is_shape(colors, (count, (3, 4))):
# save the shape and dtype for opengl color string
colors_type = f"c{colors.shape[1]}{dtype}/static"
# reshape the 2D array into a 1D one and then convert to a python list
gl_colors = colors.reshape(-1).tolist()
elif dtype is not None and colors.shape in [(3,), (4,)]:
# we've been passed a single color so tile them
gl_colors = (
(np.ones((count, colors.size), dtype=colors.dtype) * colors)
.reshape(-1)
.tolist()
)
# we know we're tiling
colors_type = f"c{colors.size}{dtype}/static"
else:
# case where colors are wrong shape
# use black as the default color
gl_colors = np.tile([0.0, 0.0, 0.0], (count, 1)).reshape(-1).tolist()
# we're returning RGB float colors
colors_type = "c3f/static"
return colors_type, gl_colors
def material_to_texture(material, upsize=True):
"""
Convert a trimesh.visual.texture.Material object into
a pyglet-compatible texture object.
Parameters
--------------
material : trimesh.visual.texture.Material
Material to be converted
upsize: bool
If True, will upscale textures to their nearest power
of two resolution to avoid weirdness
Returns
---------------
texture : pyglet.image.Texture
Texture loaded into pyglet form
"""
import pyglet
# try to extract a PIL image from material
if hasattr(material, "image"):
img = material.image
elif hasattr(material, "baseColorTexture"):
img = material.baseColorTexture
else:
return None
# if no images in texture return now
if img is None:
return None
# if we're not powers of two upsize
if upsize:
from .visual.texture import power_resize
img = power_resize(img)
# use a PNG export to exchange into pyglet
# probably a way to do this with a PIL converter
with util.BytesIO() as f:
# export PIL image as PNG
img.save(f, format="png")
f.seek(0)
# filename used for format guess
gl_image = pyglet.image.load(filename=".png", file=f)
# turn image into pyglet texture
texture = gl_image.get_texture()
return texture
def matrix_to_gl(matrix):
"""
Convert a numpy row-major homogeneous transformation matrix
to a flat column-major GLfloat transformation.
Parameters
-------------
matrix : (4,4) float
Row-major homogeneous transform
Returns
-------------
glmatrix : (16,) gl.GLfloat
Transform in pyglet format
"""
from pyglet import gl
# convert to GLfloat, switch to column major and flatten to (16,)
return (gl.GLfloat * 16)(*np.array(matrix, dtype=np.float32).T.ravel())
def vector_to_gl(array, *args):
"""
Convert an array and an optional set of args into a
flat vector of gl.GLfloat
"""
from pyglet import gl
array = np.array(array)
if len(args) > 0:
array = np.append(array, args)
vector = (gl.GLfloat * len(array))(*array)
return vector
def light_to_gl(light, transform, lightN):
"""
Convert trimesh.scene.lighting.Light objects into
args for gl.glLightFv calls
Parameters
--------------
light : trimesh.scene.lighting.Light
Light object to be converted to GL
transform : (4, 4) float
Transformation matrix of light
lightN : int
Result of gl.GL_LIGHT0, gl.GL_LIGHT1, etc
Returns
--------------
multiarg : [tuple]
List of args to pass to gl.glLightFv eg:
[gl.glLightfb(*a) for a in multiarg]
"""
from pyglet import gl
# convert color to opengl
gl_color = vector_to_gl(light.color.astype(np.float64) / 255.0)
assert len(gl_color) == 4
# cartesian translation from matrix
gl_position = vector_to_gl(transform[:3, 3])
# create the different position and color arguments
args = [
(lightN, gl.GL_POSITION, gl_position),
(lightN, gl.GL_SPECULAR, gl_color),
(lightN, gl.GL_DIFFUSE, gl_color),
(lightN, gl.GL_AMBIENT, gl_color),
]
return args
@@ -0,0 +1,454 @@
"""
repair.py
-------------
Fill holes and fix winding and normals of meshes.
"""
import numpy as np
from . import graph, triangles
from .constants import log
from .geometry import faces_to_edges
from .grouping import group_rows
try:
import networkx as nx
except BaseException as E:
# create a dummy module which will raise the ImportError
# or other exception only when someone tries to use networkx
from .exceptions import ExceptionWrapper
nx = ExceptionWrapper(E)
try:
from .path.exchange.misc import faces_to_path
except BaseException as E:
from .exceptions import ExceptionWrapper
faces_to_path = ExceptionWrapper(E)
def fix_winding(mesh):
"""
Traverse and change mesh faces in-place to make sure
winding is correct with edges on adjacent faces in
opposite directions.
Parameters
-------------
mesh : Trimesh
Source geometry to alter in-place.
"""
# anything we would fix is already done
if mesh.is_winding_consistent:
return
graph_all = nx.from_edgelist(mesh.face_adjacency)
flipped = 0
faces = mesh.faces.view(np.ndarray).copy()
# we are going to traverse the graph using BFS
# start a traversal for every connected component
for components in nx.connected_components(graph_all):
# get a subgraph for this component
g = graph_all.subgraph(components)
# get the first node in the graph in a way that works on nx's
# new API and their old API
start = next(iter(g.nodes()))
# we traverse every pair of faces in the graph
# we modify mesh.faces and mesh.face_normals in place
for face_pair in nx.bfs_edges(g, start):
# for each pair of faces, we convert them into edges,
# find the edge that both faces share and then see if edges
# are reversed in order as you would expect
# (2, ) int
face_pair = np.ravel(face_pair)
# (2, 3) int
pair = faces[face_pair]
# (6, 2) int
edges = faces_to_edges(pair)
overlap = group_rows(np.sort(edges, axis=1), require_count=2)
if len(overlap) == 0:
# only happens on non-watertight meshes
continue
edge_pair = edges[overlap[0]]
if edge_pair[0][0] == edge_pair[1][0]:
# if the edges aren't reversed, invert the order of one face
flipped += 1
faces[face_pair[1]] = faces[face_pair[1]][::-1]
if flipped > 0:
mesh.faces = faces
log.debug("flipped %d/%d edges", flipped, len(mesh.faces) * 3)
def fix_inversion(mesh, multibody=False):
"""
Check to see if a mesh has normals pointing "out."
Parameters
-------------
mesh : trimesh.Trimesh
Mesh to fix in-place.
multibody : bool
If True will try to fix normals on every body
"""
if not mesh.is_watertight:
# this will make things worse for non-watertight meshes
return
if multibody:
groups = graph.connected_components(mesh.face_adjacency)
# escape early for single body
if len(groups) == 1:
if mesh.volume < 0.0:
mesh.invert()
return
# mask of faces to flip
flip = np.zeros(len(mesh.faces), dtype=bool)
# save these to avoid thrashing cache
tri = mesh.triangles
cross = mesh.triangles_cross
# indexes of mesh.faces, not actual faces
for faces in groups:
# calculate the volume of the submesh faces
volume = triangles.mass_properties(
tri[faces], crosses=cross[faces], skip_inertia=True
)["volume"]
# if that volume is negative it is either
# inverted or just total garbage
if volume < 0.0:
flip[faces] = True
# one or more faces needs flipping
if flip.any():
# flip normals of necessary faces
if "face_normals" in mesh._cache:
normals = mesh.face_normals.copy()
normals[flip] *= -1.0
else:
normals = None
# flip faces
mesh.faces[flip] = np.fliplr(mesh.faces[flip])
if normals is not None:
mesh.face_normals = normals
elif mesh.volume < 0.0:
mesh.invert()
def fix_normals(mesh, multibody=False):
"""
Fix the winding and direction of a mesh face and
face normals in-place.
Really only meaningful on watertight meshes but will orient all
faces and winding in a uniform way for non-watertight face
patches as well.
Parameters
-------------
mesh : trimesh.Trimesh
Mesh to fix normals on
multibody : bool
if True try to correct normals direction
on every body rather than just one
Notes
--------------
mesh.faces : will flip columns on inverted faces
"""
# traverse face adjacency to correct winding
fix_winding(mesh)
# check to see if a mesh is inverted
fix_inversion(mesh, multibody=multibody)
def broken_faces(mesh, color=None):
"""
Return the index of faces in the mesh which break the
watertight status of the mesh.
Parameters
--------------
mesh : trimesh.Trimesh
Mesh to check broken faces on
color: (4,) uint8 or None
Will set broken faces to this color if not None
Returns
---------------
broken : (n, ) int
Indexes of mesh.faces
"""
adjacency = nx.from_edgelist(mesh.face_adjacency)
broken = [k for k, v in dict(adjacency.degree()).items() if v != 3]
broken = np.array(broken)
if color is not None and broken.size != 0:
# if someone passed a broken color
color = np.array(color)
if not (color.shape == (4,) or color.shape == (3,)):
color = [255, 0, 0, 255]
mesh.visual.face_colors[broken] = color
return broken
def fill_holes(mesh):
"""
Fill single- triangle holes on triangular meshes by adding
new triangles to fill the holes. New triangles will have
proper winding and normals, and if face colors exist the color
of the last face will be assigned to the new triangles.
Parameters
------------
mesh : trimesh.Trimesh
Mesh will be repaired in- place
"""
def hole_to_faces(hole):
"""
Given a loop of vertex indices representing a hole
turn it into triangular faces.
If unable to do so, return None
Parameters
-----------
hole : (n,) int
Ordered loop of vertex indices
Returns
---------
faces : (n, 3) int
New faces
vertices : (m, 3) float
New vertices
"""
hole = np.asanyarray(hole)
# the case where the hole is just a single missing triangle
if len(hole) == 3:
return [hole], []
# the hole is a quad, which we fill with two triangles
if len(hole) == 4:
face_A = hole[[0, 1, 2]]
face_B = hole[[2, 3, 0]]
return [face_A, face_B], []
return [], []
if len(mesh.faces) < 3:
return False
if mesh.is_watertight:
return True
# we know that in a watertight mesh every edge will be included twice
# thus every edge which appears only once is part of a hole boundary
boundary_groups = group_rows(mesh.edges_sorted, require_count=1)
# mesh is not watertight and we have too few edges
# edges to do a repair
# since we haven't changed anything return False
if len(boundary_groups) < 3:
return False
boundary_edges = mesh.edges[boundary_groups]
index_as_dict = [{"index": i} for i in boundary_groups]
# we create a graph of the boundary edges, and find cycles.
g = nx.from_edgelist(np.column_stack((boundary_edges, index_as_dict)))
new_faces = []
new_vertex = []
for hole in nx.cycle_basis(g):
# convert the hole, which is a polygon of vertex indices
# to triangles and new vertices
faces, vertex = hole_to_faces(hole=hole)
if len(faces) == 0:
continue
# remeshing returns new vertices as negative indices, so change those
# to absolute indices which won't be screwed up by the later appends
faces = np.array(faces)
faces[faces < 0] += len(new_vertex) + len(mesh.vertices) + len(vertex)
new_vertex.extend(vertex)
new_faces.extend(faces)
new_faces = np.array(new_faces)
new_vertex = np.array(new_vertex)
if len(new_faces) == 0:
# no new faces have been added, so nothing further to do
# the mesh is NOT watertight, as boundary groups exist
# but we didn't add any new faces to fill them in
return False
for face_index, face in enumerate(new_faces):
# we compare the edge from the new face with
# the boundary edge from the source mesh
edge_test = face[:2]
edge_boundary = mesh.edges[g.get_edge_data(*edge_test)["index"]]
# in a well constructed mesh, the winding is such that adjacent triangles
# have reversed edges to each other. Here we check to make sure the
# edges are reversed, and if they aren't we simply reverse the face
reversed = edge_test[0] == edge_boundary[1]
if not reversed:
new_faces[face_index] = face[::-1]
# stack vertices into clean (n, 3) float
if len(new_vertex) != 0:
new_vertices = np.vstack((mesh.vertices, new_vertex))
else:
new_vertices = mesh.vertices
# try to save face normals if we can
if "face_normals" in mesh._cache.cache:
cached_normals = mesh._cache.cache["face_normals"]
else:
cached_normals = None
# also we can remove any zero are triangles by masking here
new_normals, valid = triangles.normals(new_vertices[new_faces])
# all the added faces were broken
if not valid.any():
return False
# this is usually the case where two vertices of a triangle are just
# over tol.merge apart, but the normal calculation is screwed up
# these could be fixed by merging the vertices in question here:
# if not valid.all():
if mesh.visual.defined and mesh.visual.kind == "face":
color = mesh.visual.face_colors
else:
color = None
# apply the new faces and vertices
mesh.faces = np.vstack((mesh._data["faces"], new_faces[valid]))
mesh.vertices = new_vertices
# dump the cache and set id to the new hash
mesh._cache.verify()
# save us a normals recompute if we can
if cached_normals is not None:
mesh.face_normals = np.vstack((cached_normals, new_normals))
# this is usually the case where two vertices of a triangle are just
# over tol.merge apart, but the normal calculation is screwed up
# these could be fixed by merging the vertices in question here:
# if not valid.all():
if color is not None:
# if face colors exist, assign the last face color to the new faces
# note that this is a little cheesey, but it is very inexpensive and
# is the right thing to do if the mesh is a single color.
color_shape = np.shape(color)
if len(color_shape) == 2:
new_colors = np.tile(color[-1], (np.sum(valid), 1))
new_colors = np.vstack((color, new_colors))
mesh.visual.face_colors = new_colors
log.debug("Filled in mesh with %i triangles", np.sum(valid))
return mesh.is_watertight
def stitch(mesh, faces=None, insert_vertices=False):
"""
Create a fan stitch over the boundary of the specified
faces. If the boundary is non-convex a triangle fan
is going to be extremely wonky.
Parameters
-----------
mesh : trimesh.Trimesh
Mesh to create fan stitch on.
faces : (n,) int
Face indexes to stitch with triangle fans.
insert_vertices : bool
Allow stitching to insert new vertices?
Returns
----------
fan : (m, 3) int
New triangles referencing mesh.vertices.
vertices : (p, 3) float
Inserted vertices (only returned `if insert_vertices`)
"""
if faces is None:
faces = np.arange(len(mesh.faces))
# get a sequence of vertex indices representing the
# boundary of the specified faces
# will be referencing the same indexes of `mesh.vertices`
points = [
e.points
for e in faces_to_path(mesh, faces)["entities"]
if len(e.points) > 3 and e.points[0] == e.points[-1]
]
# get properties to avoid querying in loop
vertices = mesh.vertices
normals = mesh.face_normals
# find which faces are associated with an edge
edges_face = mesh.edges_face
tree_edge = mesh.edges_sorted_tree
if insert_vertices:
# create one new vertex per curve at the centroid
centroids = np.array([vertices[p].mean(axis=0) for p in points])
# save the original length of the vertices
count = len(vertices)
# for the normal check stack our local vertices
vertices = np.vstack((vertices, centroids))
# create a triangle between our new centroid vertex
# and each one of the boundary curves
fan = [
np.column_stack((np.ones(len(p) - 1, dtype=int) * (count + i), p[:-1], p[1:]))
for i, p in enumerate(points)
]
else:
# since we're not allowed to insert new vertices
# create a triangle fan for each boundary curve
fan = [
np.column_stack((np.ones(len(p) - 3, dtype=int) * p[0], p[1:-2], p[2:-1]))
for p in points
]
# now we do a normal check against an adjacent face
# to see if each region needs to be flipped
for i, t in zip(range(len(fan)), fan):
# get the edges from the original mesh
# for the first `n` new triangles
e = t[:10, 1:].copy()
e.sort(axis=1)
# find which indexes of `mesh.edges` these
# new edges correspond with by finding edges
# that exactly correspond with the tree
query = tree_edge.query_ball_point(e, r=1e-10)
if len(query) == 0:
continue
# stack all the indices that exist
edge_index = np.concatenate(query)
# get the normals from the original mesh
original = normals[edges_face[edge_index]]
# calculate the normals for a few new faces
check, valid = triangles.normals(vertices[t[:3]])
if not valid.any():
continue
# take the first valid normal from our new faces
check = check[0]
# if our new faces are reversed from the original
# Adjacent face flip them along their axis
sign = np.dot(original, check)
if sign.mean() < 0:
fan[i] = np.fliplr(t)
fan = np.vstack(fan)
if insert_vertices:
return fan, centroids
return fan
@@ -0,0 +1,617 @@
"""
resolvers.py
---------------
Provides a common interface to load assets referenced by name
like MTL files, texture images, etc. Assets can be from ZIP
archives, web assets, or a local file path.
"""
import abc
import itertools
import os
from . import caching, util
from .typed import Dict, Mapping, Optional, Union
# URL parsing for remote resources via WebResolver
try:
# Python 3
from urllib.parse import urlparse
except ImportError:
# Python 2
from urlparse import urlparse
class Resolver(util.ABC):
"""
The base class for resolvers.
"""
@abc.abstractmethod
def __init__(self, *args, **kwargs):
raise NotImplementedError("Use a resolver subclass!")
@abc.abstractmethod
def get(self, key):
raise NotImplementedError()
@abc.abstractmethod
def write(self, name: str, data):
raise NotImplementedError("`write` not implemented!")
@abc.abstractmethod
def namespaced(self, namespace: str):
raise NotImplementedError("`namespaced` not implemented!")
@abc.abstractmethod
def keys(self):
raise NotImplementedError("`keys` not implemented!")
def __getitem__(self, key: str):
return self.get(key)
def __setitem__(self, key: str, value):
return self.write(key, value)
def __contains__(self, key: str) -> bool:
return key in self.keys()
class FilePathResolver(Resolver):
"""
Resolve files from a source path on the file system.
"""
def __init__(self, source: str):
"""
Resolve files based on a source path.
Parameters
------------
source : str
File path where mesh was loaded from
"""
# remove everything other than absolute path
clean = os.path.expanduser(os.path.abspath(str(source)))
self.clean = clean
if os.path.isdir(clean):
# if we were passed a directory use it
self.parent = clean
else:
# otherwise get the parent directory we've been passed
split = os.path.split(clean)
self.parent = split[0]
# exit if directory doesn't exist
if not os.path.isdir(self.parent):
raise ValueError(f"path `{self.parent} `not a directory!")
self.file_path = source
self.file_name = os.path.basename(source)
def keys(self):
"""
List all files available to be loaded.
Yields
-----------
name : str
Name of a file which can be accessed.
"""
parent = self.parent
for path, _, names in os.walk(self.parent):
# strip any leading parent key
if path.startswith(parent):
path = path[len(parent) :]
# yield each name
for name in names:
yield os.path.join(path, name)
def namespaced(self, namespace: str) -> "FilePathResolver":
"""
Return a resolver which changes the root of the
resolver by an added namespace.
Parameters
-------------
namespace : str
Probably a subdirectory
Returns
--------------
resolver : FilePathResolver
Resolver with root directory changed.
"""
return FilePathResolver(os.path.join(self.parent, namespace))
def get(self, name: str):
"""
Get an asset.
Parameters
-------------
name : str
Name of the asset
Returns
------------
data : bytes
Loaded data from asset
"""
# load the file by path name
path = os.path.join(self.parent, name.strip())
if not os.path.exists(path):
path = os.path.join(self.parent, name.strip().lstrip("/"))
if not os.path.exists(path):
path = os.path.join(self.parent, os.path.split(name)[-1])
with open(path, "rb") as f:
data = f.read()
return data
def write(self, name: str, data: Union[str, bytes]):
"""
Write an asset to a file path.
Parameters
-----------
name : str
Name of the file to write
data : str or bytes
Data to write to the file
"""
# write files to path name
with open(os.path.join(self.parent, name.strip()), "wb") as f:
# handle encodings correctly for str/bytes
util.write_encoded(file_obj=f, stuff=data)
class ZipResolver(Resolver):
"""
Resolve files inside a ZIP archive.
"""
def __init__(self, archive: Optional[Dict] = None, namespace: Optional[str] = None):
"""
Resolve files inside a ZIP archive as loaded by
trimesh.util.decompress
Parameters
-------------
archive : dict
Contains resources as file object
namespace : None or str
If passed will only show keys that start
with this value and this substring must be
removed for any get calls.
"""
self.archive = archive
if isinstance(namespace, str):
self.namespace = namespace.strip().rstrip("/") + "/"
else:
self.namespace = None
def keys(self):
"""
Get the available keys in the current archive.
Returns
-----------
keys : iterable
Keys in the current archive.
"""
if self.namespace is not None:
namespace = self.namespace
length = len(namespace)
# only return keys that start with the namespace
# and strip off the namespace from the returned
# keys.
return [
k[length:]
for k in self.archive.keys()
if k.startswith(namespace) and len(k) > length
]
return self.archive.keys()
def write(self, key: str, value) -> None:
"""
Store a value in the current archive.
Parameters
-----------
key : hashable
Key to store data under.
value : str, bytes, file-like
Value to store.
"""
if self.archive is None:
self.archive = {}
self.archive[key] = value
def get(self, name: str) -> bytes:
"""
Get an asset from the ZIP archive.
Parameters
-------------
name : str
Name of the asset
Returns
-------------
data : bytes
Loaded data from asset
"""
# not much we can do with None
if name is None:
return
# make sure name is a string
if hasattr(name, "decode"):
name = name.decode("utf-8")
# store reference to archive inside this function
archive = self.archive
# requested name not identical in
# storage so attempt to recover
if name not in archive:
# loop through unique results
for option in nearby_names(name, self.namespace):
if option in archive:
# cleaned option is in archive
# so store value and exit
name = option
break
# get the stored data
obj = archive[name]
# if the dict is storing data as bytes just return
if isinstance(obj, (bytes, str)):
return obj
# otherwise get it as a file object
# read file object from beginning
obj.seek(0)
# data is stored as a file object
data = obj.read()
obj.seek(0)
return data
def namespaced(self, namespace: str) -> "ZipResolver":
"""
Return a "sub-resolver" with a root namespace.
Parameters
-------------
namespace : str
The root of the key to clip off, i.e. if
this resolver has key `a/b/c` you can get
'a/b/c' with resolver.namespaced('a/b').get('c')
Returns
-----------
resolver : Resolver
Namespaced resolver.
"""
return ZipResolver(archive=self.archive, namespace=namespace)
def export(self) -> bytes:
"""
Export the contents of the current archive as
a ZIP file.
Returns
------------
compressed : bytes
Compressed data in ZIP format.
"""
return util.compress(self.archive)
class WebResolver(Resolver):
"""
Resolve assets from a remote URL.
"""
def __init__(self, url: str):
"""
Resolve assets from a base URL.
Parameters
--------------
url : str
Location where a mesh was stored or
directory where mesh was stored
"""
if hasattr(url, "decode"):
url = url.decode("utf-8")
# parse string into namedtuple
parsed = urlparse(url)
# we want a base url
split = [i for i in parsed.path.split("/") if len(i) > 0]
# if the last item in the url path is a filename
# move up a "directory" for the base path
if len(split) == 0:
path = ""
elif "." in split[-1]:
# clip off last item
path = "/".join(split[:-1])
else:
# recombine into string ignoring any double slashes
path = "/".join(split)
# save the URL we were created with, i.e.
# `https://stuff.com/models/thing.glb`
self.url = url
# save the root url, i.e. `https://stuff.com/models`
self.base_url = (
"/".join(
i
for i in [parsed.scheme + ":/", parsed.netloc.strip("/"), path.strip("/")]
if len(i) > 0
)
+ "/"
)
# our string handling should have never inserted double slashes
assert "//" not in self.base_url[len(parsed.scheme) + 3 :]
# we should always have ended with a single slash
assert self.base_url.endswith("/")
self.file_name = url.split("/")[-1]
def get(self, name: str) -> bytes:
"""
Get a resource from the remote site.
Parameters
-------------
name : str
Asset name, i.e. 'quadknot.obj.mtl'
"""
# do import here to keep soft dependency
import httpx
# remove leading and trailing whitespace
name = name.strip()
# fetch the data from the remote url
# base url has been carefully formatted
url = self.base_url + name
response = httpx.get(url, follow_redirects=True)
if response.status_code >= 300:
# try to strip off filesystem crap
if name.startswith("./"):
name = name[2:]
response = httpx.get(self.base_url + name, follow_redirects=True)
# now raise if we don't have
response.raise_for_status()
# return the bytes of the response
return response.content
def get_base(self) -> bytes:
"""
Fetch the data at the full URL this resolver was
instantiated with, i.e. `https://stuff.com/hi.glb`
this will return the response.
Returns
--------
content
The value at `self.url`
"""
import httpx
# just fetch the url we were created with
response = httpx.get(self.url, follow_redirects=True)
response.raise_for_status()
return response.content
def namespaced(self, namespace: str) -> "WebResolver":
"""
Return a namespaced version of current resolver.
Parameters
-------------
namespace : str
URL fragment
Returns
-----------
resolver : WebResolver
With sub-url: `https://example.com/{namespace}`
"""
# join the base url and the namespace
return WebResolver(url=self.base_url + namespace)
def write(self, key, value):
raise NotImplementedError("`WebResolver` is read-only!")
def keys(self):
raise NotImplementedError("`WebResolver` can't list keys")
class GithubResolver(Resolver):
def __init__(
self,
repo: str,
branch: Optional[str] = None,
commit: Optional[str] = None,
save: Optional[str] = None,
):
"""
Get files from a remote Github repository by
downloading a zip file with the entire branch
or a specific commit.
Parameters
-------------
repo
In the format of `owner/repo`
branch
The remote branch you want to get files from.
commit
The full commit hash: pass either this OR branch.
save
A path if you want to save results locally.
"""
if commit is not None:
# just get the exact commit
self.url = f"https://github.com/{repo}/archive/{commit}.zip"
elif branch is not None:
# gets the latest commit on the specified branch.
self.url = f"https://github.com/{repo}/archive/refs/heads/{branch}.zip"
else:
raise ValueError("`commit` or `branch` must be passed!")
if save is not None:
self.cache = caching.DiskCache(save)
else:
self.cache = None
def keys(self):
"""
List the available files in the repository.
Returns
----------
keys : iterable
Keys available to the resolved.
"""
return self.zipped.keys()
def write(self, name, data):
raise NotImplementedError("`write` not implemented!")
@property
def zipped(self) -> ZipResolver:
"""
- opened zip file
- locally saved zip file
- retrieve zip file and saved
"""
def fetch() -> bytes:
"""
Fetch the remote zip file.
"""
import httpx
response = httpx.get(self.url, follow_redirects=True)
response.raise_for_status()
return response.content
if hasattr(self, "_zip"):
return self._zip
# download the archive or get from disc
raw = self.cache.get(self.url, fetch)
# create a zip resolver for the archive
# the root directory in the zip is the repo+commit so strip that off
# so the keys are usable, i.e. "models" instead of "trimesh-2232323/models"
self._zip = ZipResolver(
{
k.split("/", 1)[1]: v
for k, v in util.decompress(
util.wrap_as_stream(raw), file_type="zip"
).items()
}
)
return self._zip
def get(self, key):
return self.zipped.get(key)
def namespaced(self, namespace):
"""
Return a "sub-resolver" with a root namespace.
Parameters
-------------
namespace : str
The root of the key to clip off, i.e. if
this resolver has key `a/b/c` you can get
'a/b/c' with resolver.namespaced('a/b').get('c')
Returns
-----------
resolver : Resolver
Namespaced resolver.
"""
return self.zipped.namespaced(namespace)
def nearby_names(name, namespace=None):
"""
Try to find nearby variants of a specified name.
Parameters
------------
name : str
Initial name.
Yields
-----------
nearby : str
Name that is a lightly permutated version
of the initial name.
"""
# the various operations that *might* result in a correct key
def trim(prefix, item):
if item.startswith(prefix):
return item[len(prefix) :]
return item
cleaners = [
lambda x: x,
lambda x: x.strip(),
lambda x: trim("./", x),
lambda x: trim(".\\", x),
lambda x: trim("\\", x),
lambda x: os.path.split(x)[-1],
lambda x: x.replace("%20", " "),
]
if namespace is None:
namespace = ""
# make sure we don't return repeat values
hit = set()
for f in cleaners:
# try just one cleaning function
current = f(name)
if current in hit:
continue
hit.add(current)
yield namespace + current
for a, b in itertools.combinations(cleaners, 2):
# apply both clean functions
current = a(b(name))
if current in hit:
continue
hit.add(current)
yield namespace + current
# try applying in reverse order
current = b(a(name))
if current in hit:
continue
hit.add(current)
yield namespace + current
if ".." in name and namespace is not None:
# if someone specified relative paths give it one attempt
strip = namespace.strip("/").split("/")[: -name.count("..")]
strip.extend(name.split("..")[-1].strip("/").split("/"))
yield "/".join(strip)
# most loaders can use a mapping in addition to a resolver
ResolverLike = Union[Resolver, Mapping]
@@ -0,0 +1,119 @@
import gzip
import json
import os
from io import BytesIO
from ..typed import Dict
# find the current absolute path to this directory
_pwd = os.path.expanduser(os.path.abspath(os.path.dirname(__file__)))
# once resources are loaded cache them
_cache = {}
def get_schema(name: str) -> Dict:
"""
Load a schema and evaluate the referenced files.
Parameters
------------
name : str
Filename of schema.
Returns
----------
schema
Loaded and resolved schema.
"""
from ..resolvers import FilePathResolver
from ..schemas import resolve
# get a resolver for our base path
resolver = FilePathResolver(os.path.join(_pwd, "schema", name))
# recursively load `$ref` keys
return resolve(json.loads(resolver.get(name).decode("utf-8")), resolver=resolver)
def get_json(name: str) -> Dict:
"""
Get a resource from the `trimesh/resources` folder as a decoded string.
Parameters
-------------
name : str
File path relative to `trimesh/resources/{name}`
Returns
-------------
resource
File data decoded from JSON.
"""
raw = get_bytes(name)
if name.endswith(".gzip"):
raw = gzip.decompress(raw)
else:
raw = raw.decode("utf-8")
return json.loads(raw)
def get_string(name: str) -> str:
"""
Get a resource from the `trimesh/resources` folder as a decoded string.
Parameters
-------------
name
File path relative to `trimesh/resources`
Returns
-------------
resource
File data as a string.
"""
return get_bytes(name).decode("utf-8")
def get_bytes(name: str) -> bytes:
"""
Get a resource from the `trimesh/resources` folder as binary data.
Parameters
-------------
name
File path relative to `trimesh/resources`
Returns
-------------
resource
File data as raw bytes.
"""
cached = _cache.get(name, None)
if cached is not None:
return cached
# get the resource using relative names
# all templates are using POSIX relative paths
# so fix them to be platform-specific
with open(os.path.join(_pwd, *name.split("/")), "rb") as f:
resource = f.read()
_cache[name] = resource
return resource
def get_stream(name: str) -> BytesIO:
"""
Get a resource from the `trimesh/resources` folder as a binary stream.
Parameters
-------------
name : str
File path relative to `trimesh/resources`
Returns
-------------
resource
File data as a binary stream.
"""
return BytesIO(get_bytes(name))
@@ -0,0 +1 @@
{"box":{"vertices":[[0.0,0.0,0.0],[0.0,0.0,1.0],[0.0,1.0,0.0],[0.0,1.0,1.0],[1.0,0.0,0.0],[1.0,0.0,1.0],[1.0,1.0,0.0],[1.0,1.0,1.0]],"faces":[[1,3,0],[4,1,0],[0,3,2],[2,4,0],[1,7,3],[5,1,4],[5,7,1],[3,7,2],[6,4,2],[2,7,6],[6,5,4],[7,5,6]],"face_normals":[[-1,0,0],[0,-1,0],[-1,0,0],[0,0,-1],[0,0,1],[0,-1,0],[0,0,1],[0,1,0],[0,0,-1],[0,1,0],[1,0,0],[1,0,0]]},"icosahedron":{"vertices":[[-0.5257311121191336,0.85065080835204,0.0],[0.5257311121191336,0.85065080835204,0.0],[-0.5257311121191336,-0.85065080835204,0.0],[0.5257311121191336,-0.85065080835204,0.0],[0.0,-0.5257311121191336,0.85065080835204],[0.0,0.5257311121191336,0.85065080835204],[0.0,-0.5257311121191336,-0.85065080835204],[0.0,0.5257311121191336,-0.85065080835204],[0.85065080835204,0.0,-0.5257311121191336],[0.85065080835204,0.0,0.5257311121191336],[-0.85065080835204,0.0,-0.5257311121191336],[-0.85065080835204,0.0,0.5257311121191336]],"faces":[[0,11,5],[0,5,1],[0,1,7],[0,7,10],[0,10,11],[1,5,9],[5,11,4],[11,10,2],[10,7,6],[7,1,8],[3,9,4],[3,4,2],[3,2,6],[3,6,8],[3,8,9],[4,9,5],[2,4,11],[6,2,10],[8,6,7],[9,8,1]]}}
@@ -0,0 +1,5 @@
# trimesh/resources/schemas
Contain schemas for formats when available. They are currently mostly [JSON schema](https://json-schema.org/) although if formats have an XSD, DTD, or other schema format we are happy to include it here.
The `primitive` schema directory is a [JSON schema](https://json-schema.org/) for `trimesh` exports. The goal is if we implement a `to_dict` method to have a well-defined schema we can validate in unit tests.
@@ -0,0 +1,29 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/box.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "A 3D box primitive.",
"properties": {
"extents": {
"description": "The length of each side of the 3D box. The center of mass will be at the origin, and the minimum values will be at (-extents / 2) and the maximum values will be at (extents / 2).",
"items": {
"maxItems": 3,
"minItems": 3,
"type": "number"
},
"type": "array"
},
"kind": {
"pattern": "(^box$)",
"type": "string"
},
"transform": {
"$ref": "transform.schema.json"
}
},
"required": [
"extents",
"kind"
],
"type": "object"
}
@@ -0,0 +1,29 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/capsule.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "A 3D capsule primitive: a cylinder capped by tangent hemispheres.",
"properties": {
"height": {
"description": "The total height of the capsule including caps.",
"type": "number"
},
"kind": {
"pattern": "(^capsule$)",
"type": "string"
},
"radius": {
"description": "The radius of the cylinder and hemispherical caps.",
"type": "number"
},
"transform": {
"$ref": "transform.schema.json"
}
},
"required": [
"radius",
"height",
"kind"
],
"type": "object"
}
@@ -0,0 +1,29 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/cylinder.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "A 3D cylinder primitive.",
"properties": {
"height": {
"description": "The height of the cylinder along Z. The maximum Z value will be at (height / 2) and the minimum will be at (-height / 2)",
"type": "number"
},
"kind": {
"pattern": "(^cylinder$)",
"type": "string"
},
"radius": {
"description": "The radius of the cylinder.",
"type": "number"
},
"transform": {
"$ref": "transform.schema.json"
}
},
"required": [
"radius",
"height",
"kind"
],
"type": "object"
}
@@ -0,0 +1,27 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/extrusion.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "A 2D polygon extruded from Z=0 along positive Z by `height` then transformed by the `transform` matrix.",
"properties": {
"height": {
"type": "number"
},
"kind": {
"pattern": "(^extrusion$)",
"type": "string"
},
"polygon": {
"$ref": "wkt.polygon.schema.json"
},
"transform": {
"$ref": "transform.schema.json"
}
},
"required": [
"kind",
"polygon",
"height"
],
"type": "object"
}
@@ -0,0 +1,22 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/primitive.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
{
"$ref": "box.schema.json"
},
{
"$ref": "sphere.schema.json"
},
{
"$ref": "capsule.schema.json"
},
{
"$ref": "cylinder.schema.json"
},
{
"$ref": "extrusion.schema.json"
}
],
"description": "A geometric primitive."
}
@@ -0,0 +1,25 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/scenegraph.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "A scene graph in the simplest possible JSON format.",
"type": "array",
"items": {
"type": "array",
"prefixItems": [
{ "type": "string", "description": "Node name of frame from." },
{ "type": "string", "description": "Node name of frame to." },
{
"type": "object",
"additionalProperties": false,
"properties": {
"geometry": {
"type": "string",
"description": "Name of the geometry transformed to this frame."
},
"matrix": { "$ref": "transform.schema.json" }
}
}
]
}
}
@@ -0,0 +1,23 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/sphere.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "A 3D sphere primitive.",
"properties": {
"kind": {
"pattern": "(^sphere$)",
"type": "string"
},
"radius": {
"type": "number"
},
"transform": {
"$ref": "transform.schema.json"
}
},
"required": [
"radius",
"kind"
],
"type": "object"
}
@@ -0,0 +1,16 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/transform.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "A homogeneous transformation matrix. If not passed it is assumed to be an identity matrix ",
"items": {
"items": {
"maxItems": 4,
"minItems": 4,
"type": "number"
},
"maxItems": 4,
"minItems": 4,
"type": "array"
},
"type": "array"
}
@@ -0,0 +1,39 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/trimesh.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "A 3D triangular mesh in the simplest possible JSON format.",
"properties": {
"faces": {
"items": {
"items": {
"maxItems": 3,
"minItems": 3,
"type": "integer"
},
"type": "array"
},
"type": "array"
},
"kind": {
"pattern": "(^trimesh$)",
"type": "string"
},
"vertices": {
"items": {
"items": {
"maxItems": 3,
"minItems": 3,
"type": "number"
},
"type": "array"
},
"type": "array"
}
},
"required": [
"vertices",
"faces"
],
"type": "object"
}
@@ -0,0 +1,7 @@
{
"$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/wkt.polygon.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "A single polygon in the Well Known Text format (WKT).",
"pattern": " *POLYGON *\\( *\\( *([+-]?([0-9]*[.])?[0-9]+ *[+-]?([0-9]*[.])?[0-9]+ *,* *)*\\) *\\) *",
"type": "string"
}
@@ -0,0 +1,324 @@
<?xml version="1.0" ?>
<!--
XML Schema for URDF v1.0
This is a proposal XML Schema to validate the original URDF file
format. It supports PR2 extensions (transmission) but not the
Gazebo ones.
-->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
targetNamespace="http://www.ros.org"
xmlns="http://www.ros.org"
elementFormDefault="qualified">
<!-- pose node type -->
<xs:complexType name="pose">
<xs:attribute name="xyz" type="xs:string" default="0 0 0" />
<xs:attribute name="rpy" type="xs:string" default="0 0 0" />
</xs:complexType>
<!-- pose node type -->
<xs:complexType name="color">
<xs:attribute name="rgba" type="xs:string" default="0 0 0 0" />
</xs:complexType>
<!-- verbose node type -->
<xs:complexType name="verbose">
<xs:attribute name="value" type="xs:string" />
</xs:complexType>
<!-- name only node type -->
<xs:complexType name="name">
<xs:attribute name="name" type="xs:string" />
</xs:complexType>
<!-- mass node type -->
<xs:complexType name="mass">
<!-- FIXME: is value optional? -->
<xs:attribute name="value" type="xs:double" default="0" />
</xs:complexType>
<!-- inertia node type -->
<xs:complexType name="inertia">
<!-- FIXME: is it optional? default value? -->
<xs:attribute name="ixx" type="xs:double" default="0" />
<xs:attribute name="ixy" type="xs:double" default="0" />
<xs:attribute name="ixz" type="xs:double" default="0" />
<xs:attribute name="iyy" type="xs:double" default="0" />
<xs:attribute name="iyz" type="xs:double" default="0" />
<xs:attribute name="izz" type="xs:double" default="0" />
</xs:complexType>
<!-- inertial node type -->
<xs:complexType name="inertial">
<xs:all>
<xs:element name="origin"
type="pose" minOccurs="0" maxOccurs="1" />
<xs:element name="mass"
type="mass" minOccurs="0" maxOccurs="1" />
<xs:element name="inertia"
type="inertia" minOccurs="0" maxOccurs="1" />
</xs:all>
</xs:complexType>
<!-- box node type -->
<xs:complexType name="box">
<xs:attribute name="size" type="xs:string" default="0 0 0" />
</xs:complexType>
<!-- cylinder node type -->
<xs:complexType name="cylinder">
<xs:attribute name="radius" type="xs:double" use="required" />
<xs:attribute name="length" type="xs:double" use="required" />
</xs:complexType>
<!-- sphere node type -->
<xs:complexType name="sphere">
<xs:attribute name="radius" type="xs:double" use="required" />
</xs:complexType>
<!-- mesh node type -->
<xs:complexType name="mesh">
<xs:attribute name="filename" type="xs:anyURI" use="required" />
<xs:attribute name="scale" type="xs:string" default="1 1 1" />
</xs:complexType>
<!-- geometry node type -->
<xs:complexType name="geometry">
<xs:choice>
<xs:element name="box" type="box" />
<xs:element name="cylinder" type="cylinder" />
<xs:element name="sphere" type="sphere" />
<xs:element name="mesh" type="mesh" />
</xs:choice>
</xs:complexType>
<!-- texture node type -->
<xs:complexType name="texture">
<xs:attribute name="filename" type="xs:anyURI" />
</xs:complexType>
<!-- material node type -->
<xs:complexType name="material">
<xs:sequence>
<xs:element name="color" type="color" minOccurs="0" maxOccurs="1" />
<xs:element name="texture" type="texture" minOccurs="0" maxOccurs="1" />
</xs:sequence>
<xs:attribute name="name" type="xs:string" />
</xs:complexType>
<!-- material (global) node type -->
<xs:complexType name="material_global">
<xs:sequence>
<xs:element name="color" type="color" minOccurs="0" maxOccurs="1" />
<xs:element name="texture" type="texture" minOccurs="0" maxOccurs="1" />
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required" />
</xs:complexType>
<!-- visual node type -->
<xs:complexType name="visual">
<xs:sequence>
<xs:element name="origin"
type="pose" minOccurs="0" maxOccurs="1" />
<xs:element name="geometry"
type="geometry" minOccurs="1" maxOccurs="1" />
<xs:element name="material"
type="material" minOccurs="0" maxOccurs="1" />
</xs:sequence>
</xs:complexType>
<!-- collision node type -->
<xs:complexType name="collision">
<xs:sequence>
<xs:element name="origin"
type="pose" minOccurs="0" maxOccurs="1" />
<xs:element name="geometry"
type="geometry" minOccurs="1" maxOccurs="1" />
<xs:element name="verbose"
type="verbose" minOccurs="0" maxOccurs="1" />
</xs:sequence>
<!-- FIXME: used but not documented -->
<xs:attribute name="name" type="xs:string" />
</xs:complexType>
<!-- link node type -->
<xs:complexType name="link">
<xs:all>
<xs:element name="inertial"
type="inertial" minOccurs="0" maxOccurs="1" />
<xs:element name="visual"
type="visual" minOccurs="0"/>
<xs:element name="collision"
type="collision" minOccurs="0"/>
</xs:all>
<xs:attribute name="name" type="xs:string" use="required" />
<!-- FIXME: undocumented but used by PR2 -->
<xs:attribute name="type" type="xs:string" />
</xs:complexType>
<!-- parent node type -->
<xs:complexType name="parent">
<xs:attribute name="link" type="xs:string" use="required" />
</xs:complexType>
<!-- child node type -->
<xs:complexType name="child">
<xs:attribute name="link" type="xs:string" use="required" />
</xs:complexType>
<!-- axis node type -->
<xs:complexType name="axis">
<xs:attribute name="xyz" type="xs:string" default="1 0 0" />
</xs:complexType>
<!-- calibration node type -->
<xs:complexType name="calibration">
<xs:attribute name="reference_position" type="xs:double"/>
<xs:attribute name="rising" type="xs:double"/>
<xs:attribute name="falling" type="xs:double"/>
</xs:complexType>
<!-- dynamics node type -->
<xs:complexType name="dynamics">
<xs:attribute name="damping" type="xs:double" default="0" />
<xs:attribute name="friction" type="xs:double" default="0" />
</xs:complexType>
<!-- limit node type -->
<xs:complexType name="limit">
<xs:attribute name="lower" type="xs:double" default="0" />
<xs:attribute name="upper" type="xs:double" default="0" />
<xs:attribute name="effort" type="xs:double" default="0" />
<xs:attribute name="velocity" type="xs:double" default="0" />
</xs:complexType>
<!-- safety controller node type -->
<xs:complexType name="safety_controller">
<xs:attribute name="soft_lower_limit" type="xs:double" default="0" />
<xs:attribute name="soft_upper_limit" type="xs:double" default="0" />
<xs:attribute name="k_position" type="xs:double" default="0" />
<xs:attribute name="k_velocity" type="xs:double" use="required" />
</xs:complexType>
<!-- mimic node type -->
<xs:complexType name="mimic">
<xs:attribute name="joint" type="xs:string" use="required" />
<xs:attribute name="multiplier" type="xs:double" default="1" />
<xs:attribute name="offset" type="xs:double" default="0" />
</xs:complexType>
<!-- actuator transmission node type -->
<xs:complexType name="actuator_transmission">
<xs:attribute name="mechanicalReduction" type="xs:double" use="required" />
<xs:attribute name="name" type="xs:string" use="required" />
</xs:complexType>
<!-- gap joint transmission node type -->
<xs:complexType name="gap_joint_transmission">
<xs:attribute name="L0" type="xs:double" use="required" />
<xs:attribute name="a" type="xs:double" use="required" />
<xs:attribute name="b" type="xs:double" use="required" />
<xs:attribute name="gear_ratio" type="xs:double" use="required" />
<xs:attribute name="h" type="xs:double" use="required" />
<xs:attribute name="mechanical_reduction" type="xs:double" use="required" />
<xs:attribute name="name" type="xs:string" use="required" />
<xs:attribute name="phi0" type="xs:double" use="required" />
<xs:attribute name="r" type="xs:double" use="required" />
<xs:attribute name="screw_reduction" type="xs:double" use="required" />
<xs:attribute name="t0" type="xs:double" use="required" />
<xs:attribute name="theta0" type="xs:double" use="required" />
</xs:complexType>
<!-- passive joint transmission node type -->
<xs:complexType name="passive_joint_transmission">
<xs:attribute name="name" type="xs:string" use="required" />
</xs:complexType>
<!-- transmission node type -->
<xs:complexType name="transmission">
<xs:sequence minOccurs="0" maxOccurs="unbounded">
<xs:element name="leftActuator"
type="actuator_transmission" minOccurs="0" maxOccurs="1" />
<xs:element name="rightActuator"
type="actuator_transmission" minOccurs="0" maxOccurs="1" />
<xs:element name="flexJoint"
type="actuator_transmission" minOccurs="0" maxOccurs="1" />
<xs:element name="rollJoint"
type="actuator_transmission" minOccurs="0" maxOccurs="1" />
<xs:element name="gap_joint"
type="gap_joint_transmission" minOccurs="0" maxOccurs="1" />
<xs:element name="passive_joint"
type="passive_joint_transmission" minOccurs="0" maxOccurs="unbounded" />
<xs:element name="use_simulated_gripper_joint" minOccurs="0" maxOccurs="1">
<xs:complexType>
</xs:complexType>
</xs:element>
<xs:element name="mechanicalReduction" type="xs:double"
minOccurs="0" maxOccurs="1" />
<xs:element name="actuator" type="name" minOccurs="0" maxOccurs="1" />
<xs:element name="joint" type="name" minOccurs="0" maxOccurs="1" />
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required" />
<xs:attribute name="type" type="xs:string" use="required" />
</xs:complexType>
<!-- joint node type -->
<xs:complexType name="joint">
<xs:all>
<xs:element name="origin"
type="pose" minOccurs="0" maxOccurs="1" />
<xs:element name="parent"
type="parent" minOccurs="1" maxOccurs="1" />
<xs:element name="child"
type="child" minOccurs="1" maxOccurs="1" />
<xs:element name="axis"
type="axis" minOccurs="0" maxOccurs="1" />
<xs:element name="calibration"
type="calibration" minOccurs="0" maxOccurs="1" />
<xs:element name="dynamics"
type="dynamics" minOccurs="0" maxOccurs="1" />
<xs:element name="limit"
type="limit" minOccurs="0" maxOccurs="1" />
<xs:element name="safety_controller"
type="safety_controller" minOccurs="0" maxOccurs="1" />
<xs:element name="mimic"
type="mimic" minOccurs="0" maxOccurs="1" />
</xs:all>
<xs:attribute name="name" type="xs:string" use="required" />
<xs:attribute name="type" type="xs:string" use="required" />
</xs:complexType>
<!-- root node is always robot -->
<xs:element name="robot">
<xs:complexType>
<xs:sequence minOccurs="0" maxOccurs="unbounded">
<xs:element name="joint"
type="joint" minOccurs="0" maxOccurs="unbounded" />
<xs:element name="link"
type="link" minOccurs="0" maxOccurs="unbounded" />
<!-- FIXME: this is used but undocumented -->
<xs:element name="material"
type="material_global" minOccurs="0" maxOccurs="unbounded" />
<!-- FIXME: this is used but undocumented -->
<xs:element name="transmission"
type="transmission" minOccurs="0" maxOccurs="unbounded" />
<!-- FIXME: gazebo extension not supported -->
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required" />
<!-- TM: I suggest adding the following attribute. -->
<xs:attribute name="version" type="xs:string" default="1.0" />
</xs:complexType>
</xs:element>
</xs:schema>
@@ -0,0 +1,10 @@
<svg
width="100%"
height="100%"
viewBox="{min_x} {min_y} {width} {height}"
xmlns="http://www.w3.org/2000/svg"
xmlns:trimesh="https://github.com/mikedh/trimesh"
{attribs}
>
{elements}
</svg>

After

Width:  |  Height:  |  Size: 190 B

@@ -0,0 +1,67 @@
# flake8: noqa
import bpy
import os
def delete_nonresult(bpy):
objects = bpy.data.objects # use data.objects instead of context.scene.objects
if len(objects) <= 1:
return
try:
# earlier than blender <2.8
objects[0].select = False # keep the first object
for other in objects[1:]: # remove all other objects
other.select = True
bpy.ops.object.delete()
objects[0].select = True
except AttributeError:
# blender 2.8 changed this
objects[0].select_set(False)
for other in objects[1:]:
other.select_set(True)
bpy.ops.object.delete()
objects[0].select_set(True)
if __name__ == '__main__':
# clear scene of default box
bpy.ops.wm.read_homefile()
try:
bpy.ops.object.mode_set(mode='OBJECT')
except BaseException:
pass
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=True)
# get temporary files from templated locations
mesh_pre = $MESH_PRE
mesh_post = os.path.abspath(r'$MESH_POST')
for filename in mesh_pre: # use data.objects instead of context.scene.objects
bpy.ops.wm.stl_import(filepath=os.path.abspath(filename))
mesh = bpy.data.objects[0]
# Make sure mesh is the active object
try:
# earlier than blender <2.8
bpy.context.scene.objects.active = mesh
except AttributeError:
# blender 2.8 changed this
bpy.context.view_layer.objects.active = mesh
for other in bpy.data.objects[1:]:
# add boolean modifier
mod = mesh.modifiers.new('boolean', 'BOOLEAN')
mod.object = other
mod.operation = '$OPERATION'
mod.solver = '$SOLVER_OPTIONS'
mod.use_self = $USE_SELF
# used mod.name instead of hard-coded "boolean"
bpy.ops.object.modifier_apply(modifier=mod.name)
delete_nonresult(bpy)
bpy.ops.wm.stl_export(
filepath=mesh_post,
apply_modifiers=True)
@@ -0,0 +1,40 @@
# flake8: noqa
import bpy
from bl_operators.uvcalc_smart_project import main as smart_proj
import os
if __name__ == '__main__':
# clear scene of default box
bpy.ops.wm.read_homefile()
try:
bpy.ops.object.mode_set(mode='OBJECT')
except BaseException:
pass
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=True)
# get temporary files from templated locations
mesh_pre = $MESH_PRE
mesh_post = os.path.abspath(r'$MESH_POST')
# use data.objects instead of context.scene.objects
bpy.ops.import_scene.obj(filepath=os.path.abspath(mesh_pre[0]))
bpy.ops.object.select_all(action='SELECT')
mesh = bpy.data.objects[0]
# Make sure mesh is the active object
try:
# earlier than blender <2.8
bpy.context.scene.objects.active = mesh
except AttributeError:
# blender 2.8 changed this
bpy.context.view_layer.objects.active = mesh
smart_proj(bpy.context, $ISLAND_MARGIN, $ANGLE_LIMIT, 0, True, True)
bpy.ops.export_scene.obj(
filepath=mesh_post,
use_mesh_modifiers=False,
use_uvs=True)
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<path d="{path_string}" {attribs}/>

Some files were not shown because too many files have changed in this diff Show More