hub/venv/lib/python3.7/site-packages/trimesh/primitives.py

814 lines
25 KiB
Python

"""
primitives.py
----------------
Subclasses of Trimesh objects that are parameterized as primitives.
Useful because you can move boxes and spheres around, and then use
trimesh operations on them at any point.
"""
import numpy as np
import pprint
import copy
from . import util
from . import sample
from . import caching
from . import inertia
from . import creation
from . import triangles
from . import transformations as tf
from .base import Trimesh
from .constants import log, tol
class _Primitive(Trimesh):
"""
Geometric _Primitives which are a subclass of Trimesh.
Mesh is generated lazily when vertices or faces are requested.
"""
# ignore superclass copy directives
__copy__ = None
__deepcopy__ = None
def __init__(self, *args, **kwargs):
super(_Primitive, self).__init__(*args, **kwargs)
self._data.clear()
self._validate = False
def __repr__(self):
return '<trimesh.primitives.{}>'.format(type(self).__name__)
@property
def faces(self):
stored = self._cache['faces']
if util.is_shape(stored, (-1, 3)):
return stored
self._create_mesh()
return self._cache['faces']
@faces.setter
def faces(self, values):
log.warning('Primitive faces are immutable! Not setting!')
@property
def vertices(self):
stored = self._cache['vertices']
if util.is_shape(stored, (-1, 3)):
return stored
self._create_mesh()
return self._cache['vertices']
@vertices.setter
def vertices(self, values):
if values is not None:
log.warning('Primitive vertices are immutable! Not setting!')
@property
def face_normals(self):
# we need to avoid the logic in the superclass that
# is specific to the data model prioritizing faces
stored = self._cache['face_normals']
if util.is_shape(stored, (-1, 3)):
return stored
# just calculate if not stored
unit, valid = triangles.normals(self.triangles)
normals = np.zeros((len(valid), 3))
normals[valid] = unit
# store and return
self._cache['face_normals'] = normals
return normals
@face_normals.setter
def face_normals(self, values):
if values is not None:
log.warning('Primitive face normals are immutable! Not setting!')
def copy(self, **kwargs):
"""
Return a copy of the Primitive object.
Returns
-------------
copied : object
Copy of current primitive
"""
return copy.deepcopy(self)
def to_mesh(self):
"""
Return a copy of the Primitive object as a Trimesh object.
"""
result = Trimesh(vertices=self.vertices.copy(),
faces=self.faces.copy(),
face_normals=self.face_normals.copy(),
process=False)
return result
def apply_transform(self, matrix):
"""
Apply a transform to the current primitive by
setting self.transform
Parameters
------------
matrix: (4,4) float
Homogeneous transformation
"""
matrix = np.asanyarray(matrix, order='C', dtype=np.float64)
if matrix.shape != (4, 4):
raise ValueError('Transformation matrix must be (4,4)!')
if util.allclose(matrix, np.eye(4), 1e-8):
log.debug('apply_transform received identity matrix')
return
new_transform = np.dot(matrix, self.primitive.transform)
self.primitive.transform = new_transform
return self
def _create_mesh(self):
raise ValueError('Primitive doesn\'t define mesh creation!')
class _PrimitiveAttributes(object):
"""
Hold the mutable data which defines a primitive.
"""
def __init__(self, parent, defaults, kwargs):
self._data = parent._data
self._defaults = defaults
self._parent = parent
self._data.update(defaults)
self._mutable = True
for key, value in kwargs.items():
if key in defaults:
self._data[key] = util.convert_like(
value, defaults[key])
# if configured as immutable, apply setting after instantiation values
# are set
if 'mutable' in kwargs:
self._mutable = bool(kwargs['mutable'])
@property
def __doc__(self):
# this is generated dynamically as the format operation can be surprisingly
# slow and if generated in __init__ it is called a lot of times
# when we didn't really need to generate it
doc = (
'Store the attributes of a {name} object.\n\n' +
'When these values are changed, the mesh geometry will \n' +
'automatically be updated to reflect the new values.\n\n' +
'Available properties and their default values are:\n {defaults}' +
'\n\nExample\n---------------\n' +
'p = trimesh.primitives.{name}()\n' +
'p.primitive.radius = 10\n' +
'\n').format(
name=self._parent.__class__.__name__,
defaults=pprint.pformat(
self._defaults,
width=-1)[1:-1])
return doc
def __getattr__(self, key):
if '_' in key:
return super(_PrimitiveAttributes, self).__getattr__(key)
elif key in self._defaults:
return util.convert_like(self._data[key], self._defaults[key])
return super(_PrimitiveAttributes, self).__getattr__(key)
def __setattr__(self, key, value):
if '_' in key:
return super(_PrimitiveAttributes, self).__setattr__(key, value)
elif key in self._defaults:
if self._mutable:
self._data[key] = util.convert_like(value,
self._defaults[key])
else:
raise ValueError(
'Primitive is configured as immutable! Cannot set attribute!')
else:
keys = list(self._defaults.keys())
raise ValueError(
'Only default attributes {} can be set!'.format(keys))
def to_kwargs(self):
"""
Return a dict with copies of kwargs for the current
Primitive.
Returns
------------
kwargs : dict
Arguments to reconstruct current PrimitiveAttributes
"""
return {k: copy.deepcopy(self._data[k])
for k in self._defaults.keys()}
def __dir__(self):
result = sorted(dir(type(self)) +
list(self._defaults.keys()))
return result
class Cylinder(_Primitive):
def __init__(self, *args, **kwargs):
"""
Create a Cylinder Primitive, a subclass of Trimesh.
Parameters
-------------
radius : float
Radius of cylinder
height : float
Height of cylinder
transform : (4, 4) float
Homogeneous transformation matrix
sections : int
Number of facets in circle
"""
super(Cylinder, self).__init__(*args, **kwargs)
defaults = {'height': 10.0,
'radius': 1.0,
'transform': np.eye(4),
'sections': 32}
self.primitive = _PrimitiveAttributes(self,
defaults,
kwargs)
@caching.cache_decorator
def volume(self):
"""
The analytic volume of the cylinder primitive.
Returns
---------
volume : float
Volume of the cylinder
"""
volume = ((np.pi * self.primitive.radius ** 2) *
self.primitive.height)
return volume
@caching.cache_decorator
def moment_inertia(self):
"""
The analytic inertia tensor of the cylinder primitive.
Returns
----------
tensor: (3,3) float, 3D inertia tensor
"""
tensor = inertia.cylinder_inertia(
mass=self.volume,
radius=self.primitive.radius,
height=self.primitive.height,
transform=self.primitive.transform)
return tensor
@caching.cache_decorator
def direction(self):
"""
The direction of the cylinder's axis.
Returns
--------
axis: (3,) float, vector along the cylinder axis
"""
axis = np.dot(self.primitive.transform, [0, 0, 1, 0])[:3]
return axis
@property
def segment(self):
"""
A line segment which if inflated by cylinder radius
would represent the cylinder primitive.
Returns
-------------
segment : (2, 3) float
Points representing a single line segment
"""
# half the height
half = self.primitive.height / 2.0
# apply the transform to the Z- aligned segment
points = np.dot(
self.primitive.transform,
np.transpose([[0, 0, -half, 1], [0, 0, half, 1]])).T[:, :3]
return points
def buffer(self, distance):
"""
Return a cylinder primitive which covers the source cylinder
by distance: radius is inflated by distance, height by twice
the distance.
Parameters
------------
distance : float
Distance to inflate cylinder radius and height
Returns
-------------
buffered : Cylinder
Cylinder primitive inflated by distance
"""
distance = float(distance)
buffered = Cylinder(
height=self.primitive.height + distance * 2,
radius=self.primitive.radius + distance,
transform=self.primitive.transform.copy())
return buffered
def _create_mesh(self):
log.debug('creating mesh for Cylinder primitive')
mesh = creation.cylinder(radius=self.primitive.radius,
height=self.primitive.height,
sections=self.primitive.sections,
transform=self.primitive.transform)
self._cache['vertices'] = mesh.vertices
self._cache['faces'] = mesh.faces
self._cache['face_normals'] = mesh.face_normals
class Capsule(_Primitive):
def __init__(self, *args, **kwargs):
"""
Create a Capsule Primitive, a subclass of Trimesh.
Parameters
----------
radius: float, radius of cylinder
height: float, height of cylinder
transform: (4,4) float, transformation matrix
sections: int, number of facets in circle
"""
super(Capsule, self).__init__(*args, **kwargs)
defaults = {'height': 1.0,
'radius': 1.0,
'transform': np.eye(4),
'sections': 32}
self.primitive = _PrimitiveAttributes(self,
defaults,
kwargs)
@caching.cache_decorator
def direction(self):
"""
The direction of the capsule's axis.
Returns
--------
axis: (3,) float, vector along the cylinder axis
"""
axis = np.dot(self.primitive.transform, [0, 0, 1, 0])[:3]
return axis
def _create_mesh(self):
log.debug('creating mesh for Capsule primitive')
mesh = creation.capsule(radius=self.primitive.radius,
height=self.primitive.height)
mesh.apply_transform(self.primitive.transform)
self._cache['vertices'] = mesh.vertices
self._cache['faces'] = mesh.faces
self._cache['face_normals'] = mesh.face_normals
class Sphere(_Primitive):
def __init__(self, *args, **kwargs):
"""
Create a Sphere Primitive, a subclass of Trimesh.
Parameters
----------
radius: float, radius of sphere
center: (3,) float, center of sphere
subdivisions: int, number of subdivisions for icosphere. Default is 3
"""
super(Sphere, self).__init__(*args, **kwargs)
defaults = {'radius': 1.0,
'center': np.zeros(3, dtype=np.float64),
'subdivisions': 3}
self.primitive = _PrimitiveAttributes(self,
defaults,
kwargs)
def apply_transform(self, matrix):
"""
Apply a transform to the sphere primitive
Parameters
------------
matrix: (4,4) float, homogeneous transformation
"""
matrix = np.asanyarray(matrix, dtype=np.float64)
if matrix.shape != (4, 4):
raise ValueError('shape must be 4,4')
center = np.dot(matrix,
np.append(self.primitive.center, 1.0))[:3]
self.primitive.center = center
@property
def bounds(self):
# no docstring so will inherit Trimesh docstring
# return exact bounds from primitive center and radius (rather than faces)
# self.extents will also use this information
bounds = np.array([self.primitive.center - self.primitive.radius,
self.primitive.center + self.primitive.radius])
return bounds
@property
def bounding_box_oriented(self):
# for a sphere the oriented bounding box is the same as the axis aligned
# bounding box, and a sphere is the absolute slowest case for the OBB calculation
# as it is a convex surface with a ton of face normals that all need to
# be checked
return self.bounding_box
@caching.cache_decorator
def area(self):
"""
Surface area of the current sphere primitive.
Returns
--------
area: float, surface area of the sphere Primitive
"""
area = 4.0 * np.pi * (self.primitive.radius ** 2)
return area
@caching.cache_decorator
def volume(self):
"""
Volume of the current sphere primitive.
Returns
--------
volume: float, volume of the sphere Primitive
"""
volume = (4.0 * np.pi * (self.primitive.radius ** 3)) / 3.0
return volume
@caching.cache_decorator
def moment_inertia(self):
"""
The analytic inertia tensor of the sphere primitive.
Returns
----------
tensor: (3,3) float, 3D inertia tensor
"""
tensor = inertia.sphere_inertia(mass=self.volume,
radius=self.primitive.radius)
return tensor
def _create_mesh(self):
log.debug('creating mesh for Sphere primitive')
unit = creation.icosphere(subdivisions=self.primitive.subdivisions)
unit.vertices *= self.primitive.radius
unit.vertices += self.primitive.center
self._cache['vertices'] = unit.vertices
self._cache['faces'] = unit.faces
self._cache['face_normals'] = unit.face_normals
class Box(_Primitive):
def __init__(self, *args, **kwargs):
"""
Create a Box Primitive, a subclass of Trimesh
Parameters
----------
extents: (3,) float, size of box
transform: (4,4) float, transformation matrix for box center
"""
super(Box, self).__init__(*args, **kwargs)
defaults = {'transform': np.eye(4),
'extents': np.ones(3)}
self.primitive = _PrimitiveAttributes(self,
defaults,
kwargs)
def sample_volume(self, count):
"""
Return random samples from inside the volume of the box.
Parameters
-------------
count : int
Number of samples to return
Returns
----------
samples : (count, 3) float
Points inside the volume
"""
samples = sample.volume_rectangular(
extents=self.primitive.extents,
count=count,
transform=self.primitive.transform)
return samples
def sample_grid(self, count=None, step=None):
"""
Return a 3D grid which is contained by the box.
Samples are either 'step' distance apart, or there are
'count' samples per box side.
Parameters
-----------
count : int or (3,) int
If specified samples are spaced with np.linspace
step : float or (3,) float
If specified samples are spaced with np.arange
Returns
-----------
grid : (n, 3) float
Points inside the box
"""
if (count is not None and
step is not None):
raise ValueError('only step OR count can be specified!')
# create pre- transform bounds from extents
bounds = np.array([-self.primitive.extents,
self.primitive.extents]) * .5
if step is not None:
grid = util.grid_arange(bounds, step=step)
elif count is not None:
grid = util.grid_linspace(bounds, count=count)
else:
raise ValueError('either count or step must be specified!')
transformed = tf.transform_points(
grid, matrix=self.primitive.transform)
return transformed
@property
def is_oriented(self):
"""
Returns whether or not the current box is rotated at all.
"""
if util.is_shape(self.primitive.transform, (4, 4)):
return not np.allclose(self.primitive.transform[
0:3, 0:3], np.eye(3))
else:
return False
@caching.cache_decorator
def volume(self):
"""
Volume of the box Primitive.
Returns
--------
volume: float, volume of box
"""
volume = float(np.product(self.primitive.extents))
return volume
def _create_mesh(self):
log.debug('creating mesh for Box primitive')
box = creation.box(extents=self.primitive.extents,
transform=self.primitive.transform)
self._cache['vertices'] = box.vertices
self._cache['faces'] = box.faces
self._cache['face_normals'] = box.face_normals
def as_outline(self):
"""
Return a Path3D containing the outline of the box.
Returns
-----------
outline : trimesh.path.Path3D
Outline of box primitive
"""
# do the import in function to keep soft dependency
from .path.creation import box_outline
# return outline with same size as primitive
return box_outline(
extents=self.primitive.extents,
transform=self.primitive.transform)
class Extrusion(_Primitive):
def __init__(self, triangle_args=None, *args, **kwargs):
"""
Create an Extrusion primitive, which
is a subclass of Trimesh.
Parameters
----------
polygon : shapely.geometry.Polygon
Polygon to extrude
transform : (4,4) float
Transform to apply after extrusion
height : float
Height to extrude polygon by
triangle_args : str
Arguments to pass to triangle
"""
# do the import here, fail early if Shapely isn't installed
from shapely.geometry import Point
super(Extrusion, self).__init__(*args, **kwargs)
# save arguments for triangulation
self.triangle_args = triangle_args
# set default values
defaults = {'polygon': Point([0, 0]).buffer(1.0),
'transform': np.eye(4),
'height': 1.0}
self.primitive = _PrimitiveAttributes(self,
defaults,
kwargs)
@caching.cache_decorator
def area(self):
"""
The surface area of the primitive extrusion.
Calculated from polygon and height to avoid mesh creation.
Returns
----------
area: float
Surface area of 3D extrusion
"""
# area of the sides of the extrusion
area = abs(self.primitive.height *
self.primitive.polygon.length)
# area of the two caps of the extrusion
area += self.primitive.polygon.area * 2
return area
@caching.cache_decorator
def volume(self):
"""
The volume of the Extrusion primitive.
Calculated from polygon and height to avoid mesh creation.
Returns
----------
volume : float
Volume of 3D extrusion
"""
# height may be negative
volume = abs(self.primitive.polygon.area *
self.primitive.height)
return volume
@caching.cache_decorator
def direction(self):
"""
Based on the extrudes transform what is the
vector along which the polygon will be extruded.
Returns
---------
direction : (3,) float
Unit direction vector
"""
# only consider rotation and signed height
direction = np.dot(
self.primitive.transform[:3, :3],
[0.0, 0.0, np.sign(self.primitive.height)])
return direction
@property
def origin(self):
"""
Based on the extrude transform what is the
origin of the plane it is extruded from.
Returns
-----------
origin : (3,) float
Origin of extrusion plane
"""
return self.primitive.transform[:3, 3]
@caching.cache_decorator
def bounding_box_oriented(self):
# no docstring for inheritance
# calculate OBB using 2D polygon and known axis
from . import bounds
# find the 2D bounding box using the polygon
to_origin, box = bounds.oriented_bounds_2D(
self.primitive.polygon.exterior.coords)
# 3D extents
extents = np.append(box, abs(self.primitive.height))
# calculate to_3D transform from 2D obb
rotation_Z = np.linalg.inv(tf.planar_matrix_to_3D(to_origin))
rotation_Z[2, 3] = self.primitive.height / 2.0
# combine the 2D OBB transformation with the 2D projection transform
to_3D = np.dot(self.primitive.transform, rotation_Z)
obb = Box(transform=to_3D,
extents=extents,
mutable=False)
return obb
def slide(self, distance):
"""
Alter the transform of the current extrusion to slide it
along its extrude_direction vector
Parameters
-----------
distance : float
Distance along self.extrude_direction to move
"""
distance = float(distance)
translation = np.eye(4)
translation[2, 3] = distance
new_transform = np.dot(self.primitive.transform.copy(),
translation.copy())
self.primitive.transform = new_transform
def buffer(self, distance, distance_height=None, **kwargs):
"""
Return a new Extrusion object which is expanded in profile
and in height by a specified distance.
Parameters
--------------
distance : float
Distance to buffer polygon
distance_height : float
Distance to buffer above and below extrusion
kwargs : dict
Passed to Extrusion constructor
Returns
----------
buffered : primitives.Extrusion
Extrusion object with new values
"""
distance = float(distance)
# if not specified use same distance for everything
if distance_height is None:
distance_height = distance
# start with current height
height = self.primitive.height
# if current height is negative offset by negative amount
height += np.sign(height) * 2.0 * distance_height
# create a new extrusion with a buffered polygon
# use type(self) vs Extrusion to handle subclasses
buffered = type(self)(
transform=self.primitive.transform.copy(),
polygon=self.primitive.polygon.buffer(distance),
height=height,
**kwargs)
# slide the stock along the axis
buffered.slide(-np.sign(height) * distance_height)
return buffered
def _create_mesh(self):
log.debug('creating mesh for Extrusion primitive')
# extrude the polygon along Z
mesh = creation.extrude_polygon(
polygon=self.primitive.polygon,
height=self.primitive.height,
transform=self.primitive.transform,
triangle_args=self.triangle_args)
# check volume here in unit tests
if tol.strict and mesh.volume < 0.0:
raise ValueError('matrix inverted mesh!')
# cache mesh geometry in the primitive
self._cache['vertices'] = mesh.vertices
self._cache['faces'] = mesh.faces