init
This commit is contained in:
@@ -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))
|
||||
Binary file not shown.
@@ -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.
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+27
@@ -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"
|
||||
}
|
||||
+22
@@ -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."
|
||||
}
|
||||
+25
@@ -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" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+39
@@ -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"
|
||||
}
|
||||
+7
@@ -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)
|
||||
+40
@@ -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
Reference in New Issue
Block a user