This commit is contained in:
cjw
2026-02-12 23:22:11 +08:00
parent 7b09eb3d89
commit 89660bba4e
5988 changed files with 2517516 additions and 0 deletions
@@ -0,0 +1,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