Compare commits

..

No commits in common. "master" and "path_improvements" have entirely different histories.

20 changed files with 116 additions and 144 deletions

View File

@ -172,7 +172,6 @@ my_pattern.place(abstract, ...)
# or # or
my_pattern.place(library << make_tree(...), ...) my_pattern.place(library << make_tree(...), ...)
```
### Quickly add geometry, labels, or refs: ### Quickly add geometry, labels, or refs:

View File

@ -83,12 +83,10 @@ from .builder import (
from .utils import ( from .utils import (
ports2data as ports2data, ports2data as ports2data,
oneshot as oneshot, oneshot as oneshot,
R90 as R90,
R180 as R180,
) )
__author__ = 'Jan Petykiewicz' __author__ = 'Jan Petykiewicz'
__version__ = '3.3' __version__ = '3.2'
version = __version__ # legacy version = __version__ # legacy

View File

@ -21,7 +21,7 @@ def ell(
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
) -> dict[str, numpy.float64]: ) -> dict[str, float]:
""" """
Calculate extension for each port in order to build a 90-degree bend with the provided Calculate extension for each port in order to build a 90-degree bend with the provided
channel spacing: channel spacing:
@ -169,11 +169,11 @@ def ell(
'emax', 'max_extension', 'emax', 'max_extension',
'min_past_furthest',): 'min_past_furthest',):
if numpy.size(bound) == 2: if numpy.size(bound) == 2:
bound = cast('Sequence[float]', bound) bound = cast(Sequence[float], bound)
rot_bound = (rot_matrix @ ((bound[0], 0), rot_bound = (rot_matrix @ ((bound[0], 0),
(0, bound[1])))[0, :] (0, bound[1])))[0, :]
else: else:
bound = cast('float', bound) bound = cast(float, bound)
rot_bound = numpy.array(bound) rot_bound = numpy.array(bound)
if rot_bound < 0: if rot_bound < 0:
@ -185,10 +185,10 @@ def ell(
offsets += rot_bound.min() - offsets.max() offsets += rot_bound.min() - offsets.max()
else: else:
if numpy.size(bound) == 2: if numpy.size(bound) == 2:
bound = cast('Sequence[float]', bound) bound = cast(Sequence[float], bound)
rot_bound = (rot_matrix @ bound)[0] rot_bound = (rot_matrix @ bound)[0]
else: else:
bound = cast('float', bound) bound = cast(float, bound)
neg = (direction + pi / 4) % (2 * pi) > pi neg = (direction + pi / 4) % (2 * pi) > pi
rot_bound = -bound if neg else bound rot_bound = -bound if neg else bound

View File

@ -132,7 +132,7 @@ def writefile(
with tmpfile(path) as base_stream: with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,) streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz': if path.suffix == '.gz':
gz_stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) gz_stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb'))
streams = (gz_stream,) + streams streams = (gz_stream,) + streams
else: else:
gz_stream = base_stream gz_stream = base_stream

View File

@ -145,7 +145,7 @@ def writefile(
with tmpfile(path) as base_stream: with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,) streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz': if path.suffix == '.gz':
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6)) stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
streams = (stream,) + streams streams = (stream,) + streams
else: else:
stream = base_stream stream = base_stream

View File

@ -190,7 +190,7 @@ def writefile(
with tmpfile(path) as base_stream: with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,) streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz': if path.suffix == '.gz':
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb'))
streams += (stream,) streams += (stream,)
else: else:
stream = base_stream stream = base_stream
@ -551,7 +551,7 @@ def _shapes_to_elements(
circle = fatrec.Circle( circle = fatrec.Circle(
layer=layer, layer=layer,
datatype=datatype, datatype=datatype,
radius=cast('int', radius), radius=cast(int, radius),
x=offset[0], x=offset[0],
y=offset[1], y=offset[1],
properties=properties, properties=properties,
@ -568,8 +568,8 @@ def _shapes_to_elements(
path = fatrec.Path( path = fatrec.Path(
layer=layer, layer=layer,
datatype=datatype, datatype=datatype,
point_list=cast('Sequence[Sequence[int]]', deltas), point_list=cast(Sequence[Sequence[int]], deltas),
half_width=cast('int', half_width), half_width=cast(int, half_width),
x=xy[0], x=xy[0],
y=xy[1], y=xy[1],
extension_start=extension_start, # TODO implement multiple cap types? extension_start=extension_start, # TODO implement multiple cap types?
@ -587,7 +587,7 @@ def _shapes_to_elements(
datatype=datatype, datatype=datatype,
x=xy[0], x=xy[0],
y=xy[1], y=xy[1],
point_list=cast('list[list[int]]', points), point_list=cast(list[list[int]], points),
properties=properties, properties=properties,
repetition=repetition, repetition=repetition,
)) ))
@ -651,10 +651,10 @@ def repetition_masq2fata(
a_count = rint_cast(rep.a_count) a_count = rint_cast(rep.a_count)
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None b_count = rint_cast(rep.b_count) if rep.b_count is not None else None
frep = fatamorgana.GridRepetition( frep = fatamorgana.GridRepetition(
a_vector=cast('list[int]', a_vector), a_vector=cast(list[int], a_vector),
b_vector=cast('list[int] | None', b_vector), b_vector=cast(list[int] | None, b_vector),
a_count=cast('int', a_count), a_count=cast(int, a_count),
b_count=cast('int | None', b_count), b_count=cast(int | None, b_count),
) )
offset = (0, 0) offset = (0, 0)
elif isinstance(rep, Arbitrary): elif isinstance(rep, Arbitrary):

View File

@ -211,7 +211,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str): if isinstance(tops, str):
tops = (tops,) tops = (tops,)
keep = cast('set[str]', self.referenced_patterns(tops) - {None}) keep = cast(set[str], self.referenced_patterns(tops) - {None})
keep |= set(tops) keep |= set(tops)
filtered = {kk: vv for kk, vv in self.items() if kk in keep} filtered = {kk: vv for kk, vv in self.items() if kk in keep}
@ -314,7 +314,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
flatten_single(top) flatten_single(top)
assert None not in flattened.values() assert None not in flattened.values()
return cast('dict[str, Pattern]', flattened) return cast(dict[str, 'Pattern'], flattened)
def get_name( def get_name(
self, self,
@ -504,7 +504,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
raise LibraryError('visit_* functions returned a new `Pattern` object' raise LibraryError('visit_* functions returned a new `Pattern` object'
' but no top-level name was provided in `hierarchy`') ' but no top-level name was provided in `hierarchy`')
cast('ILibrary', self)[name] = pattern cast(ILibrary, self)[name] = pattern
return self return self
@ -542,7 +542,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
Return: Return:
Topologically sorted list of pattern names. Topologically sorted list of pattern names.
""" """
return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order())) return cast(list[str], list(TopologicalSorter(self.child_graph()).static_order()))
def find_refs_local( def find_refs_local(
self, self,
@ -827,7 +827,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
for old_name in temp: for old_name in temp:
new_name = rename_map.get(old_name, old_name) new_name = rename_map.get(old_name, old_name)
pat = self[new_name] pat = self[new_name]
pat.refs = map_targets(pat.refs, lambda tt: cast('dict[str | None, str | None]', rename_map).get(tt, tt)) pat.refs = map_targets(pat.refs, lambda tt: cast(dict[str | None, str | None], rename_map).get(tt, tt))
return rename_map return rename_map
@ -944,8 +944,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
shape_table: dict[tuple, list] = defaultdict(list) shape_table: dict[tuple, list] = defaultdict(list)
for layer, sseq in pat.shapes.items(): for layer, sseq in pat.shapes.items():
for ii, shape in enumerate(sseq): for i, shape in enumerate(sseq):
if any(isinstance(shape, tt) for tt in exclude_types): if any(isinstance(shape, t) for t in exclude_types):
continue continue
base_label, values, _func = shape.normalized_form(norm_value) base_label, values, _func = shape.normalized_form(norm_value)
@ -954,16 +954,16 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
if label not in shape_pats: if label not in shape_pats:
continue continue
shape_table[label].append((ii, values)) shape_table[label].append((i, values))
# For repeated shapes, create a `Pattern` holding a normalized shape object, # For repeated shapes, create a `Pattern` holding a normalized shape object,
# and add `pat.refs` entries for each occurrence in pat. Also, note down that # and add `pat.refs` entries for each occurrence in pat. Also, note down that
# we should delete the `pat.shapes` entries for which we made `Ref`s. # we should delete the `pat.shapes` entries for which we made `Ref`s.
shapes_to_remove = [] shapes_to_remove = []
for label, shape_entries in shape_table.items(): for label in shape_table:
layer = label[-1] layer = label[-1]
target = label2name(label) target = label2name(label)
for ii, values in shape_entries: for ii, values in shape_table[label]:
offset, scale, rotation, mirror_x = values offset, scale, rotation, mirror_x = values
pat.ref(target=target, offset=offset, scale=scale, pat.ref(target=target, offset=offset, scale=scale,
rotation=rotation, mirrored=(mirror_x, False)) rotation=rotation, mirrored=(mirror_x, False))
@ -1047,7 +1047,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str): if isinstance(tops, str):
tops = (tops,) tops = (tops,)
keep = cast('set[str]', self.referenced_patterns(tops) - {None}) keep = cast(set[str], self.referenced_patterns(tops) - {None})
keep |= set(tops) keep |= set(tops)
new = type(self)() new = type(self)()

View File

@ -491,7 +491,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
""" """
pat = self.deepcopy().polygonize().flatten(library=library) pat = self.deepcopy().polygonize().flatten(library=library)
polys = [ polys = [
cast('Polygon', shape).vertices + cast('Polygon', shape).offset cast(Polygon, shape).vertices + cast(Polygon, shape).offset
for shape in chain_elements(pat.shapes) for shape in chain_elements(pat.shapes)
] ]
return polys return polys
@ -533,7 +533,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
n_elems = sum(1 for _ in chain_elements(self.shapes, self.labels)) n_elems = sum(1 for _ in chain_elements(self.shapes, self.labels))
ebounds = numpy.full((n_elems, 2, 2), nan) ebounds = numpy.full((n_elems, 2, 2), nan)
for ee, entry in enumerate(chain_elements(self.shapes, self.labels)): for ee, entry in enumerate(chain_elements(self.shapes, self.labels)):
maybe_ebounds = cast('Bounded', entry).get_bounds() maybe_ebounds = cast(Bounded, entry).get_bounds()
if maybe_ebounds is not None: if maybe_ebounds is not None:
ebounds[ee] = maybe_ebounds ebounds[ee] = maybe_ebounds
mask = ~numpy.isnan(ebounds[:, 0, 0]) mask = ~numpy.isnan(ebounds[:, 0, 0])
@ -631,7 +631,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
cast('Positionable', entry).translate(offset) cast(Positionable, entry).translate(offset)
return self return self
def scale_elements(self, c: float) -> Self: def scale_elements(self, c: float) -> Self:
@ -645,37 +645,33 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain_elements(self.shapes, self.refs): for entry in chain_elements(self.shapes, self.refs):
cast('Scalable', entry).scale_by(c) cast(Scalable, entry).scale_by(c)
return self return self
def scale_by(self, c: float, scale_refs: bool = True) -> Self: def scale_by(self, c: float) -> Self:
""" """
Scale this Pattern by the given value Scale this Pattern by the given value
All shapes and (optionally) refs and their offsets are scaled, (all shapes and refs and their offsets are scaled,
as are all label and port offsets. as are all label and port offsets)
Args: Args:
c: factor to scale by c: factor to scale by
scale_refs: Whether to scale refs. Ref offsets are always scaled,
but it may be desirable to not scale the ref itself (e.g. if
the target cell was also scaled).
Returns: Returns:
self self
""" """
for entry in chain_elements(self.shapes, self.refs): for entry in chain_elements(self.shapes, self.refs):
cast('Positionable', entry).offset *= c cast(Positionable, entry).offset *= c
if scale_refs or not isinstance(entry, Ref): cast(Scalable, entry).scale_by(c)
cast('Scalable', entry).scale_by(c)
rep = cast('Repeatable', entry).repetition rep = cast(Repeatable, entry).repetition
if rep: if rep:
rep.scale_by(c) rep.scale_by(c)
for label in chain_elements(self.labels): for label in chain_elements(self.labels):
cast('Positionable', label).offset *= c cast(Positionable, label).offset *= c
rep = cast('Repeatable', label).repetition rep = cast(Repeatable, label).repetition
if rep: if rep:
rep.scale_by(c) rep.scale_by(c)
@ -712,8 +708,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
old_offset = cast('Positionable', entry).offset old_offset = cast(Positionable, entry).offset
cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) cast(Positionable, entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset)
return self return self
def rotate_elements(self, rotation: float) -> Self: def rotate_elements(self, rotation: float) -> Self:
@ -727,7 +723,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast('Rotatable', entry).rotate(rotation) cast(Rotatable, entry).rotate(rotation)
return self return self
def mirror_element_centers(self, across_axis: int = 0) -> Self: def mirror_element_centers(self, across_axis: int = 0) -> Self:
@ -742,7 +738,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
cast('Positionable', entry).offset[across_axis - 1] *= -1 cast(Positionable, entry).offset[across_axis - 1] *= -1
return self return self
def mirror_elements(self, across_axis: int = 0) -> Self: def mirror_elements(self, across_axis: int = 0) -> Self:
@ -758,7 +754,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast('Mirrorable', entry).mirror(across_axis) cast(Mirrorable, entry).mirror(across_axis)
return self return self
def mirror(self, across_axis: int = 0) -> Self: def mirror(self, across_axis: int = 0) -> Self:

View File

@ -294,7 +294,7 @@ class Grid(Repetition):
def __le__(self, other: Repetition) -> bool: def __le__(self, other: Repetition) -> bool:
if type(self) is not type(other): if type(self) is not type(other):
return repr(type(self)) < repr(type(other)) return repr(type(self)) < repr(type(other))
other = cast('Grid', other) other = cast(Grid, other)
if self.a_count != other.a_count: if self.a_count != other.a_count:
return self.a_count < other.a_count return self.a_count < other.a_count
if self.b_count != other.b_count: if self.b_count != other.b_count:
@ -357,7 +357,7 @@ class Arbitrary(Repetition):
def __le__(self, other: Repetition) -> bool: def __le__(self, other: Repetition) -> bool:
if type(self) is not type(other): if type(self) is not type(other):
return repr(type(self)) < repr(type(other)) return repr(type(self)) < repr(type(other))
other = cast('Arbitrary', other) other = cast(Arbitrary, other)
if self.displacements.size != other.displacements.size: if self.displacements.size != other.displacements.size:
return self.displacements.size < other.displacements.size return self.displacements.size < other.displacements.size

View File

@ -206,7 +206,7 @@ class Arc(Shape):
if repr(type(self)) != repr(type(other)): if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other)) return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other)) return id(type(self)) < id(type(other))
other = cast('Arc', other) other = cast(Arc, other)
if self.width != other.width: if self.width != other.width:
return self.width < other.width return self.width < other.width
if not numpy.array_equal(self.radii, other.radii): if not numpy.array_equal(self.radii, other.radii):
@ -233,7 +233,7 @@ class Arc(Shape):
r0, r1 = self.radii r0, r1 = self.radii
# Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation) # Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation)
a_ranges = cast('_array2x2_t', self._angles_to_parameters()) a_ranges = cast(tuple[tuple[float, float], tuple[float, float]], self._angles_to_parameters())
# Approximate perimeter via numerical integration # Approximate perimeter via numerical integration
@ -246,13 +246,13 @@ class Arc(Shape):
def get_arclens(n_pts: int, a0: float, a1: float, dr: float) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]: def get_arclens(n_pts: int, a0: float, a1: float, dr: float) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
""" Get `n_pts` arclengths """ """ Get `n_pts` arclengths """
tt, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points t, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points
r0sin = (r0 + dr) * numpy.sin(tt) r0sin = (r0 + dr) * numpy.sin(t)
r1cos = (r1 + dr) * numpy.cos(tt) r1cos = (r1 + dr) * numpy.cos(t)
arc_dl = numpy.sqrt(r0sin * r0sin + r1cos * r1cos) arc_dl = numpy.sqrt(r0sin * r0sin + r1cos * r1cos)
#arc_lengths = numpy.diff(tt) * (arc_dl[1:] + arc_dl[:-1]) / 2 #arc_lengths = numpy.diff(t) * (arc_dl[1:] + arc_dl[:-1]) / 2
arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2 arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2
return arc_lengths, tt return arc_lengths, t
wh = self.width / 2.0 wh = self.width / 2.0
if num_vertices is not None: if num_vertices is not None:
@ -286,7 +286,6 @@ class Arc(Shape):
thetas = thetas[::-1] thetas = thetas[::-1]
return thetas return thetas
thetas_inner: NDArray[numpy.float64]
if wh in (r0, r1): if wh in (r0, r1):
thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin
else: else:
@ -321,11 +320,11 @@ class Arc(Shape):
If the extrema are innaccessible due to arc constraints, check the arc endpoints instead. If the extrema are innaccessible due to arc constraints, check the arc endpoints instead.
""" """
a_ranges = cast('_array2x2_t', self._angles_to_parameters()) a_ranges = self._angles_to_parameters()
mins = [] mins = []
maxs = [] maxs = []
for aa, sgn in zip(a_ranges, (-1, +1), strict=True): for a, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2 wh = sgn * self.width / 2
rx = self.radius_x + wh rx = self.radius_x + wh
ry = self.radius_y + wh ry = self.radius_y + wh
@ -336,13 +335,13 @@ class Arc(Shape):
maxs.append([0, 0]) maxs.append([0, 0])
continue continue
a0, a1 = aa a0, a1 = a
a0_offset = a0 - (a0 % (2 * pi)) a0_offset = a0 - (a0 % (2 * pi))
sin_r = numpy.sin(self.rotation) sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation) cos_r = numpy.cos(self.rotation)
sin_a = numpy.sin(aa) sin_a = numpy.sin(a)
cos_a = numpy.cos(aa) cos_a = numpy.cos(a)
# Cutoff angles # Cutoff angles
xpt = (-self.rotation) % (2 * pi) + a0_offset xpt = (-self.rotation) % (2 * pi) + a0_offset
@ -432,19 +431,19 @@ class Arc(Shape):
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse. [[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
``` ```
""" """
a_ranges = cast('_array2x2_t', self._angles_to_parameters()) a_ranges = self._angles_to_parameters()
mins = [] mins = []
maxs = [] maxs = []
for aa, sgn in zip(a_ranges, (-1, +1), strict=True): for a, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2 wh = sgn * self.width / 2
rx = self.radius_x + wh rx = self.radius_x + wh
ry = self.radius_y + wh ry = self.radius_y + wh
sin_r = numpy.sin(self.rotation) sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation) cos_r = numpy.cos(self.rotation)
sin_a = numpy.sin(aa) sin_a = numpy.sin(a)
cos_a = numpy.cos(aa) cos_a = numpy.cos(a)
# arc endpoints # arc endpoints
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a) xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
@ -462,23 +461,21 @@ class Arc(Shape):
"Eccentric anomaly" parameter ranges for the inner and outer edges, in the form "Eccentric anomaly" parameter ranges for the inner and outer edges, in the form
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]` `[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
""" """
aa = [] a = []
for sgn in (-1, +1): for sgn in (-1, +1):
wh = sgn * self.width / 2.0 wh = sgn * self.width / 2.0
rx = self.radius_x + wh rx = self.radius_x + wh
ry = self.radius_y + wh ry = self.radius_y + wh
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles) a0, a1 = (numpy.arctan2(rx * numpy.sin(a), ry * numpy.cos(a)) for a in self.angles)
sign = numpy.sign(self.angles[1] - self.angles[0]) sign = numpy.sign(self.angles[1] - self.angles[0])
if sign != numpy.sign(a1 - a0): if sign != numpy.sign(a1 - a0):
a1 += sign * 2 * pi a1 += sign * 2 * pi
aa.append((a0, a1)) a.append((a0, a1))
return numpy.array(aa, dtype=float) return numpy.array(a, dtype=float)
def __repr__(self) -> str: def __repr__(self) -> str:
angles = f'{numpy.rad2deg(self.angles)}' angles = f'{numpy.rad2deg(self.angles)}'
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>' return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]

View File

@ -84,7 +84,7 @@ class Circle(Shape):
if repr(type(self)) != repr(type(other)): if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other)) return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other)) return id(type(self)) < id(type(other))
other = cast('Circle', other) other = cast(Circle, other)
if not self.radius == other.radius: if not self.radius == other.radius:
return self.radius < other.radius return self.radius < other.radius
if not numpy.array_equal(self.offset, other.offset): if not numpy.array_equal(self.offset, other.offset):

View File

@ -134,7 +134,7 @@ class Ellipse(Shape):
if repr(type(self)) != repr(type(other)): if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other)) return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other)) return id(type(self)) < id(type(other))
other = cast('Ellipse', other) other = cast(Ellipse, other)
if not numpy.array_equal(self.radii, other.radii): if not numpy.array_equal(self.radii, other.radii):
return tuple(self.radii) < tuple(other.radii) return tuple(self.radii) < tuple(other.radii)
if not numpy.array_equal(self.offset, other.offset): if not numpy.array_equal(self.offset, other.offset):

View File

@ -223,7 +223,7 @@ class Path(Shape):
if repr(type(self)) != repr(type(other)): if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other)) return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other)) return id(type(self)) < id(type(other))
other = cast('Path', other) other = cast(Path, other)
if self.width != other.width: if self.width != other.width:
return self.width < other.width return self.width < other.width
if self.cap != other.cap: if self.cap != other.cap:
@ -271,7 +271,7 @@ class Path(Shape):
# TODO: Path.travel() needs testing # TODO: Path.travel() needs testing
direction = numpy.array([1, 0]) direction = numpy.array([1, 0])
verts: list[NDArray[numpy.float64]] = [numpy.zeros(2)] verts = [numpy.zeros(2)]
for angle, distance in travel_pairs: for angle, distance in travel_pairs:
direction = numpy.dot(rotation_matrix_2d(angle), direction.T).T direction = numpy.dot(rotation_matrix_2d(angle), direction.T).T
verts.append(verts[-1] + direction * distance) verts.append(verts[-1] + direction * distance)
@ -307,8 +307,8 @@ class Path(Shape):
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
ds = 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[:, :, None])[:, 0] rp = numpy.linalg.solve(As, bs)[:, 0, None]
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0] rn = numpy.linalg.solve(As, ds)[:, 0, None]
intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1]
intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1] intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1]
@ -405,7 +405,7 @@ class Path(Shape):
x_min = rotated_vertices[:, 0].argmin() x_min = rotated_vertices[:, 0].argmin()
if not is_scalar(x_min): if not is_scalar(x_min):
y_min = rotated_vertices[x_min, 1].argmin() y_min = rotated_vertices[x_min, 1].argmin()
x_min = cast('Sequence', x_min)[y_min] x_min = cast(Sequence, x_min)[y_min]
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
width0 = self.width / norm_value width0 = self.width / norm_value

View File

@ -1,4 +1,5 @@
from typing import Any, cast, TYPE_CHECKING from typing import Any, cast
from collections.abc import Sequence
import copy import copy
import functools import functools
@ -12,9 +13,6 @@ from ..repetition import Repetition
from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key
from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
if TYPE_CHECKING:
from collections.abc import Sequence
@functools.total_ordering @functools.total_ordering
class Polygon(Shape): class Polygon(Shape):
@ -131,7 +129,7 @@ class Polygon(Shape):
if repr(type(self)) != repr(type(other)): if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other)) return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other)) return id(type(self)) < id(type(other))
other = cast('Polygon', other) other = cast(Polygon, other)
if not numpy.array_equal(self.vertices, other.vertices): if not numpy.array_equal(self.vertices, other.vertices):
min_len = min(self.vertices.shape[0], other.vertices.shape[0]) min_len = min(self.vertices.shape[0], other.vertices.shape[0])
eq_mask = self.vertices[:min_len] != other.vertices[:min_len] eq_mask = self.vertices[:min_len] != other.vertices[:min_len]
@ -397,7 +395,7 @@ class Polygon(Shape):
x_min = rotated_vertices[:, 0].argmin() x_min = rotated_vertices[:, 0].argmin()
if not is_scalar(x_min): if not is_scalar(x_min):
y_min = rotated_vertices[x_min, 1].argmin() y_min = rotated_vertices[x_min, 1].argmin()
x_min = cast('Sequence', x_min)[y_min] x_min = cast(Sequence, x_min)[y_min]
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
# TODO: normalize mirroring? # TODO: normalize mirroring?

View File

@ -115,7 +115,7 @@ class Text(RotatableImpl, Shape):
if repr(type(self)) != repr(type(other)): if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other)) return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other)) return id(type(self)) < id(type(other))
other = cast('Text', other) other = cast(Text, other)
if not self.height == other.height: if not self.height == other.height:
return self.height < other.height return self.height < other.height
if not self.string == other.string: if not self.string == other.string:

View File

@ -1,15 +1,14 @@
from typing import Self, cast, Any, TYPE_CHECKING from typing import Self, cast, Any
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import numpy import numpy
from numpy import pi from numpy import pi
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from .positionable import Positionable
from ..error import MasqueError from ..error import MasqueError
from ..utils import rotation_matrix_2d from ..utils import rotation_matrix_2d
if TYPE_CHECKING:
from .positionable import Positionable
_empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass
@ -114,9 +113,9 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta):
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
pivot = numpy.asarray(pivot, dtype=float) pivot = numpy.asarray(pivot, dtype=float)
cast('Positionable', self).translate(-pivot) cast(Positionable, self).translate(-pivot)
cast('Rotatable', self).rotate(rotation) cast(Rotatable, self).rotate(rotation)
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) # type: ignore # mypy#3004 self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) # type: ignore # mypy#3004
cast('Positionable', self).translate(+pivot) cast(Positionable, self).translate(+pivot)
return self return self

View File

@ -25,8 +25,6 @@ from .transform import (
normalize_mirror as normalize_mirror, normalize_mirror as normalize_mirror,
rotate_offsets_around as rotate_offsets_around, rotate_offsets_around as rotate_offsets_around,
apply_transforms as apply_transforms, apply_transforms as apply_transforms,
R90 as R90,
R180 as R180,
) )
from .comparisons import ( from .comparisons import (
annotation2key as annotation2key, annotation2key as annotation2key,

View File

@ -2,11 +2,6 @@ import numpy
from numpy.typing import ArrayLike, NDArray from numpy.typing import ArrayLike, NDArray
from numpy import pi from numpy import pi
try:
from numpy import trapezoid
except ImportError:
from numpy import trapz as trapezoid
def bezier( def bezier(
nodes: ArrayLike, nodes: ArrayLike,
@ -27,30 +22,28 @@ def bezier(
Returns: Returns:
`[[x0, y0], [x1, y1], ...]` corresponding to `[tt0, tt1, ...]` `[[x0, y0], [x1, y1], ...]` corresponding to `[tt0, tt1, ...]`
""" """
nodes = numpy.asarray(nodes)
tt = numpy.asarray(tt)
nn = nodes.shape[0] nn = nodes.shape[0]
weights = numpy.ones(nn) if weights is None else numpy.asarray(weights) if weights is None:
weights = numpy.ones(nn)
with numpy.errstate(divide='ignore'): t_half0 = tt <= 0.5
umul = (tt / (1 - tt)).clip(max=1) umul = tt / (1 - tt)
udiv = ((1 - tt) / tt).clip(max=1) udiv = 1 / umul
umul[~t_half0] = 1
udiv[t_half0] = 1
hh = numpy.ones((tt.size,)) hh = numpy.ones((tt.size, 1))
qq = nodes[None, 0, :] * hh[:, None] qq = nodes[None, 0] * hh
for kk in range(1, nn): for kk in range(1, nn):
hh *= umul * (nn - kk) * weights[kk] hh *= umul * (nn + 1 - kk) * weights[kk]
hh /= kk * udiv * weights[kk - 1] + hh hh /= kk * udiv * weights[kk - 1] + hh
qq *= 1.0 - hh[:, None] qq *= 1.0 - hh
qq += hh[:, None] * nodes[None, kk, :] qq += hh * nodes[None, kk]
return qq return qq
def euler_bend( def euler_bend(switchover_angle: float) -> NDArray[numpy.float64]:
switchover_angle: float,
num_points: int = 200,
) -> NDArray[numpy.float64]:
""" """
Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral). Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral).
@ -58,44 +51,42 @@ def euler_bend(
switchover_angle: After this angle, the bend will transition into a circular arc switchover_angle: After this angle, the bend will transition into a circular arc
(and transition back to an Euler spiral on the far side). If this is set to (and transition back to an Euler spiral on the far side). If this is set to
`>= pi / 4`, no circular arc will be added. `>= pi / 4`, no circular arc will be added.
num_points: Number of points in the curve
Returns: Returns:
`[[x0, y0], ...]` for the curve `[[x0, y0], ...]` for the curve
""" """
ll_max = numpy.sqrt(2 * switchover_angle) # total length of (one) spiral portion # Switchover angle
ll_tot = 2 * ll_max + (pi / 2 - 2 * switchover_angle) # AKA: Clothoid bend, Cornu spiral
num_points_spiral = numpy.floor(ll_max / ll_tot * num_points).astype(int) theta_max = numpy.sqrt(2 * switchover_angle)
num_points_arc = num_points - 2 * num_points_spiral
def gen_spiral(ll_max: float) -> NDArray[numpy.float64]: def gen_curve(theta_max: float):
xx = [] xx = []
yy = [] yy = []
for ll in numpy.linspace(0, ll_max, num_points_spiral): for theta in numpy.linspace(0, theta_max, 100):
qq = numpy.linspace(0, ll, 1000) # integrate to current arclength qq = numpy.linspace(0, theta, 1000)
xx.append(trapezoid( numpy.cos(qq * qq / 2), qq)) xx.append(numpy.trapz( numpy.cos(qq * qq / 2), qq))
yy.append(trapezoid(-numpy.sin(qq * qq / 2), qq)) yy.append(numpy.trapz(-numpy.sin(qq * qq / 2), qq))
xy_part = numpy.stack((xx, yy), axis=1) xy_part = numpy.stack((xx, yy), axis=1)
return xy_part return xy_part
xy_spiral = gen_spiral(ll_max) xy_part = gen_curve(theta_max)
xy_parts = [xy_spiral] xy_parts = [xy_part]
if switchover_angle < pi / 4: if switchover_angle < pi / 4:
# Build a circular segment to join the two euler portions # Build a circular segment to join the two euler portions
rmin = 1.0 / ll_max rmin = 1.0 / theta_max
half_angle = pi / 4 - switchover_angle half_angle = pi / 4 - switchover_angle
qq = numpy.linspace(half_angle * 2, 0, num_points_arc + 1) + switchover_angle qq = numpy.linspace(half_angle * 2, 0, 10) + switchover_angle
xc = rmin * numpy.cos(qq) xc = rmin * numpy.cos(qq)
yc = rmin * numpy.sin(qq) + xy_spiral[-1, 1] yc = rmin * numpy.sin(qq) + xy_part[-1, 1]
xc += xy_spiral[-1, 0] - xc[0] xc += xy_part[-1, 0] - xc[0]
yc += xy_spiral[-1, 1] - yc[0] yc += xy_part[-1, 1] - yc[0]
xy_parts.append(numpy.stack((xc[1:], yc[1:]), axis=1)) xy_parts.append(numpy.stack((xc, yc), axis=1))
endpoint_xy = xy_parts[-1][-1, :] endpoint_xy = xy_parts[-1][-1, :]
second_spiral = xy_spiral[::-1, ::-1] + endpoint_xy - xy_spiral[-1, ::-1] second_curve = xy_part[::-1, ::-1] + endpoint_xy - xy_part[-1, ::-1]
xy_parts.append(second_spiral) xy_parts.append(second_curve)
xy = numpy.concatenate(xy_parts) xy = numpy.concatenate(xy_parts)
# Remove any 2x-duplicate points # Remove any 2x-duplicate points

View File

@ -9,11 +9,6 @@ from numpy.typing import NDArray, ArrayLike
from numpy import pi from numpy import pi
# Constants for shorthand rotations
R90 = pi / 2
R180 = pi
@lru_cache @lru_cache
def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
""" """

View File

@ -78,6 +78,7 @@ lint.ignore = [
"ANN002", # *args "ANN002", # *args
"ANN003", # **kwargs "ANN003", # **kwargs
"ANN401", # Any "ANN401", # Any
"ANN101", # self: Self
"SIM108", # single-line if / else assignment "SIM108", # single-line if / else assignment
"RET504", # x=y+z; return x "RET504", # x=y+z; return x
"PIE790", # unnecessary pass "PIE790", # unnecessary pass