1182 lines
37 KiB
Python
1182 lines
37 KiB
Python
|
"""
|
||
|
creation.py
|
||
|
--------------
|
||
|
|
||
|
Create meshes from primitives, or with operations.
|
||
|
"""
|
||
|
|
||
|
from .base import Trimesh
|
||
|
from .constants import log, tol
|
||
|
from .geometry import faces_to_edges, align_vectors, plane_transform
|
||
|
|
||
|
from . import util
|
||
|
from . import grouping
|
||
|
from . import triangles
|
||
|
from . import transformations as tf
|
||
|
|
||
|
import numpy as np
|
||
|
import collections
|
||
|
|
||
|
try:
|
||
|
# shapely is a soft dependency
|
||
|
from shapely.geometry import Polygon
|
||
|
from shapely.wkb import loads as load_wkb
|
||
|
except BaseException as E:
|
||
|
# shapely will sometimes raise OSErrors
|
||
|
# on import rather than just ImportError
|
||
|
from . import exceptions
|
||
|
# re-raise the exception when someone tries
|
||
|
# to use the module that they don't have
|
||
|
Polygon = exceptions.closure(E)
|
||
|
load_wkb = exceptions.closure(E)
|
||
|
|
||
|
|
||
|
def revolve(linestring,
|
||
|
angle=None,
|
||
|
sections=None,
|
||
|
transform=None,
|
||
|
**kwargs):
|
||
|
"""
|
||
|
Revolve a 2D line string around the 2D Y axis, with a result with
|
||
|
the 2D Y axis pointing along the 3D Z axis.
|
||
|
|
||
|
This function is intended to handle the complexity of indexing
|
||
|
and is intended to be used to create all radially symmetric primitives,
|
||
|
eventually including cylinders, annular cylinders, capsules, cones,
|
||
|
and UV spheres.
|
||
|
|
||
|
Note that if your linestring is closed, it needs to be counterclockwise
|
||
|
if you would like face winding and normals facing outwards.
|
||
|
|
||
|
Parameters
|
||
|
-------------
|
||
|
linestring : (n, 2) float
|
||
|
Lines in 2D which will be revolved
|
||
|
angle : None or float
|
||
|
Angle in radians to revolve curve by
|
||
|
sections : None or int
|
||
|
Number of sections result should have
|
||
|
If not specified default is 32 per revolution
|
||
|
transform : None or (4, 4) float
|
||
|
Transform to apply to mesh after construction
|
||
|
**kwargs : dict
|
||
|
Passed to Trimesh constructor
|
||
|
|
||
|
Returns
|
||
|
--------------
|
||
|
revolved : Trimesh
|
||
|
Mesh representing revolved result
|
||
|
"""
|
||
|
linestring = np.asanyarray(linestring, dtype=np.float64)
|
||
|
|
||
|
# linestring must be ordered 2D points
|
||
|
if len(linestring.shape) != 2 or linestring.shape[1] != 2:
|
||
|
raise ValueError('linestring must be 2D!')
|
||
|
|
||
|
if angle is None:
|
||
|
# default to closing the revolution
|
||
|
angle = np.pi * 2
|
||
|
closed = True
|
||
|
else:
|
||
|
# check passed angle value
|
||
|
closed = angle >= ((np.pi * 2) - 1e-8)
|
||
|
|
||
|
if sections is None:
|
||
|
# default to 32 sections for a full revolution
|
||
|
sections = int(angle / (np.pi * 2) * 32)
|
||
|
# change to face count
|
||
|
sections += 1
|
||
|
# create equally spaced angles
|
||
|
theta = np.linspace(0, angle, sections)
|
||
|
|
||
|
# 2D points around the revolution
|
||
|
points = np.column_stack((np.cos(theta), np.sin(theta)))
|
||
|
|
||
|
# how many points per slice
|
||
|
per = len(linestring)
|
||
|
# use the 2D X component as radius
|
||
|
radius = linestring[:, 0]
|
||
|
# use the 2D Y component as the height along revolution
|
||
|
height = linestring[:, 1]
|
||
|
# a lot of tiling to get our 3D vertices
|
||
|
vertices = np.column_stack((
|
||
|
np.tile(points, (1, per)).reshape((-1, 2)) *
|
||
|
np.tile(radius, len(points)).reshape((-1, 1)),
|
||
|
np.tile(height, len(points))))
|
||
|
|
||
|
if closed:
|
||
|
# should be a duplicate set of vertices
|
||
|
assert np.allclose(vertices[:per],
|
||
|
vertices[-per:])
|
||
|
# chop off duplicate vertices
|
||
|
vertices = vertices[:-per]
|
||
|
|
||
|
if transform is not None:
|
||
|
# apply transform to vertices
|
||
|
vertices = tf.transform_points(vertices, transform)
|
||
|
|
||
|
# how many slices of the pie
|
||
|
slices = len(theta) - 1
|
||
|
|
||
|
# start with a quad for every segment
|
||
|
# this is a superset which will then be reduced
|
||
|
quad = np.array([0, per, 1,
|
||
|
1, per, per + 1])
|
||
|
# stack the faces for a single slice of the revolution
|
||
|
single = np.tile(quad, per).reshape((-1, 3))
|
||
|
# `per` is basically the stride of the vertices
|
||
|
single += np.tile(np.arange(per), (2, 1)).T.reshape((-1, 1))
|
||
|
# remove any zero-area triangle
|
||
|
# this covers many cases without having to think too much
|
||
|
single = single[triangles.area(vertices[single]) > tol.merge]
|
||
|
|
||
|
# how much to offset each slice
|
||
|
# note arange multiplied by vertex stride
|
||
|
# but tiled by the number of faces we actually have
|
||
|
offset = np.tile(np.arange(slices) * per,
|
||
|
(len(single), 1)).T.reshape((-1, 1))
|
||
|
# stack a single slice into N slices
|
||
|
stacked = np.tile(single.ravel(), slices).reshape((-1, 3))
|
||
|
|
||
|
if tol.strict:
|
||
|
# make sure we didn't screw up stacking operation
|
||
|
assert np.allclose(stacked.reshape((-1, single.shape[0], 3)) - single, 0)
|
||
|
|
||
|
# offset stacked and wrap vertices
|
||
|
faces = (stacked + offset) % len(vertices)
|
||
|
|
||
|
# create the mesh from our vertices and faces
|
||
|
mesh = Trimesh(vertices=vertices, faces=faces)
|
||
|
|
||
|
# strict checks run only in unit tests
|
||
|
if (tol.strict and
|
||
|
np.allclose(radius[[0, -1]], 0.0) or
|
||
|
np.allclose(linestring[0], linestring[-1])):
|
||
|
# if revolved curve starts and ends with zero radius
|
||
|
# it should really be a valid volume, unless the sign
|
||
|
# reversed on the input linestring
|
||
|
assert mesh.is_volume
|
||
|
|
||
|
return mesh
|
||
|
|
||
|
|
||
|
def extrude_polygon(polygon,
|
||
|
height,
|
||
|
transform=None,
|
||
|
triangle_args=None,
|
||
|
**kwargs):
|
||
|
"""
|
||
|
Extrude a 2D shapely polygon into a 3D mesh
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
polygon : shapely.geometry.Polygon
|
||
|
2D geometry to extrude
|
||
|
height : float
|
||
|
Distance to extrude polygon along Z
|
||
|
triangle_args : str or None
|
||
|
Passed to triangle
|
||
|
**kwargs:
|
||
|
passed to Trimesh
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
mesh : trimesh.Trimesh
|
||
|
Resulting extrusion as watertight body
|
||
|
"""
|
||
|
# create a triangulation from the polygon
|
||
|
vertices, faces = triangulate_polygon(
|
||
|
polygon, triangle_args=triangle_args, **kwargs)
|
||
|
# extrude that triangulation along Z
|
||
|
mesh = extrude_triangulation(vertices=vertices,
|
||
|
faces=faces,
|
||
|
height=height,
|
||
|
transform=transform,
|
||
|
**kwargs)
|
||
|
return mesh
|
||
|
|
||
|
|
||
|
def sweep_polygon(polygon,
|
||
|
path,
|
||
|
angles=None,
|
||
|
**kwargs):
|
||
|
"""
|
||
|
Extrude a 2D shapely polygon into a 3D mesh along an
|
||
|
arbitrary 3D path. Doesn't handle sharp curvature well.
|
||
|
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
polygon : shapely.geometry.Polygon
|
||
|
Profile to sweep along path
|
||
|
path : (n, 3) float
|
||
|
A path in 3D
|
||
|
angles : (n,) float
|
||
|
Optional rotation angle relative to prior vertex
|
||
|
at each vertex
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
mesh : trimesh.Trimesh
|
||
|
Geometry of result
|
||
|
"""
|
||
|
|
||
|
path = np.asanyarray(path, dtype=np.float64)
|
||
|
if not util.is_shape(path, (-1, 3)):
|
||
|
raise ValueError('Path must be (n, 3)!')
|
||
|
|
||
|
# Extract 2D vertices and triangulation
|
||
|
verts_2d = np.array(polygon.exterior)[:-1]
|
||
|
base_verts_2d, faces_2d = triangulate_polygon(polygon, **kwargs)
|
||
|
n = len(verts_2d)
|
||
|
|
||
|
# Create basis for first planar polygon cap
|
||
|
x, y, z = util.generate_basis(path[0] - path[1])
|
||
|
tf_mat = np.ones((4, 4))
|
||
|
tf_mat[:3, :3] = np.c_[x, y, z]
|
||
|
tf_mat[:3, 3] = path[0]
|
||
|
|
||
|
# Compute 3D locations of those vertices
|
||
|
verts_3d = np.c_[verts_2d, np.zeros(n)]
|
||
|
verts_3d = tf.transform_points(verts_3d, tf_mat)
|
||
|
base_verts_3d = np.c_[base_verts_2d,
|
||
|
np.zeros(len(base_verts_2d))]
|
||
|
base_verts_3d = tf.transform_points(base_verts_3d,
|
||
|
tf_mat)
|
||
|
|
||
|
# keep matching sequence of vertices and 0- indexed faces
|
||
|
vertices = [base_verts_3d]
|
||
|
faces = [faces_2d]
|
||
|
|
||
|
# Compute plane normals for each turn --
|
||
|
# each turn induces a plane halfway between the two vectors
|
||
|
v1s = util.unitize(path[1:-1] - path[:-2])
|
||
|
v2s = util.unitize(path[1:-1] - path[2:])
|
||
|
norms = np.cross(np.cross(v1s, v2s), v1s + v2s)
|
||
|
norms[(norms == 0.0).all(1)] = v1s[(norms == 0.0).all(1)]
|
||
|
norms = util.unitize(norms)
|
||
|
final_v1 = util.unitize(path[-1] - path[-2])
|
||
|
norms = np.vstack((norms, final_v1))
|
||
|
v1s = np.vstack((v1s, final_v1))
|
||
|
|
||
|
# Create all side walls by projecting the 3d vertices into each plane
|
||
|
# in succession
|
||
|
for i in range(len(norms)):
|
||
|
verts_3d_prev = verts_3d
|
||
|
|
||
|
# Rotate if needed
|
||
|
if angles is not None:
|
||
|
tf_mat = tf.rotation_matrix(angles[i],
|
||
|
norms[i],
|
||
|
path[i])
|
||
|
verts_3d_prev = tf.transform_points(verts_3d_prev,
|
||
|
tf_mat)
|
||
|
|
||
|
# Project vertices onto plane in 3D
|
||
|
ds = np.einsum('ij,j->i', (path[i + 1] - verts_3d_prev), norms[i])
|
||
|
ds = ds / np.dot(v1s[i], norms[i])
|
||
|
|
||
|
verts_3d_new = np.einsum('i,j->ij', ds, v1s[i]) + verts_3d_prev
|
||
|
|
||
|
# Add to face and vertex lists
|
||
|
new_faces = [[i + n, (i + 1) % n, i] for i in range(n)]
|
||
|
new_faces.extend([[(i - 1) % n + n, i + n, i] for i in range(n)])
|
||
|
|
||
|
# save faces and vertices into a sequence
|
||
|
faces.append(np.array(new_faces))
|
||
|
vertices.append(np.vstack((verts_3d, verts_3d_new)))
|
||
|
|
||
|
verts_3d = verts_3d_new
|
||
|
|
||
|
# do the main stack operation from a sequence to (n,3) arrays
|
||
|
# doing one vstack provides a substantial speedup by
|
||
|
# avoiding a bunch of temporary allocations
|
||
|
vertices, faces = util.append_faces(vertices, faces)
|
||
|
|
||
|
# Create final cap
|
||
|
x, y, z = util.generate_basis(path[-1] - path[-2])
|
||
|
vecs = verts_3d - path[-1]
|
||
|
coords = np.c_[np.einsum('ij,j->i', vecs, x),
|
||
|
np.einsum('ij,j->i', vecs, y)]
|
||
|
base_verts_2d, faces_2d = triangulate_polygon(Polygon(coords))
|
||
|
base_verts_3d = (np.einsum('i,j->ij', base_verts_2d[:, 0], x) +
|
||
|
np.einsum('i,j->ij', base_verts_2d[:, 1], y)) + path[-1]
|
||
|
faces = np.vstack((faces, faces_2d + len(vertices)))
|
||
|
vertices = np.vstack((vertices, base_verts_3d))
|
||
|
|
||
|
return Trimesh(vertices, faces)
|
||
|
|
||
|
|
||
|
def extrude_triangulation(vertices,
|
||
|
faces,
|
||
|
height,
|
||
|
transform=None,
|
||
|
**kwargs):
|
||
|
"""
|
||
|
Extrude a 2D triangulation into a watertight mesh.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
vertices : (n, 2) float
|
||
|
2D vertices
|
||
|
faces : (m, 3) int
|
||
|
Triangle indexes of vertices
|
||
|
height : float
|
||
|
Distance to extrude triangulation
|
||
|
**kwargs : dict
|
||
|
Passed to Trimesh constructor
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
mesh : trimesh.Trimesh
|
||
|
Mesh created from extrusion
|
||
|
"""
|
||
|
vertices = np.asanyarray(vertices, dtype=np.float64)
|
||
|
height = float(height)
|
||
|
faces = np.asanyarray(faces, dtype=np.int64)
|
||
|
|
||
|
if not util.is_shape(vertices, (-1, 2)):
|
||
|
raise ValueError('Vertices must be (n,2)')
|
||
|
if not util.is_shape(faces, (-1, 3)):
|
||
|
raise ValueError('Faces must be (n,3)')
|
||
|
if np.abs(height) < tol.merge:
|
||
|
raise ValueError('Height must be nonzero!')
|
||
|
|
||
|
# make sure triangulation winding is pointing up
|
||
|
normal_test = triangles.normals(
|
||
|
[util.stack_3D(vertices[faces[0]])])[0]
|
||
|
|
||
|
normal_dot = np.dot(normal_test,
|
||
|
[0.0, 0.0, np.sign(height)])[0]
|
||
|
|
||
|
# make sure the triangulation is aligned with the sign of
|
||
|
# the height we've been passed
|
||
|
if normal_dot < 0.0:
|
||
|
faces = np.fliplr(faces)
|
||
|
|
||
|
# stack the (n,3) faces into (3*n, 2) edges
|
||
|
edges = faces_to_edges(faces)
|
||
|
edges_sorted = np.sort(edges, axis=1)
|
||
|
# edges which only occur once are on the boundary of the polygon
|
||
|
# since the triangulation may have subdivided the boundary of the
|
||
|
# shapely polygon, we need to find it again
|
||
|
edges_unique = grouping.group_rows(
|
||
|
edges_sorted, require_count=1)
|
||
|
|
||
|
# (n, 2, 2) set of line segments (positions, not references)
|
||
|
boundary = vertices[edges[edges_unique]]
|
||
|
|
||
|
# we are creating two vertical triangles for every 2D line segment
|
||
|
# on the boundary of the 2D triangulation
|
||
|
vertical = np.tile(boundary.reshape((-1, 2)), 2).reshape((-1, 2))
|
||
|
vertical = np.column_stack((vertical,
|
||
|
np.tile([0, height, 0, height],
|
||
|
len(boundary))))
|
||
|
vertical_faces = np.tile([3, 1, 2, 2, 1, 0],
|
||
|
(len(boundary), 1))
|
||
|
vertical_faces += np.arange(len(boundary)).reshape((-1, 1)) * 4
|
||
|
vertical_faces = vertical_faces.reshape((-1, 3))
|
||
|
|
||
|
# stack the (n,2) vertices with zeros to make them (n, 3)
|
||
|
vertices_3D = util.stack_3D(vertices)
|
||
|
|
||
|
# a sequence of zero- indexed faces, which will then be appended
|
||
|
# with offsets to create the final mesh
|
||
|
faces_seq = [faces[:, ::-1],
|
||
|
faces.copy(),
|
||
|
vertical_faces]
|
||
|
vertices_seq = [vertices_3D,
|
||
|
vertices_3D.copy() + [0.0, 0, height],
|
||
|
vertical]
|
||
|
|
||
|
# append sequences into flat nicely indexed arrays
|
||
|
vertices, faces = util.append_faces(vertices_seq, faces_seq)
|
||
|
if transform is not None:
|
||
|
# apply transform here to avoid later bookkeeping
|
||
|
vertices = tf.transform_points(
|
||
|
vertices, transform)
|
||
|
# if the transform flips the winding flip faces back
|
||
|
# so that the normals will be facing outwards
|
||
|
if tf.flips_winding(transform):
|
||
|
# fliplr makes arrays non-contiguous
|
||
|
faces = np.ascontiguousarray(np.fliplr(faces))
|
||
|
# create mesh object with passed keywords
|
||
|
mesh = Trimesh(vertices=vertices,
|
||
|
faces=faces,
|
||
|
**kwargs)
|
||
|
# only check in strict mode (unit tests)
|
||
|
if tol.strict:
|
||
|
assert mesh.volume > 0.0
|
||
|
|
||
|
return mesh
|
||
|
|
||
|
|
||
|
def triangulate_polygon(polygon,
|
||
|
triangle_args=None,
|
||
|
**kwargs):
|
||
|
"""
|
||
|
Given a shapely polygon create a triangulation using a
|
||
|
python interface to `triangle.c`:
|
||
|
> `pip install triangle`
|
||
|
|
||
|
Parameters
|
||
|
---------
|
||
|
polygon : Shapely.geometry.Polygon
|
||
|
Polygon object to be triangulated
|
||
|
triangle_args : str or None
|
||
|
Passed to triangle.triangulate i.e: 'p', 'pq30'
|
||
|
|
||
|
Returns
|
||
|
--------------
|
||
|
vertices : (n, 2) float
|
||
|
Points in space
|
||
|
faces : (n, 3) int
|
||
|
Index of vertices that make up triangles
|
||
|
"""
|
||
|
# do the import here for soft requirement
|
||
|
from triangle import triangulate
|
||
|
# set default triangulation arguments if not specified
|
||
|
if triangle_args is None:
|
||
|
triangle_args = 'p'
|
||
|
# turn the polygon in to vertices, segments, and hole points
|
||
|
arg = _polygon_to_kwargs(polygon)
|
||
|
# run the triangulation
|
||
|
result = triangulate(arg, triangle_args)
|
||
|
|
||
|
return result['vertices'], result['triangles']
|
||
|
|
||
|
|
||
|
def _polygon_to_kwargs(polygon):
|
||
|
"""
|
||
|
Given a shapely polygon generate the data to pass to
|
||
|
the triangle mesh generator
|
||
|
|
||
|
Parameters
|
||
|
---------
|
||
|
polygon : Shapely.geometry.Polygon
|
||
|
Input geometry
|
||
|
|
||
|
Returns
|
||
|
--------
|
||
|
result : dict
|
||
|
Has keys: vertices, segments, holes
|
||
|
"""
|
||
|
|
||
|
if not polygon.is_valid:
|
||
|
raise ValueError('invalid shapely polygon passed!')
|
||
|
|
||
|
def round_trip(start, length):
|
||
|
"""
|
||
|
Given a start index and length, create a series of (n, 2) edges which
|
||
|
create a closed traversal.
|
||
|
|
||
|
Examples
|
||
|
---------
|
||
|
start, length = 0, 3
|
||
|
returns: [(0,1), (1,2), (2,0)]
|
||
|
"""
|
||
|
tiled = np.tile(np.arange(start, start + length).reshape((-1, 1)), 2)
|
||
|
tiled = tiled.reshape(-1)[1:-1].reshape((-1, 2))
|
||
|
tiled = np.vstack((tiled, [tiled[-1][-1], tiled[0][0]]))
|
||
|
return tiled
|
||
|
|
||
|
def add_boundary(boundary, start):
|
||
|
# coords is an (n, 2) ordered list of points on the polygon boundary
|
||
|
# the first and last points are the same, and there are no
|
||
|
# guarantees on points not being duplicated (which will
|
||
|
# later cause meshpy/triangle to shit a brick)
|
||
|
coords = np.array(boundary.coords)
|
||
|
# find indices points which occur only once, and sort them
|
||
|
# to maintain order
|
||
|
unique = np.sort(grouping.unique_rows(coords)[0])
|
||
|
cleaned = coords[unique]
|
||
|
|
||
|
vertices.append(cleaned)
|
||
|
facets.append(round_trip(start, len(cleaned)))
|
||
|
|
||
|
# holes require points inside the region of the hole, which we find
|
||
|
# by creating a polygon from the cleaned boundary region, and then
|
||
|
# using a representative point. You could do things like take the mean of
|
||
|
# the points, but this is more robust (to things like concavity), if
|
||
|
# slower.
|
||
|
test = Polygon(cleaned)
|
||
|
holes.append(np.array(test.representative_point().coords)[0])
|
||
|
|
||
|
return len(cleaned)
|
||
|
|
||
|
# sequence of (n,2) points in space
|
||
|
vertices = collections.deque()
|
||
|
# sequence of (n,2) indices of vertices
|
||
|
facets = collections.deque()
|
||
|
# list of (2) vertices in interior of hole regions
|
||
|
holes = collections.deque()
|
||
|
|
||
|
start = add_boundary(polygon.exterior, 0)
|
||
|
for interior in polygon.interiors:
|
||
|
try:
|
||
|
start += add_boundary(interior, start)
|
||
|
except BaseException:
|
||
|
log.warning('invalid interior, continuing')
|
||
|
continue
|
||
|
|
||
|
# create clean (n,2) float array of vertices
|
||
|
# and (m, 2) int array of facets
|
||
|
# by stacking the sequence of (p,2) arrays
|
||
|
vertices = np.vstack(vertices)
|
||
|
facets = np.vstack(facets).tolist()
|
||
|
|
||
|
# shapely polygons can include a Z component
|
||
|
# strip it out for the triangulation
|
||
|
if vertices.shape[1] == 3:
|
||
|
vertices = vertices[:, :2]
|
||
|
|
||
|
result = {'vertices': vertices,
|
||
|
'segments': facets}
|
||
|
# holes in meshpy lingo are a (h, 2) list of (x,y) points
|
||
|
# which are inside the region of the hole
|
||
|
# we added a hole for the exterior, which we slice away here
|
||
|
holes = np.array(holes)[1:]
|
||
|
if len(holes) > 0:
|
||
|
result['holes'] = holes
|
||
|
|
||
|
return result
|
||
|
|
||
|
|
||
|
def box(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.Trimesh
|
||
|
Mesh of a cuboid
|
||
|
"""
|
||
|
# vertices of the cube
|
||
|
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 cube based on passed extents
|
||
|
if extents is not None:
|
||
|
extents = np.asanyarray(extents, dtype=np.float64)
|
||
|
if extents.shape != (3,):
|
||
|
raise ValueError('Extents must be (3,)!')
|
||
|
vertices *= extents
|
||
|
|
||
|
# hardcoded face indices
|
||
|
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]
|
||
|
faces = np.array(faces, order='C', dtype=np.int64).reshape((-1, 3))
|
||
|
|
||
|
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]
|
||
|
face_normals = np.asanyarray(face_normals,
|
||
|
order='C',
|
||
|
dtype=np.float64).reshape(-1, 3)
|
||
|
|
||
|
box = Trimesh(vertices=vertices,
|
||
|
faces=faces,
|
||
|
face_normals=face_normals,
|
||
|
process=False,
|
||
|
**kwargs)
|
||
|
|
||
|
# do the transform here to preserve face normals
|
||
|
if transform is not None:
|
||
|
box.apply_transform(transform)
|
||
|
|
||
|
return box
|
||
|
|
||
|
|
||
|
def icosahedron():
|
||
|
"""
|
||
|
Create an icosahedron, a 20 faced polyhedron.
|
||
|
|
||
|
Returns
|
||
|
-------------
|
||
|
ico : trimesh.Trimesh
|
||
|
Icosahederon centered at the origin.
|
||
|
"""
|
||
|
t = (1.0 + 5.0**.5) / 2.0
|
||
|
vertices = [-1, t, 0, 1, t, 0, -1, -t, 0, 1, -t, 0, 0, -1, t, 0, 1, t,
|
||
|
0, -1, -t, 0, 1, -t, t, 0, -1, t, 0, 1, -t, 0, -1, -t, 0, 1]
|
||
|
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]
|
||
|
# scale vertices so each vertex radius is 1.0
|
||
|
vertices = np.reshape(vertices, (-1, 3)) / np.sqrt(2.0 + t)
|
||
|
faces = np.reshape(faces, (-1, 3))
|
||
|
mesh = Trimesh(vertices=vertices,
|
||
|
faces=faces,
|
||
|
process=False)
|
||
|
return mesh
|
||
|
|
||
|
|
||
|
def icosphere(subdivisions=3, radius=1.0, color=None):
|
||
|
"""
|
||
|
Create an isophere centered at the origin.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
subdivisions : int
|
||
|
How many times to subdivide the mesh.
|
||
|
Note that the number of faces will grow as function of
|
||
|
4 ** subdivisions, so you probably want to keep this under ~5
|
||
|
radius : float
|
||
|
Desired radius of sphere
|
||
|
color: (3,) float or uint8
|
||
|
Desired color of sphere
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
ico : trimesh.Trimesh
|
||
|
Meshed sphere
|
||
|
"""
|
||
|
def refine_spherical():
|
||
|
vectors = ico.vertices
|
||
|
scalar = (vectors ** 2).sum(axis=1)**.5
|
||
|
unit = vectors / scalar.reshape((-1, 1))
|
||
|
offset = radius - scalar
|
||
|
ico.vertices += unit * offset.reshape((-1, 1))
|
||
|
ico = icosahedron()
|
||
|
ico._validate = False
|
||
|
for j in range(subdivisions):
|
||
|
ico = ico.subdivide()
|
||
|
refine_spherical()
|
||
|
ico._validate = True
|
||
|
if color is not None:
|
||
|
ico.visual.face_colors = color
|
||
|
return ico
|
||
|
|
||
|
|
||
|
def uv_sphere(radius=1.0,
|
||
|
count=[32, 32],
|
||
|
theta=None,
|
||
|
phi=None):
|
||
|
"""
|
||
|
Create a UV sphere (latitude + longitude) centered at the
|
||
|
origin. Roughly one order of magnitude faster than an
|
||
|
icosphere but slightly uglier.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
radius : float
|
||
|
Radius of sphere
|
||
|
count : (2,) int
|
||
|
Number of latitude and longitude lines
|
||
|
theta : (n,) float
|
||
|
Optional theta angles in radians
|
||
|
phi : (n,) float
|
||
|
Optional phi angles in radians
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
mesh : trimesh.Trimesh
|
||
|
Mesh of UV sphere with specified parameters
|
||
|
"""
|
||
|
|
||
|
count = np.array(count, dtype=np.int)
|
||
|
count += np.mod(count, 2)
|
||
|
count[1] *= 2
|
||
|
|
||
|
# generate vertices on a sphere using spherical coordinates
|
||
|
if theta is None:
|
||
|
theta = np.linspace(0, np.pi, count[0])
|
||
|
if phi is None:
|
||
|
phi = np.linspace(0, np.pi * 2, count[1])[:-1]
|
||
|
spherical = np.dstack((np.tile(phi, (len(theta), 1)).T,
|
||
|
np.tile(theta, (len(phi), 1)))).reshape((-1, 2))
|
||
|
vertices = util.spherical_to_vector(spherical) * radius
|
||
|
|
||
|
# generate faces by creating a bunch of pie wedges
|
||
|
c = len(theta)
|
||
|
# a quad face as two triangles
|
||
|
pairs = np.array([[c, 0, 1],
|
||
|
[c + 1, c, 1]])
|
||
|
|
||
|
# increment both triangles in each quad face by the same offset
|
||
|
incrementor = np.tile(np.arange(c - 1), (2, 1)).T.reshape((-1, 1))
|
||
|
# create the faces for a single pie wedge of the sphere
|
||
|
strip = np.tile(pairs, (c - 1, 1))
|
||
|
strip += incrementor
|
||
|
# the first and last faces will be degenerate since the first
|
||
|
# and last vertex are identical in the two rows
|
||
|
strip = strip[1:-1]
|
||
|
|
||
|
# tile pie wedges into a sphere
|
||
|
faces = np.vstack([strip + (i * c) for i in range(len(phi))])
|
||
|
|
||
|
# poles are repeated in every strip, so a mask to merge them
|
||
|
mask = np.arange(len(vertices))
|
||
|
# the top pole are all the same vertex
|
||
|
mask[0::c] = 0
|
||
|
# the bottom pole are all the same vertex
|
||
|
mask[c - 1::c] = c - 1
|
||
|
|
||
|
# faces masked to remove the duplicated pole vertices
|
||
|
# and mod to wrap to fill in the last pie wedge
|
||
|
faces = mask[np.mod(faces, len(vertices))]
|
||
|
|
||
|
# we save a lot of time by not processing again
|
||
|
# since we did some bookkeeping mesh is watertight
|
||
|
mesh = Trimesh(vertices=vertices, faces=faces, process=False)
|
||
|
return mesh
|
||
|
|
||
|
|
||
|
def capsule(height=1.0,
|
||
|
radius=1.0,
|
||
|
count=[32, 32]):
|
||
|
"""
|
||
|
Create a mesh of a capsule, or a cylinder with hemispheric ends.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
height : float
|
||
|
Center to center distance of two spheres
|
||
|
radius : float
|
||
|
Radius of the cylinder and hemispheres
|
||
|
count : (2,) int
|
||
|
Number of sections on latitude and longitude
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
capsule : trimesh.Trimesh
|
||
|
Capsule geometry with:
|
||
|
- cylinder axis is along Z
|
||
|
- one hemisphere is centered at the origin
|
||
|
- other hemisphere is centered along the Z axis at height
|
||
|
"""
|
||
|
height = float(height)
|
||
|
radius = float(radius)
|
||
|
count = np.array(count, dtype=np.int)
|
||
|
count += np.mod(count, 2)
|
||
|
|
||
|
# create a theta where there is a double band around the equator
|
||
|
# so that we can offset the top and bottom of a sphere to
|
||
|
# get a nicely meshed capsule
|
||
|
theta = np.linspace(0, np.pi, count[0])
|
||
|
center = np.clip(np.arctan(tol.merge / radius),
|
||
|
tol.merge, np.inf)
|
||
|
offset = np.array([-center, center]) + (np.pi / 2)
|
||
|
theta = np.insert(theta,
|
||
|
int(len(theta) / 2),
|
||
|
offset)
|
||
|
|
||
|
capsule = uv_sphere(radius=radius,
|
||
|
count=count,
|
||
|
theta=theta)
|
||
|
|
||
|
top = capsule.vertices[:, 2] > tol.zero
|
||
|
capsule.vertices[top] += [0, 0, height]
|
||
|
|
||
|
return capsule
|
||
|
|
||
|
|
||
|
def cone(radius,
|
||
|
height,
|
||
|
sections=None,
|
||
|
transform=None,
|
||
|
**kwargs):
|
||
|
"""
|
||
|
Create a mesh of a cone along Z centered at the origin.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
radius : float
|
||
|
The radius of the cylinder
|
||
|
height : float
|
||
|
The height of the cylinder
|
||
|
sections : int or None
|
||
|
How many pie wedges per revolution
|
||
|
transform : (4, 4) float or None
|
||
|
Transform to apply after creation
|
||
|
**kwargs : dict
|
||
|
Passed to Trimesh constructor
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
cone: trimesh.Trimesh
|
||
|
Resulting mesh of a cone
|
||
|
"""
|
||
|
# create the 2D outline of a cone
|
||
|
linestring = [[0, 0],
|
||
|
[radius, 0],
|
||
|
[0, height]]
|
||
|
# revolve the profile to create a cone
|
||
|
cone = revolve(linestring=linestring,
|
||
|
sections=sections,
|
||
|
transform=transform,
|
||
|
**kwargs)
|
||
|
|
||
|
return cone
|
||
|
|
||
|
|
||
|
def cylinder(radius,
|
||
|
height,
|
||
|
sections=None,
|
||
|
segment=None,
|
||
|
transform=None,
|
||
|
**kwargs):
|
||
|
"""
|
||
|
Create a mesh of a cylinder along Z centered at the origin.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
radius : float
|
||
|
The radius of the cylinder
|
||
|
height : float
|
||
|
The height of the cylinder
|
||
|
sections : int
|
||
|
How many pie wedges should the cylinder have
|
||
|
segment : (2, 3) float
|
||
|
Endpoints of axis, overrides transform and height
|
||
|
transform : (4, 4) float
|
||
|
Transform to apply
|
||
|
**kwargs:
|
||
|
passed to Trimesh to create cylinder
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
cylinder: trimesh.Trimesh
|
||
|
Resulting mesh of a cylinder
|
||
|
"""
|
||
|
|
||
|
if segment is not None:
|
||
|
segment = np.asanyarray(segment, dtype=np.float64)
|
||
|
if segment.shape != (2, 3):
|
||
|
raise ValueError('segment must be 2 3D points!')
|
||
|
vector = segment[1] - segment[0]
|
||
|
# override height with segment length
|
||
|
height = np.linalg.norm(vector)
|
||
|
# point in middle of line
|
||
|
midpoint = segment[0] + (vector * 0.5)
|
||
|
# align Z with our desired direction
|
||
|
rotation = align_vectors([0, 0, 1], vector)
|
||
|
# translate to midpoint of segment
|
||
|
translation = tf.translation_matrix(midpoint)
|
||
|
# compound the rotation and translation
|
||
|
transform = np.dot(translation, rotation)
|
||
|
|
||
|
half = abs(float(height)) / 2.0
|
||
|
# create a profile to revolve
|
||
|
linestring = [[0, -half],
|
||
|
[radius, -half],
|
||
|
[radius, half],
|
||
|
[0, half]]
|
||
|
# generate cylinder through simple revolution
|
||
|
return revolve(linestring=linestring,
|
||
|
sections=sections,
|
||
|
transform=transform)
|
||
|
|
||
|
return cylinder
|
||
|
|
||
|
|
||
|
def annulus(r_min,
|
||
|
r_max,
|
||
|
height,
|
||
|
sections=None,
|
||
|
transform=None,
|
||
|
**kwargs):
|
||
|
"""
|
||
|
Create a mesh of an annular cylinder along Z centered at the origin.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
r_min : float
|
||
|
The inner radius of the annular cylinder
|
||
|
r_max : float
|
||
|
The outer radius of the annular cylinder
|
||
|
height : float
|
||
|
The height of the annular cylinder
|
||
|
sections : int or None
|
||
|
How many pie wedges should the annular cylinder have
|
||
|
transform : (4, 4) float or None
|
||
|
Transform to apply to move result from the origin
|
||
|
**kwargs:
|
||
|
passed to Trimesh to create annulus
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
annulus : trimesh.Trimesh
|
||
|
Mesh of annular cylinder
|
||
|
"""
|
||
|
r_min = abs(float(r_min))
|
||
|
# if center radius is zero this is a cylinder
|
||
|
if r_min < tol.merge:
|
||
|
return cylinder(radius=r_max,
|
||
|
height=height,
|
||
|
sections=sections,
|
||
|
transform=transform)
|
||
|
r_max = abs(float(r_max))
|
||
|
# we're going to center at XY plane so take half the height
|
||
|
half = abs(float(height)) / 2.0
|
||
|
# create counter-clockwise rectangle
|
||
|
linestring = [[r_min, -half],
|
||
|
[r_max, -half],
|
||
|
[r_max, half],
|
||
|
[r_min, half],
|
||
|
[r_min, -half]]
|
||
|
|
||
|
# revolve the curve
|
||
|
annulus = revolve(linestring=linestring,
|
||
|
sections=sections,
|
||
|
transform=transform,
|
||
|
**kwargs)
|
||
|
|
||
|
return annulus
|
||
|
|
||
|
|
||
|
def random_soup(face_count=100):
|
||
|
"""
|
||
|
Return random triangles as a Trimesh
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
face_count : int
|
||
|
Number of faces desired in mesh
|
||
|
|
||
|
Returns
|
||
|
-----------
|
||
|
soup : trimesh.Trimesh
|
||
|
Geometry with face_count random faces
|
||
|
"""
|
||
|
vertices = np.random.random((face_count * 3, 3)) - 0.5
|
||
|
faces = np.arange(face_count * 3).reshape((-1, 3))
|
||
|
soup = Trimesh(vertices=vertices, faces=faces)
|
||
|
return soup
|
||
|
|
||
|
|
||
|
def axis(origin_size=0.04,
|
||
|
transform=None,
|
||
|
origin_color=None,
|
||
|
axis_radius=None,
|
||
|
axis_length=None):
|
||
|
"""
|
||
|
Return an XYZ axis marker as a Trimesh, which represents position
|
||
|
and orientation. If you set the origin size the other parameters
|
||
|
will be set relative to it.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
transform : (4, 4) float
|
||
|
Transformation matrix
|
||
|
origin_size : float
|
||
|
Radius of sphere that represents the origin
|
||
|
origin_color : (3,) float or int, uint8 or float
|
||
|
Color of the origin
|
||
|
axis_radius : float
|
||
|
Radius of cylinder that represents x, y, z axis
|
||
|
axis_length: float
|
||
|
Length of cylinder that represents x, y, z axis
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
marker : trimesh.Trimesh
|
||
|
Mesh geometry of axis indicators
|
||
|
"""
|
||
|
# the size of the ball representing the origin
|
||
|
origin_size = float(origin_size)
|
||
|
|
||
|
# set the transform and use origin-relative
|
||
|
# sized for other parameters if not specified
|
||
|
if transform is None:
|
||
|
transform = np.eye(4)
|
||
|
if origin_color is None:
|
||
|
origin_color = [255, 255, 255, 255]
|
||
|
if axis_radius is None:
|
||
|
axis_radius = origin_size / 5.0
|
||
|
if axis_length is None:
|
||
|
axis_length = origin_size * 10.0
|
||
|
|
||
|
# generate a ball for the origin
|
||
|
axis_origin = uv_sphere(radius=origin_size,
|
||
|
count=[10, 10])
|
||
|
axis_origin.apply_transform(transform)
|
||
|
|
||
|
# apply color to the origin ball
|
||
|
axis_origin.visual.face_colors = origin_color
|
||
|
|
||
|
# create the cylinder for the z-axis
|
||
|
translation = tf.translation_matrix(
|
||
|
[0, 0, axis_length / 2])
|
||
|
z_axis = cylinder(
|
||
|
radius=axis_radius,
|
||
|
height=axis_length,
|
||
|
transform=transform.dot(translation))
|
||
|
# XYZ->RGB, Z is blue
|
||
|
z_axis.visual.face_colors = [0, 0, 255]
|
||
|
|
||
|
# create the cylinder for the y-axis
|
||
|
translation = tf.translation_matrix(
|
||
|
[0, 0, axis_length / 2])
|
||
|
rotation = tf.rotation_matrix(np.radians(-90),
|
||
|
[1, 0, 0])
|
||
|
y_axis = cylinder(
|
||
|
radius=axis_radius,
|
||
|
height=axis_length,
|
||
|
transform=transform.dot(rotation).dot(translation))
|
||
|
# XYZ->RGB, Y is green
|
||
|
y_axis.visual.face_colors = [0, 255, 0]
|
||
|
|
||
|
# create the cylinder for the x-axis
|
||
|
translation = tf.translation_matrix(
|
||
|
[0, 0, axis_length / 2])
|
||
|
rotation = tf.rotation_matrix(np.radians(90),
|
||
|
[0, 1, 0])
|
||
|
x_axis = cylinder(
|
||
|
radius=axis_radius,
|
||
|
height=axis_length,
|
||
|
transform=transform.dot(rotation).dot(translation))
|
||
|
# XYZ->RGB, X is red
|
||
|
x_axis.visual.face_colors = [255, 0, 0]
|
||
|
|
||
|
# append the sphere and three cylinders
|
||
|
marker = util.concatenate([axis_origin,
|
||
|
x_axis,
|
||
|
y_axis,
|
||
|
z_axis])
|
||
|
return marker
|
||
|
|
||
|
|
||
|
def camera_marker(camera,
|
||
|
marker_height=0.4,
|
||
|
origin_size=None):
|
||
|
"""
|
||
|
Create a visual marker for a camera object, including an axis and FOV.
|
||
|
|
||
|
Parameters
|
||
|
---------------
|
||
|
camera : trimesh.scene.Camera
|
||
|
Camera object with FOV and transform defined
|
||
|
marker_height : float
|
||
|
How far along the camera Z should FOV indicators be
|
||
|
origin_size : float
|
||
|
Sphere radius of the origin (default: marker_height / 10.0)
|
||
|
|
||
|
Returns
|
||
|
------------
|
||
|
meshes : list
|
||
|
Contains Trimesh and Path3D objects which can be visualized
|
||
|
"""
|
||
|
|
||
|
# append the visualizations to an array
|
||
|
meshes = [axis(origin_size=marker_height / 10.0)]
|
||
|
|
||
|
try:
|
||
|
# path is a soft dependency
|
||
|
from .path.exchange.load import load_path
|
||
|
except ImportError:
|
||
|
# they probably don't have shapely installed
|
||
|
log.warning('unable to create FOV visualization!',
|
||
|
exc_info=True)
|
||
|
return meshes
|
||
|
|
||
|
# create sane origin size from marker height
|
||
|
if origin_size is None:
|
||
|
origin_size = marker_height / 10.0
|
||
|
|
||
|
# calculate vertices from camera FOV angles
|
||
|
x = marker_height * np.tan(np.deg2rad(camera.fov[0]) / 2.0)
|
||
|
y = marker_height * np.tan(np.deg2rad(camera.fov[1]) / 2.0)
|
||
|
z = marker_height
|
||
|
|
||
|
# combine the points into the vertices of an FOV visualization
|
||
|
points = np.array(
|
||
|
[(0, 0, 0),
|
||
|
(-x, -y, z),
|
||
|
(x, -y, z),
|
||
|
(x, y, z),
|
||
|
(-x, y, z)],
|
||
|
dtype=float)
|
||
|
|
||
|
# create line segments for the FOV visualization
|
||
|
# a segment from the origin to each bound of the FOV
|
||
|
segments = np.column_stack(
|
||
|
(np.zeros_like(points), points)).reshape(
|
||
|
(-1, 3))
|
||
|
|
||
|
# add a loop for the outside of the FOV then reshape
|
||
|
# the whole thing into multiple line segments
|
||
|
segments = np.vstack((segments,
|
||
|
points[[1, 2,
|
||
|
2, 3,
|
||
|
3, 4,
|
||
|
4, 1]])).reshape((-1, 2, 3))
|
||
|
|
||
|
# add a single Path3D object for all line segments
|
||
|
meshes.append(load_path(segments))
|
||
|
|
||
|
return meshes
|
||
|
|
||
|
|
||
|
def truncated_prisms(tris, origin=None, normal=None):
|
||
|
"""
|
||
|
Return a mesh consisting of multiple watertight prisms below
|
||
|
a list of triangles, truncated by a specified plane.
|
||
|
|
||
|
Parameters
|
||
|
-------------
|
||
|
triangles : (n, 3, 3) float
|
||
|
Triangles in space
|
||
|
origin : None or (3,) float
|
||
|
Origin of truncation plane
|
||
|
normal : None or (3,) float
|
||
|
Unit normal vector of truncation plane
|
||
|
|
||
|
Returns
|
||
|
-----------
|
||
|
mesh : trimesh.Trimesh
|
||
|
Triangular mesh
|
||
|
"""
|
||
|
if origin is None:
|
||
|
transform = np.eye(4)
|
||
|
else:
|
||
|
transform = plane_transform(origin=origin, normal=normal)
|
||
|
|
||
|
# transform the triangles to the specified plane
|
||
|
transformed = tf.transform_points(
|
||
|
tris.reshape((-1, 3)), transform).reshape((-1, 9))
|
||
|
|
||
|
# stack triangles such that every other one is repeated
|
||
|
vs = np.column_stack((transformed, transformed)).reshape((-1, 3, 3))
|
||
|
# set the Z value of the second triangle to zero
|
||
|
vs[1::2, :, 2] = 0
|
||
|
# reshape triangles to a flat array of points and transform back to original frame
|
||
|
vertices = tf.transform_points(
|
||
|
vs.reshape((-1, 3)), matrix=np.linalg.inv(transform))
|
||
|
|
||
|
# face indexes for a *single* truncated triangular prism
|
||
|
f = np.array([[2, 1, 0],
|
||
|
[3, 4, 5],
|
||
|
[0, 1, 4],
|
||
|
[1, 2, 5],
|
||
|
[2, 0, 3],
|
||
|
[4, 3, 0],
|
||
|
[5, 4, 1],
|
||
|
[3, 5, 2]])
|
||
|
# find the projection of each triangle with the normal vector
|
||
|
cross = np.dot([0, 0, 1], triangles.cross(transformed.reshape((-1, 3, 3))).T)
|
||
|
# stack faces into one prism per triangle
|
||
|
f_seq = np.tile(f, (len(transformed), 1)).reshape((-1, len(f), 3))
|
||
|
# if the normal of the triangle was positive flip the winding
|
||
|
f_seq[cross > 0] = np.fliplr(f)
|
||
|
# offset stacked faces to create correct indices
|
||
|
faces = (f_seq + (np.arange(len(f_seq)) * 6).reshape((-1, 1, 1))).reshape((-1, 3))
|
||
|
|
||
|
# create a mesh from the data
|
||
|
mesh = Trimesh(vertices=vertices, faces=faces, process=False)
|
||
|
|
||
|
return mesh
|