init
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user