You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
masque/masque/shapes/path.py

445 lines
16 KiB
Python

from typing import List, Tuple, Dict, Optional, Sequence, Any
import copy
from enum import Enum
import numpy
from numpy import pi, inf
from numpy.typing import NDArray, ArrayLike
from . import Shape, normalized_shape_tuple, Polygon, Circle
from .. import PatternError
from ..repetition import Repetition
from ..utils import is_scalar, rotation_matrix_2d, layer_t, AutoSlots
from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
from ..traits import LockableImpl
class PathCap(Enum):
Flush = 0 # Path ends at final vertices
Circle = 1 # Path extends past final vertices with a semicircle of radius width/2
Square = 2 # Path extends past final vertices with a width-by-width/2 rectangle
SquareCustom = 4 # Path extends past final vertices with a rectangle of length
# # defined by path.cap_extensions
class Path(Shape, metaclass=AutoSlots):
"""
A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape,
and an offset.
A normalized_form(...) is available, but can be quite slow with lots of vertices.
"""
__slots__ = ('_vertices', '_width', '_cap', '_cap_extensions')
_vertices: NDArray[numpy.float64]
_width: float
_cap: PathCap
_cap_extensions: Optional[NDArray[numpy.float64]]
Cap = PathCap
# width property
@property
def width(self) -> float:
"""
Path width (float, >= 0)
"""
return self._width
@width.setter
def width(self, val: float) -> None:
if not is_scalar(val):
raise PatternError('Width must be a scalar')
if not val >= 0:
raise PatternError('Width must be non-negative')
self._width = val
# cap property
@property
def cap(self) -> PathCap:
"""
Path end-cap
"""
return self._cap
@cap.setter
def cap(self, val: PathCap) -> None:
# TODO: Document that setting cap can change cap_extensions
self._cap = PathCap(val)
if self.cap != PathCap.SquareCustom:
self.cap_extensions = None
elif self.cap_extensions is None:
# just got set to SquareCustom
self.cap_extensions = numpy.zeros(2)
# cap_extensions property
@property
def cap_extensions(self) -> Optional[Any]: #TODO mypy#3004 NDArray[numpy.float64]]:
"""
Path end-cap extension
Returns:
2-element ndarray or `None`
"""
return self._cap_extensions
@cap_extensions.setter
def cap_extensions(self, vals: Optional[ArrayLike]) -> None:
custom_caps = (PathCap.SquareCustom,)
if self.cap in custom_caps:
if vals is None:
raise Exception('Tried to set cap extensions to None on path with custom cap type')
self._cap_extensions = numpy.array(vals, dtype=float)
else:
if vals is not None:
raise Exception('Tried to set custom cap extensions on path with non-custom cap type')
self._cap_extensions = vals
# vertices property
@property
def vertices(self) -> Any: #TODO mypy#3004 NDArray[numpy.float64]]:
"""
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)
"""
return self._vertices
@vertices.setter
def vertices(self, val: ArrayLike) -> None:
val = numpy.array(val, dtype=float) # TODO document that these might not be copied
if len(val.shape) < 2 or val.shape[1] != 2:
raise PatternError('Vertices must be an Nx2 array')
if val.shape[0] < 2:
raise PatternError('Must have at least 2 vertices (Nx2 where N>1)')
self._vertices = val
# xs property
@property
def xs(self) -> NDArray[numpy.float64]:
"""
All vertex x coords as a 1D ndarray
"""
return self.vertices[:, 0]
@xs.setter
def xs(self, val: ArrayLike) -> None:
val = numpy.array(val, dtype=float).flatten()
if val.size != self.vertices.shape[0]:
raise PatternError('Wrong number of vertices')
self.vertices[:, 0] = val
# ys property
@property
def ys(self) -> NDArray[numpy.float64]:
"""
All vertex y coords as a 1D ndarray
"""
return self.vertices[:, 1]
@ys.setter
def ys(self, val: ArrayLike) -> None:
val = numpy.array(val, dtype=float).flatten()
if val.size != self.vertices.shape[0]:
raise PatternError('Wrong number of vertices')
self.vertices[:, 1] = val
def __init__(
self,
vertices: ArrayLike,
width: float = 0.0,
*,
cap: PathCap = PathCap.Flush,
cap_extensions: Optional[ArrayLike] = None,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0,
mirrored: Sequence[bool] = (False, False),
layer: layer_t = 0,
dose: float = 1.0,
repetition: Optional[Repetition] = None,
annotations: Optional[annotations_t] = None,
locked: bool = False,
raw: bool = False,
) -> None:
LockableImpl.unlock(self)
self._cap_extensions = None # Since .cap setter might access it
self.identifier = ()
if raw:
assert(isinstance(vertices, numpy.ndarray))
assert(isinstance(offset, numpy.ndarray))
assert(isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None)
self._vertices = vertices
self._offset = offset
self._repetition = repetition
self._annotations = annotations if annotations is not None else {}
self._layer = layer
self._dose = dose
self._width = width
self._cap = cap
self._cap_extensions = cap_extensions
else:
self.vertices = vertices
self.offset = offset
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
self.layer = layer
self.dose = dose
self.width = width
self.cap = cap
self.cap_extensions = cap_extensions
self.rotate(rotation)
[self.mirror(a) for a, do in enumerate(mirrored) if do]
self.set_locked(locked)
def __deepcopy__(self, memo: Dict = None) -> 'Path':
memo = {} if memo is None else memo
new = copy.copy(self).unlock()
new._offset = self._offset.copy()
new._vertices = self._vertices.copy()
new._cap = copy.deepcopy(self._cap, memo)
new._cap_extensions = copy.deepcopy(self._cap_extensions, memo)
new._annotations = copy.deepcopy(self._annotations)
new.set_locked(self.locked)
return new
@staticmethod
def travel(
travel_pairs: Sequence[Tuple[float, float]],
width: float = 0.0,
cap: PathCap = PathCap.Flush,
cap_extensions: Optional[Tuple[float, float]] = None,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0,
mirrored: Sequence[bool] = (False, False),
layer: layer_t = 0,
dose: float = 1.0,
) -> 'Path':
"""
Build a path by specifying the turn angles and travel distances
rather than setting the distances directly.
Args:
travel_pairs: A list of (angle, distance) pairs that define
the path. Angles are counterclockwise, in radians, and are relative
to the previous segment's direction (the initial angle is relative
to the +x axis).
width: Path width, default `0`
cap: End-cap type, default `Path.Cap.Flush` (no end-cap)
cap_extensions: End-cap extension distances, when using `Path.Cap.CustomSquare`.
Default `(0, 0)` or `None`, depending on cap type
offset: Offset, default `(0, 0)`
rotation: Rotation counterclockwise, in radians. Default `0`
mirrored: Whether to mirror across the x or y axes. For example,
`mirrored=(True, False)` results in a reflection across the x-axis,
multiplying the path's y-coordinates by -1. Default `(False, False)`
layer: Layer, default `0`
dose: Dose, default `1.0`
Returns:
The resulting Path object
"""
#TODO: needs testing
direction = numpy.array([1, 0])
verts = [numpy.zeros(2)]
for angle, distance in travel_pairs:
direction = numpy.dot(rotation_matrix_2d(angle), direction.T).T
verts.append(verts[-1] + direction * distance)
return Path(vertices=verts, width=width, cap=cap, cap_extensions=cap_extensions,
offset=offset, rotation=rotation, mirrored=mirrored,
layer=layer, dose=dose)
def to_polygons(
self,
poly_num_points: int = None,
poly_max_arclen: float = None,
) -> List['Polygon']:
extensions = self._calculate_cap_extensions()
v = remove_colinear_vertices(self.vertices, closed_path=False)
dv = numpy.diff(v, axis=0)
dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None]
if self.width == 0:
verts = numpy.vstack((v, v[::-1]))
return [Polygon(offset=self.offset, vertices=verts, dose=self.dose, layer=self.layer)]
perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2
# add extensions
if (extensions != 0).any():
v[0] -= dvdir[0] * extensions[0]
v[-1] += dvdir[-1] * extensions[1]
dv = numpy.diff(v, axis=0) # recalculate dv; dvdir and perp should stay the same
# Find intersections of expanded sides
As = numpy.stack((dv[:-1], -dv[1:]), axis=2)
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]
rp = numpy.linalg.solve(As, bs)[:, 0, None]
rn = numpy.linalg.solve(As, ds)[:, 0, None]
intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1]
intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1]
towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp?
# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight
acute = (dv[1:] * dv[:-1]).sum(axis=1) < 0 # angle is acute?
# Build vertices
o0 = [v[0] + perp[0]]
o1 = [v[0] - perp[0]]
for i in range(dv.shape[0] - 1):
if towards_perp[i]:
o0.append(intersection_p[i])
if acute[i]:
# Opposite is >270
pt0 = v[i + 1] - perp[i + 0] + dvdir[i + 0] * self.width / 2
pt1 = v[i + 1] - perp[i + 1] - dvdir[i + 1] * self.width / 2
o1 += [pt0, pt1]
else:
o1.append(intersection_n[i])
else:
o1.append(intersection_n[i])
if acute[i]:
# > 270
pt0 = v[i + 1] + perp[i + 0] + dvdir[i + 0] * self.width / 2
pt1 = v[i + 1] + perp[i + 1] - dvdir[i + 1] * self.width / 2
o0 += [pt0, pt1]
else:
o0.append(intersection_p[i])
o0.append(v[-1] + perp[-1])
o1.append(v[-1] - perp[-1])
verts = numpy.vstack((o0, o1[::-1]))
polys = [Polygon(offset=self.offset, vertices=verts, dose=self.dose, layer=self.layer)]
if self.cap == PathCap.Circle:
#for vert in v: # not sure if every vertex, or just ends?
for vert in [v[0], v[-1]]:
circ = Circle(offset=vert, radius=self.width / 2, dose=self.dose, layer=self.layer)
polys += circ.to_polygons(poly_num_points=poly_num_points, poly_max_arclen=poly_max_arclen)
return polys
def get_bounds(self) -> NDArray[numpy.float64]:
if self.cap == PathCap.Circle:
bounds = self.offset + numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2,
numpy.max(self.vertices, axis=0) + self.width / 2))
elif self.cap in (PathCap.Flush,
PathCap.Square,
PathCap.SquareCustom):
bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
polys = self.to_polygons()
for poly in polys:
poly_bounds = poly.get_bounds_nonempty()
bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
else:
raise PatternError(f'get_bounds() not implemented for endcaps: {self.cap}')
return bounds
def rotate(self, theta: float) -> 'Path':
if theta != 0:
self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
return self
def mirror(self, axis: int) -> 'Path':
self.vertices[:, axis - 1] *= -1
return self
def scale_by(self, c: float) -> 'Path':
self.vertices *= c
self.width *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
# Note: this function is going to be pretty slow for many-vertexed paths, relative to
# other shapes
offset = self.vertices.mean(axis=0) + self.offset
zeroed_vertices = self.vertices - offset
scale = zeroed_vertices.std()
normed_vertices = zeroed_vertices / scale
_, _, vertex_axis = numpy.linalg.svd(zeroed_vertices)
rotation = numpy.arctan2(vertex_axis[0][1], vertex_axis[0][0]) % (2 * pi)
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
for v in normed_vertices])
# Reorder the vertices so that the one with lowest x, then y, comes first.
x_min = rotated_vertices[:, 0].argmin()
if not is_scalar(x_min):
y_min = rotated_vertices[x_min, 1].argmin()
x_min = x_min[y_min]
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
width0 = self.width / norm_value
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, self.layer),
(offset, scale / norm_value, rotation, False, self.dose),
lambda: Path(reordered_vertices * norm_value, width=self.width * norm_value,
cap=self.cap, layer=self.layer))
def clean_vertices(self) -> 'Path':
"""
Removes duplicate, co-linear and otherwise redundant vertices.
Returns:
self
"""
self.remove_colinear_vertices()
return self
def remove_duplicate_vertices(self) -> 'Path':
'''
Removes all consecutive duplicate (repeated) vertices.
Returns:
self
'''
self.vertices = remove_duplicate_vertices(self.vertices, closed_path=False)
return self
def remove_colinear_vertices(self) -> 'Path':
'''
Removes consecutive co-linear vertices.
Returns:
self
'''
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
return self
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:
if self.cap == PathCap.Square:
extensions = numpy.full(2, self.width / 2)
elif self.cap == PathCap.SquareCustom:
assert(isinstance(self.cap_extensions, numpy.ndarray))
extensions = self.cap_extensions
else:
# Flush or Circle
extensions = numpy.zeros(2)
return extensions
def lock(self) -> 'Path':
self.vertices.flags.writeable = False
if self.cap_extensions is not None:
self.cap_extensions.flags.writeable = False
Shape.lock(self)
return self
def unlock(self) -> 'Path':
Shape.unlock(self)
self.vertices.flags.writeable = True
if self.cap_extensions is not None:
self.cap_extensions.flags.writeable = True
return self
def __repr__(self) -> str:
centroid = self.offset + self.vertices.mean(axis=0)
dose = f' d{self.dose:g}' if self.dose != 1 else ''
locked = ' L' if self.locked else ''
return f'<Path l{self.layer} centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}{dose}{locked}>'