From a54ee5a26c41552c951f7914caacca3c40985193 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 1 Aug 2024 00:41:01 -0700 Subject: [PATCH 001/100] bump klamath req --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bd430b8..ab4661e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ requires-python = ">=3.11" dynamic = ["version"] dependencies = [ "numpy>=1.26", - "klamath~=1.2", + "klamath~=1.4", ] From febaaeff0bcce6e8de4ba94b22df462854de0ee6 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 18 Sep 2024 20:46:48 -0700 Subject: [PATCH 002/100] add Library functions for finding instances and extracting hierarchy added child_graph, parent_graph, child_order, find_refs_local and find_refs_global --- masque/library.py | 167 ++++++++++++++++++++++++++++++++++---- masque/ref.py | 10 +++ masque/utils/__init__.py | 1 + masque/utils/transform.py | 56 ++++++++++++- 4 files changed, 217 insertions(+), 17 deletions(-) diff --git a/masque/library.py b/masque/library.py index 001ae72..08a15b9 100644 --- a/masque/library.py +++ b/masque/library.py @@ -22,12 +22,13 @@ import copy from pprint import pformat from collections import defaultdict from abc import ABCMeta, abstractmethod +from graphlib import TopologicalSorter import numpy from numpy.typing import ArrayLike, NDArray from .error import LibraryError, PatternError -from .utils import rotation_matrix_2d, layer_t +from .utils import layer_t, apply_transforms from .shapes import Shape, Polygon from .label import Label from .abstract import Abstract @@ -474,24 +475,21 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): raise LibraryError(f'.dfs() called on pattern with circular reference to "{target}"') for ref in pattern.refs[target]: + ref_transforms: list[bool] | NDArray[numpy.float64] if transform is not False: - sign = numpy.ones(2) - if transform[3]: - sign[1] = -1 - xy = numpy.dot(rotation_matrix_2d(transform[2]), ref.offset * sign) - ref_transform = transform + (xy[0], xy[1], ref.rotation, ref.mirrored) - ref_transform[3] %= 2 + ref_transforms = apply_transforms(transform, ref.as_transforms()) else: - ref_transform = False + ref_transforms = [False] - self.dfs( - pattern=self[target], - visit_before=visit_before, - visit_after=visit_after, - hierarchy=hierarchy + (target,), - transform=ref_transform, - memo=memo, - ) + for ref_transform in ref_transforms: + self.dfs( + pattern=self[target], + visit_before=visit_before, + visit_after=visit_after, + hierarchy=hierarchy + (target,), + transform=ref_transform, + memo=memo, + ) if visit_after is not None: pattern = visit_after(pattern, hierarchy=hierarchy, memo=memo, transform=transform) @@ -510,6 +508,143 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): return self + def child_graph(self) -> dict[str, set[str | None]]: + """ + Return a mapping from pattern name to a set of all child patterns + (patterns it references). + + Returns: + Mapping from pattern name to a set of all pattern names it references. + """ + graph = {name: set(pat.refs.keys()) for name, pat in self.items()} + return graph + + def parent_graph(self) -> dict[str, set[str]]: + """ + Return a mapping from pattern name to a set of all parent patterns + (patterns which reference it). + + Returns: + Mapping from pattern name to a set of all patterns which reference it. + """ + igraph: dict[str, set[str]] = {name: set() for name in self} + for name, pat in self.items(): + for child, reflist in pat.refs.items(): + if reflist and child is not None: + igraph[child].add(name) + return igraph + + def child_order(self) -> list[str]: + """ + Return a topologically sorted list of all contained pattern names. + Child (referenced) patterns will appear before their parents. + + Return: + Topologically sorted list of pattern names. + """ + return list(TopologicalSorter(self.child_graph()).static_order()) + + def find_refs_local( + self, + name: str, + parent_graph: dict[str, set[str]] | None = None, + ) -> dict[str, list[NDArray[numpy.float64]]]: + """ + Find the location and orientation of all refs pointing to `name`. + Refs with a `repetition` are resolved into multiple instances (locations). + + Args: + name: Name of the referenced pattern. + parent_graph: Mapping from pattern name to the set of patterns which + reference it. Default (`None`) calls `self.parent_graph()`. + The provided graph may be for a superset of `self` (i.e. it may + contain additional patterns which are not present in self; they + will be ignored). + + Returns: + Mapping of {parent_name: transform_list}, where transform_list + is an Nx4 ndarray with rows + `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. + """ + instances = defaultdict(list) + if parent_graph is None: + parent_graph = self.parent_graph() + for parent in parent_graph[name]: + if parent not in self: # parent_graph may be a for a superset of self + continue + for ref in self[parent].refs[name]: + instances[parent].append(ref.as_transforms()) + + return instances + + def find_refs_global( + self, + name: str, + order: list[str] | None = None, + parent_graph: dict[str, set[str]] | None = None, + ) -> dict[tuple[str, ...], NDArray[numpy.float64]]: + """ + Find the absolute (top-level) location and orientation of all refs (including + repetitions) pointing to `name`. + + Args: + name: Name of the referenced pattern. + order: List of pattern names in which children are guaranteed + to appear before their parents (i.e. topologically sorted). + Default (`None`) calls `self.child_order()`. + parent_graph: Passed to `find_refs_local`. + Mapping from pattern name to the set of patterns which + reference it. Default (`None`) calls `self.parent_graph()`. + The provided graph may be for a superset of `self` (i.e. it may + contain additional patterns which are not present in self; they + will be ignored). + + Returns: + Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form + `(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray + with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. + """ + if name not in self: + return {} + if order is None: + order = self.child_order() + if parent_graph is None: + parent_graph = self.parent_graph() + + self_keys = set(self.keys()) + + transforms: dict[str, list[tuple[ + tuple[str, ...], + NDArray[numpy.float64] + ]]] + transforms = defaultdict(list) + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items(): + transforms[parent] = [((name,), numpy.concatenate(vals))] + + for next_name in order: + if next_name not in transforms: + continue + if not parent_graph[next_name] & self_keys: + continue + + outers = self.find_refs_local(next_name, parent_graph=parent_graph) + inners = transforms.pop(next_name) + for parent, outer in outers.items(): + for path, inner in inners: + combined = apply_transforms(numpy.concatenate(outer), inner) + transforms[parent].append(( + (next_name,) + path, + combined, + )) + result = {} + for parent, targets in transforms.items(): + for path, instances in targets: + full_path = (parent,) + path + assert full_path not in result + result[full_path] = instances + return result + + class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): """ diff --git a/masque/ref.py b/masque/ref.py index b84114b..09e00b1 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -183,6 +183,16 @@ class Ref( self.rotation += pi return self + def as_transforms(self) -> NDArray[numpy.float64]: + xys = self.offset[None, :] + if self.repetition is not None: + xys = xys + self.repetition.displacements + transforms = numpy.empty((xys.shape[0], 4)) + transforms[:, :2] = xys + transforms[:, 2] = self.rotation + transforms[:, 3] = self.mirrored + return transforms + def get_bounds_single( self, pattern: 'Pattern', diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py index ffa9e85..11391a5 100644 --- a/masque/utils/__init__.py +++ b/masque/utils/__init__.py @@ -24,6 +24,7 @@ from .transform import ( rotation_matrix_2d as rotation_matrix_2d, normalize_mirror as normalize_mirror, rotate_offsets_around as rotate_offsets_around, + apply_transforms as apply_transforms, ) from .comparisons import ( annotation2key as annotation2key, diff --git a/masque/utils/transform.py b/masque/utils/transform.py index 3071d99..e2ea9aa 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -5,7 +5,7 @@ from collections.abc import Sequence from functools import lru_cache import numpy -from numpy.typing import NDArray +from numpy.typing import NDArray, ArrayLike from numpy import pi @@ -57,8 +57,62 @@ def rotate_offsets_around( ) -> NDArray[numpy.float64]: """ Rotates offsets around a pivot point. + + Args: + offsets: Nx2 array, rows are (x, y) offsets + pivot: (x, y) location to rotate around + angle: rotation angle in radians + + Returns: + Nx2 ndarray of (x, y) position after the rotation is applied. """ offsets -= pivot offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T offsets += pivot return offsets + + +def apply_transforms( + outer: ArrayLike, + inner: ArrayLike, + tensor: bool = False, + ) -> NDArray[numpy.float64]: + """ + Apply a set of transforms (`outer`) to a second set (`inner`). + This is used to find the "absolute" transform for nested `Ref`s. + + The two transforms should be of shape Ox4 and Ix4. + Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. + The output will be of the form (O*I)x4 (if `tensor=False`) or OxIx4 (`tensor=True`). + + Args: + outer: Transforms for the container refs. Shape Ox4. + inner: Transforms for the contained refs. Shape Ix4. + tensor: If `True`, an OxIx4 array is returned, with `result[oo, ii, :]` corresponding + to the `oo`th `outer` transform applied to the `ii`th inner transform. + If `False` (default), this is concatenated into `(O*I)x4` to allow simple + chaining into additional `apply_transforms()` calls. + + Returns: + OxIx4 or (O*I)x4 array. Final dimension is + `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`. + """ + outer = numpy.atleast_2d(outer).astype(float, copy=False) + inner = numpy.atleast_2d(inner).astype(float, copy=False) + + # If mirrored, flip y's + xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm + xy_mir[outer[:, 3].astype(bool), :, 1] *= -1 + + rot_mats = [rotation_matrix_2d(angle) for angle in outer[:, 2]] + xy = numpy.einsum('ort,oit->oir', rot_mats, xy_mir) + + tot = numpy.empty((outer.shape[0], inner.shape[0], 4)) + tot[:, :, :2] = outer[:, None, :2] + xy + tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored + tot[:, :, 2] %= 2 * pi # clamp rot + tot[:, :, 3] %= 2 # clamp mirrored + + if tensor: + return tot + return numpy.concatenate(tot) From 73193473df1e4ff31eb6bbb7ea031d982e619c69 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Oct 2024 11:24:40 -0700 Subject: [PATCH 003/100] Fixup arclength calculation for wedges (or other thick arcs) --- masque/shapes/arc.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index eb565bf..da5d3f8 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -244,30 +244,31 @@ class Arc(Shape): #t0 = ellipeinc(a0 - pi / 2, m) #perimeter2 = r0 * (t1 - t0) - def get_arclens(n_pts: int, a0: float, a1: 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 """ t, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points - r0sin = r0 * numpy.sin(t) - r1cos = r1 * numpy.cos(t) + r0sin = (r0 + dr) * numpy.sin(t) + r1cos = (r1 + dr) * numpy.cos(t) arc_dl = numpy.sqrt(r0sin * r0sin + r1cos * r1cos) #arc_lengths = numpy.diff(t) * (arc_dl[1:] + arc_dl[:-1]) / 2 arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2 return arc_lengths, t + wh = self.width / 2.0 if num_vertices is not None: - n_pts = numpy.ceil(max(self.radii) / min(self.radii) * num_vertices * 100).astype(int) - perimeter_inner = get_arclens(n_pts, *a_ranges[0])[0].sum() - perimeter_outer = get_arclens(n_pts, *a_ranges[1])[0].sum() + n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int) + perimeter_inner = get_arclens(n_pts, *a_ranges[0], dr=-wh)[0].sum() + perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum() implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices max_arclen = min(implied_arclen, max_arclen if max_arclen is not None else numpy.inf) assert max_arclen is not None def get_thetas(inner: bool) -> NDArray[numpy.float64]: """ Figure out the parameter values at which we should place vertices to meet the arclength constraint""" - #dr = -self.width / 2.0 * (-1 if inner else 1) + dr = -wh if inner else wh - n_pts = numpy.ceil(2 * pi * max(self.radii) / max_arclen).astype(int) - arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1]) + n_pts = numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen).astype(int) + arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) keep = [0] removable = (numpy.cumsum(arc_lengths) <= max_arclen) @@ -285,7 +286,6 @@ class Arc(Shape): thetas = thetas[::-1] return thetas - wh = self.width / 2.0 if wh in (r0, r1): thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin else: @@ -455,17 +455,18 @@ class Arc(Shape): def _angles_to_parameters(self) -> NDArray[numpy.float64]: """ + Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation) + Returns: "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 = [] for sgn in (-1, +1): - wh = sgn * self.width / 2 + wh = sgn * self.width / 2.0 rx = self.radius_x + wh ry = self.radius_y + wh - # create paremeter 'a' for parametrized ellipse 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]) if sign != numpy.sign(a1 - a0): From 7c7a7e916c09e4076c07d10cbd9c56e153d01c46 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 14 Oct 2024 17:24:49 -0700 Subject: [PATCH 004/100] Fix offset handling in polygon normalized_form() --- masque/shapes/polygon.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 1e0352f..1f72ea2 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -379,8 +379,9 @@ class Polygon(Shape): def normalized_form(self, norm_value: float) -> normalized_shape_tuple: # Note: this function is going to be pretty slow for many-vertexed polygons, relative to # other shapes - offset = self.vertices.mean(axis=0) + self.offset - zeroed_vertices = self.vertices - offset + meanv = self.vertices.mean(axis=0) + zeroed_vertices = self.vertices - meanv + offset = meanv + self.offset scale = zeroed_vertices.std() normed_vertices = zeroed_vertices / scale From 94a1b3d7937f8ffdfc83f7614c98dcb420ce3cb8 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 14 Oct 2024 17:25:01 -0700 Subject: [PATCH 005/100] cleanup comment --- masque/shapes/polygon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 1f72ea2..cbcbe63 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -20,7 +20,7 @@ class Polygon(Shape): A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an implicitly-closed boundary, and an offset. - Note that the setter for `Polygon.vertices` may creates a copy of the + Note that the setter for `Polygon.vertices` creates a copy of the passed vertex coordinates. A `normalized_form(...)` is available, but can be quite slow with lots of vertices. From 9917355bb08e268f8b7659eee5c5f2954e4d96f0 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 12 Dec 2024 23:47:19 -0800 Subject: [PATCH 006/100] speed up prune_empty() on large patterns --- masque/library.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/masque/library.py b/masque/library.py index 08a15b9..82277e1 100644 --- a/masque/library.py +++ b/masque/library.py @@ -1068,20 +1068,22 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: A set containing the names of all deleted patterns """ + parent_graph = self.parent_graph() + empty = {name for name, pat in self.items() if pat.is_empty()} trimmed = set() - while empty := {name for name, pat in self.items() if pat.is_empty()}: + while empty: + parents = set() for name in empty: del self[name] - - for pat in self.values(): - for name in empty: - # Second pass to skip looking at refs in empty patterns - if name in pat.refs: - del pat.refs[name] + for parent in parent_graph[name]: + del self[parent].refs[name] + parents |= parent_graph[name] trimmed |= empty if not repeat: break + + empty = {parent for parent in parents if self[parent].is_empty()} return trimmed def delete( From 4e862d6853ae8c4e8cb051cd0e0d3b162ce1dd8e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Oct 2024 14:27:12 -0700 Subject: [PATCH 007/100] cleanup --- masque/ports.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/masque/ports.py b/masque/ports.py index 6f029b1..90ba729 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -467,7 +467,7 @@ class PortList(metaclass=ABCMeta): specifying port connections, find the transform which will correctly align the specified o_ports onto their respective s_ports. - Args:t + Args: s_ports: A list of stationary ports o_ports: A list of ports which are to be moved/mirrored. map_in: dict of `{'s_port': 'o_port'}` mappings, specifying @@ -523,8 +523,8 @@ class PortList(metaclass=ABCMeta): if not numpy.allclose(rotations[:1], rotations): rot_deg = numpy.rad2deg(rotations) msg = 'Port orientations do not match:\n' - for nn, (k, v) in enumerate(map_in.items()): - msg += f'{k} | {rot_deg[nn]:g} | {v}\n' + for nn, (kk, vv) in enumerate(map_in.items()): + msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n' raise PortError(msg) pivot = o_offsets[0].copy() @@ -532,8 +532,8 @@ class PortList(metaclass=ABCMeta): translations = s_offsets - o_offsets if not numpy.allclose(translations[:1], translations): msg = 'Port translations do not match:\n' - for nn, (k, v) in enumerate(map_in.items()): - msg += f'{k} | {translations[nn]} | {v}\n' + for nn, (kk, vv) in enumerate(map_in.items()): + msg += f'{kk} | {translations[nn]} | {vv}\n' raise PortError(msg) return translations[0], rotations[0], o_offsets[0] From 93471a221ce1efe0dd0b9b3f51dca3cc5389a4fd Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Oct 2024 14:29:02 -0700 Subject: [PATCH 008/100] add `ok_connections` arg to allow plugging mismatched ports without warnings --- masque/builder/builder.py | 9 ++++++++- masque/pattern.py | 7 +++++++ masque/ports.py | 16 +++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/masque/builder/builder.py b/masque/builder/builder.py index b92c9ac..fed839a 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -2,7 +2,7 @@ Simplified Pattern assembly (`Builder`) """ from typing import Self -from collections.abc import Sequence, Mapping +from collections.abc import Iterable, Sequence, Mapping import copy import logging from functools import wraps @@ -226,6 +226,7 @@ class Builder(PortList): inherit_name: bool = True, set_rotation: bool | None = None, append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), ) -> Self: """ Wrapper around `Pattern.plug` which allows a string for `other`. @@ -260,6 +261,11 @@ class Builder(PortList): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: self @@ -293,6 +299,7 @@ class Builder(PortList): inherit_name=inherit_name, set_rotation=set_rotation, append=append, + ok_connections=ok_connections, ) return self diff --git a/masque/pattern.py b/masque/pattern.py index 1816762..0ae230d 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1225,6 +1225,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): inherit_name: bool = True, set_rotation: bool | None = None, append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), ) -> Self: """ Instantiate or append a pattern into the current pattern, connecting @@ -1270,6 +1271,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: self @@ -1300,6 +1306,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_in, mirrored=mirrored, set_rotation=set_rotation, + ok_connections=ok_connections, ) # get rid of plugged ports diff --git a/masque/ports.py b/masque/ports.py index 90ba729..1cc711a 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -419,6 +419,7 @@ class PortList(metaclass=ABCMeta): *, mirrored: bool = False, set_rotation: bool | None = None, + ok_connections: Iterable[tuple[str, str]] = (), ) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: """ Given a device `other` and a mapping `map_in` specifying port connections, @@ -435,6 +436,11 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `other` should be rotated. Otherwise, `set_rotation` must remain `None`. + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -451,6 +457,7 @@ class PortList(metaclass=ABCMeta): map_in=map_in, mirrored=mirrored, set_rotation=set_rotation, + ok_connections=ok_connections, ) @staticmethod @@ -461,6 +468,7 @@ class PortList(metaclass=ABCMeta): *, mirrored: bool = False, set_rotation: bool | None = None, + ok_connections: Iterable[tuple[str, str]] = (), ) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: """ Given two sets of ports (s_ports and o_ports) and a mapping `map_in` @@ -479,6 +487,11 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `o_ports` should be rotated. Otherwise, `set_rotation` must remain `None`. + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -502,7 +515,8 @@ class PortList(metaclass=ABCMeta): o_offsets[:, 1] *= -1 o_rotations *= -1 - type_conflicts = numpy.array([st != ot and 'unk' not in (st, ot) + ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]} + type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs) for st, ot in zip(s_types, o_types, strict=True)]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' From fcb470a02c920ab98731b65f68a80f54638c4c07 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Oct 2024 15:51:09 -0700 Subject: [PATCH 009/100] use cast() to clarify some type checker complaints --- masque/library.py | 2 +- masque/shapes/arc.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/library.py b/masque/library.py index 82277e1..90202dc 100644 --- a/masque/library.py +++ b/masque/library.py @@ -542,7 +542,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): Return: Topologically sorted list of pattern names. """ - return list(TopologicalSorter(self.child_graph()).static_order()) + return cast(list[str], list(TopologicalSorter(self.child_graph()).static_order())) def find_refs_local( self, diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index da5d3f8..67d932d 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -233,7 +233,7 @@ class Arc(Shape): r0, r1 = self.radii # Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation) - a_ranges = self._angles_to_parameters() + a_ranges = cast(tuple[tuple[float, float], tuple[float, float]], self._angles_to_parameters()) # Approximate perimeter via numerical integration From cd60dcc7659c13c3facad4543f66607e838b539b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Oct 2024 15:51:58 -0700 Subject: [PATCH 010/100] add the toolctx() context manager to simplify temporary retool() calls --- examples/tutorial/pather.py | 6 ++++++ masque/builder/pather.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index a7aad68..101fbb5 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -265,6 +265,12 @@ def main() -> None: # when using pather.retool(). pather.path_to('VCC', None, -50_000, out_ptype='m1wire') + # Now extend GND out to x=-50_000, using M2 for a portion of the path. + # We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice. + with pather.toolctx(M2_tool, keys=['GND']): + pather.path_to('GND', None, -40_000) + pather.path_to('GND', None, -50_000) + # Save the pather's pattern into our library library['Pather_and_BasicTool'] = pather.pattern diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 17d73b3..87f08a3 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -2,9 +2,10 @@ Manual wire/waveguide routing (`Pather`) """ from typing import Self -from collections.abc import Sequence, MutableMapping, Mapping +from collections.abc import Sequence, MutableMapping, Mapping, Iterator import copy import logging +from contextlib import contextmanager from pprint import pformat import numpy @@ -281,6 +282,37 @@ class Pather(Builder): self.tools[key] = tool return self + @contextmanager + def toolctx( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Iterator[Self]: + """ + Context manager for temporarily `retool`-ing and reverting the `retool` + upon exiting the context. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + if keys is None or isinstance(keys, str): + keys = [keys] + saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` + try: + yield self.retool(tool=tool, keys=keys) + finally: + for kk, tt in saved_tools.items(): + if tt is None: + # delete if present + self.tools.pop(kk, None) + else: + self.tools[kk] = tt + def path( self, portspec: str, From 6631c5e558a6bcf3852ca01559517206902d6850 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Oct 2024 15:52:58 -0700 Subject: [PATCH 011/100] clear out some old TODOs --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 23743e3..68c0686 100644 --- a/README.md +++ b/README.md @@ -233,5 +233,3 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...) * Tests tests tests * check renderpather * pather and renderpather examples -* context manager for retool -* allow a specific mismatch when connecting ports From f14528654b23d103cfd5af283b95c00cb0b167b4 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 25 Feb 2025 21:07:36 -0800 Subject: [PATCH 012/100] [utils.curves] add masque.utils.curves with Bezier and Euler curves --- masque/utils/curves.py | 95 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 masque/utils/curves.py diff --git a/masque/utils/curves.py b/masque/utils/curves.py new file mode 100644 index 0000000..b17b015 --- /dev/null +++ b/masque/utils/curves.py @@ -0,0 +1,95 @@ +import numpy +from numpy.typing import ArrayLike, NDArray +from numpy import pi + + +def bezier( + nodes: ArrayLike, + tt: ArrayLike, + weights: ArrayLike | None = None, + ) -> NDArray[numpy.float64]: + """ + Sample a Bezier curve with the provided control points at the parametrized positions `tt`. + + Using the calculation method from arXiv:1803.06843, Chudy and Woźny. + + Args: + nodes: `[[x0, y0], ...]` control points for the Bezier curve + tt: Parametrized positions at which to sample the curve (1D array with values in the interval [0, 1]) + weights: Control point weights; if provided, length should be the same as number of control points. + Default 1 for all control points. + + Returns: + `[[x0, y0], [x1, y1], ...]` corresponding to `[tt0, tt1, ...]` + """ + nn = nodes.shape[0] + if weights is None: + weights = numpy.ones(nn) + + t_half0 = tt <= 0.5 + umul = tt / (1 - tt) + udiv = 1 / umul + umul[~t_half0] = 1 + udiv[t_half0] = 1 + + hh = numpy.ones((tt.size, 1)) + qq = nodes[None, 0] * hh + for kk in range(1, nn): + hh *= umul * (nn + 1 - kk) * weights[kk] + hh /= kk * udiv * weights[kk - 1] + hh + qq *= 1.0 - hh + qq += hh * nodes[None, kk] + return qq + + + +def euler_bend(switchover_angle: float) -> NDArray[numpy.float64]: + """ + Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral). + + Args: + 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 + `>= pi / 4`, no circular arc will be added. + + Returns: + `[[x0, y0], ...]` for the curve + """ + # Switchover angle + # AKA: Clothoid bend, Cornu spiral + theta_max = numpy.sqrt(2 * switchover_angle) + + def gen_curve(theta_max: float): + xx = [] + yy = [] + for theta in numpy.linspace(0, theta_max, 100): + qq = numpy.linspace(0, theta, 1000) + xx.append(numpy.trapz( numpy.cos(qq * qq / 2), qq)) + yy.append(numpy.trapz(-numpy.sin(qq * qq / 2), qq)) + xy_part = numpy.stack((xx, yy), axis=1) + return xy_part + + xy_part = gen_curve(theta_max) + xy_parts = [xy_part] + + if switchover_angle < pi / 4: + # Build a circular segment to join the two euler portions + rmin = 1.0 / theta_max + half_angle = pi / 4 - switchover_angle + qq = numpy.linspace(half_angle * 2, 0, 10) + switchover_angle + xc = rmin * numpy.cos(qq) + yc = rmin * numpy.sin(qq) + xy_part[-1, 1] + xc += xy_part[-1, 0] - xc[0] + yc += xy_part[-1, 1] - yc[0] + xy_parts.append(numpy.stack((xc, yc), axis=1)) + + endpoint_xy = xy_parts[-1][-1, :] + second_curve = xy_part[::-1, ::-1] + endpoint_xy - xy_part[-1, ::-1] + + xy_parts.append(second_curve) + xy = numpy.concatenate(xy_parts) + + # Remove any 2x-duplicate points + xy = xy[(numpy.roll(xy, 1, axis=0) != xy).any(axis=1)] + + return xy From cae6de69c162eccc93693632210fbbed18fbecfd Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 25 Feb 2025 21:15:32 -0800 Subject: [PATCH 013/100] [path] Fix calls to numpy.linalg.solve for numpy 2 --- masque/shapes/path.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index aaac0d2..a53b5c7 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -307,8 +307,8 @@ class Path(Shape): 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] + rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0] + rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0] intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1] From c3534beb3ff60799455fb9f57537aebc6f66eb6b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 25 Feb 2025 21:23:41 -0800 Subject: [PATCH 014/100] [utils.curves.bezier] be more explicit about broadcast axes --- masque/utils/curves.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index b17b015..276c0ed 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -32,13 +32,13 @@ def bezier( umul[~t_half0] = 1 udiv[t_half0] = 1 - hh = numpy.ones((tt.size, 1)) - qq = nodes[None, 0] * hh + hh = numpy.ones((tt.size,)) + qq = nodes[None, 0] * hh[:, None] for kk in range(1, nn): hh *= umul * (nn + 1 - kk) * weights[kk] hh /= kk * udiv * weights[kk - 1] + hh - qq *= 1.0 - hh - qq += hh * nodes[None, kk] + qq *= 1.0 - hh[:, None] + qq += hh[:, None] * nodes[None, kk] return qq From 0e34242ba5946a9f478c5b6dd2b37017177f75d7 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 3 Mar 2025 00:51:45 -0800 Subject: [PATCH 015/100] misc type hint fixes --- masque/builder/utils.py | 2 +- masque/shapes/arc.py | 9 ++++++--- masque/shapes/path.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/masque/builder/utils.py b/masque/builder/utils.py index c466c71..6e3334d 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -21,7 +21,7 @@ def ell( *, spacing: float | ArrayLike | None = None, set_rotation: float | None = None, - ) -> dict[str, float]: + ) -> dict[str, numpy.float64]: """ Calculate extension for each port in order to build a 90-degree bend with the provided channel spacing: diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 67d932d..4b69dbb 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -233,7 +233,7 @@ class Arc(Shape): r0, r1 = self.radii # Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation) - a_ranges = cast(tuple[tuple[float, float], tuple[float, float]], self._angles_to_parameters()) + a_ranges = cast(_array2x2_t, self._angles_to_parameters()) # Approximate perimeter via numerical integration @@ -286,6 +286,7 @@ class Arc(Shape): thetas = thetas[::-1] return thetas + thetas_inner: NDArray[numpy.float64] if wh in (r0, r1): thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin else: @@ -320,7 +321,7 @@ class Arc(Shape): If the extrema are innaccessible due to arc constraints, check the arc endpoints instead. """ - a_ranges = self._angles_to_parameters() + a_ranges = cast(_array2x2_t, self._angles_to_parameters()) mins = [] maxs = [] @@ -431,7 +432,7 @@ class Arc(Shape): [[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse. ``` """ - a_ranges = self._angles_to_parameters() + a_ranges = cast(_array2x2_t, self._angles_to_parameters()) mins = [] maxs = [] @@ -479,3 +480,5 @@ class Arc(Shape): angles = f' a°{numpy.rad2deg(self.angles)}' rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' return f'' + +_array2x2_t = tuple[tuple[float, float], tuple[float, float]] diff --git a/masque/shapes/path.py b/masque/shapes/path.py index a53b5c7..717e59f 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -271,7 +271,7 @@ class Path(Shape): # TODO: Path.travel() needs testing direction = numpy.array([1, 0]) - verts = [numpy.zeros(2)] + verts: list[NDArray[numpy.float64]] = [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) From c74573e7ddc71b4b5dd1aed66477db74da1f90d3 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 3 Mar 2025 00:52:24 -0800 Subject: [PATCH 016/100] [Arc] improve some variable names --- masque/shapes/arc.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 4b69dbb..b3a9b7d 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -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]]: """ Get `n_pts` arclengths """ - t, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points - r0sin = (r0 + dr) * numpy.sin(t) - r1cos = (r1 + dr) * numpy.cos(t) + tt, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points + r0sin = (r0 + dr) * numpy.sin(tt) + r1cos = (r1 + dr) * numpy.cos(tt) arc_dl = numpy.sqrt(r0sin * r0sin + r1cos * r1cos) - #arc_lengths = numpy.diff(t) * (arc_dl[1:] + arc_dl[:-1]) / 2 + #arc_lengths = numpy.diff(tt) * (arc_dl[1:] + arc_dl[:-1]) / 2 arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2 - return arc_lengths, t + return arc_lengths, tt wh = self.width / 2.0 if num_vertices is not None: @@ -325,7 +325,7 @@ class Arc(Shape): mins = [] maxs = [] - for a, sgn in zip(a_ranges, (-1, +1), strict=True): + for aa, sgn in zip(a_ranges, (-1, +1), strict=True): wh = sgn * self.width / 2 rx = self.radius_x + wh ry = self.radius_y + wh @@ -336,13 +336,13 @@ class Arc(Shape): maxs.append([0, 0]) continue - a0, a1 = a + a0, a1 = aa a0_offset = a0 - (a0 % (2 * pi)) sin_r = numpy.sin(self.rotation) cos_r = numpy.cos(self.rotation) - sin_a = numpy.sin(a) - cos_a = numpy.cos(a) + sin_a = numpy.sin(aa) + cos_a = numpy.cos(aa) # Cutoff angles xpt = (-self.rotation) % (2 * pi) + a0_offset @@ -436,15 +436,15 @@ class Arc(Shape): mins = [] maxs = [] - for a, sgn in zip(a_ranges, (-1, +1), strict=True): + for aa, sgn in zip(a_ranges, (-1, +1), strict=True): wh = sgn * self.width / 2 rx = self.radius_x + wh ry = self.radius_y + wh sin_r = numpy.sin(self.rotation) cos_r = numpy.cos(self.rotation) - sin_a = numpy.sin(a) - cos_a = numpy.cos(a) + sin_a = numpy.sin(aa) + cos_a = numpy.cos(aa) # arc endpoints xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a) @@ -462,19 +462,19 @@ class Arc(Shape): "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 = [] + aa = [] for sgn in (-1, +1): wh = sgn * self.width / 2.0 rx = self.radius_x + wh ry = self.radius_y + wh - a0, a1 = (numpy.arctan2(rx * numpy.sin(a), ry * numpy.cos(a)) for a in self.angles) + a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles) sign = numpy.sign(self.angles[1] - self.angles[0]) if sign != numpy.sign(a1 - a0): a1 += sign * 2 * pi - a.append((a0, a1)) - return numpy.array(a, dtype=float) + aa.append((a0, a1)) + return numpy.array(aa, dtype=float) def __repr__(self) -> str: angles = f' a°{numpy.rad2deg(self.angles)}' From b27b1d93d85fb6047375d718cfa6b518e72fa378 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 3 Mar 2025 00:52:51 -0800 Subject: [PATCH 017/100] [utils.curves.bezier] improve handling of non-ndarray inputs --- masque/utils/curves.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 276c0ed..871b58e 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -22,9 +22,10 @@ def bezier( Returns: `[[x0, y0], [x1, y1], ...]` corresponding to `[tt0, tt1, ...]` """ + nodes = numpy.asarray(nodes) + tt = numpy.asarray(tt) nn = nodes.shape[0] - if weights is None: - weights = numpy.ones(nn) + weights = numpy.ones(nn) if weights is None else numpy.asarray(weights) t_half0 = tt <= 0.5 umul = tt / (1 - tt) From 858ef4a1146a2a2c72fd165f6f36cf40f2d31bba Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 3 Mar 2025 00:53:34 -0800 Subject: [PATCH 018/100] [utils.curves.euler_bend] add num_point arg and improve naming --- masque/utils/curves.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 871b58e..41f82ad 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -44,7 +44,10 @@ def bezier( -def euler_bend(switchover_angle: float) -> NDArray[numpy.float64]: +def euler_bend( + switchover_angle: float, + num_points: int = 200, + ) -> NDArray[numpy.float64]: """ Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral). @@ -52,42 +55,44 @@ def euler_bend(switchover_angle: float) -> NDArray[numpy.float64]: 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 `>= pi / 4`, no circular arc will be added. + num_points: Number of points in the curve Returns: `[[x0, y0], ...]` for the curve """ - # Switchover angle - # AKA: Clothoid bend, Cornu spiral - theta_max = numpy.sqrt(2 * switchover_angle) + ll_max = numpy.sqrt(2 * switchover_angle) # total length of (one) spiral portion + ll_tot = 2 * ll_max + (pi / 2 - 2 * switchover_angle) + num_points_spiral = numpy.floor(ll_max / ll_tot * num_points).astype(int) + num_points_arc = num_points - 2 * num_points_spiral - def gen_curve(theta_max: float): + def gen_spiral(ll_max: float): xx = [] yy = [] - for theta in numpy.linspace(0, theta_max, 100): - qq = numpy.linspace(0, theta, 1000) + for ll in numpy.linspace(0, ll_max, num_points_spiral): + qq = numpy.linspace(0, ll, 1000) # integrate to current arclength xx.append(numpy.trapz( numpy.cos(qq * qq / 2), qq)) yy.append(numpy.trapz(-numpy.sin(qq * qq / 2), qq)) xy_part = numpy.stack((xx, yy), axis=1) return xy_part - xy_part = gen_curve(theta_max) - xy_parts = [xy_part] + xy_spiral = gen_spiral(ll_max) + xy_parts = [xy_spiral] if switchover_angle < pi / 4: # Build a circular segment to join the two euler portions - rmin = 1.0 / theta_max + rmin = 1.0 / ll_max half_angle = pi / 4 - switchover_angle - qq = numpy.linspace(half_angle * 2, 0, 10) + switchover_angle + qq = numpy.linspace(half_angle * 2, 0, num_points_arc + 1) + switchover_angle xc = rmin * numpy.cos(qq) - yc = rmin * numpy.sin(qq) + xy_part[-1, 1] - xc += xy_part[-1, 0] - xc[0] - yc += xy_part[-1, 1] - yc[0] - xy_parts.append(numpy.stack((xc, yc), axis=1)) + yc = rmin * numpy.sin(qq) + xy_spiral[-1, 1] + xc += xy_spiral[-1, 0] - xc[0] + yc += xy_spiral[-1, 1] - yc[0] + xy_parts.append(numpy.stack((xc[1:], yc[1:]), axis=1)) endpoint_xy = xy_parts[-1][-1, :] - second_curve = xy_part[::-1, ::-1] + endpoint_xy - xy_part[-1, ::-1] + second_spiral = xy_spiral[::-1, ::-1] + endpoint_xy - xy_spiral[-1, ::-1] - xy_parts.append(second_curve) + xy_parts.append(second_spiral) xy = numpy.concatenate(xy_parts) # Remove any 2x-duplicate points From 6567394fbf1209eb1a96a2f62c53c87e7374248c Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 4 Mar 2025 23:00:52 -0800 Subject: [PATCH 019/100] [utils.curves.bezier] Fix and clarify bezier() code - Accuracy fix (incorrect +1 term) - Explicitly index last dim of `nodes` - Suppress warnings about div by zero - simplify `umul` and `udiv` calculation --- masque/utils/curves.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 41f82ad..027d205 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -27,19 +27,17 @@ def bezier( nn = nodes.shape[0] weights = numpy.ones(nn) if weights is None else numpy.asarray(weights) - t_half0 = tt <= 0.5 - umul = tt / (1 - tt) - udiv = 1 / umul - umul[~t_half0] = 1 - udiv[t_half0] = 1 + with numpy.errstate(divide='ignore'): + umul = (tt / (1 - tt)).clip(max=1) + udiv = ((1 - tt) / tt).clip(max=1) hh = numpy.ones((tt.size,)) - qq = nodes[None, 0] * hh[:, None] + qq = nodes[None, 0, :] * hh[:, None] for kk in range(1, nn): - hh *= umul * (nn + 1 - kk) * weights[kk] + hh *= umul * (nn - kk) * weights[kk] hh /= kk * udiv * weights[kk - 1] + hh qq *= 1.0 - hh[:, None] - qq += hh[:, None] * nodes[None, kk] + qq += hh[:, None] * nodes[None, kk, :] return qq From 6c76e1f5cf4b564c78097a81447cf7eac7595ef8 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Mar 2025 23:04:51 -0700 Subject: [PATCH 020/100] Add R90 and R180 constants for rotation shorthand --- masque/__init__.py | 2 ++ masque/utils/__init__.py | 2 ++ masque/utils/transform.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/masque/__init__.py b/masque/__init__.py index 93eabdc..dbd0670 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -83,6 +83,8 @@ from .builder import ( from .utils import ( ports2data as ports2data, oneshot as oneshot, + R90 as R90, + R180 as R180, ) diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py index 11391a5..f33142f 100644 --- a/masque/utils/__init__.py +++ b/masque/utils/__init__.py @@ -25,6 +25,8 @@ from .transform import ( normalize_mirror as normalize_mirror, rotate_offsets_around as rotate_offsets_around, apply_transforms as apply_transforms, + R90 as R90, + R180 as R180, ) from .comparisons import ( annotation2key as annotation2key, diff --git a/masque/utils/transform.py b/masque/utils/transform.py index e2ea9aa..dfb6492 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -9,6 +9,11 @@ from numpy.typing import NDArray, ArrayLike from numpy import pi +# Constants for shorthand rotations +R90 = pi / 2 +R180 = pi + + @lru_cache def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: """ From 9b2f8599e6506632692c0eb4968cfc5b82a5682d Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Mar 2025 23:09:45 -0700 Subject: [PATCH 021/100] [utils.curves] use numpy.trapezoid for 2.0 compatibility fall back to trapz if import fails --- masque/utils/curves.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 027d205..b664835 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -2,6 +2,11 @@ import numpy from numpy.typing import ArrayLike, NDArray from numpy import pi +try: + from numpy import trapezoid +except ImportError: + from numpy import trapz as trapezoid + def bezier( nodes: ArrayLike, @@ -68,8 +73,8 @@ def euler_bend( yy = [] for ll in numpy.linspace(0, ll_max, num_points_spiral): qq = numpy.linspace(0, ll, 1000) # integrate to current arclength - xx.append(numpy.trapz( numpy.cos(qq * qq / 2), qq)) - yy.append(numpy.trapz(-numpy.sin(qq * qq / 2), qq)) + xx.append(trapezoid( numpy.cos(qq * qq / 2), qq)) + yy.append(trapezoid(-numpy.sin(qq * qq / 2), qq)) xy_part = numpy.stack((xx, yy), axis=1) return xy_part From d11c910dfdc101bddb2369507db830ca02e8b9f4 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Mar 2025 23:10:49 -0700 Subject: [PATCH 022/100] [utils.curves] improve type annotations --- masque/utils/curves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index b664835..1e41c35 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -68,7 +68,7 @@ def euler_bend( num_points_spiral = numpy.floor(ll_max / ll_tot * num_points).astype(int) num_points_arc = num_points - 2 * num_points_spiral - def gen_spiral(ll_max: float): + def gen_spiral(ll_max: float) -> NDArray[numpy.float64]: xx = [] yy = [] for ll in numpy.linspace(0, ll_max, num_points_spiral): From 30cfa0da313540a301bc8e415aef1a981b4b46ef Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Mar 2025 23:11:35 -0700 Subject: [PATCH 023/100] Bump version to v3.3 --- masque/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/__init__.py b/masque/__init__.py index dbd0670..86fae91 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -90,5 +90,5 @@ from .utils import ( __author__ = 'Jan Petykiewicz' -__version__ = '3.2' +__version__ = '3.3' version = __version__ # legacy From 5a4b9609bd3043b600119074d34f738be00eb8c0 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Mar 2025 23:14:30 -0700 Subject: [PATCH 024/100] close code block --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 68c0686..2ed7489 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ my_pattern.place(abstract, ...) # or my_pattern.place(library << make_tree(...), ...) +``` ### Quickly add geometry, labels, or refs: From 1eac3baf6a83962bf70d69bd8024071fa6d8447a Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 15 Apr 2025 17:21:49 -0700 Subject: [PATCH 025/100] [pattern] add arg to , useful for whole-library scaling --- masque/pattern.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index 0ae230d..afd73fc 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -648,21 +648,25 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): cast(Scalable, entry).scale_by(c) return self - def scale_by(self, c: float) -> Self: + def scale_by(self, c: float, scale_refs: bool = True) -> Self: """ Scale this Pattern by the given value - (all shapes and refs and their offsets are scaled, - as are all label and port offsets) + All shapes and (optionally) refs and their offsets are scaled, + as are all label and port offsets. Args: 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: self """ for entry in chain_elements(self.shapes, self.refs): cast(Positionable, entry).offset *= c - cast(Scalable, entry).scale_by(c) + if scale_refs or not isinstance(entry, Ref): + cast(Scalable, entry).scale_by(c) rep = cast(Repeatable, entry).repetition if rep: From 284c7e4fd0f3671d538e38e91fcea1d4be5b087b Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 15 Apr 2025 17:25:56 -0700 Subject: [PATCH 026/100] Use quoted first arg for cast() ruff rule TC006 --- masque/builder/utils.py | 8 ++++---- masque/file/dxf.py | 2 +- masque/file/gdsii.py | 2 +- masque/file/oasis.py | 18 +++++++++--------- masque/library.py | 12 ++++++------ masque/pattern.py | 28 ++++++++++++++-------------- masque/repetition.py | 4 ++-- masque/shapes/arc.py | 8 ++++---- masque/shapes/circle.py | 2 +- masque/shapes/ellipse.py | 2 +- masque/shapes/path.py | 4 ++-- masque/shapes/polygon.py | 10 ++++++---- masque/shapes/text.py | 2 +- masque/traits/rotatable.py | 11 ++++++----- 14 files changed, 58 insertions(+), 55 deletions(-) diff --git a/masque/builder/utils.py b/masque/builder/utils.py index 6e3334d..3109f46 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -169,11 +169,11 @@ def ell( 'emax', 'max_extension', 'min_past_furthest',): if numpy.size(bound) == 2: - bound = cast(Sequence[float], bound) + bound = cast('Sequence[float]', bound) rot_bound = (rot_matrix @ ((bound[0], 0), (0, bound[1])))[0, :] else: - bound = cast(float, bound) + bound = cast('float', bound) rot_bound = numpy.array(bound) if rot_bound < 0: @@ -185,10 +185,10 @@ def ell( offsets += rot_bound.min() - offsets.max() else: if numpy.size(bound) == 2: - bound = cast(Sequence[float], bound) + bound = cast('Sequence[float]', bound) rot_bound = (rot_matrix @ bound)[0] else: - bound = cast(float, bound) + bound = cast('float', bound) neg = (direction + pi / 4) % (2 * pi) > pi rot_bound = -bound if neg else bound diff --git a/masque/file/dxf.py b/masque/file/dxf.py index dc3d6f3..1cf5e88 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -132,7 +132,7 @@ def writefile( with tmpfile(path) as base_stream: streams: tuple[Any, ...] = (base_stream,) 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 else: gz_stream = base_stream diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index 71ea94f..c323ecf 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -145,7 +145,7 @@ def writefile( with tmpfile(path) as base_stream: streams: tuple[Any, ...] = (base_stream,) 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 else: stream = base_stream diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 0e2305a..e64bb4c 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -190,7 +190,7 @@ def writefile( with tmpfile(path) as base_stream: streams: tuple[Any, ...] = (base_stream,) 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,) else: stream = base_stream @@ -551,7 +551,7 @@ def _shapes_to_elements( circle = fatrec.Circle( layer=layer, datatype=datatype, - radius=cast(int, radius), + radius=cast('int', radius), x=offset[0], y=offset[1], properties=properties, @@ -568,8 +568,8 @@ def _shapes_to_elements( path = fatrec.Path( layer=layer, datatype=datatype, - point_list=cast(Sequence[Sequence[int]], deltas), - half_width=cast(int, half_width), + point_list=cast('Sequence[Sequence[int]]', deltas), + half_width=cast('int', half_width), x=xy[0], y=xy[1], extension_start=extension_start, # TODO implement multiple cap types? @@ -587,7 +587,7 @@ def _shapes_to_elements( datatype=datatype, x=xy[0], y=xy[1], - point_list=cast(list[list[int]], points), + point_list=cast('list[list[int]]', points), properties=properties, repetition=repetition, )) @@ -651,10 +651,10 @@ def repetition_masq2fata( a_count = rint_cast(rep.a_count) b_count = rint_cast(rep.b_count) if rep.b_count is not None else None frep = fatamorgana.GridRepetition( - a_vector=cast(list[int], a_vector), - b_vector=cast(list[int] | None, b_vector), - a_count=cast(int, a_count), - b_count=cast(int | None, b_count), + a_vector=cast('list[int]', a_vector), + b_vector=cast('list[int] | None', b_vector), + a_count=cast('int', a_count), + b_count=cast('int | None', b_count), ) offset = (0, 0) elif isinstance(rep, Arbitrary): diff --git a/masque/library.py b/masque/library.py index 90202dc..e41d27f 100644 --- a/masque/library.py +++ b/masque/library.py @@ -211,7 +211,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): if isinstance(tops, str): tops = (tops,) - keep = cast(set[str], self.referenced_patterns(tops) - {None}) + keep = cast('set[str]', self.referenced_patterns(tops) - {None}) keep |= set(tops) 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) assert None not in flattened.values() - return cast(dict[str, 'Pattern'], flattened) + return cast('dict[str, Pattern]', flattened) def get_name( self, @@ -504,7 +504,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): raise LibraryError('visit_* functions returned a new `Pattern` object' ' but no top-level name was provided in `hierarchy`') - cast(ILibrary, self)[name] = pattern + cast('ILibrary', self)[name] = pattern return self @@ -542,7 +542,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): Return: 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( self, @@ -827,7 +827,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): for old_name in temp: new_name = rename_map.get(old_name, old_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 @@ -1047,7 +1047,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): if isinstance(tops, str): tops = (tops,) - keep = cast(set[str], self.referenced_patterns(tops) - {None}) + keep = cast('set[str]', self.referenced_patterns(tops) - {None}) keep |= set(tops) new = type(self)() diff --git a/masque/pattern.py b/masque/pattern.py index afd73fc..5bf030a 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -491,7 +491,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): """ pat = self.deepcopy().polygonize().flatten(library=library) polys = [ - cast(Polygon, shape).vertices + cast(Polygon, shape).offset + cast('Polygon', shape).vertices + cast('Polygon', shape).offset for shape in chain_elements(pat.shapes) ] return polys @@ -533,7 +533,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): n_elems = sum(1 for _ in chain_elements(self.shapes, self.labels)) ebounds = numpy.full((n_elems, 2, 2), nan) 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: ebounds[ee] = maybe_ebounds mask = ~numpy.isnan(ebounds[:, 0, 0]) @@ -631,7 +631,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ 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 def scale_elements(self, c: float) -> Self: @@ -645,7 +645,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ for entry in chain_elements(self.shapes, self.refs): - cast(Scalable, entry).scale_by(c) + cast('Scalable', entry).scale_by(c) return self def scale_by(self, c: float, scale_refs: bool = True) -> Self: @@ -664,18 +664,18 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ 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: rep.scale_by(c) 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: rep.scale_by(c) @@ -712,8 +712,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - old_offset = cast(Positionable, entry).offset - cast(Positionable, entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) + old_offset = cast('Positionable', entry).offset + cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) return self def rotate_elements(self, rotation: float) -> Self: @@ -727,7 +727,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ 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 def mirror_element_centers(self, across_axis: int = 0) -> Self: @@ -742,7 +742,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ 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 def mirror_elements(self, across_axis: int = 0) -> Self: @@ -758,7 +758,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ 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 def mirror(self, across_axis: int = 0) -> Self: diff --git a/masque/repetition.py b/masque/repetition.py index a365909..e6d00fc 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -294,7 +294,7 @@ class Grid(Repetition): def __le__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) - other = cast(Grid, other) + other = cast('Grid', other) if self.a_count != other.a_count: return self.a_count < other.a_count if self.b_count != other.b_count: @@ -357,7 +357,7 @@ class Arbitrary(Repetition): def __le__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) - other = cast(Arbitrary, other) + other = cast('Arbitrary', other) if self.displacements.size != other.displacements.size: return self.displacements.size < other.displacements.size diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index b3a9b7d..f3f4e1e 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -206,7 +206,7 @@ class Arc(Shape): if repr(type(self)) != repr(type(other)): return repr(type(self)) < repr(type(other)) return id(type(self)) < id(type(other)) - other = cast(Arc, other) + other = cast('Arc', other) if self.width != other.width: return self.width < other.width if not numpy.array_equal(self.radii, other.radii): @@ -233,7 +233,7 @@ class Arc(Shape): r0, r1 = self.radii # 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('_array2x2_t', self._angles_to_parameters()) # Approximate perimeter via numerical integration @@ -321,7 +321,7 @@ class Arc(Shape): 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 = cast('_array2x2_t', self._angles_to_parameters()) mins = [] maxs = [] @@ -432,7 +432,7 @@ class Arc(Shape): [[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse. ``` """ - a_ranges = cast(_array2x2_t, self._angles_to_parameters()) + a_ranges = cast('_array2x2_t', self._angles_to_parameters()) mins = [] maxs = [] diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 5f8ebe0..2d403b4 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -84,7 +84,7 @@ class Circle(Shape): if repr(type(self)) != repr(type(other)): return repr(type(self)) < repr(type(other)) return id(type(self)) < id(type(other)) - other = cast(Circle, other) + other = cast('Circle', other) if not self.radius == other.radius: return self.radius < other.radius if not numpy.array_equal(self.offset, other.offset): diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 9c671d6..0d6a6c5 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -134,7 +134,7 @@ class Ellipse(Shape): if repr(type(self)) != repr(type(other)): return repr(type(self)) < repr(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): return tuple(self.radii) < tuple(other.radii) if not numpy.array_equal(self.offset, other.offset): diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 717e59f..93e85ea 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -223,7 +223,7 @@ class Path(Shape): if repr(type(self)) != repr(type(other)): return repr(type(self)) < repr(type(other)) return id(type(self)) < id(type(other)) - other = cast(Path, other) + other = cast('Path', other) if self.width != other.width: return self.width < other.width if self.cap != other.cap: @@ -405,7 +405,7 @@ class Path(Shape): x_min = rotated_vertices[:, 0].argmin() if not is_scalar(x_min): 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) width0 = self.width / norm_value diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index cbcbe63..10fd522 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,5 +1,4 @@ -from typing import Any, cast -from collections.abc import Sequence +from typing import Any, cast, TYPE_CHECKING import copy import functools @@ -13,6 +12,9 @@ from ..repetition import Repetition from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t +if TYPE_CHECKING: + from collections.abc import Sequence + @functools.total_ordering class Polygon(Shape): @@ -129,7 +131,7 @@ class Polygon(Shape): if repr(type(self)) != repr(type(other)): return repr(type(self)) < repr(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): min_len = min(self.vertices.shape[0], other.vertices.shape[0]) eq_mask = self.vertices[:min_len] != other.vertices[:min_len] @@ -395,7 +397,7 @@ class Polygon(Shape): x_min = rotated_vertices[:, 0].argmin() if not is_scalar(x_min): 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) # TODO: normalize mirroring? diff --git a/masque/shapes/text.py b/masque/shapes/text.py index e936796..69318ac 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -115,7 +115,7 @@ class Text(RotatableImpl, Shape): if repr(type(self)) != repr(type(other)): return repr(type(self)) < repr(type(other)) return id(type(self)) < id(type(other)) - other = cast(Text, other) + other = cast('Text', other) if not self.height == other.height: return self.height < other.height if not self.string == other.string: diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index f873ce4..04816f1 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -1,14 +1,15 @@ -from typing import Self, cast, Any +from typing import Self, cast, Any, TYPE_CHECKING from abc import ABCMeta, abstractmethod import numpy from numpy import pi from numpy.typing import ArrayLike -from .positionable import Positionable from ..error import MasqueError 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 @@ -113,9 +114,9 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta): def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: pivot = numpy.asarray(pivot, dtype=float) - cast(Positionable, self).translate(-pivot) - cast(Rotatable, self).rotate(rotation) + cast('Positionable', self).translate(-pivot) + cast('Rotatable', self).rotate(rotation) 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 From 560c165f2ed5c151bce74f6f55aa0e53843751ec Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 15 Apr 2025 17:26:33 -0700 Subject: [PATCH 027/100] remove deprecated rule from ignore list --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ab4661e..9587a04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,6 @@ lint.ignore = [ "ANN002", # *args "ANN003", # **kwargs "ANN401", # Any - "ANN101", # self: Self "SIM108", # single-line if / else assignment "RET504", # x=y+z; return x "PIE790", # unnecessary pass From c1bfee1dddb55b30d247306759a348cad9783c1f Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 15 Apr 2025 17:34:05 -0700 Subject: [PATCH 028/100] [library] minor stylistic cleanup --- masque/library.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/masque/library.py b/masque/library.py index e41d27f..b52da74 100644 --- a/masque/library.py +++ b/masque/library.py @@ -944,8 +944,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): shape_table: dict[tuple, list] = defaultdict(list) for layer, sseq in pat.shapes.items(): - for i, shape in enumerate(sseq): - if any(isinstance(shape, t) for t in exclude_types): + for ii, shape in enumerate(sseq): + if any(isinstance(shape, tt) for tt in exclude_types): continue 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: continue - shape_table[label].append((i, values)) + shape_table[label].append((ii, values)) # 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 # we should delete the `pat.shapes` entries for which we made `Ref`s. shapes_to_remove = [] - for label in shape_table: + for label, shape_entries in shape_table.items(): layer = label[-1] target = label2name(label) - for ii, values in shape_table[label]: + for ii, values in shape_entries: offset, scale, rotation, mirror_x = values pat.ref(target=target, offset=offset, scale=scale, rotation=rotation, mirrored=(mirror_x, False)) From 35e28acb89e4d0e460cd1d31be1407dbd80d84b5 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 20 Apr 2025 23:24:01 -0700 Subject: [PATCH 029/100] [polygon] Only call rotate if necessary --- masque/shapes/polygon.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 10fd522..2976271 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -107,7 +107,8 @@ class Polygon(Shape): self.offset = offset self.repetition = repetition self.annotations = annotations if annotations is not None else {} - self.rotate(rotation) + if rotation: + self.rotate(rotation) def __deepcopy__(self, memo: dict | None = None) -> 'Polygon': memo = {} if memo is None else memo From 385a37e0a2e2208465608354b8df822f41a83881 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 28 May 2025 00:39:25 -0700 Subject: [PATCH 030/100] [Pather] fix path_into for some 2-bend cases --- masque/builder/pather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 87f08a3..d0deacb 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -541,7 +541,7 @@ class Pather(Builder): tree2 = tool.path(ccw, length, in_ptype=in_ptype, port_names=('A', 'B'), out_ptype=out_ptype, **kwargs) top2 = tree2.top_pattern() jog = rotation_matrix_2d(top2['A'].rotation) @ (top2['B'].offset - top2['A'].offset) - return jog[1] + return jog[1] * [-1, 1][int(bool(ccw))] dst_extra_args = {'out_ptype': out_ptype} if plug_destination: From ad00ade097a83d6a407a39c39cd87d8b9502d6b6 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 28 May 2025 00:39:44 -0700 Subject: [PATCH 031/100] [Tool] correctly set input ptype --- masque/builder/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 0e9ec33..ca27c86 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -290,7 +290,7 @@ class BasicTool(Tool, metaclass=ABCMeta): gen_straight, sport_in, sport_out = self.straight tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names) + pat.add_port_pair(names=port_names, ptype=in_ptype) if data.in_transition: ipat, iport_theirs, _iport_ours = data.in_transition pat.plug(ipat, {port_names[1]: iport_theirs}) From 5e0eef7c59724ace704b7fdb51399818268475ec Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 28 May 2025 00:40:24 -0700 Subject: [PATCH 032/100] [dxf] match ezdxf syntax changes --- masque/file/dxf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 1cf5e88..078245b 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -214,7 +214,7 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if isinstance(element, LWPolyline): points = numpy.asarray(element.get_points()) elif isinstance(element, Polyline): - points = numpy.asarray(element.points())[:, :2] + points = numpy.asarray([pp.xyz for pp in element.points()]) attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) From 42a7df30557eeadb8180ffd525279490df411ce6 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 17 Aug 2025 21:02:01 +0200 Subject: [PATCH 033/100] add pytest config --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9587a04..062098d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,3 +89,7 @@ lint.ignore = [ "TRY003", # Long exception message ] +[tool.pytest.ini_options] +addopts = "-rsXx" +testpaths = ["masque"] + From c071b33732d2b52a2604a96af086e62eeb8becdf Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 17 Aug 2025 21:05:15 +0200 Subject: [PATCH 034/100] Bump version to v3.4 --- masque/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/__init__.py b/masque/__init__.py index 86fae91..3515fa5 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -90,5 +90,5 @@ from .utils import ( __author__ = 'Jan Petykiewicz' -__version__ = '3.3' +__version__ = '3.4' version = __version__ # legacy From 325a8b95902212365820438c316505054f2d9a84 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 14 Sep 2025 12:36:17 -0700 Subject: [PATCH 035/100] [circle] fix mirror ignoring axis --- masque/shapes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 2d403b4..291ca05 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -123,7 +123,7 @@ class Circle(Shape): return self def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused) - self.offset *= -1 + self.offset[axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Circle': From 4d74eea2538bb30ce23bdb91c3aac660544e6fd0 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 12 Oct 2025 23:34:39 -0700 Subject: [PATCH 036/100] [file.gdsii] attributes may have key=126 --- masque/file/gdsii.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index c323ecf..bd2c18a 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -411,8 +411,8 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) - i = int(key) except ValueError as err: raise PatternError(f'Annotation key {key} is not convertable to an integer') from err - if not (0 < i < 126): - raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])') + if not (0 < i <= 126): + raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,126])') val_strings = ' '.join(str(val) for val in vals) b = val_strings.encode() From e00d82bbc40988a35f0b9089157cae6158140625 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 21 Apr 2025 20:26:34 -0700 Subject: [PATCH 037/100] allow annotations to be None breaking change, but properties are seldom used by anyone afaik --- masque/file/gdsii.py | 11 +++++++++-- masque/file/oasis.py | 2 ++ masque/pattern.py | 15 +++++++++------ masque/utils/types.py | 2 +- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index bd2c18a..6972cfa 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -21,6 +21,7 @@ Notes: """ from typing import IO, cast, Any from collections.abc import Iterable, Mapping, Callable +from types import MappingProxyType import io import mmap import logging @@ -52,6 +53,8 @@ path_cap_map = { 4: Path.Cap.SquareCustom, } +RO_EMPTY_DICT: Mapping[int, bytes] = MappingProxyType({}) + def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]: return numpy.rint(val).astype(numpy.int32) @@ -399,11 +402,15 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R return grefs -def _properties_to_annotations(properties: dict[int, bytes]) -> annotations_t: +def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t: + if not properties: + return None return {str(k): [v.decode()] for k, v in properties.items()} -def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> dict[int, bytes]: +def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]: + if annotations is None: + return RO_EMPTY_DICT cum_len = 0 props = {} for key, vals in annotations.items(): diff --git a/masque/file/oasis.py b/masque/file/oasis.py index e64bb4c..642ad44 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -671,6 +671,8 @@ def repetition_masq2fata( def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Property]: #TODO determine is_standard based on key? + if annotations is None: + return [] properties = [] for key, values in annotations.items(): vals = [AString(v) if isinstance(v, str) else v diff --git a/masque/pattern.py b/masque/pattern.py index 5bf030a..f77c64f 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -332,7 +332,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): )) self.ports = dict(sorted(self.ports.items())) - self.annotations = dict(sorted(self.annotations.items())) + self.annotations = dict(sorted(self.annotations.items())) if self.annotations is not None else None return self @@ -354,10 +354,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for layer, lseq in other_pattern.labels.items(): self.labels[layer].extend(lseq) - annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys()) - if annotation_conflicts: - raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') - self.annotations.update(other_pattern.annotations) + if other_pattern.annotations is not None: + if self.annotations is None: + self.annotations = {} + annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys()) + if annotation_conflicts: + raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') + self.annotations.update(other_pattern.annotations) port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys()) if port_conflicts: @@ -415,7 +418,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): elif default_keep: pat.refs = copy.copy(self.refs) - if annotations is not None: + if annotations is not None and self.annotations is not None: pat.annotations = {k: v for k, v in self.annotations.items() if annotations(k, v)} elif default_keep: pat.annotations = copy.copy(self.annotations) diff --git a/masque/utils/types.py b/masque/utils/types.py index 4060ac4..c06b7b4 100644 --- a/masque/utils/types.py +++ b/masque/utils/types.py @@ -5,7 +5,7 @@ from typing import Protocol layer_t = int | tuple[int, int] | str -annotations_t = dict[str, list[int | float | str]] +annotations_t = dict[str, list[int | float | str]] | None class SupportsBool(Protocol): From aee0d5b619f14161d672c479cd65c09a94c0de87 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 22 Apr 2025 20:19:59 -0700 Subject: [PATCH 038/100] [utils.curves] ignore re-import of trapeziod --- masque/utils/curves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 1e41c35..8b3fcc4 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -5,7 +5,7 @@ from numpy import pi try: from numpy import trapezoid except ImportError: - from numpy import trapz as trapezoid + from numpy import trapz as trapezoid # type:ignore def bezier( From fbe804750b7d633b47b7b61cf4ad83a2e3e3e5de Mon Sep 17 00:00:00 2001 From: jan Date: Thu, 17 Oct 2024 18:04:25 -0700 Subject: [PATCH 039/100] [PolyCollection] add PolyCollection shape based on ndarrays of vertices and offsets --- masque/shapes/__init__.py | 1 + masque/shapes/poly_collection.py | 207 +++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 masque/shapes/poly_collection.py diff --git a/masque/shapes/__init__.py b/masque/shapes/__init__.py index 8ad46ef..fd66c59 100644 --- a/masque/shapes/__init__.py +++ b/masque/shapes/__init__.py @@ -10,6 +10,7 @@ from .shape import ( ) from .polygon import Polygon as Polygon +from .poly_collection import PolyCollection as PolyCollection from .circle import Circle as Circle from .ellipse import Ellipse as Ellipse from .arc import Arc as Arc diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py new file mode 100644 index 0000000..bd2c23c --- /dev/null +++ b/masque/shapes/poly_collection.py @@ -0,0 +1,207 @@ +from typing import Any, cast, Self +from collections.abc import Iterator +import copy +import functools +from itertools import chain + +import numpy +from numpy import pi +from numpy.typing import NDArray, ArrayLike + +from . import Shape, normalized_shape_tuple +from .polygon import Polygon +from ..repetition import Repetition +from ..utils import rotation_matrix_2d, annotations_lt, annotations_eq, rep2key, annotations_t + + +@functools.total_ordering +class PolyCollection(Shape): + """ + A collection of polygons, consisting of concatenated vertex arrays (N_m x 2 ndarray) which specify + implicitly-closed boundaries, and an array of offets specifying the first vertex of each + successive polygon. + + A `normalized_form(...)` is available, but is untested and probably fairly slow. + """ + __slots__ = ( + '_vertex_lists', + '_vertex_offsets', + # Inherited + '_offset', '_repetition', '_annotations', + ) + + _vertex_lists: NDArray[numpy.float64] + """ 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """ + + _vertex_offsets: NDArray[numpy.intp] + """ 1D NDArray specifying the starting offset for each polygon """ + + @property + def vertex_lists(self) -> Any: # mypy#3004 NDArray[numpy.float64]: + """ + Vertices of the polygons, ((N+M+...) x 2). Use with `vertex_offsets`. + """ + return self._vertex_lists + + @property + def vertex_offsets(self) -> Any: # mypy#3004 NDArray[numpy.intp]: + """ + Starting offset (in `vertex_lists`) for each polygon + """ + return self._vertex_offsets + + @property + def vertex_slices(self) -> Iterator[slice]: + """ + Iterator which provides slices which index vertex_lists + """ + for ii, ff in zip( + self._vertex_offsets, + chain(self._vertex_offsets, (self._vertex_lists.shape[0],)), + strict=True, + ): + yield slice(ii, ff) + + @property + def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]: + for slc in self.vertex_slices: + yield self._vertex_lists[slc] + + def __init__( + self, + vertex_lists: ArrayLike, + vertex_offsets: ArrayLike, + *, + offset: ArrayLike = (0.0, 0.0), + rotation: float = 0.0, + repetition: Repetition | None = None, + annotations: annotations_t = None, + raw: bool = False, + ) -> None: + if raw: + assert isinstance(vertex_lists, numpy.ndarray) + assert isinstance(vertex_offsets, numpy.ndarray) + assert isinstance(offset, numpy.ndarray) + self._vertex_lists = vertex_lists + self._vertex_offsets = vertex_offsets + self._offset = offset + self._repetition = repetition + self._annotations = annotations + else: + self._vertex_lists = numpy.asarray(vertex_lists, dtype=float) + self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp) + self.offset = offset + self.repetition = repetition + self.annotations = annotations + if rotation: + self.rotate(rotation) + + def __deepcopy__(self, memo: dict | None = None) -> Self: + memo = {} if memo is None else memo + new = copy.copy(self) + new._offset = self._offset.copy() + new._vertex_lists = self._vertex_lists.copy() + new._vertex_offsets = self._vertex_offsets.copy() + new._annotations = copy.deepcopy(self._annotations) + return new + + def __eq__(self, other: Any) -> bool: + return ( + type(self) is type(other) + and numpy.array_equal(self.offset, other.offset) + and numpy.array_equal(self._vertex_lists, other._vertex_lists) + and numpy.array_equal(self._vertex_offsets, other._vertex_offsets) + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) is not type(other): + if repr(type(self)) != repr(type(other)): + return repr(type(self)) < repr(type(other)) + return id(type(self)) < id(type(other)) + + other = cast('PolyCollection', other) + + for vv, oo in zip(self.polygon_vertices, other.polygon_vertices, strict=False): + if not numpy.array_equal(vv, oo): + min_len = min(vv.shape[0], oo.shape[0]) + eq_mask = vv[:min_len] != oo[:min_len] + eq_lt = vv[:min_len] < oo[:min_len] + eq_lt_masked = eq_lt[eq_mask] + if eq_lt_masked.size > 0: + return eq_lt_masked.flat[0] + return vv.shape[0] < oo.shape[0] + if len(self.vertex_lists) != len(other.vertex_lists): + return len(self.vertex_lists) < len(other.vertex_lists) + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + + def to_polygons( + self, + num_vertices: int | None = None, # unused # noqa: ARG002 + max_arclen: float | None = None, # unused # noqa: ARG002 + ) -> list['Polygon']: + return [Polygon( + vertices = vv, + offset = self.offset, + repetition = copy.deepcopy(self.repetition), + annotations = copy.deepcopy(self.annotations), + ) for vv in self.polygon_vertices] + + def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition + return numpy.vstack((self.offset + numpy.min(self._vertex_lists, axis=0), + self.offset + numpy.max(self._vertex_lists, axis=0))) + + def rotate(self, theta: float) -> Self: + if theta != 0: + rot = rotation_matrix_2d(theta) + self._vertex_lists = numpy.einsum('ij,kj->ki', rot, self._vertex_lists) + return self + + def mirror(self, axis: int = 0) -> Self: + self._vertex_lists[:, axis - 1] *= -1 + return self + + def scale_by(self, c: float) -> Self: + self._vertex_lists *= 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 polygons, relative to + # other shapes + meanv = self._vertex_lists.mean(axis=0) + zeroed_vertices = self._vertex_lists - [meanv] + offset = meanv + self.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.einsum('ij,kj->ki', rotation_matrix_2d(-rotation), normed_vertices) + + # TODO consider how to reorder vertices for polycollection + ## 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 = cast('Sequence', x_min)[y_min] + #reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) + + # TODO: normalize mirroring? + + return ((type(self), rotated_vertices.data.tobytes() + self._vertex_offsets.tobytes()), + (offset, scale / norm_value, rotation, False), + lambda: PolyCollection( + vertex_lists=rotated_vertices * norm_value, + vertex_offsets=self._vertex_offsets, + ), + ) + + def __repr__(self) -> str: + centroid = self.offset + self.vertex_lists.mean(axis=0) + return f'' From 314910d3639df2a9f99839220a267561b6e6407c Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 24 Apr 2025 23:19:40 -0700 Subject: [PATCH 040/100] [shapes] Don't create empty dicts for annotations --- masque/shapes/arc.py | 6 +++--- masque/shapes/circle.py | 6 +++--- masque/shapes/ellipse.py | 6 +++--- masque/shapes/path.py | 6 +++--- masque/shapes/polygon.py | 6 +++--- masque/shapes/text.py | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index f3f4e1e..9fe8b31 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -157,7 +157,7 @@ class Arc(Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t | None = None, + annotations: annotations_t = None, raw: bool = False, ) -> None: if raw: @@ -170,7 +170,7 @@ class Arc(Shape): self._offset = offset self._rotation = rotation self._repetition = repetition - self._annotations = annotations if annotations is not None else {} + self._annotations = annotations else: self.radii = radii self.angles = angles @@ -178,7 +178,7 @@ class Arc(Shape): self.offset = offset self.rotation = rotation self.repetition = repetition - self.annotations = annotations if annotations is not None else {} + self.annotations = annotations def __deepcopy__(self, memo: dict | None = None) -> 'Arc': memo = {} if memo is None else memo diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 291ca05..0b71198 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -48,7 +48,7 @@ class Circle(Shape): *, offset: ArrayLike = (0.0, 0.0), repetition: Repetition | None = None, - annotations: annotations_t | None = None, + annotations: annotations_t = None, raw: bool = False, ) -> None: if raw: @@ -56,12 +56,12 @@ class Circle(Shape): self._radius = radius self._offset = offset self._repetition = repetition - self._annotations = annotations if annotations is not None else {} + self._annotations = annotations else: self.radius = radius self.offset = offset self.repetition = repetition - self.annotations = annotations if annotations is not None else {} + self.annotations = annotations def __deepcopy__(self, memo: dict | None = None) -> 'Circle': memo = {} if memo is None else memo diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 0d6a6c5..56ee73f 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -93,7 +93,7 @@ class Ellipse(Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t | None = None, + annotations: annotations_t = None, raw: bool = False, ) -> None: if raw: @@ -103,13 +103,13 @@ class Ellipse(Shape): self._offset = offset self._rotation = rotation self._repetition = repetition - self._annotations = annotations if annotations is not None else {} + self._annotations = annotations else: self.radii = radii self.offset = offset self.rotation = rotation self.repetition = repetition - self.annotations = annotations if annotations is not None else {} + self.annotations = annotations def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 93e85ea..700f02f 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -170,7 +170,7 @@ class Path(Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t | None = None, + annotations: annotations_t = None, raw: bool = False, ) -> None: self._cap_extensions = None # Since .cap setter might access it @@ -182,7 +182,7 @@ class Path(Shape): self._vertices = vertices self._offset = offset self._repetition = repetition - self._annotations = annotations if annotations is not None else {} + self._annotations = annotations self._width = width self._cap = cap self._cap_extensions = cap_extensions @@ -190,7 +190,7 @@ class Path(Shape): self.vertices = vertices self.offset = offset self.repetition = repetition - self.annotations = annotations if annotations is not None else {} + self.annotations = annotations self.width = width self.cap = cap self.cap_extensions = cap_extensions diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 2976271..9c228d4 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -92,7 +92,7 @@ class Polygon(Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, repetition: Repetition | None = None, - annotations: annotations_t | None = None, + annotations: annotations_t = None, raw: bool = False, ) -> None: if raw: @@ -101,12 +101,12 @@ class Polygon(Shape): self._vertices = vertices self._offset = offset self._repetition = repetition - self._annotations = annotations if annotations is not None else {} + self._annotations = annotations else: self.vertices = vertices self.offset = offset self.repetition = repetition - self.annotations = annotations if annotations is not None else {} + self.annotations = annotations if rotation: self.rotate(rotation) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 69318ac..e8b97ed 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -71,7 +71,7 @@ class Text(RotatableImpl, Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, repetition: Repetition | None = None, - annotations: annotations_t | None = None, + annotations: annotations_t = None, raw: bool = False, ) -> None: if raw: @@ -81,14 +81,14 @@ class Text(RotatableImpl, Shape): self._height = height self._rotation = rotation self._repetition = repetition - self._annotations = annotations if annotations is not None else {} + self._annotations = annotations else: self.offset = offset self.string = string self.height = height self.rotation = rotation self.repetition = repetition - self.annotations = annotations if annotations is not None else {} + self.annotations = annotations self.font_path = font_path def __deepcopy__(self, memo: dict | None = None) -> Self: From ba05e40f846e858e6295e972d3dec7c0374d3311 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 25 Apr 2025 01:24:01 -0700 Subject: [PATCH 041/100] [traits.annotatable] Don't break when setting annotations to None --- masque/traits/annotatable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/traits/annotatable.py b/masque/traits/annotatable.py index 1b2ba23..1ae41e1 100644 --- a/masque/traits/annotatable.py +++ b/masque/traits/annotatable.py @@ -45,6 +45,6 @@ class AnnotatableImpl(Annotatable, metaclass=ABCMeta): @annotations.setter def annotations(self, annotations: annotations_t) -> None: - if not isinstance(annotations, dict): - raise MasqueError(f'annotations expected dict, got {type(annotations)}') + if not isinstance(annotations, dict) and annotations is not None: + raise MasqueError(f'annotations expected dict or None, got {type(annotations)}') self._annotations = annotations From 2961ae54710a86f8b2086746bce1b486336215f9 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 26 Oct 2025 18:28:21 -0700 Subject: [PATCH 042/100] [builder.tools] Handle in_ptype=None --- masque/builder/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index ca27c86..a8d13de 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -290,7 +290,7 @@ class BasicTool(Tool, metaclass=ABCMeta): gen_straight, sport_in, sport_out = self.straight tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype=in_ptype) + pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) if data.in_transition: ipat, iport_theirs, _iport_ours = data.in_transition pat.plug(ipat, {port_names[1]: iport_theirs}) From e5f0c8556013a17f0d385c3ccde6c14c777b03fc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 26 Oct 2025 18:43:14 -0700 Subject: [PATCH 043/100] [BasicTool] enable straight to handle trees (not just flat patterns) --- masque/builder/tools.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index a8d13de..6542271 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -251,7 +251,7 @@ class BasicTool(Tool, metaclass=ABCMeta): for generating straight paths, and a table of pre-rendered `transitions` for converting from non-native ptypes. """ - straight: tuple[Callable[[float], Pattern], str, str] + straight: tuple[Callable[[float], Pattern] | Callable[[float], Library], str, str] """ `create_straight(length: float), in_port_name, out_port_name` """ bend: abstract_tuple_t # Assumed to be clockwise @@ -295,7 +295,11 @@ class BasicTool(Tool, metaclass=ABCMeta): ipat, iport_theirs, _iport_ours = data.in_transition pat.plug(ipat, {port_names[1]: iport_theirs}) if not numpy.isclose(data.straight_length, 0): - straight = tree <= {SINGLE_USE_PREFIX + 'straight': gen_straight(data.straight_length, **kwargs)} + straight_pat_or_tree = gen_straight(data.straight_length, **kwargs) + if isinstance(straight_pat_or_tree, Pattern): + straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat_or_tree} + else: + straight = tree <= straight_pat_or_tree pat.plug(straight, {port_names[1]: sport_in}) if data.ccw is not None: bend, bport_in, bport_out = self.bend @@ -405,12 +409,24 @@ class BasicTool(Tool, metaclass=ABCMeta): ipat, iport_theirs, _iport_ours = in_transition pat.plug(ipat, {port_names[1]: iport_theirs}) if not numpy.isclose(straight_length, 0): - straight_pat = gen_straight(straight_length, **kwargs) - if append: - pat.plug(straight_pat, {port_names[1]: sport_in}, append=True) + straight_pat_or_tree = gen_straight(straight_length, **kwargs) + pmap = {port_names[1]: sport_in} + if isinstance(straight_pat_or_tree, Pattern): + straight_pat = straight_pat_or_tree + if append: + pat.plug(straight_pat, pmap, append=True) + else: + straight_name = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} + pat.plug(straight_name, pmap) else: - straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} - pat.plug(straight, {port_names[1]: sport_in}, append=True) + straight_tree = straight_pat_or_tree + if append: + top = straight_tree.top() + straight_tree.flatten(top) + pat.plug(straight_tree[top], pmap, append=True) + else: + straight = tree <= straight_pat_or_tree + pat.plug(straight, pmap) if ccw is not None: bend, bport_in, bport_out = self.bend pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw)) From 0c04bf8ea3dba0589bf69d4c12fc5b9feda05a9b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 26 Oct 2025 18:45:09 -0700 Subject: [PATCH 044/100] Various type-checking improvements --- masque/file/oasis.py | 2 +- masque/pattern.py | 2 +- masque/ports.py | 2 +- masque/ref.py | 6 +++--- masque/repetition.py | 2 +- masque/shapes/arc.py | 10 +++++----- masque/shapes/ellipse.py | 2 +- masque/shapes/path.py | 4 ++-- masque/shapes/poly_collection.py | 4 ++-- masque/shapes/polygon.py | 2 +- masque/shapes/text.py | 11 ++++++----- masque/traits/positionable.py | 4 ++-- masque/traits/rotatable.py | 2 +- 13 files changed, 27 insertions(+), 26 deletions(-) diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 642ad44..672af25 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -661,7 +661,7 @@ def repetition_masq2fata( diffs = numpy.diff(rep.displacements, axis=0) diff_ints = rint_cast(diffs) frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) # type: ignore - offset = rep.displacements[0, :] + offset = tuple(rep.displacements[0, :]) else: assert rep is None frep = None diff --git a/masque/pattern.py b/masque/pattern.py index f77c64f..01ddf6a 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -584,7 +584,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): bounds = numpy.vstack((numpy.min(corners, axis=0), numpy.max(corners, axis=0))) * ref.scale + [ref.offset] if ref.repetition is not None: - bounds += ref.repetition.get_bounds() + bounds += ref.repetition.get_bounds_nonempty() else: # Non-manhattan rotation, have to figure out bounds by rotating the pattern diff --git a/masque/ports.py b/masque/ports.py index 1cc711a..8060616 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -64,7 +64,7 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): return self._rotation @rotation.setter - def rotation(self, val: float) -> None: + def rotation(self, val: float | None) -> None: if val is None: self._rotation = None else: diff --git a/masque/ref.py b/masque/ref.py index 09e00b1..b3a684c 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -11,7 +11,7 @@ import numpy from numpy import pi from numpy.typing import NDArray, ArrayLike -from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key +from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key, SupportsBool from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, @@ -50,11 +50,11 @@ class Ref( # Mirrored property @property - def mirrored(self) -> bool: # mypy#3004, setter should be SupportsBool + def mirrored(self) -> bool: return self._mirrored @mirrored.setter - def mirrored(self, val: bool) -> None: + def mirrored(self, val: SupportsBool) -> None: self._mirrored = bool(val) def __init__( diff --git a/masque/repetition.py b/masque/repetition.py index e6d00fc..5e7a7f0 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -327,7 +327,7 @@ class Arbitrary(Repetition): """ @property - def displacements(self) -> Any: # mypy#3004 NDArray[numpy.float64]: + def displacements(self) -> NDArray[numpy.float64]: return self._displacements @displacements.setter diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 9fe8b31..7eb310a 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -42,7 +42,7 @@ class Arc(Shape): # radius properties @property - def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]: + def radii(self) -> NDArray[numpy.float64]: """ Return the radii `[rx, ry]` """ @@ -79,7 +79,7 @@ class Arc(Shape): # arc start/stop angle properties @property - def angles(self) -> Any: # mypy#3004 NDArray[numpy.float64]: + def angles(self) -> NDArray[numpy.float64]: """ Return the start and stop angles `[a_start, a_stop]`. Angles are measured from x-axis after rotation @@ -412,15 +412,15 @@ class Arc(Shape): start_angle -= pi rotation += pi - angles = (start_angle, start_angle + delta_angle) + norm_angles = (start_angle, start_angle + delta_angle) rotation %= 2 * pi width = self.width - return ((type(self), radii, angles, width / norm_value), + return ((type(self), radii, norm_angles, width / norm_value), (self.offset, scale / norm_value, rotation, False), lambda: Arc( radii=radii * norm_value, - angles=angles, + angles=norm_angles, width=width * norm_value, )) diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 56ee73f..40d67d7 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -33,7 +33,7 @@ class Ellipse(Shape): # radius properties @property - def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]: + def radii(self) -> NDArray[numpy.float64]: """ Return the radii `[rx, ry]` """ diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 700f02f..48f3776 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -87,7 +87,7 @@ class Path(Shape): # cap_extensions property @property - def cap_extensions(self) -> Any | None: # mypy#3004 NDArray[numpy.float64]]: + def cap_extensions(self) -> NDArray[numpy.float64] | None: """ Path end-cap extension @@ -113,7 +113,7 @@ class Path(Shape): # vertices property @property - def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]]: + def vertices(self) -> NDArray[numpy.float64]: """ Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]` diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index bd2c23c..0369fd4 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -37,14 +37,14 @@ class PolyCollection(Shape): """ 1D NDArray specifying the starting offset for each polygon """ @property - def vertex_lists(self) -> Any: # mypy#3004 NDArray[numpy.float64]: + def vertex_lists(self) -> NDArray[numpy.float64]: """ Vertices of the polygons, ((N+M+...) x 2). Use with `vertex_offsets`. """ return self._vertex_lists @property - def vertex_offsets(self) -> Any: # mypy#3004 NDArray[numpy.intp]: + def vertex_offsets(self) -> NDArray[numpy.intp]: """ Starting offset (in `vertex_lists`) for each polygon """ diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 9c228d4..16e8729 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -38,7 +38,7 @@ class Polygon(Shape): # vertices property @property - def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]: + def vertices(self) -> NDArray[numpy.float64]: """ Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index e8b97ed..e30fe1c 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -10,7 +10,7 @@ from . import Shape, Polygon, normalized_shape_tuple from ..error import PatternError from ..repetition import Repetition from ..traits import RotatableImpl -from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key +from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key, SupportsBool # Loaded on use: # from freetype import Face @@ -55,11 +55,11 @@ class Text(RotatableImpl, Shape): self._height = val @property - def mirrored(self) -> bool: # mypy#3004, should be bool + def mirrored(self) -> bool: return self._mirrored @mirrored.setter - def mirrored(self, val: bool) -> None: + def mirrored(self, val: SupportsBool) -> None: self._mirrored = bool(val) def __init__( @@ -201,7 +201,7 @@ def get_char_as_polygons( font_path: str, char: str, resolution: float = 48 * 64, - ) -> tuple[list[list[list[float]]], float]: + ) -> tuple[list[NDArray[numpy.float64]], float]: from freetype import Face # type: ignore from matplotlib.path import Path # type: ignore @@ -276,11 +276,12 @@ def get_char_as_polygons( advance = slot.advance.x / resolution + polygons: list[NDArray[numpy.float64]] if len(all_verts) == 0: polygons = [] else: path = Path(all_verts, all_codes) path.should_simplify = False - polygons = path.to_polygons() + polygons = [numpy.asarray(poly) for poly in path.to_polygons()] return polygons, advance diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py index 66e6e7d..fd8551b 100644 --- a/masque/traits/positionable.py +++ b/masque/traits/positionable.py @@ -73,7 +73,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta): # # offset property @property - def offset(self) -> Any: # mypy#3004 NDArray[numpy.float64]: + def offset(self) -> NDArray[numpy.float64]: """ [x, y] offset """ @@ -95,7 +95,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta): return self def translate(self, offset: ArrayLike) -> Self: - self._offset += offset # type: ignore # NDArray += ArrayLike should be fine?? + self._offset += numpy.asarray(offset) return self diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 04816f1..2fa86c1 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -116,7 +116,7 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta): pivot = numpy.asarray(pivot, dtype=float) cast('Positionable', self).translate(-pivot) 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) cast('Positionable', self).translate(+pivot) return self From e231fa89cb64ae495d5489c9aa430ddb71e58cd8 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 26 Oct 2025 18:45:58 -0700 Subject: [PATCH 045/100] [Polygon.rect] use floats more explicitly --- masque/shapes/polygon.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 16e8729..1f1183e 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -248,11 +248,11 @@ class Polygon(Shape): elif xmax is None: assert xmin is not None assert xctr is not None - lx = 2 * (xctr - xmin) + lx = 2.0 * (xctr - xmin) elif xmin is None: assert xctr is not None assert xmax is not None - lx = 2 * (xmax - xctr) + lx = 2.0 * (xmax - xctr) else: raise PatternError('Two of xmin, xctr, xmax, lx must be None!') else: # noqa: PLR5501 @@ -278,11 +278,11 @@ class Polygon(Shape): elif ymax is None: assert ymin is not None assert yctr is not None - ly = 2 * (yctr - ymin) + ly = 2.0 * (yctr - ymin) elif ymin is None: assert yctr is not None assert ymax is not None - ly = 2 * (ymax - yctr) + ly = 2.0 * (ymax - yctr) else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') else: # noqa: PLR5501 From aca49dc7e30dbc5116925b3bac3358b481a83b4e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 26 Oct 2025 19:13:10 -0700 Subject: [PATCH 046/100] [Polygon / Path / PolyCollection] Force polygon/path offset to (0, 0) And disallow setting it. This offset was basically just a footgun. --- masque/shapes/arc.py | 3 +- masque/shapes/circle.py | 3 +- masque/shapes/ellipse.py | 3 +- masque/shapes/path.py | 53 +++++++++++++++++++++----------- masque/shapes/poly_collection.py | 42 +++++++++++++++++-------- masque/shapes/polygon.py | 44 ++++++++++++++++++-------- masque/shapes/shape.py | 8 ++--- masque/shapes/text.py | 4 +-- 8 files changed, 107 insertions(+), 53 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 7eb310a..480835e 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -10,10 +10,11 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key +from ..traits import PositionableImpl @functools.total_ordering -class Arc(Shape): +class Arc(PositionableImpl, Shape): """ An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its center. It has a position, two radii, a start and stop angle, a rotation, and a width. diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 0b71198..b20a681 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -10,10 +10,11 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key +from ..traits import PositionableImpl @functools.total_ordering -class Circle(Shape): +class Circle(PositionableImpl, Shape): """ A circle, which has a position and radius. """ diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 40d67d7..6029f2f 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -11,10 +11,11 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key +from ..traits import PositionableImpl @functools.total_ordering -class Ellipse(Shape): +class Ellipse(PositionableImpl, Shape): """ An ellipse, which has a position, two radii, and a rotation. The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius. diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 48f3776..b9d2d4d 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -1,4 +1,4 @@ -from typing import Any, cast +from typing import Any, cast, Self from collections.abc import Sequence import copy import functools @@ -30,8 +30,7 @@ class PathCap(Enum): @functools.total_ordering class Path(Shape): """ - A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape, - and an offset. + A path, consisting of a bunch of vertices (Nx2 ndarray), a width, and an end-cap shape. Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates. @@ -40,7 +39,7 @@ class Path(Shape): __slots__ = ( '_vertices', '_width', '_cap', '_cap_extensions', # Inherited - '_offset', '_repetition', '_annotations', + '_repetition', '_annotations', ) _vertices: NDArray[numpy.float64] _width: float @@ -160,6 +159,28 @@ class Path(Shape): raise PatternError('Wrong number of vertices') self.vertices[:, 1] = val + # Offset property for `Positionable` + @property + def offset(self) -> NDArray[numpy.float64]: + """ + [x, y] offset + """ + return numpy.zeros(2) + + @offset.setter + def offset(self, val: ArrayLike) -> None: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + + def set_offset(self, val: ArrayLike) -> Self: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + return self + + def translate(self, offset: ArrayLike) -> Self: + self._vertices += numpy.atleast_2d(offset) + return self + def __init__( self, vertices: ArrayLike, @@ -177,10 +198,8 @@ class Path(Shape): 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 self._width = width @@ -188,18 +207,19 @@ class Path(Shape): self._cap_extensions = cap_extensions else: self.vertices = vertices - self.offset = offset self.repetition = repetition self.annotations = annotations self.width = width self.cap = cap self.cap_extensions = cap_extensions - self.rotate(rotation) + if numpy.any(offset): + self.translate(offset) + if rotation: + self.rotate(rotation) def __deepcopy__(self, memo: dict | None = None) -> 'Path': memo = {} if memo is None else memo new = copy.copy(self) - 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) @@ -209,7 +229,6 @@ class Path(Shape): def __eq__(self, other: Any) -> bool: return ( type(self) is type(other) - and numpy.array_equal(self.offset, other.offset) and numpy.array_equal(self.vertices, other.vertices) and self.width == other.width and self.cap == other.cap @@ -234,8 +253,6 @@ class Path(Shape): if self.cap_extensions is None: return True return tuple(self.cap_extensions) < tuple(other.cap_extensions) - if not numpy.array_equal(self.offset, other.offset): - return tuple(self.offset) < tuple(other.offset) if self.repetition != other.repetition: return rep2key(self.repetition) < rep2key(other.repetition) return annotations_lt(self.annotations, other.annotations) @@ -292,7 +309,7 @@ class Path(Shape): if self.width == 0: verts = numpy.vstack((v, v[::-1])) - return [Polygon(offset=self.offset, vertices=verts)] + return [Polygon(vertices=verts)] perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2 @@ -343,7 +360,7 @@ class Path(Shape): o1.append(v[-1] - perp[-1]) verts = numpy.vstack((o0, o1[::-1])) - polys = [Polygon(offset=self.offset, vertices=verts)] + polys = [Polygon(vertices=verts)] if self.cap == PathCap.Circle: #for vert in v: # not sure if every vertex, or just ends? @@ -355,8 +372,8 @@ class Path(Shape): def get_bounds_single(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)) + bounds = 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, @@ -390,7 +407,7 @@ class Path(Shape): 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 + offset = self.vertices.mean(axis=0) zeroed_vertices = self.vertices - offset scale = zeroed_vertices.std() @@ -460,5 +477,5 @@ class Path(Shape): return extensions def __repr__(self) -> str: - centroid = self.offset + self.vertices.mean(axis=0) + centroid = self.vertices.mean(axis=0) return f'' diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index 0369fd4..e37d417 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -10,6 +10,7 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, normalized_shape_tuple from .polygon import Polygon +from ..error import PatternError from ..repetition import Repetition from ..utils import rotation_matrix_2d, annotations_lt, annotations_eq, rep2key, annotations_t @@ -27,7 +28,7 @@ class PolyCollection(Shape): '_vertex_lists', '_vertex_offsets', # Inherited - '_offset', '_repetition', '_annotations', + '_repetition', '_annotations', ) _vertex_lists: NDArray[numpy.float64] @@ -67,6 +68,27 @@ class PolyCollection(Shape): for slc in self.vertex_slices: yield self._vertex_lists[slc] + # Offset property for `Positionable` + @property + def offset(self) -> NDArray[numpy.float64]: + """ + [x, y] offset + """ + return numpy.zeros(2) + + @offset.setter + def offset(self, val: ArrayLike) -> None: + raise PatternError('PolyCollection offset is forced to (0, 0)') + + def set_offset(self, val: ArrayLike) -> Self: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + return self + + def translate(self, offset: ArrayLike) -> Self: + self._vertex_lists += numpy.atleast_2d(offset) + return self + def __init__( self, vertex_lists: ArrayLike, @@ -81,25 +103,23 @@ class PolyCollection(Shape): if raw: assert isinstance(vertex_lists, numpy.ndarray) assert isinstance(vertex_offsets, numpy.ndarray) - assert isinstance(offset, numpy.ndarray) self._vertex_lists = vertex_lists self._vertex_offsets = vertex_offsets - self._offset = offset self._repetition = repetition self._annotations = annotations else: self._vertex_lists = numpy.asarray(vertex_lists, dtype=float) self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp) - self.offset = offset self.repetition = repetition self.annotations = annotations + if numpy.any(offset): + self.translate(offset) if rotation: self.rotate(rotation) def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) - new._offset = self._offset.copy() new._vertex_lists = self._vertex_lists.copy() new._vertex_offsets = self._vertex_offsets.copy() new._annotations = copy.deepcopy(self._annotations) @@ -108,7 +128,6 @@ class PolyCollection(Shape): def __eq__(self, other: Any) -> bool: return ( type(self) is type(other) - and numpy.array_equal(self.offset, other.offset) and numpy.array_equal(self._vertex_lists, other._vertex_lists) and numpy.array_equal(self._vertex_offsets, other._vertex_offsets) and self.repetition == other.repetition @@ -134,8 +153,6 @@ class PolyCollection(Shape): return vv.shape[0] < oo.shape[0] if len(self.vertex_lists) != len(other.vertex_lists): return len(self.vertex_lists) < len(other.vertex_lists) - if not numpy.array_equal(self.offset, other.offset): - return tuple(self.offset) < tuple(other.offset) if self.repetition != other.repetition: return rep2key(self.repetition) < rep2key(other.repetition) return annotations_lt(self.annotations, other.annotations) @@ -147,14 +164,13 @@ class PolyCollection(Shape): ) -> list['Polygon']: return [Polygon( vertices = vv, - offset = self.offset, repetition = copy.deepcopy(self.repetition), annotations = copy.deepcopy(self.annotations), ) for vv in self.polygon_vertices] def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition - return numpy.vstack((self.offset + numpy.min(self._vertex_lists, axis=0), - self.offset + numpy.max(self._vertex_lists, axis=0))) + return numpy.vstack((numpy.min(self._vertex_lists, axis=0), + numpy.max(self._vertex_lists, axis=0))) def rotate(self, theta: float) -> Self: if theta != 0: @@ -175,7 +191,7 @@ class PolyCollection(Shape): # other shapes meanv = self._vertex_lists.mean(axis=0) zeroed_vertices = self._vertex_lists - [meanv] - offset = meanv + self.offset + offset = meanv scale = zeroed_vertices.std() normed_vertices = zeroed_vertices / scale @@ -203,5 +219,5 @@ class PolyCollection(Shape): ) def __repr__(self) -> str: - centroid = self.offset + self.vertex_lists.mean(axis=0) + centroid = self.vertex_lists.mean(axis=0) return f'' diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 1f1183e..6b27606 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING +from typing import Any, cast, TYPE_CHECKING, Self import copy import functools @@ -20,7 +20,7 @@ if TYPE_CHECKING: class Polygon(Shape): """ A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an - implicitly-closed boundary, and an offset. + implicitly-closed boundary. Note that the setter for `Polygon.vertices` creates a copy of the passed vertex coordinates. @@ -30,7 +30,7 @@ class Polygon(Shape): __slots__ = ( '_vertices', # Inherited - '_offset', '_repetition', '_annotations', + '_repetition', '_annotations', ) _vertices: NDArray[numpy.float64] @@ -85,6 +85,28 @@ class Polygon(Shape): raise PatternError('Wrong number of vertices') self.vertices[:, 1] = val + # Offset property for `Positionable` + @property + def offset(self) -> NDArray[numpy.float64]: + """ + [x, y] offset + """ + return numpy.zeros(2) + + @offset.setter + def offset(self, val: ArrayLike) -> None: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + + def set_offset(self, val: ArrayLike) -> Self: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + return self + + def translate(self, offset: ArrayLike) -> Self: + self._vertices += numpy.atleast_2d(offset) + return self + def __init__( self, vertices: ArrayLike, @@ -99,21 +121,20 @@ class Polygon(Shape): assert isinstance(vertices, numpy.ndarray) assert isinstance(offset, numpy.ndarray) self._vertices = vertices - self._offset = offset self._repetition = repetition self._annotations = annotations else: self.vertices = vertices - self.offset = offset self.repetition = repetition self.annotations = annotations + if numpy.any(offset): + self.translate(offset) if rotation: self.rotate(rotation) def __deepcopy__(self, memo: dict | None = None) -> 'Polygon': memo = {} if memo is None else memo new = copy.copy(self) - new._offset = self._offset.copy() new._vertices = self._vertices.copy() new._annotations = copy.deepcopy(self._annotations) return new @@ -121,7 +142,6 @@ class Polygon(Shape): def __eq__(self, other: Any) -> bool: return ( type(self) is type(other) - and numpy.array_equal(self.offset, other.offset) and numpy.array_equal(self.vertices, other.vertices) and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) @@ -141,8 +161,6 @@ class Polygon(Shape): if eq_lt_masked.size > 0: return eq_lt_masked.flat[0] return self.vertices.shape[0] < other.vertices.shape[0] - if not numpy.array_equal(self.offset, other.offset): - return tuple(self.offset) < tuple(other.offset) if self.repetition != other.repetition: return rep2key(self.repetition) < rep2key(other.repetition) return annotations_lt(self.annotations, other.annotations) @@ -363,8 +381,8 @@ class Polygon(Shape): return [copy.deepcopy(self)] def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition - return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0), - self.offset + numpy.max(self.vertices, axis=0))) + return numpy.vstack((numpy.min(self.vertices, axis=0), + numpy.max(self.vertices, axis=0))) def rotate(self, theta: float) -> 'Polygon': if theta != 0: @@ -384,7 +402,7 @@ class Polygon(Shape): # other shapes meanv = self.vertices.mean(axis=0) zeroed_vertices = self.vertices - meanv - offset = meanv + self.offset + offset = meanv scale = zeroed_vertices.std() normed_vertices = zeroed_vertices / scale @@ -438,5 +456,5 @@ class Polygon(Shape): return self def __repr__(self) -> str: - centroid = self.offset + self.vertices.mean(axis=0) + centroid = self.vertices.mean(axis=0) return f'' diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 0a7c86d..90bca2b 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -7,7 +7,7 @@ from numpy.typing import NDArray, ArrayLike from ..traits import ( Rotatable, Mirrorable, Copyable, Scalable, - PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, + Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,7 +26,7 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, +class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): """ Class specifying functions common to all shapes. @@ -134,7 +134,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, mins, maxs = bounds vertex_lists = [] - p_verts = polygon.vertices + polygon.offset + p_verts = polygon.vertices for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True): dv = v_next - v @@ -282,7 +282,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, offset = (numpy.where(keep_x)[0][0], numpy.where(keep_y)[0][0]) - rastered = float_raster.raster((polygon.vertices + polygon.offset).T, gx, gy) + rastered = float_raster.raster((polygon.vertices).T, gx, gy) binary_rastered = (numpy.abs(rastered) >= 0.5) supersampled = binary_rastered.repeat(2, axis=0).repeat(2, axis=1) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index e30fe1c..78632f6 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -9,7 +9,7 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, Polygon, normalized_shape_tuple from ..error import PatternError from ..repetition import Repetition -from ..traits import RotatableImpl +from ..traits import PositionableImpl, RotatableImpl from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key, SupportsBool # Loaded on use: @@ -18,7 +18,7 @@ from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotatio @functools.total_ordering -class Text(RotatableImpl, Shape): +class Text(PositionableImpl, RotatableImpl, Shape): """ Text (to be printed e.g. as a set of polygons). This is distinct from non-printed Label objects. From 34a43a707cfde0730bc6071a6a802f0346afa9f1 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 26 Oct 2025 21:36:29 -0700 Subject: [PATCH 047/100] [Path / PolyCollection / Polygon] fix order of rotation/offset --- masque/shapes/path.py | 4 ++-- masque/shapes/poly_collection.py | 4 ++-- masque/shapes/polygon.py | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index b9d2d4d..7778428 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -212,10 +212,10 @@ class Path(Shape): self.width = width self.cap = cap self.cap_extensions = cap_extensions - if numpy.any(offset): - self.translate(offset) if rotation: self.rotate(rotation) + if numpy.any(offset): + self.translate(offset) def __deepcopy__(self, memo: dict | None = None) -> 'Path': memo = {} if memo is None else memo diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index e37d417..b7fbe48 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -112,10 +112,10 @@ class PolyCollection(Shape): self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp) self.repetition = repetition self.annotations = annotations - if numpy.any(offset): - self.translate(offset) if rotation: self.rotate(rotation) + if numpy.any(offset): + self.translate(offset) def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 6b27606..fc17f61 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -119,7 +119,6 @@ class Polygon(Shape): ) -> None: if raw: assert isinstance(vertices, numpy.ndarray) - assert isinstance(offset, numpy.ndarray) self._vertices = vertices self._repetition = repetition self._annotations = annotations @@ -127,10 +126,10 @@ class Polygon(Shape): self.vertices = vertices self.repetition = repetition self.annotations = annotations - if numpy.any(offset): - self.translate(offset) if rotation: self.rotate(rotation) + if numpy.any(offset): + self.translate(offset) def __deepcopy__(self, memo: dict | None = None) -> 'Polygon': memo = {} if memo is None else memo From b8ab3b91f522e0f34824b38d12e6400248317bc5 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 30 Oct 2025 01:13:23 -0700 Subject: [PATCH 048/100] misc cleanup: variable naming, typing, comments --- masque/error.py | 4 +++- masque/ports.py | 22 +++++++++++----------- masque/traits/positionable.py | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/masque/error.py b/masque/error.py index 0e46849..00775d3 100644 --- a/masque/error.py +++ b/masque/error.py @@ -25,12 +25,14 @@ class BuildError(MasqueError): """ pass + class PortError(MasqueError): """ - Exception raised by builder-related functions + Exception raised by port-related functions """ pass + class OneShotError(MasqueError): """ Exception raised when a function decorated with `@oneshot` is called more than once diff --git a/masque/ports.py b/masque/ports.py index 8060616..f12706e 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -305,11 +305,11 @@ class PortList(metaclass=ABCMeta): if type_conflicts.any(): msg = 'Ports have conflicting types:\n' - for nn, (k, v) in enumerate(connections.items()): + for nn, (kk, vv) in enumerate(connections.items()): if type_conflicts[nn]: - msg += f'{k} | {a_types[nn]}:{b_types[nn]} | {v}\n' msg = ''.join(traceback.format_stack()) + '\n' + msg warnings.warn(msg, stacklevel=2) + msg += f'{kk} | {a_types[nn]}:{b_types[nn]} | {vv}\n' a_offsets = numpy.array([pp.offset for pp in a_ports]) b_offsets = numpy.array([pp.offset for pp in b_ports]) @@ -326,17 +326,17 @@ class PortList(metaclass=ABCMeta): if not numpy.allclose(rotations, 0): rot_deg = numpy.rad2deg(rotations) msg = 'Port orientations do not match:\n' - for nn, (k, v) in enumerate(connections.items()): + for nn, (kk, vv) in enumerate(connections.items()): if not numpy.isclose(rot_deg[nn], 0): - msg += f'{k} | {rot_deg[nn]:g} | {v}\n' + msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n' raise PortError(msg) translations = a_offsets - b_offsets if not numpy.allclose(translations, 0): msg = 'Port translations do not match:\n' - for nn, (k, v) in enumerate(connections.items()): + for nn, (kk, vv) in enumerate(connections.items()): if not numpy.allclose(translations[nn], 0): - msg += f'{k} | {translations[nn]} | {v}\n' + msg += f'{kk} | {translations[nn]} | {vv}\n' raise PortError(msg) for pp in chain(a_names, b_names): @@ -406,7 +406,7 @@ class PortList(metaclass=ABCMeta): map_out_counts = Counter(map_out.values()) map_out_counts[None] = 0 - conflicts_out = {k for k, v in map_out_counts.items() if v > 1} + conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1} if conflicts_out: raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}') @@ -438,7 +438,7 @@ class PortList(metaclass=ABCMeta): `set_rotation` must remain `None`. ok_connections: Set of "allowed" ptype combinations. Identical ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a + any other ptypte. Non-allowed ptype connections will log a warning. Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. @@ -489,7 +489,7 @@ class PortList(metaclass=ABCMeta): `set_rotation` must remain `None`. ok_connections: Set of "allowed" ptype combinations. Identical ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a + any other ptypte. Non-allowed ptype connections will log a warning. Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. @@ -520,11 +520,11 @@ class PortList(metaclass=ABCMeta): for st, ot in zip(s_types, o_types, strict=True)]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' - for nn, (k, v) in enumerate(map_in.items()): + for nn, (kk, vv) in enumerate(map_in.items()): if type_conflicts[nn]: - msg += f'{k} | {s_types[nn]}:{o_types[nn]} | {v}\n' msg = ''.join(traceback.format_stack()) + '\n' + msg warnings.warn(msg, stacklevel=2) + msg += f'{kk} | {s_types[nn]}:{o_types[nn]} | {vv}\n' rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) if not has_rot.any(): diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py index fd8551b..6779869 100644 --- a/masque/traits/positionable.py +++ b/masque/traits/positionable.py @@ -1,4 +1,4 @@ -from typing import Self, Any +from typing import Self from abc import ABCMeta, abstractmethod import numpy From 705a1cef782900668e14cef3ff335fa2e55d9521 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 30 Oct 2025 01:14:37 -0700 Subject: [PATCH 049/100] [error, ports] Make stack traces more directly reflect teh location of the issue --- masque/error.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ masque/ports.py | 12 +++++------ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/masque/error.py b/masque/error.py index 00775d3..95c3fd2 100644 --- a/masque/error.py +++ b/masque/error.py @@ -1,3 +1,10 @@ +import traceback +import pathlib + + +MASQUE_DIR = str(pathlib.Path(__file__).parent) + + class MasqueError(Exception): """ Parent exception for all Masque-related Exceptions @@ -39,3 +46,50 @@ class OneShotError(MasqueError): """ def __init__(self, func_name: str) -> None: Exception.__init__(self, f'Function "{func_name}" with @oneshot was called more than once') + + +def format_stacktrace( + stacklevel: int = 1, + *, + skip_file_prefixes: tuple[str, ...] = (MASQUE_DIR,), + low_file_prefixes: tuple[str, ...] = (''), + low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', ), + ) -> str: + """ + Utility function for making nicer stack traces (e.g. excluding and similar) + + Args: + stacklevel: Number of frames to remove from near this function (default is to + show caller but not ourselves). Similar to `warnings.warn` and `logging.warning`. + skip_file_prefixes: Indicates frames to ignore after counting stack levels; similar + to `warnings.warn` *TODO check if this is actually the same effect re:stacklevel*. + Forces stacklevel to max(2, stacklevel). + Default is to exclude anything within `masque`. + low_file_prefixes: Indicates frames to ignore on the other (entry-point) end of the stack, + based on prefixes on their filenames. + low_file_suffixes: Indicates frames to ignore on the other (entry-point) end of the stack, + based on suffixes on their filenames. + + Returns: + Formatted trimmed stack trace + """ + if skip_file_prefixes: + stacklevel = max(2, stacklevel) + + stack = traceback.extract_stack() + + bad_inds = [ii + 1 for ii, frame in enumerate(stack) + if frame.filename.startswith(low_file_prefixes) or frame.filename.endswith(low_file_suffixes)] + first_ok = max([0] + bad_inds) + + last_ok = -stacklevel - 1 + while last_ok >= -len(stack) and stack[last_ok].filename.startswith(skip_file_prefixes): + last_ok -= 1 + + if selected := stack[first_ok:last_ok + 1]: + pass + elif selected := stack[:-stacklevel]: + pass + else: + selected = stack + return ''.join(traceback.format_list(selected)) diff --git a/masque/ports.py b/masque/ports.py index f12706e..a8aaa0c 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -1,7 +1,5 @@ from typing import overload, Self, NoReturn, Any from collections.abc import Iterable, KeysView, ValuesView, Mapping -import warnings -import traceback import logging import functools from collections import Counter @@ -14,7 +12,7 @@ from numpy.typing import ArrayLike, NDArray from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable from .utils import rotate_offsets_around -from .error import PortError +from .error import PortError, format_stacktrace logger = logging.getLogger(__name__) @@ -307,9 +305,9 @@ class PortList(metaclass=ABCMeta): msg = 'Ports have conflicting types:\n' for nn, (kk, vv) in enumerate(connections.items()): if type_conflicts[nn]: - msg = ''.join(traceback.format_stack()) + '\n' + msg - warnings.warn(msg, stacklevel=2) msg += f'{kk} | {a_types[nn]}:{b_types[nn]} | {vv}\n' + msg += '\nStack trace:\n' + format_stacktrace() + logger.warning(msg) a_offsets = numpy.array([pp.offset for pp in a_ports]) b_offsets = numpy.array([pp.offset for pp in b_ports]) @@ -522,9 +520,9 @@ class PortList(metaclass=ABCMeta): msg = 'Ports have conflicting types:\n' for nn, (kk, vv) in enumerate(map_in.items()): if type_conflicts[nn]: - msg = ''.join(traceback.format_stack()) + '\n' + msg - warnings.warn(msg, stacklevel=2) msg += f'{kk} | {s_types[nn]}:{o_types[nn]} | {vv}\n' + msg += '\nStack trace:\n' + format_stacktrace() + logger.warning(msg) rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) if not has_rot.any(): From 6a494b99a0fcc592926464d32c415b83d138370c Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 30 Oct 2025 01:15:20 -0700 Subject: [PATCH 050/100] [ports] make port mismatch deltas more obvious --- masque/ports.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/masque/ports.py b/masque/ports.py index a8aaa0c..b56ad70 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -544,8 +544,11 @@ class PortList(metaclass=ABCMeta): translations = s_offsets - o_offsets if not numpy.allclose(translations[:1], translations): msg = 'Port translations do not match:\n' + common_translation = numpy.min(translations, axis=0) + msg += f'Common: {common_translation} \n' + msg += 'Deltas:\n' for nn, (kk, vv) in enumerate(map_in.items()): - msg += f'{kk} | {translations[nn]} | {vv}\n' + msg += f'{kk} | {translations[nn] - common_translation} | {vv}\n' raise PortError(msg) return translations[0], rotations[0], o_offsets[0] From 549193534fec1449fb91ba8d27e857382f6709ec Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 30 Oct 2025 01:15:44 -0700 Subject: [PATCH 051/100] [file.svg] use logger.warning over warnings.warn (for flexibility) --- masque/file/svg.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/masque/file/svg.py b/masque/file/svg.py index 148f6d4..859c074 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -2,7 +2,7 @@ SVG file format readers and writers """ from collections.abc import Mapping -import warnings +import logging import numpy from numpy.typing import ArrayLike @@ -12,6 +12,9 @@ from .utils import mangle_name from .. import Pattern +logger = logging.getLogger(__name__) + + def writefile( library: Mapping[str, Pattern], top: str, @@ -50,7 +53,7 @@ def writefile( bounds = pattern.get_bounds(library=library) if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) - warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) + logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) else: bounds_min, bounds_max = bounds @@ -117,7 +120,7 @@ def writefile_inverted( bounds = pattern.get_bounds(library=library) if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) - warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) + logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) else: bounds_min, bounds_max = bounds From a4b93419b4fb9602978d7b744ffe4e3e9d808bf7 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 30 Oct 2025 01:22:46 -0700 Subject: [PATCH 052/100] [error] also exclude frames starting with ''), + low_file_prefixes: tuple[str, ...] = (''), low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', ), ) -> str: """ From 8fa1d0479c650f613d5457906d3cf4d76857bac4 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 30 Oct 2025 01:25:06 -0700 Subject: [PATCH 053/100] [error] also exclude concurrent.futures.process from traces --- masque/error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/error.py b/masque/error.py index fcf69a5..f434e51 100644 --- a/masque/error.py +++ b/masque/error.py @@ -53,7 +53,7 @@ def format_stacktrace( *, skip_file_prefixes: tuple[str, ...] = (MASQUE_DIR,), low_file_prefixes: tuple[str, ...] = (''), - low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', ), + low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', 'concurrent/futures/process.py'), ) -> str: """ Utility function for making nicer stack traces (e.g. excluding and similar) From fc9d4c6ba26a3ec3ca0df3581495647845caf6e8 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 11 Nov 2025 18:30:23 -0800 Subject: [PATCH 054/100] [pather] code style changes --- masque/builder/pather.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index d0deacb..c1e7d31 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -458,8 +458,8 @@ class Pather(Builder): portspec, ccw, length, - tool_port_names=tool_port_names, - plug_into=plug_into, + tool_port_names = tool_port_names, + plug_into = plug_into, **kwargs, ) @@ -678,15 +678,12 @@ class Pather(Builder): bound_types = set() if 'bound_type' in kwargs: - bound_types.add(kwargs['bound_type']) - bound = kwargs['bound'] - del kwargs['bound_type'] - del kwargs['bound'] + bound_types.add(kwargs.pop('bound_type')) + bound = kwargs.pop('bound') for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): if bt in kwargs: bound_types.add(bt) - bound = kwargs[bt] - del kwargs[bt] + bound = kwargs.pop('bt') if not bound_types: raise BuildError('No bound type specified for mpath') From 11306dbb5631742a7be0f509ea26d15aa64dfa81 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 11 Nov 2025 20:30:45 -0800 Subject: [PATCH 055/100] [Pather / RenderPather] move common functionality into PatherMixin; redo hierarchy - (BREAKING change) Pather.mpath no longer wraps the whole bus into a container, since this has no equivalent in RenderPather. Possible this functionality will return in the future - Removed `tool_port_names` arg from Pather functions - In general RenderPather should be much closer to Pather now --- masque/builder/pather.py | 431 +------------------------------ masque/builder/pather_mixin.py | 446 +++++++++++++++++++++++++++++++++ masque/builder/renderpather.py | 204 ++------------- masque/builder/tools.py | 18 +- 4 files changed, 484 insertions(+), 615 deletions(-) create mode 100644 masque/builder/pather_mixin.py diff --git a/masque/builder/pather.py b/masque/builder/pather.py index c1e7d31..48a3484 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -2,10 +2,9 @@ Manual wire/waveguide routing (`Pather`) """ from typing import Self -from collections.abc import Sequence, MutableMapping, Mapping, Iterator +from collections.abc import Sequence, Mapping, MutableMapping import copy import logging -from contextlib import contextmanager from pprint import pformat import numpy @@ -13,20 +12,19 @@ from numpy import pi from numpy.typing import ArrayLike from ..pattern import Pattern -from ..library import ILibrary, SINGLE_USE_PREFIX +from ..library import ILibrary from ..error import PortError, BuildError from ..ports import PortList, Port -from ..abstract import Abstract -from ..utils import SupportsBool, rotation_matrix_2d +from ..utils import SupportsBool from .tools import Tool -from .utils import ell +from .pather_mixin import PatherMixin from .builder import Builder logger = logging.getLogger(__name__) -class Pather(Builder): +class Pather(Builder, PatherMixin): """ An extension of `Builder` which provides functionality for routing and attaching single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns. @@ -258,60 +256,6 @@ class Pather(Builder): s = f'' return s - def retool( - self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, - ) -> Self: - """ - Update the `Tool` which will be used when generating `Pattern`s for the ports - given by `keys`. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - self.tools[keys] = tool - else: - for key in keys: - self.tools[key] = tool - return self - - @contextmanager - def toolctx( - self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, - ) -> Iterator[Self]: - """ - Context manager for temporarily `retool`-ing and reverting the `retool` - upon exiting the context. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - keys = [keys] - saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` - try: - yield self.retool(tool=tool, keys=keys) - finally: - for kk, tt in saved_tools.items(): - if tt is None: - # delete if present - self.tools.pop(kk, None) - else: - self.tools[kk] = tt def path( self, @@ -319,7 +263,6 @@ class Pather(Builder): ccw: SupportsBool | None, length: float, *, - tool_port_names: tuple[str, str] = ('A', 'B'), plug_into: str | None = None, **kwargs, ) -> Self: @@ -338,9 +281,6 @@ class Pather(Builder): and clockwise otherwise. length: The total distance from input to output, along the input's axis only. (There may be a tool-dependent offset along the other axis.) - tool_port_names: The names of the ports on the generated pattern. It is unlikely - that you will need to change these. The first port is the input (to be - connected to `portspec`). plug_into: If not None, attempts to plug the wire's output port into the provided port on `self`. @@ -355,369 +295,16 @@ class Pather(Builder): logger.error('Skipping path() since device is dead') return self + tool_port_names = ('A', 'B') + tool = self.tools.get(portspec, self.tools[None]) in_ptype = self.pattern[portspec].ptype tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - abstract = self.library << tree + abstract = self.library << tree # TODO this seems like a name, not an abstract if plug_into is not None: output = {plug_into: tool_port_names[1]} else: output = {} - return self.plug(abstract, {portspec: tool_port_names[0], **output}) - - def path_to( - self, - portspec: str, - ccw: SupportsBool | None, - position: float | None = None, - *, - x: float | None = None, - y: float | None = None, - tool_port_names: tuple[str, str] = ('A', 'B'), - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of ending exactly at a target position. - - The wire will travel so that the output port will be placed at exactly the target - position along the input port's axis. There can be an unspecified (tool-dependent) - offset in the perpendicular direction. The output port will be rotated (or not) - based on the `ccw` parameter. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - position: The final port position, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - Only one of `position`, `x`, and `y` may be specified. - x: The final port position along the x axis. - `portspec` must refer to a horizontal port if `x` is passed, otherwise a - BuildError will be raised. - y: The final port position along the y axis. - `portspec` must refer to a vertical port if `y` is passed, otherwise a - BuildError will be raised. - tool_port_names: The names of the ports on the generated pattern. It is unlikely - that you will need to change these. The first port is the input (to be - connected to `portspec`). - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Raises: - BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend - is present). - BuildError if `x` or `y` is specified but does not match the axis of `portspec`. - BuildError if more than one of `x`, `y`, and `position` is specified. - """ - if self._dead: - logger.error('Skipping path_to() since device is dead') - return self - - pos_count = sum(vv is not None for vv in (position, x, y)) - if pos_count > 1: - raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') - if pos_count < 1: - raise BuildError('One of `position`, `x`, and `y` must be specified') - - port = self.pattern[portspec] - if port.rotation is None: - raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') - - if not numpy.isclose(port.rotation % (pi / 2), 0): - raise BuildError('path_to was asked to route from non-manhattan port') - - is_horizontal = numpy.isclose(port.rotation % pi, 0) - if is_horizontal: - if y is not None: - raise BuildError('Asked to path to y-coordinate, but port is horizontal') - if position is None: - position = x - else: - if x is not None: - raise BuildError('Asked to path to x-coordinate, but port is vertical') - if position is None: - position = y - - x0, y0 = port.offset - if is_horizontal: - if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): - raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') - length = numpy.abs(position - x0) - else: - if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): - raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') - length = numpy.abs(position - y0) - - return self.path( - portspec, - ccw, - length, - tool_port_names = tool_port_names, - plug_into = plug_into, - **kwargs, - ) - - def path_into( - self, - portspec_src: str, - portspec_dst: str, - *, - tool_port_names: tuple[str, str] = ('A', 'B'), - out_ptype: str | None = None, - plug_destination: bool = True, - **kwargs, - ) -> Self: - """ - Create a "wire"/"waveguide" and traveling between the ports `portspec_src` and - `portspec_dst`, and `plug` it into both (or just the source port). - - Only unambiguous scenarios are allowed: - - Straight connector between facing ports - - Single 90 degree bend - - Jog between facing ports - (jog is done as late as possible, i.e. only 2 L-shaped segments are used) - - By default, the destination's `pytpe` will be used as the `out_ptype` for the - wire, and the `portspec_dst` will be plugged (i.e. removed). - - Args: - portspec_src: The name of the starting port into which the wire will be plugged. - portspec_dst: The name of the destination port. - tool_port_names: The names of the ports on the generated pattern. It is unlikely - that you will need to change these. The first port is the input (to be - connected to `portspec`). - out_ptype: Passed to the pathing tool in order to specify the desired port type - to be generated at the destination end. If `None` (default), the destination - port's `ptype` will be used. - - Returns: - self - - Raises: - PortError if either port does not have a specified rotation. - BuildError if and invalid port config is encountered: - - Non-manhattan ports - - U-bend - - Destination too close to (or behind) source - """ - if self._dead: - logger.error('Skipping path_into() since device is dead') - return self - - port_src = self.pattern[portspec_src] - port_dst = self.pattern[portspec_dst] - - if out_ptype is None: - out_ptype = port_dst.ptype - - if port_src.rotation is None: - raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()') - if port_dst.rotation is None: - raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()') - - if not numpy.isclose(port_src.rotation % (pi / 2), 0): - raise BuildError('path_into was asked to route from non-manhattan port') - if not numpy.isclose(port_dst.rotation % (pi / 2), 0): - raise BuildError('path_into was asked to route to non-manhattan port') - - src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0) - dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0) - xs, ys = port_src.offset - xd, yd = port_dst.offset - - angle = (port_dst.rotation - port_src.rotation) % (2 * pi) - - src_ne = port_src.rotation % (2 * pi) > (3 * pi / 4) # path from src will go north or east - - def get_jog(ccw: SupportsBool, length: float) -> float: - tool = self.tools.get(portspec_src, self.tools[None]) - in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already... - tree2 = tool.path(ccw, length, in_ptype=in_ptype, port_names=('A', 'B'), out_ptype=out_ptype, **kwargs) - top2 = tree2.top_pattern() - jog = rotation_matrix_2d(top2['A'].rotation) @ (top2['B'].offset - top2['A'].offset) - return jog[1] * [-1, 1][int(bool(ccw))] - - dst_extra_args = {'out_ptype': out_ptype} - if plug_destination: - dst_extra_args['plug_into'] = portspec_dst - - src_args = {**kwargs, 'tool_port_names': tool_port_names} - dst_args = {**src_args, **dst_extra_args} - if src_is_horizontal and not dst_is_horizontal: - # single bend should suffice - self.path_to(portspec_src, angle > pi, x=xd, **src_args) - self.path_to(portspec_src, None, y=yd, **dst_args) - elif dst_is_horizontal and not src_is_horizontal: - # single bend should suffice - self.path_to(portspec_src, angle > pi, y=yd, **src_args) - self.path_to(portspec_src, None, x=xd, **dst_args) - elif numpy.isclose(angle, pi): - if src_is_horizontal and ys == yd: - # straight connector - self.path_to(portspec_src, None, x=xd, **dst_args) - elif not src_is_horizontal and xs == xd: - # straight connector - self.path_to(portspec_src, None, y=yd, **dst_args) - elif src_is_horizontal: - # figure out how much x our y-segment (2nd) takes up, then path based on that - y_len = numpy.abs(yd - ys) - ccw2 = src_ne != (yd > ys) - jog = get_jog(ccw2, y_len) * numpy.sign(xd - xs) - self.path_to(portspec_src, not ccw2, x=xd - jog, **src_args) - self.path_to(portspec_src, ccw2, y=yd, **dst_args) - else: - # figure out how much y our x-segment (2nd) takes up, then path based on that - x_len = numpy.abs(xd - xs) - ccw2 = src_ne != (xd < xs) - jog = get_jog(ccw2, x_len) * numpy.sign(yd - ys) - self.path_to(portspec_src, not ccw2, y=yd - jog, **src_args) - self.path_to(portspec_src, ccw2, x=xd, **dst_args) - elif numpy.isclose(angle, 0): - raise BuildError('Don\'t know how to route a U-bend at this time!') - else: - raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') - - return self - - def mpath( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - set_rotation: float | None = None, - tool_port_names: tuple[str, str] = ('A', 'B'), - force_container: bool = False, - base_name: str = SINGLE_USE_PREFIX + 'mpath', - **kwargs, - ) -> Self: - """ - `mpath` is a superset of `path` and `path_to` which can act on bundles or buses - of "wires or "waveguides". - - The wires will travel so that the output ports will be placed at well-defined - locations along the axis of their input ports, but may have arbitrary (tool- - dependent) offsets in the perpendicular direction. - - If `ccw` is not `None`, the wire bundle will turn 90 degres in either the - clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the - bundle, the center-to-center wire spacings after the turn are set by `spacing`, - which is required when `ccw` is not `None`. The final position of bundle as a - whole can be set in a number of ways: - - =A>---------------------------V turn direction: `ccw=False` - =B>-------------V | - =C>-----------------------V | - =D=>----------------V | - | - - x---x---x---x `spacing` (can be scalar or array) - - <--------------> `emin=` - <------> `bound_type='min_past_furthest', bound=` - <--------------------------------> `emax=` - x `pmin=` - x `pmax=` - - - `emin=`, equivalent to `bound_type='min_extension', bound=` - The total extension value for the furthest-out port (B in the diagram). - - `emax=`, equivalent to `bound_type='max_extension', bound=`: - The total extension value for the closest-in port (C in the diagram). - - `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`: - The coordinate of the innermost bend (D's bend). - The x/y versions throw an error if they do not match the port axis (for debug) - - `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`: - The coordinate of the outermost bend (A's bend). - The x/y versions throw an error if they do not match the port axis (for debug) - - `bound_type='min_past_furthest', bound=`: - The distance between furthest out-port (B) and the innermost bend (D's bend). - - If `ccw=None`, final output positions (along the input axis) of all wires will be - identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is - required. In this case, `emin=` and `emax=` are equivalent to each other, and - `pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other. - - - Args: - portspec: The names of the ports which are to be routed. - ccw: If `None`, the outputs should be along the same axis as the inputs. - Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` - and clockwise otherwise. - spacing: Center-to-center distance between output ports along the input port's axis. - Must be provided if (and only if) `ccw` is not `None`. - set_rotation: If the provided ports have `rotation=None`, this can be used - to set a rotation for them. - tool_port_names: The names of the ports on the generated pattern. It is unlikely - that you will need to change these. The first port is the input (to be - connected to `portspec`). - force_container: If `False` (default), and only a single port is provided, the - generated wire for that port will be referenced directly, rather than being - wrapped in an additonal `Pattern`. - base_name: Name to use for the generated `Pattern`. This will be passed through - `self.library.get_name()` to get a unique name for each new `Pattern`. - - Returns: - self - - Raises: - BuildError if the implied length for any wire is too close to fit the bend - (if a bend is requested). - BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not - match the axis of `portspec`. - BuildError if an incorrect bound type or spacing is specified. - """ - if self._dead: - logger.error('Skipping mpath() since device is dead') - return self - - bound_types = set() - if 'bound_type' in kwargs: - bound_types.add(kwargs.pop('bound_type')) - bound = kwargs.pop('bound') - for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): - if bt in kwargs: - bound_types.add(bt) - bound = kwargs.pop('bt') - - if not bound_types: - raise BuildError('No bound type specified for mpath') - if len(bound_types) > 1: - raise BuildError(f'Too many bound types specified for mpath: {bound_types}') - bound_type = tuple(bound_types)[0] - - if isinstance(portspec, str): - portspec = [portspec] - ports = self.pattern[tuple(portspec)] - - extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) - - if len(ports) == 1 and not force_container: - # Not a bus, so having a container just adds noise to the layout - port_name = tuple(portspec)[0] - return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names, **kwargs) - - bld = Pather.interface(source=ports, library=self.library, tools=self.tools) - for port_name, length in extensions.items(): - bld.path(port_name, ccw, length, tool_port_names=tool_port_names, **kwargs) - name = self.library.get_name(base_name) - self.library[name] = bld.pattern - return self.plug(Abstract(name, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'? - - # TODO def bus_join()? - - def flatten(self) -> Self: - """ - Flatten the contained pattern, using the contained library to resolve references. - - Returns: - self - """ - self.pattern.flatten(self.library) + self.plug(abstract, {portspec: tool_port_names[0], **output}) return self diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py new file mode 100644 index 0000000..0a81aa9 --- /dev/null +++ b/masque/builder/pather_mixin.py @@ -0,0 +1,446 @@ +from typing import SupportsFloat, cast, TYPE_CHECKING, Iterator, Self +from collections.abc import Sequence, Mapping, MutableMapping +import logging +from contextlib import contextmanager +from abc import abstractmethod, ABCMeta + +import numpy +from numpy import pi +from numpy.typing import ArrayLike, NDArray + +from ..pattern import Pattern +from ..library import ILibrary +from ..error import PortError, BuildError +from ..ports import PortList, Port +from ..utils import rotation_matrix_2d, SupportsBool +from ..abstract import Abstract +from .tools import Tool +from .utils import ell + + +logger = logging.getLogger(__name__) + + +class PatherMixin(metaclass=ABCMeta): + pattern: Pattern + """ Layout of this device """ + + library: ILibrary + """ Library from which patterns should be referenced """ + + _dead: bool + """ If True, plug()/place() are skipped (for debugging) """ + + tools: dict[str | None, Tool] + """ + Tool objects are used to dynamically generate new single-use Devices + (e.g wires or waveguides) to be plugged into this device. + """ + + @abstractmethod + def path( + self, + portspec: str, + ccw: SupportsBool | None, + length: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + pass + + def retool( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Self: + """ + Update the `Tool` which will be used when generating `Pattern`s for the ports + given by `keys`. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + if keys is None or isinstance(keys, str): + self.tools[keys] = tool + else: + for key in keys: + self.tools[key] = tool + return self + + @contextmanager + def toolctx( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Iterator[Self]: + """ + Context manager for temporarily `retool`-ing and reverting the `retool` + upon exiting the context. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + if keys is None or isinstance(keys, str): + keys = [keys] + saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` + try: + yield self.retool(tool=tool, keys=keys) + finally: + for kk, tt in saved_tools.items(): + if tt is None: + # delete if present + self.tools.pop(kk, None) + else: + self.tools[kk] = tt + + def path_to( + self, + portspec: str, + ccw: SupportsBool | None, + position: float | None = None, + *, + x: float | None = None, + y: float | None = None, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Build a "wire"/"waveguide" extending from the port `portspec`, with the aim + of ending exactly at a target position. + + The wire will travel so that the output port will be placed at exactly the target + position along the input port's axis. There can be an unspecified (tool-dependent) + offset in the perpendicular direction. The output port will be rotated (or not) + based on the `ccw` parameter. + + If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec: The name of the port into which the wire will be plugged. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + position: The final port position, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + Only one of `position`, `x`, and `y` may be specified. + x: The final port position along the x axis. + `portspec` must refer to a horizontal port if `x` is passed, otherwise a + BuildError will be raised. + y: The final port position along the y axis. + `portspec` must refer to a vertical port if `y` is passed, otherwise a + BuildError will be raised. + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend + is present). + BuildError if `x` or `y` is specified but does not match the axis of `portspec`. + BuildError if more than one of `x`, `y`, and `position` is specified. + """ + if self._dead: + logger.error('Skipping path_to() since device is dead') + return self + + pos_count = sum(vv is not None for vv in (position, x, y)) + if pos_count > 1: + raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') + if pos_count < 1: + raise BuildError('One of `position`, `x`, and `y` must be specified') + + port = self.pattern[portspec] + if port.rotation is None: + raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') + + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise BuildError('path_to was asked to route from non-manhattan port') + + is_horizontal = numpy.isclose(port.rotation % pi, 0) + if is_horizontal: + if y is not None: + raise BuildError('Asked to path to y-coordinate, but port is horizontal') + if position is None: + position = x + else: + if x is not None: + raise BuildError('Asked to path to x-coordinate, but port is vertical') + if position is None: + position = y + + x0, y0 = port.offset + if is_horizontal: + if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): + raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') + length = numpy.abs(position - x0) + else: + if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): + raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') + length = numpy.abs(position - y0) + + return self.path( + portspec, + ccw, + length, + plug_into = plug_into, + **kwargs, + ) + + def path_into( + self, + portspec_src: str, + portspec_dst: str, + *, + out_ptype: str | None = None, + plug_destination: bool = True, + **kwargs, + ) -> Self: + """ + Create a "wire"/"waveguide" traveling between the ports `portspec_src` and + `portspec_dst`, and `plug` it into both (or just the source port). + + Only unambiguous scenarios are allowed: + - Straight connector between facing ports + - Single 90 degree bend + - Jog between facing ports + (jog is done as late as possible, i.e. only 2 L-shaped segments are used) + + By default, the destination's `pytpe` will be used as the `out_ptype` for the + wire, and the `portspec_dst` will be plugged (i.e. removed). + + If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec_src: The name of the starting port into which the wire will be plugged. + portspec_dst: The name of the destination port. + out_ptype: Passed to the pathing tool in order to specify the desired port type + to be generated at the destination end. If `None` (default), the destination + port's `ptype` will be used. + + Returns: + self + + Raises: + PortError if either port does not have a specified rotation. + BuildError if and invalid port config is encountered: + - Non-manhattan ports + - U-bend + - Destination too close to (or behind) source + """ + if self._dead: + logger.error('Skipping path_into() since device is dead') + return self + + port_src = self.pattern[portspec_src] + port_dst = self.pattern[portspec_dst] + + if out_ptype is None: + out_ptype = port_dst.ptype + + if port_src.rotation is None: + raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()') + if port_dst.rotation is None: + raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()') + + if not numpy.isclose(port_src.rotation % (pi / 2), 0): + raise BuildError('path_into was asked to route from non-manhattan port') + if not numpy.isclose(port_dst.rotation % (pi / 2), 0): + raise BuildError('path_into was asked to route to non-manhattan port') + + src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0) + dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0) + xs, ys = port_src.offset + xd, yd = port_dst.offset + + angle = (port_dst.rotation - port_src.rotation) % (2 * pi) + + src_ne = port_src.rotation % (2 * pi) > (3 * pi / 4) # path from src will go north or east + + def get_jog(ccw: SupportsBool, length: float) -> float: + tool = self.tools.get(portspec_src, self.tools[None]) + in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already... + tree2 = tool.path(ccw, length, in_ptype=in_ptype, port_names=('A', 'B'), out_ptype=out_ptype, **kwargs) + top2 = tree2.top_pattern() + jog = rotation_matrix_2d(top2['A'].rotation) @ (top2['B'].offset - top2['A'].offset) + return jog[1] * [-1, 1][int(bool(ccw))] + + dst_extra_args = {'out_ptype': out_ptype} + if plug_destination: + dst_extra_args['plug_into'] = portspec_dst + + src_args = {**kwargs} + dst_args = {**src_args, **dst_extra_args} + if src_is_horizontal and not dst_is_horizontal: + # single bend should suffice + self.path_to(portspec_src, angle > pi, x=xd, **src_args) + self.path_to(portspec_src, None, y=yd, **dst_args) + elif dst_is_horizontal and not src_is_horizontal: + # single bend should suffice + self.path_to(portspec_src, angle > pi, y=yd, **src_args) + self.path_to(portspec_src, None, x=xd, **dst_args) + elif numpy.isclose(angle, pi): + if src_is_horizontal and ys == yd: + # straight connector + self.path_to(portspec_src, None, x=xd, **dst_args) + elif not src_is_horizontal and xs == xd: + # straight connector + self.path_to(portspec_src, None, y=yd, **dst_args) + elif src_is_horizontal: + # figure out how much x our y-segment (2nd) takes up, then path based on that + y_len = numpy.abs(yd - ys) + ccw2 = src_ne != (yd > ys) + jog = get_jog(ccw2, y_len) * numpy.sign(xd - xs) + self.path_to(portspec_src, not ccw2, x=xd - jog, **src_args) + self.path_to(portspec_src, ccw2, y=yd, **dst_args) + else: + # figure out how much y our x-segment (2nd) takes up, then path based on that + x_len = numpy.abs(xd - xs) + ccw2 = src_ne != (xd < xs) + jog = get_jog(ccw2, x_len) * numpy.sign(yd - ys) + self.path_to(portspec_src, not ccw2, y=yd - jog, **src_args) + self.path_to(portspec_src, ccw2, x=xd, **dst_args) + elif numpy.isclose(angle, 0): + raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') + else: + raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') + + return self + + def mpath( + self, + portspec: str | Sequence[str], + ccw: SupportsBool | None, + *, + spacing: float | ArrayLike | None = None, + set_rotation: float | None = None, + **kwargs, + ) -> Self: + """ + `mpath` is a superset of `path` and `path_to` which can act on bundles or buses + of "wires or "waveguides". + + The wires will travel so that the output ports will be placed at well-defined + locations along the axis of their input ports, but may have arbitrary (tool- + dependent) offsets in the perpendicular direction. + + If `ccw` is not `None`, the wire bundle will turn 90 degres in either the + clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the + bundle, the center-to-center wire spacings after the turn are set by `spacing`, + which is required when `ccw` is not `None`. The final position of bundle as a + whole can be set in a number of ways: + + =A>---------------------------V turn direction: `ccw=False` + =B>-------------V | + =C>-----------------------V | + =D=>----------------V | + | + + x---x---x---x `spacing` (can be scalar or array) + + <--------------> `emin=` + <------> `bound_type='min_past_furthest', bound=` + <--------------------------------> `emax=` + x `pmin=` + x `pmax=` + + - `emin=`, equivalent to `bound_type='min_extension', bound=` + The total extension value for the furthest-out port (B in the diagram). + - `emax=`, equivalent to `bound_type='max_extension', bound=`: + The total extension value for the closest-in port (C in the diagram). + - `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`: + The coordinate of the innermost bend (D's bend). + The x/y versions throw an error if they do not match the port axis (for debug) + - `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`: + The coordinate of the outermost bend (A's bend). + The x/y versions throw an error if they do not match the port axis (for debug) + - `bound_type='min_past_furthest', bound=`: + The distance between furthest out-port (B) and the innermost bend (D's bend). + + If `ccw=None`, final output positions (along the input axis) of all wires will be + identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is + required. In this case, `emin=` and `emax=` are equivalent to each other, and + `pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other. + + If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec: The names of the ports which are to be routed. + ccw: If `None`, the outputs should be along the same axis as the inputs. + Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` + and clockwise otherwise. + spacing: Center-to-center distance between output ports along the input port's axis. + Must be provided if (and only if) `ccw` is not `None`. + set_rotation: If the provided ports have `rotation=None`, this can be used + to set a rotation for them. + + Returns: + self + + Raises: + BuildError if the implied length for any wire is too close to fit the bend + (if a bend is requested). + BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not + match the axis of `portspec`. + BuildError if an incorrect bound type or spacing is specified. + """ + if self._dead: + logger.error('Skipping mpath() since device is dead') + return self + + bound_types = set() + if 'bound_type' in kwargs: + bound_types.add(kwargs.pop('bound_type')) + bound = kwargs.pop('bound') + for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): + if bt in kwargs: + bound_types.add(bt) + bound = kwargs.pop(bt) + + if not bound_types: + raise BuildError('No bound type specified for mpath') + if len(bound_types) > 1: + raise BuildError(f'Too many bound types specified for mpath: {bound_types}') + bound_type = tuple(bound_types)[0] + + if isinstance(portspec, str): + portspec = [portspec] + ports = self.pattern[tuple(portspec)] + + extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) + + #if container: + # assert not getattr(self, 'render'), 'Containers not implemented for RenderPather' + # bld = self.interface(source=ports, library=self.library, tools=self.tools) + # for port_name, length in extensions.items(): + # bld.path(port_name, ccw, length, **kwargs) + # self.library[container] = bld.pattern + # self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'? + #else: + for port_name, length in extensions.items(): + self.path(port_name, ccw, length, **kwargs) + return self + + # TODO def bus_join()? + + def flatten(self) -> Self: + """ + Flatten the contained pattern, using the contained library to resolve references. + + Returns: + self + """ + self.pattern.flatten(self.library) + return self diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 8dae18b..168992f 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -19,13 +19,13 @@ from ..ports import PortList, Port from ..abstract import Abstract from ..utils import SupportsBool from .tools import Tool, RenderStep -from .utils import ell +from .pather_mixin import PatherMixin logger = logging.getLogger(__name__) -class RenderPather(PortList): +class RenderPather(PortList, PatherMixin): """ `RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath` functions to plan out wire paths without incrementally generating the layout. Instead, @@ -108,15 +108,11 @@ class RenderPather(PortList): if self.pattern.ports: raise BuildError('Ports supplied for pattern with pre-existing ports!') if isinstance(ports, str): - if library is None: - raise BuildError('Ports given as a string, but `library` was `None`!') ports = library.abstract(ports).ports self.pattern.ports.update(copy.deepcopy(dict(ports))) if name is not None: - if library is None: - raise BuildError('Name was supplied, but no library was given!') library[name] = self.pattern if tools is None: @@ -186,6 +182,10 @@ class RenderPather(PortList): new = RenderPather(library=library, pattern=pat, name=name, tools=tools) return new + def __repr__(self) -> str: + s = f'' + return s + def plug( self, other: Abstract | str, @@ -345,28 +345,16 @@ class RenderPather(PortList): return self - def retool( + def plugged( self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, + connections: dict[str, str], ) -> Self: - """ - Update the `Tool` which will be used when generating `Pattern`s for the ports - given by `keys`. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - self.tools[keys] = tool - else: - for key in keys: - self.tools[key] = tool + for aa, bb in connections.items(): + porta = self.ports[aa] + portb = self.ports[bb] + self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None)) + self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None)) + PortList.plugged(self, connections) return self def path( @@ -374,6 +362,8 @@ class RenderPather(PortList): portspec: str, ccw: SupportsBool | None, length: float, + *, + plug_into: str | None = None, **kwargs, ) -> Self: """ @@ -393,6 +383,8 @@ class RenderPather(PortList): and clockwise otherwise. length: The total distance from input to output, along the input's axis only. (There may be a tool-dependent offset along the other axis.) + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. Returns: self @@ -423,161 +415,9 @@ class RenderPather(PortList): self.pattern.ports[portspec] = out_port.copy() - return self + if plug_into is not None: + self.plugged({portspec: plug_into}) - def path_to( - self, - portspec: str, - ccw: SupportsBool | None, - position: float | None = None, - *, - x: float | None = None, - y: float | None = None, - **kwargs, - ) -> Self: - """ - Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim - of ending exactly at a target position. - - The wire will travel so that the output port will be placed at exactly the target - position along the input port's axis. There can be an unspecified (tool-dependent) - offset in the perpendicular direction. The output port will be rotated (or not) - based on the `ccw` parameter. - - `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - position: The final port position, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - Only one of `position`, `x`, and `y` may be specified. - x: The final port position along the x axis. - `portspec` must refer to a horizontal port if `x` is passed, otherwise a - BuildError will be raised. - y: The final port position along the y axis. - `portspec` must refer to a vertical port if `y` is passed, otherwise a - BuildError will be raised. - - Returns: - self - - Raises: - BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend - is present). - BuildError if `x` or `y` is specified but does not match the axis of `portspec`. - BuildError if more than one of `x`, `y`, and `position` is specified. - """ - if self._dead: - logger.error('Skipping path_to() since device is dead') - return self - - pos_count = sum(vv is not None for vv in (position, x, y)) - if pos_count > 1: - raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') - if pos_count < 1: - raise BuildError('One of `position`, `x`, and `y` must be specified') - - port = self.pattern[portspec] - if port.rotation is None: - raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') - - if not numpy.isclose(port.rotation % (pi / 2), 0): - raise BuildError('path_to was asked to route from non-manhattan port') - - is_horizontal = numpy.isclose(port.rotation % pi, 0) - if is_horizontal: - if y is not None: - raise BuildError('Asked to path to y-coordinate, but port is horizontal') - if position is None: - position = x - else: - if x is not None: - raise BuildError('Asked to path to x-coordinate, but port is vertical') - if position is None: - position = y - - x0, y0 = port.offset - if is_horizontal: - if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): - raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') - length = numpy.abs(position - x0) - else: - if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): - raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') - length = numpy.abs(position - y0) - - return self.path(portspec, ccw, length, **kwargs) - - def mpath( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - set_rotation: float | None = None, - **kwargs, - ) -> Self: - """ - `mpath` is a superset of `path` and `path_to` which can act on bundles or buses - of "wires or "waveguides". - - See `Pather.mpath` for details. - - Args: - portspec: The names of the ports which are to be routed. - ccw: If `None`, the outputs should be along the same axis as the inputs. - Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` - and clockwise otherwise. - spacing: Center-to-center distance between output ports along the input port's axis. - Must be provided if (and only if) `ccw` is not `None`. - set_rotation: If the provided ports have `rotation=None`, this can be used - to set a rotation for them. - - Returns: - self - - Raises: - BuildError if the implied length for any wire is too close to fit the bend - (if a bend is requested). - BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not - match the axis of `portspec`. - BuildError if an incorrect bound type or spacing is specified. - """ - if self._dead: - logger.error('Skipping mpath() since device is dead') - return self - - bound_types = set() - if 'bound_type' in kwargs: - bound_types.add(kwargs['bound_type']) - bound = kwargs['bound'] - for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): - if bt in kwargs: - bound_types.add(bt) - bound = kwargs[bt] - - if not bound_types: - raise BuildError('No bound type specified for mpath') - if len(bound_types) > 1: - raise BuildError(f'Too many bound types specified for mpath: {bound_types}') - bound_type = tuple(bound_types)[0] - - if isinstance(portspec, str): - portspec = [portspec] - ports = self.pattern[tuple(portspec)] - - extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) - - if len(ports) == 1: - # Not a bus, so having a container just adds noise to the layout - port_name = tuple(portspec)[0] - self.path(port_name, ccw, extensions[port_name]) - else: - for port_name, length in extensions.items(): - self.path(port_name, ccw, length) return self def render( @@ -696,8 +536,4 @@ class RenderPather(PortList): self._dead = True return self - def __repr__(self) -> str: - s = f'' - return s - diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 6542271..7501b18 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -401,15 +401,15 @@ class BasicTool(Tool, metaclass=ABCMeta): gen_straight, sport_in, _sport_out = self.straight for step in batch: - straight_length, ccw, in_transition, out_transition = step.data + data = step.data assert step.tool == self if step.opcode == 'L': - if in_transition: - ipat, iport_theirs, _iport_ours = in_transition + if data.in_transition: + ipat, iport_theirs, _iport_ours = data.in_transition pat.plug(ipat, {port_names[1]: iport_theirs}) - if not numpy.isclose(straight_length, 0): - straight_pat_or_tree = gen_straight(straight_length, **kwargs) + if not numpy.isclose(data.straight_length, 0): + straight_pat_or_tree = gen_straight(data.straight_length, **kwargs) pmap = {port_names[1]: sport_in} if isinstance(straight_pat_or_tree, Pattern): straight_pat = straight_pat_or_tree @@ -427,11 +427,11 @@ class BasicTool(Tool, metaclass=ABCMeta): else: straight = tree <= straight_pat_or_tree pat.plug(straight, pmap) - if ccw is not None: + if data.ccw is not None: bend, bport_in, bport_out = self.bend - pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw)) - if out_transition: - opat, oport_theirs, oport_ours = out_transition + pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(data.ccw)) + if data.out_transition: + opat, oport_theirs, oport_ours = data.out_transition pat.plug(opat, {port_names[1]: oport_ours}) return tree From 92875cfdb6a221f3a45086dda038ab916b952616 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 11 Nov 2025 20:35:55 -0800 Subject: [PATCH 056/100] [Pather/RenderPather/PatherMixin] clean up imports --- masque/builder/pather.py | 6 +----- masque/builder/pather_mixin.py | 9 ++++----- masque/builder/renderpather.py | 3 +-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 48a3484..e9cd125 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -7,13 +7,9 @@ import copy import logging from pprint import pformat -import numpy -from numpy import pi -from numpy.typing import ArrayLike - from ..pattern import Pattern from ..library import ILibrary -from ..error import PortError, BuildError +from ..error import BuildError from ..ports import PortList, Port from ..utils import SupportsBool from .tools import Tool diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 0a81aa9..e2c7eee 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -1,19 +1,18 @@ -from typing import SupportsFloat, cast, TYPE_CHECKING, Iterator, Self -from collections.abc import Sequence, Mapping, MutableMapping +from typing import Self +from collections.abc import Sequence, Iterator import logging from contextlib import contextmanager from abc import abstractmethod, ABCMeta import numpy from numpy import pi -from numpy.typing import ArrayLike, NDArray +from numpy.typing import ArrayLike from ..pattern import Pattern from ..library import ILibrary from ..error import PortError, BuildError -from ..ports import PortList, Port from ..utils import rotation_matrix_2d, SupportsBool -from ..abstract import Abstract +#from ..abstract import Abstract from .tools import Tool from .utils import ell diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 168992f..0335d7b 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -8,13 +8,12 @@ import logging from collections import defaultdict from pprint import pformat -import numpy from numpy import pi from numpy.typing import ArrayLike from ..pattern import Pattern from ..library import ILibrary -from ..error import PortError, BuildError +from ..error import BuildError from ..ports import PortList, Port from ..abstract import Abstract from ..utils import SupportsBool From a908fadfc3e37929a90b03dc04f1b8a883bd1809 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 11 Nov 2025 20:40:13 -0800 Subject: [PATCH 057/100] [RenderPather] add wrapped label/ref/polygon/rect functions --- masque/builder/renderpather.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 0335d7b..c546f8b 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -6,6 +6,7 @@ from collections.abc import Sequence, Mapping, MutableMapping import copy import logging from collections import defaultdict +from functools import wraps from pprint import pformat from numpy import pi @@ -535,4 +536,23 @@ class RenderPather(PortList, PatherMixin): self._dead = True return self + @wraps(Pattern.label) + def label(self, *args, **kwargs) -> Self: + self.pattern.label(*args, **kwargs) + return self + + @wraps(Pattern.ref) + def ref(self, *args, **kwargs) -> Self: + self.pattern.ref(*args, **kwargs) + return self + + @wraps(Pattern.polygon) + def polygon(self, *args, **kwargs) -> Self: + self.pattern.polygon(*args, **kwargs) + return self + + @wraps(Pattern.rect) + def rect(self, *args, **kwargs) -> Self: + self.pattern.rect(*args, **kwargs) + return self From d00899bb39a6f139d8f54dd1acc866bd25ffc3cb Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Nov 2025 01:08:58 -0800 Subject: [PATCH 058/100] [AutoTool] Add first pass for AutoTool --- masque/builder/tools.py | 227 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 7501b18..d8fdcc4 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -436,6 +436,233 @@ class BasicTool(Tool, metaclass=ABCMeta): return tree +@dataclass +class AutoTool(Tool, metaclass=ABCMeta): + """ + A simple tool which relies on a single pre-rendered `bend` pattern, a function + for generating straight paths, and a table of pre-rendered `transitions` for converting + from non-native ptypes. + """ + straights: list[tuple[str, Callable[[float], Pattern] | Callable[[float], Library], str, str]] + + bends: list[abstract_tuple_t] # Assumed to be clockwise + """ `clockwise_bend_abstract, in_port_name, out_port_name` """ + + transitions: dict[tuple[str, str], abstract_tuple_t] + """ `{(external_ptype, internal_ptype): (transition_abstract`, ptype_port_name, other_port_name), ...}` """ + + default_out_ptype: str + """ Default value for out_ptype """ + + @dataclass(frozen=True, slots=True) + class LData: + """ Data for planL """ + straight_length: float + straight_tuple: tuple[str, Callable[[float], Pattern] | Callable[[float], Library], str, str] + ccw: SupportsBool | None + bend_tuple: abstract_tuple_t | None + in_transition: abstract_tuple_t | None + b_transition: abstract_tuple_t | None + out_transition: abstract_tuple_t | None + + def path( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, + ) -> Library: + _out_port, data = self.planL( + ccw, + length, + in_ptype = in_ptype, + out_ptype = out_ptype, + ) + + stype, gen_straight, sport_in, sport_out = data.straight_tuple + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) + if not numpy.isclose(data.straight_length, 0): + straight_pat_or_tree = gen_straight(data.straight_length, **kwargs) + if isinstance(straight_pat_or_tree, Pattern): + straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat_or_tree} + else: + straight = tree <= straight_pat_or_tree + pat.plug(straight, {port_names[1]: sport_in}) + if data.b_transition: + btpat, btport_bend, btport_straight = data.b_transition + pat.plug(btpat, {port_names[1]: btport_straight}) + if data.ccw is not None: + assert data.bend_tuple is not None + bend, bport_in, bport_out = data.bend_tuple + pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(data.ccw)) + if data.out_transition: + opat, oport_theirs, oport_ours = data.out_transition + pat.plug(opat, {port_names[1]: oport_ours}) + + return tree + + @staticmethod + def _bend2dxy(bend_tuple: abstract_tuple_t, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: + if ccw is None: + return numpy.zeros(2), 0.0 + bend, bport_in, bport_out = bend_tuple + + angle_in = bend.ports[bport_in].rotation + angle_out = bend.ports[bport_out].rotation + assert angle_in is not None + assert angle_out is not None + + bend_dxy = rotation_matrix_2d(-angle_in) @ ( + bend.ports[bport_out].offset + - bend.ports[bport_in].offset + ) + + bend_angle = angle_out - angle_in + + if bool(ccw): + bend_dxy[1] *= -1 + bend_angle *= -1 + return bend_dxy, bend_angle + + @staticmethod + def _itransition2dxy(in_transition: abstract_tuple_t | None) -> NDArray[numpy.float64]: + if in_transition is None: + return numpy.zeros(2) + ipat, iport_theirs, iport_ours = in_transition + irot = ipat.ports[iport_theirs].rotation + assert irot is not None + itrans_dxy = rotation_matrix_2d(-irot) @ ( + ipat.ports[iport_ours].offset + - ipat.ports[iport_theirs].offset + ) + return itrans_dxy + + @staticmethod + def _otransition2dxy(out_transition: abstract_tuple_t | None, bend_angle: float) -> NDArray[numpy.float64]: + if out_transition is None: + return numpy.zeros(2) + opat, oport_theirs, oport_ours = out_transition + orot = opat.ports[oport_ours].rotation + assert orot is not None + otrans_dxy = rotation_matrix_2d(-orot + bend_angle) @ ( + opat.ports[oport_theirs].offset + - opat.ports[oport_ours].offset + ) + return otrans_dxy + + def planL( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, # noqa: ARG002 (unused) + ) -> tuple[Port, LData]: + # TODO check all the math for L-shaped bends + + + for straight_tuple in self.straights: + stype = straight_tuple[0] + for bend_tuple in self.bends: + bend_dxy, bend_angle = self._bend2dxy(bend_tuple, ccw) + btypei = bend_tuple[0][bend_tuple[1]].ptype + btypeo = bend_tuple[0][bend_tuple[1]].ptype + + in_transition = self.transitions.get(('unk' if in_ptype is None else in_ptype, stype), None) + itrans_dxy = self._itransition2dxy(in_transition) + + out_transition = self.transitions.get(('unk' if out_ptype is None else out_ptype, stype if ccw is None else btypeo), None) + otrans_dxy = self._otransition2dxy(in_transition, bend_angle) + + b_transition = None + if ccw is not None and btypei != stype: + b_transition = self.transitions.get((btypei, stype), None) + btrans_dxy = self._itransition2dxy(b_transition) + + straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] + bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] + if straight_length >= 0: + break + if straight_length >= 0: + break + else: + # Failed to break + raise BuildError( + f'Asked to draw path with total length {length:,g}, shorter than required bends and transitions:\n' + f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' + f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' + ) + + if out_transition is not None: + opat, oport_theirs, _ = out_transition + out_ptype_actual = opat.ports[oport_theirs].ptype + elif ccw is not None: + bend, _, bport_out = bend_tuple + out_ptype_actual = bend.ports[bport_out].ptype + else: + out_ptype_actual = self.default_out_ptype + + data = self.LData(straight_length, straight_tuple, ccw, bend_tuple, in_transition, b_transition, out_transition) + out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) + return out_port, data + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: Sequence[str] = ('A', 'B'), + append: bool = True, + **kwargs, + ) -> ILibrary: + + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.add_port_pair(names=(port_names[0], port_names[1])) + + for step in batch: + _stype, gen_straight, sport_in, _sport_out = step.data.straight_tuple + assert step.tool == self + + if step.opcode == 'L': + if step.data.in_transition: + ipat, iport_theirs, _iport_ours = step.data.in_transition + pat.plug(ipat, {port_names[1]: iport_theirs}) + if not numpy.isclose(step.data.straight_length, 0): + straight_pat_or_tree = gen_straight(step.data.straight_length, **kwargs) + pmap = {port_names[1]: sport_in} + if isinstance(straight_pat_or_tree, Pattern): + straight_pat = straight_pat_or_tree + if append: + pat.plug(straight_pat, pmap, append=True) + else: + straight_name = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} + pat.plug(straight_name, pmap) + else: + straight_tree = straight_pat_or_tree + if append: + top = straight_tree.top() + straight_tree.flatten(top) + pat.plug(straight_tree[top], pmap, append=True) + else: + straight = tree <= straight_pat_or_tree + pat.plug(straight, pmap) + if step.data.b_transition: + btpat, btport_bend, btport_straight = step.data.b_transition + pat.plug(btpat, {port_names[1]: btport_straight}) + if step.data.ccw is not None: + bend, bport_in, bport_out = step.data.bend_tuple + pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(step.data.ccw)) + if step.data.out_transition: + opat, oport_theirs, oport_ours = step.data.out_transition + pat.plug(opat, {port_names[1]: oport_ours}) + return tree + + + @dataclass class PathTool(Tool, metaclass=ABCMeta): """ From 4822ae87085e8095628f7666e6a95f40dc2b0049 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Nov 2025 17:40:34 -0800 Subject: [PATCH 059/100] [BasicTool/AutoTool] fix port orientation for straight segments when using RenderPather --- masque/builder/tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index d8fdcc4..05d3115 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -340,7 +340,7 @@ class BasicTool(Tool, metaclass=ABCMeta): bend_angle *= -1 else: bend_dxy = numpy.zeros(2) - bend_angle = 0 + bend_angle = pi in_transition = self.transitions.get('unk' if in_ptype is None else in_ptype, None) if in_transition is not None: @@ -508,7 +508,7 @@ class AutoTool(Tool, metaclass=ABCMeta): @staticmethod def _bend2dxy(bend_tuple: abstract_tuple_t, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: if ccw is None: - return numpy.zeros(2), 0.0 + return numpy.zeros(2), pi bend, bport_in, bport_out = bend_tuple angle_in = bend.ports[bport_in].rotation From 38d4c4b6af7748cd98608edb6981c277aee4c221 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Nov 2025 17:42:52 -0800 Subject: [PATCH 060/100] [AutoTool] support min/max length for straight segments --- masque/builder/tools.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 05d3115..d6da748 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -443,7 +443,8 @@ class AutoTool(Tool, metaclass=ABCMeta): for generating straight paths, and a table of pre-rendered `transitions` for converting from non-native ptypes. """ - straights: list[tuple[str, Callable[[float], Pattern] | Callable[[float], Library], str, str]] + straights: list[tuple[str, Callable[[float], Pattern] | Callable[[float], Library], str, str, tuple[float, float]]] + # TODO add min length? bends: list[abstract_tuple_t] # Assumed to be clockwise """ `clockwise_bend_abstract, in_port_name, out_port_name` """ @@ -565,9 +566,10 @@ class AutoTool(Tool, metaclass=ABCMeta): ) -> tuple[Port, LData]: # TODO check all the math for L-shaped bends - + success = False for straight_tuple in self.straights: stype = straight_tuple[0] + straight_bounds = straight_tuple[-1] for bend_tuple in self.bends: bend_dxy, bend_angle = self._bend2dxy(bend_tuple, ccw) btypei = bend_tuple[0][bend_tuple[1]].ptype @@ -586,9 +588,10 @@ class AutoTool(Tool, metaclass=ABCMeta): straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] - if straight_length >= 0: + success = straight_bounds[0] <= straight_length < straight_bounds[1] + if success: break - if straight_length >= 0: + if success: break else: # Failed to break From a308b1515a8e550a3375fb9ad3f7aab9aaf8b2ef Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Nov 2025 17:47:07 -0800 Subject: [PATCH 061/100] [format_stacktrace] suppress linter --- masque/error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/error.py b/masque/error.py index f434e51..e475bb0 100644 --- a/masque/error.py +++ b/masque/error.py @@ -89,7 +89,7 @@ def format_stacktrace( if selected := stack[first_ok:last_ok + 1]: pass elif selected := stack[:-stacklevel]: - pass + pass # noqa: SIM114 # separate elif for clarity else: selected = stack return ''.join(traceback.format_list(selected)) From 82f3e7ab8fa5830f785520ac1a51ce70e10fe13e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Nov 2025 17:49:12 -0800 Subject: [PATCH 062/100] [PolyCollection] rename setter arg to placate linter --- masque/shapes/poly_collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index b7fbe48..6048f24 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -77,7 +77,7 @@ class PolyCollection(Shape): return numpy.zeros(2) @offset.setter - def offset(self, val: ArrayLike) -> None: + def offset(self, _val: ArrayLike) -> None: raise PatternError('PolyCollection offset is forced to (0, 0)') def set_offset(self, val: ArrayLike) -> Self: From a69860ad9cbcf4444693a31632f1f86fa9aaa3d6 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Nov 2025 18:50:57 -0800 Subject: [PATCH 063/100] [AutoTool] Use more dataclasses to clarify internal code --- masque/builder/tools.py | 183 ++++++++++++++++++++++------------------ 1 file changed, 101 insertions(+), 82 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index d6da748..d9f70c7 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -443,28 +443,69 @@ class AutoTool(Tool, metaclass=ABCMeta): for generating straight paths, and a table of pre-rendered `transitions` for converting from non-native ptypes. """ - straights: list[tuple[str, Callable[[float], Pattern] | Callable[[float], Library], str, str, tuple[float, float]]] - # TODO add min length? - bends: list[abstract_tuple_t] # Assumed to be clockwise - """ `clockwise_bend_abstract, in_port_name, out_port_name` """ + @dataclass(frozen=True, slots=True) + class Straight: + """ Description of a straight-path generator """ + ptype: str + fn: Callable[[float], Pattern] | Callable[[float], Library] + in_port_name: str + out_port_name: str + length_range: tuple[float, float] = (0, numpy.inf) - transitions: dict[tuple[str, str], abstract_tuple_t] - """ `{(external_ptype, internal_ptype): (transition_abstract`, ptype_port_name, other_port_name), ...}` """ + @dataclass(frozen=True, slots=True) + class Bend: + """ Description of a pre-rendered bend """ + abstract: Abstract + in_port_name: str + out_port_name: str + clockwise: bool = True - default_out_ptype: str - """ Default value for out_ptype """ + @property + def in_port(self) -> Port: + return self.abstract.ports[self.in_port_name] + + @property + def out_port(self) -> Port: + return self.abstract.ports[self.out_port_name] + + @dataclass(frozen=True, slots=True) + class Transition: + """ Description of a pre-rendered transition """ + abstract: Abstract + their_port_name: str + our_port_name: str + + @property + def our_port(self) -> Port: + return self.abstract.ports[self.our_port_name] + + @property + def their_port(self) -> Port: + return self.abstract.ports[self.their_port_name] @dataclass(frozen=True, slots=True) class LData: """ Data for planL """ straight_length: float - straight_tuple: tuple[str, Callable[[float], Pattern] | Callable[[float], Library], str, str] + straight: 'AutoTool.Straight' ccw: SupportsBool | None - bend_tuple: abstract_tuple_t | None - in_transition: abstract_tuple_t | None - b_transition: abstract_tuple_t | None - out_transition: abstract_tuple_t | None + bend: 'AutoTool.Bend | None' + in_transition: 'AutoTool.Transition | None' + b_transition: 'AutoTool.Transition | None' + out_transition: 'AutoTool.Transition | None' + + straights: list[Straight] + """ List of straight-generators to choose from, in order of priority """ + + bends: list[Bend] + """ List of bends to choose from, in order of priority. """ + + transitions: dict[tuple[str, str], Transition] + """ `{(external_ptype, internal_ptype): Transition, ...}` """ + + default_out_ptype: str + """ Default value for out_ptype """ def path( self, @@ -483,45 +524,38 @@ class AutoTool(Tool, metaclass=ABCMeta): out_ptype = out_ptype, ) - stype, gen_straight, sport_in, sport_out = data.straight_tuple tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) + if data.in_transition: + pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = gen_straight(data.straight_length, **kwargs) + straight_pat_or_tree = data.straight.fn(data.straight_length, **kwargs) if isinstance(straight_pat_or_tree, Pattern): straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat_or_tree} else: straight = tree <= straight_pat_or_tree - pat.plug(straight, {port_names[1]: sport_in}) + pat.plug(straight, {port_names[1]: data.straight.in_port_name}) if data.b_transition: - btpat, btport_bend, btport_straight = data.b_transition - pat.plug(btpat, {port_names[1]: btport_straight}) + pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) if data.ccw is not None: - assert data.bend_tuple is not None - bend, bport_in, bport_out = data.bend_tuple - pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(data.ccw)) + assert data.bend is not None + pat.plug(data.bend.abstract, {port_names[1]: data.bend.in_port_name}, mirrored=bool(data.ccw) == data.bend.clockwise) if data.out_transition: - opat, oport_theirs, oport_ours = data.out_transition - pat.plug(opat, {port_names[1]: oport_ours}) + pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) return tree @staticmethod - def _bend2dxy(bend_tuple: abstract_tuple_t, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: + def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: if ccw is None: return numpy.zeros(2), pi - bend, bport_in, bport_out = bend_tuple - angle_in = bend.ports[bport_in].rotation - angle_out = bend.ports[bport_out].rotation + angle_in = bend.in_port.rotation + angle_out = bend.out_port.rotation assert angle_in is not None assert angle_out is not None - bend_dxy = rotation_matrix_2d(-angle_in) @ ( - bend.ports[bport_out].offset - - bend.ports[bport_in].offset - ) - + bend_dxy = rotation_matrix_2d(-angle_in) @ (bend.out_port.offset - bend.in_port.offset) bend_angle = angle_out - angle_in if bool(ccw): @@ -530,29 +564,21 @@ class AutoTool(Tool, metaclass=ABCMeta): return bend_dxy, bend_angle @staticmethod - def _itransition2dxy(in_transition: abstract_tuple_t | None) -> NDArray[numpy.float64]: + def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: if in_transition is None: return numpy.zeros(2) - ipat, iport_theirs, iport_ours = in_transition - irot = ipat.ports[iport_theirs].rotation + irot = in_transition.their_port.rotation assert irot is not None - itrans_dxy = rotation_matrix_2d(-irot) @ ( - ipat.ports[iport_ours].offset - - ipat.ports[iport_theirs].offset - ) + itrans_dxy = rotation_matrix_2d(-irot) @ (in_transition.our_port.offset - in_transition.their_port.offset) return itrans_dxy @staticmethod - def _otransition2dxy(out_transition: abstract_tuple_t | None, bend_angle: float) -> NDArray[numpy.float64]: + def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]: if out_transition is None: return numpy.zeros(2) - opat, oport_theirs, oport_ours = out_transition - orot = opat.ports[oport_ours].rotation + orot = out_transition.our_port.rotation assert orot is not None - otrans_dxy = rotation_matrix_2d(-orot + bend_angle) @ ( - opat.ports[oport_theirs].offset - - opat.ports[oport_ours].offset - ) + otrans_dxy = rotation_matrix_2d(pi - orot - bend_angle) @ (out_transition.their_port.offset - out_transition.our_port.offset) return otrans_dxy def planL( @@ -564,31 +590,31 @@ class AutoTool(Tool, metaclass=ABCMeta): out_ptype: str | None = None, **kwargs, # noqa: ARG002 (unused) ) -> tuple[Port, LData]: - # TODO check all the math for L-shaped bends success = False - for straight_tuple in self.straights: - stype = straight_tuple[0] - straight_bounds = straight_tuple[-1] - for bend_tuple in self.bends: - bend_dxy, bend_angle = self._bend2dxy(bend_tuple, ccw) - btypei = bend_tuple[0][bend_tuple[1]].ptype - btypeo = bend_tuple[0][bend_tuple[1]].ptype + for straight in self.straights: + for bend in self.bends: + bend_dxy, bend_angle = self._bend2dxy(bend, ccw) - in_transition = self.transitions.get(('unk' if in_ptype is None else in_ptype, stype), None) + in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) + in_transition = self.transitions.get(in_ptype_pair, None) itrans_dxy = self._itransition2dxy(in_transition) - out_transition = self.transitions.get(('unk' if out_ptype is None else out_ptype, stype if ccw is None else btypeo), None) - otrans_dxy = self._otransition2dxy(in_transition, bend_angle) + out_ptype_pair = ( + 'unk' if out_ptype is None else out_ptype, + straight.ptype if ccw is None else bend.out_port.ptype + ) + out_transition = self.transitions.get(out_ptype_pair, None) + otrans_dxy = self._otransition2dxy(out_transition, bend_angle) b_transition = None - if ccw is not None and btypei != stype: - b_transition = self.transitions.get((btypei, stype), None) + if ccw is not None and bend.in_port.ptype != straight.ptype: + b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) btrans_dxy = self._itransition2dxy(b_transition) straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] - success = straight_bounds[0] <= straight_length < straight_bounds[1] + success = straight.length_range[0] <= straight_length < straight.length_range[1] if success: break if success: @@ -602,15 +628,13 @@ class AutoTool(Tool, metaclass=ABCMeta): ) if out_transition is not None: - opat, oport_theirs, _ = out_transition - out_ptype_actual = opat.ports[oport_theirs].ptype + out_ptype_actual = out_transition.their_port.ptype elif ccw is not None: - bend, _, bport_out = bend_tuple - out_ptype_actual = bend.ports[bport_out].ptype + out_ptype_actual = bend.out_port.ptype else: out_ptype_actual = self.default_out_ptype - data = self.LData(straight_length, straight_tuple, ccw, bend_tuple, in_transition, b_transition, out_transition) + data = self.LData(straight_length, straight, ccw, bend, in_transition, b_transition, out_transition) out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) return out_port, data @@ -627,16 +651,15 @@ class AutoTool(Tool, metaclass=ABCMeta): pat.add_port_pair(names=(port_names[0], port_names[1])) for step in batch: - _stype, gen_straight, sport_in, _sport_out = step.data.straight_tuple + data = step.data assert step.tool == self if step.opcode == 'L': - if step.data.in_transition: - ipat, iport_theirs, _iport_ours = step.data.in_transition - pat.plug(ipat, {port_names[1]: iport_theirs}) - if not numpy.isclose(step.data.straight_length, 0): - straight_pat_or_tree = gen_straight(step.data.straight_length, **kwargs) - pmap = {port_names[1]: sport_in} + if data.in_transition: + pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) + if not numpy.isclose(data.straight_length, 0): + straight_pat_or_tree = data.straight.fn(data.straight_length, **kwargs) + pmap = {port_names[1]: data.straight.in_port_name} if isinstance(straight_pat_or_tree, Pattern): straight_pat = straight_pat_or_tree if append: @@ -653,19 +676,15 @@ class AutoTool(Tool, metaclass=ABCMeta): else: straight = tree <= straight_pat_or_tree pat.plug(straight, pmap) - if step.data.b_transition: - btpat, btport_bend, btport_straight = step.data.b_transition - pat.plug(btpat, {port_names[1]: btport_straight}) - if step.data.ccw is not None: - bend, bport_in, bport_out = step.data.bend_tuple - pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(step.data.ccw)) - if step.data.out_transition: - opat, oport_theirs, oport_ours = step.data.out_transition - pat.plug(opat, {port_names[1]: oport_ours}) + if data.b_transition: + pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) + if data.ccw is not None: + pat.plug(data.bend.abstract, {port_names[1]: data.bend.in_port_name}, mirrored=bool(data.ccw) == data.bend.clockwise) + if data.out_transition: + pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) return tree - @dataclass class PathTool(Tool, metaclass=ABCMeta): """ From 2e577240950735d92ade1dd16cfc9bf445c11411 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Nov 2025 19:02:53 -0800 Subject: [PATCH 064/100] [AutoTool] add `add_complementary_transitions()` --- masque/builder/tools.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index d9f70c7..0c804dc 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir # TODO document all tools """ -from typing import Literal, Any +from typing import Literal, Any, Self from collections.abc import Sequence, Callable from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? from dataclasses import dataclass @@ -484,6 +484,9 @@ class AutoTool(Tool, metaclass=ABCMeta): def their_port(self) -> Port: return self.abstract.ports[self.their_port_name] + def reversed(self) -> Self: + return type(self)(self.abstract, self.their_port_name, self.our_port_name) + @dataclass(frozen=True, slots=True) class LData: """ Data for planL """ @@ -507,6 +510,12 @@ class AutoTool(Tool, metaclass=ABCMeta): default_out_ptype: str """ Default value for out_ptype """ + def add_complementary_transitions(self) -> Self: + for iioo in list(self.transitions.keys()): + ooii = (iioo[1], iioo[0]) + self.transitions.setdefault(ooii, self.transitions[iioo].reversed()) + return self + def path( self, ccw: SupportsBool | None, From fcb622441b1a731a98f80ab6822bc774f3004b4b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 12 Nov 2025 23:53:45 -0800 Subject: [PATCH 065/100] [AutoTool] consolidate duplicate code for path() and render() --- masque/builder/tools.py | 130 +++++++++++++++++++--------------------- 1 file changed, 63 insertions(+), 67 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 0c804dc..589e591 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -516,44 +516,6 @@ class AutoTool(Tool, metaclass=ABCMeta): self.transitions.setdefault(ooii, self.transitions[iioo].reversed()) return self - def path( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **kwargs) - if isinstance(straight_pat_or_tree, Pattern): - straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat_or_tree} - else: - straight = tree <= straight_pat_or_tree - pat.plug(straight, {port_names[1]: data.straight.in_port_name}) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if data.ccw is not None: - assert data.bend is not None - pat.plug(data.bend.abstract, {port_names[1]: data.bend.in_port_name}, mirrored=bool(data.ccw) == data.bend.clockwise) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) - - return tree - @staticmethod def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: if ccw is None: @@ -647,6 +609,68 @@ class AutoTool(Tool, metaclass=ABCMeta): out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) return out_port, data + def _renderL( + self, + data: LData, + tree: ILibrary, + port_names: tuple[str, str], + append: bool, + ) -> ILibrary: + """ + Render an L step into a preexisting tree + """ + pat = tree.top() + if data.in_transition: + pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) + if not numpy.isclose(data.straight_length, 0): + straight_pat_or_tree = data.straight.fn(data.straight_length, **kwargs) + pmap = {port_names[1]: data.straight.in_port_name} + if isinstance(straight_pat_or_tree, Pattern): + straight_pat = straight_pat_or_tree + if append: + pat.plug(straight_pat, pmap, append=True) + else: + straight_name = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} + pat.plug(straight_name, pmap) + else: + straight_tree = straight_pat_or_tree + if append: + top = straight_tree.top() + straight_tree.flatten(top) + pat.plug(straight_tree[top], pmap, append=True) + else: + straight = tree <= straight_pat_or_tree + pat.plug(straight, pmap) + if data.b_transition: + pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) + if data.ccw is not None: + pat.plug(data.bend.abstract, {port_names[1]: data.bend.in_port_name}, mirrored=bool(data.ccw) == data.bend.clockwise) + if data.out_transition: + pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) + return tree + + def path( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, + ) -> Library: + _out_port, data = self.planL( + ccw, + length, + in_ptype = in_ptype, + out_ptype = out_ptype, + ) + + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) + self._renderL(data=data, tree=tree, port_names=port_names, append=False) + return tree + def render( self, batch: Sequence[RenderStep], @@ -660,37 +684,9 @@ class AutoTool(Tool, metaclass=ABCMeta): pat.add_port_pair(names=(port_names[0], port_names[1])) for step in batch: - data = step.data assert step.tool == self - if step.opcode == 'L': - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **kwargs) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - if append: - pat.plug(straight_pat, pmap, append=True) - else: - straight_name = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} - pat.plug(straight_name, pmap) - else: - straight_tree = straight_pat_or_tree - if append: - top = straight_tree.top() - straight_tree.flatten(top) - pat.plug(straight_tree[top], pmap, append=True) - else: - straight = tree <= straight_pat_or_tree - pat.plug(straight, pmap) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if data.ccw is not None: - pat.plug(data.bend.abstract, {port_names[1]: data.bend.in_port_name}, mirrored=bool(data.ccw) == data.bend.clockwise) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) + self._renderL(data=step.data, tree=tree, port_names=port_names, append=append) return tree From cf92cc06b377aa78a63eec074186f8ff655dd530 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 13 Nov 2025 00:01:12 -0800 Subject: [PATCH 066/100] [AutoTool] pass in kwargs to straight fn call --- masque/builder/tools.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 589e591..9d66a96 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -492,6 +492,7 @@ class AutoTool(Tool, metaclass=ABCMeta): """ Data for planL """ straight_length: float straight: 'AutoTool.Straight' + straight_kwargs: dict[str, Any] ccw: SupportsBool | None bend: 'AutoTool.Bend | None' in_transition: 'AutoTool.Transition | None' @@ -605,7 +606,7 @@ class AutoTool(Tool, metaclass=ABCMeta): else: out_ptype_actual = self.default_out_ptype - data = self.LData(straight_length, straight, ccw, bend, in_transition, b_transition, out_transition) + data = self.LData(straight_length, straight, kwargs, ccw, bend, in_transition, b_transition, out_transition) out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) return out_port, data @@ -615,15 +616,16 @@ class AutoTool(Tool, metaclass=ABCMeta): tree: ILibrary, port_names: tuple[str, str], append: bool, + straight_kwargs: dict[str, Any], ) -> ILibrary: """ Render an L step into a preexisting tree """ - pat = tree.top() + pat = tree.top_pattern() if data.in_transition: pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **kwargs) + straight_pat_or_tree = data.straight.fn(data.straight_length, **(straight_kwargs | data.straight_kwargs)) pmap = {port_names[1]: data.straight.in_port_name} if isinstance(straight_pat_or_tree, Pattern): straight_pat = straight_pat_or_tree @@ -668,7 +670,7 @@ class AutoTool(Tool, metaclass=ABCMeta): tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, append=False) + self._renderL(data=data, tree=tree, port_names=port_names, append=False, straight_kwargs=kwargs) return tree def render( @@ -686,7 +688,7 @@ class AutoTool(Tool, metaclass=ABCMeta): for step in batch: assert step.tool == self if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, append=append) + self._renderL(data=step.data, tree=tree, port_names=port_names, append=append, straight_kwargs=kwargs) return tree From 601773d17e17440a35b364c3a749921ae4fc7a1e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 13 Nov 2025 00:39:41 -0800 Subject: [PATCH 067/100] [AutoTool/SimpleTool/BasicTool] Rename BasicTool->SimpleTool and remove transition handling. Export AutoTool and SimpleTool at top level. --- masque/__init__.py | 3 +- masque/builder/__init__.py | 3 +- masque/builder/tools.py | 191 ++++++++++++++----------------------- 3 files changed, 78 insertions(+), 119 deletions(-) diff --git a/masque/__init__.py b/masque/__init__.py index 3515fa5..9dbf233 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -77,7 +77,8 @@ from .builder import ( Pather as Pather, RenderPather as RenderPather, RenderStep as RenderStep, - BasicTool as BasicTool, + SimpleTool as SimpleTool, + AutoTool as AutoTool, PathTool as PathTool, ) from .utils import ( diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 1eea9f1..eb5047f 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -5,6 +5,7 @@ from .utils import ell as ell from .tools import ( Tool as Tool, RenderStep as RenderStep, - BasicTool as BasicTool, + SimpleTool as SimpleTool, + AutoTool as AutoTool, PathTool as PathTool, ) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 9d66a96..04fb18e 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -223,7 +223,7 @@ class Tool: self, batch: Sequence[RenderStep], *, - port_names: Sequence[str] = ('A', 'B'), # noqa: ARG002 (unused) + port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused) **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: """ @@ -245,7 +245,7 @@ abstract_tuple_t = tuple[Abstract, str, str] @dataclass -class BasicTool(Tool, metaclass=ABCMeta): +class SimpleTool(Tool, metaclass=ABCMeta): """ A simple tool which relies on a single pre-rendered `bend` pattern, a function for generating straight paths, and a table of pre-rendered `transitions` for converting @@ -257,9 +257,6 @@ class BasicTool(Tool, metaclass=ABCMeta): bend: abstract_tuple_t # Assumed to be clockwise """ `clockwise_bend_abstract, in_port_name, out_port_name` """ - transitions: dict[str, abstract_tuple_t] - """ `{ptype: (transition_abstract`, ptype_port_name, other_port_name), ...}` """ - default_out_ptype: str """ Default value for out_ptype """ @@ -267,59 +264,18 @@ class BasicTool(Tool, metaclass=ABCMeta): class LData: """ Data for planL """ straight_length: float + straight_kwargs: dict[str, Any] ccw: SupportsBool | None - in_transition: abstract_tuple_t | None - out_transition: abstract_tuple_t | None - - def path( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype=in_ptype, - out_ptype=out_ptype, - ) - - gen_straight, sport_in, sport_out = self.straight - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - if data.in_transition: - ipat, iport_theirs, _iport_ours = data.in_transition - pat.plug(ipat, {port_names[1]: iport_theirs}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = gen_straight(data.straight_length, **kwargs) - if isinstance(straight_pat_or_tree, Pattern): - straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat_or_tree} - else: - straight = tree <= straight_pat_or_tree - pat.plug(straight, {port_names[1]: sport_in}) - if data.ccw is not None: - bend, bport_in, bport_out = self.bend - pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw)) - if data.out_transition: - opat, oport_theirs, oport_ours = data.out_transition - pat.plug(opat, {port_names[1]: oport_ours}) - - return tree def planL( self, ccw: SupportsBool | None, length: float, *, - in_ptype: str | None = None, - out_ptype: str | None = None, + in_ptype: str | None = None, # noqa: ARG002 (unused) + out_ptype: str | None = None, # noqa: ARG002 (unused) **kwargs, # noqa: ARG002 (unused) ) -> tuple[Port, LData]: - # TODO check all the math for L-shaped bends if ccw is not None: bend, bport_in, bport_out = self.bend @@ -342,56 +298,87 @@ class BasicTool(Tool, metaclass=ABCMeta): bend_dxy = numpy.zeros(2) bend_angle = pi - in_transition = self.transitions.get('unk' if in_ptype is None else in_ptype, None) - if in_transition is not None: - ipat, iport_theirs, iport_ours = in_transition - irot = ipat.ports[iport_theirs].rotation - assert irot is not None - itrans_dxy = rotation_matrix_2d(-irot) @ ( - ipat.ports[iport_ours].offset - - ipat.ports[iport_theirs].offset - ) - else: - itrans_dxy = numpy.zeros(2) - - out_transition = self.transitions.get('unk' if out_ptype is None else out_ptype, None) - if out_transition is not None: - opat, oport_theirs, oport_ours = out_transition - orot = opat.ports[oport_ours].rotation - assert orot is not None - - otrans_dxy = rotation_matrix_2d(-orot + bend_angle) @ ( - opat.ports[oport_theirs].offset - - opat.ports[oport_ours].offset - ) - else: - otrans_dxy = numpy.zeros(2) - - if out_transition is not None: - out_ptype_actual = opat.ports[oport_theirs].ptype - elif ccw is not None: + if ccw is not None: out_ptype_actual = bend.ports[bport_out].ptype else: out_ptype_actual = self.default_out_ptype - straight_length = length - bend_dxy[0] - itrans_dxy[0] - otrans_dxy[0] - bend_run = bend_dxy[1] + itrans_dxy[1] + otrans_dxy[1] + straight_length = length - bend_dxy[0] + bend_run = bend_dxy[1] if straight_length < 0: raise BuildError( - f'Asked to draw path with total length {length:,g}, shorter than required bends and transitions:\n' - f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g} out_trans: {otrans_dxy[0]:,g}' + f'Asked to draw path with total length {length:,g}, shorter than required bends ({bend_dxy[0]:,})' ) - data = self.LData(straight_length, ccw, in_transition, out_transition) + data = self.LData(straight_length, kwargs, ccw) out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) return out_port, data + def _renderL( + self, + data: LData, + tree: ILibrary, + port_names: tuple[str, str], + append: bool, + straight_kwargs: dict[str, Any], + ) -> ILibrary: + """ + Render an L step into a preexisting tree + """ + pat = tree.top_pattern() + gen_straight, sport_in, _sport_out = self.straight + if not numpy.isclose(data.straight_length, 0): + straight_pat_or_tree = gen_straight(data.straight_length, **(straight_kwargs | data.straight_kwargs)) + pmap = {port_names[1]: sport_in} + if isinstance(straight_pat_or_tree, Pattern): + straight_pat = straight_pat_or_tree + if append: + pat.plug(straight_pat, pmap, append=True) + else: + straight_name = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} + pat.plug(straight_name, pmap) + else: + straight_tree = straight_pat_or_tree + if append: + top = straight_tree.top() + straight_tree.flatten(top) + pat.plug(straight_tree[top], pmap, append=True) + else: + straight = tree <= straight_pat_or_tree + pat.plug(straight, pmap) + if data.ccw is not None: + bend, bport_in, bport_out = self.bend + pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(data.ccw)) + return tree + + def path( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, + ) -> Library: + _out_port, data = self.planL( + ccw, + length, + in_ptype = in_ptype, + out_ptype = out_ptype, + ) + + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) + self._renderL(data=data, tree=tree, port_names=port_names, append=False, straight_kwargs=kwargs) + return tree + def render( self, batch: Sequence[RenderStep], *, - port_names: Sequence[str] = ('A', 'B'), + port_names: tuple[str, str] = ('A', 'B'), append: bool = True, **kwargs, ) -> ILibrary: @@ -399,43 +386,12 @@ class BasicTool(Tool, metaclass=ABCMeta): tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=(port_names[0], port_names[1])) - gen_straight, sport_in, _sport_out = self.straight for step in batch: - data = step.data assert step.tool == self - if step.opcode == 'L': - if data.in_transition: - ipat, iport_theirs, _iport_ours = data.in_transition - pat.plug(ipat, {port_names[1]: iport_theirs}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = gen_straight(data.straight_length, **kwargs) - pmap = {port_names[1]: sport_in} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - if append: - pat.plug(straight_pat, pmap, append=True) - else: - straight_name = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} - pat.plug(straight_name, pmap) - else: - straight_tree = straight_pat_or_tree - if append: - top = straight_tree.top() - straight_tree.flatten(top) - pat.plug(straight_tree[top], pmap, append=True) - else: - straight = tree <= straight_pat_or_tree - pat.plug(straight, pmap) - if data.ccw is not None: - bend, bport_in, bport_out = self.bend - pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(data.ccw)) - if data.out_transition: - opat, oport_theirs, oport_ours = data.out_transition - pat.plug(opat, {port_names[1]: oport_ours}) + self._renderL(data=step.data, tree=tree, port_names=port_names, append=append, straight_kwargs=kwargs) return tree - @dataclass class AutoTool(Tool, metaclass=ABCMeta): """ @@ -646,6 +602,7 @@ class AutoTool(Tool, metaclass=ABCMeta): if data.b_transition: pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) if data.ccw is not None: + assert data.bend is not None pat.plug(data.bend.abstract, {port_names[1]: data.bend.in_port_name}, mirrored=bool(data.ccw) == data.bend.clockwise) if data.out_transition: pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) @@ -677,7 +634,7 @@ class AutoTool(Tool, metaclass=ABCMeta): self, batch: Sequence[RenderStep], *, - port_names: Sequence[str] = ('A', 'B'), + port_names: tuple[str, str] = ('A', 'B'), append: bool = True, **kwargs, ) -> ILibrary: @@ -793,7 +750,7 @@ class PathTool(Tool, metaclass=ABCMeta): self, batch: Sequence[RenderStep], *, - port_names: Sequence[str] = ('A', 'B'), + port_names: tuple[str, str] = ('A', 'B'), **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: From 7c033edc2148a1f76e43f25619510d0e205b2043 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 14 Nov 2025 21:28:02 -0800 Subject: [PATCH 068/100] [SimpleTool/AutoTool] clarify some error messages --- masque/builder/tools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 04fb18e..2b3c6cf 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -308,7 +308,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): if straight_length < 0: raise BuildError( - f'Asked to draw path with total length {length:,g}, shorter than required bends ({bend_dxy[0]:,})' + f'Asked to draw L-path with total length {length:,g}, shorter than required bends ({bend_dxy[0]:,})' ) data = self.LData(straight_length, kwargs, ccw) @@ -550,7 +550,7 @@ class AutoTool(Tool, metaclass=ABCMeta): else: # Failed to break raise BuildError( - f'Asked to draw path with total length {length:,g}, shorter than required bends and transitions:\n' + f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n' f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' ) @@ -740,7 +740,7 @@ class PathTool(Tool, metaclass=ABCMeta): if straight_length < 0: raise BuildError( - f'Asked to draw path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' + f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' ) data = numpy.array((length, bend_run)) out_port = Port(data, rotation=bend_angle, ptype=self.ptype) From b1c838c8fddab231d371da54794361ac27d69652 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 14 Nov 2025 21:29:44 -0800 Subject: [PATCH 069/100] [AutoTool / SimpleTool] remove `append` arg --- masque/builder/tools.py | 45 +++++++++++------------------------------ 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 2b3c6cf..d4638c0 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -320,7 +320,6 @@ class SimpleTool(Tool, metaclass=ABCMeta): data: LData, tree: ILibrary, port_names: tuple[str, str], - append: bool, straight_kwargs: dict[str, Any], ) -> ILibrary: """ @@ -333,20 +332,12 @@ class SimpleTool(Tool, metaclass=ABCMeta): pmap = {port_names[1]: sport_in} if isinstance(straight_pat_or_tree, Pattern): straight_pat = straight_pat_or_tree - if append: - pat.plug(straight_pat, pmap, append=True) - else: - straight_name = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} - pat.plug(straight_name, pmap) + pat.plug(straight_pat, pmap, append=True) else: straight_tree = straight_pat_or_tree - if append: - top = straight_tree.top() - straight_tree.flatten(top) - pat.plug(straight_tree[top], pmap, append=True) - else: - straight = tree <= straight_pat_or_tree - pat.plug(straight, pmap) + top = straight_tree.top() + straight_tree.flatten(top) + pat.plug(straight_tree[top], pmap, append=True) if data.ccw is not None: bend, bport_in, bport_out = self.bend pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(data.ccw)) @@ -371,7 +362,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, append=False, straight_kwargs=kwargs) + self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) return tree def render( @@ -379,7 +370,6 @@ class SimpleTool(Tool, metaclass=ABCMeta): batch: Sequence[RenderStep], *, port_names: tuple[str, str] = ('A', 'B'), - append: bool = True, **kwargs, ) -> ILibrary: @@ -389,7 +379,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): for step in batch: assert step.tool == self if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, append=append, straight_kwargs=kwargs) + self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) return tree @dataclass @@ -571,7 +561,6 @@ class AutoTool(Tool, metaclass=ABCMeta): data: LData, tree: ILibrary, port_names: tuple[str, str], - append: bool, straight_kwargs: dict[str, Any], ) -> ILibrary: """ @@ -584,21 +573,12 @@ class AutoTool(Tool, metaclass=ABCMeta): straight_pat_or_tree = data.straight.fn(data.straight_length, **(straight_kwargs | data.straight_kwargs)) pmap = {port_names[1]: data.straight.in_port_name} if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - if append: - pat.plug(straight_pat, pmap, append=True) - else: - straight_name = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} - pat.plug(straight_name, pmap) + pat.plug(straight_pat_or_tree, pmap, append=True) else: straight_tree = straight_pat_or_tree - if append: - top = straight_tree.top() - straight_tree.flatten(top) - pat.plug(straight_tree[top], pmap, append=True) - else: - straight = tree <= straight_pat_or_tree - pat.plug(straight, pmap) + top = straight_tree.top() + straight_tree.flatten(top) + pat.plug(straight_tree[top], pmap, append=True) if data.b_transition: pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) if data.ccw is not None: @@ -627,7 +607,7 @@ class AutoTool(Tool, metaclass=ABCMeta): tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, append=False, straight_kwargs=kwargs) + self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) return tree def render( @@ -635,7 +615,6 @@ class AutoTool(Tool, metaclass=ABCMeta): batch: Sequence[RenderStep], *, port_names: tuple[str, str] = ('A', 'B'), - append: bool = True, **kwargs, ) -> ILibrary: @@ -645,7 +624,7 @@ class AutoTool(Tool, metaclass=ABCMeta): for step in batch: assert step.tool == self if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, append=append, straight_kwargs=kwargs) + self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) return tree From 021142533d9a69636938f5acc7d1ea28aa0eed6f Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Fri, 14 Nov 2025 21:30:12 -0800 Subject: [PATCH 070/100] [AutoTool] enable S-bends --- masque/builder/tools.py | 163 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index d4638c0..2b88d24 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -399,6 +399,20 @@ class AutoTool(Tool, metaclass=ABCMeta): out_port_name: str length_range: tuple[float, float] = (0, numpy.inf) + @dataclass(frozen=True, slots=True) + class SBend: + """ Description of an s-bend generator """ + ptype: str + + fn: Callable[[float], Pattern] | Callable[[float], Library] + """ + Generator function. `jog` (only argument) is assumed to be left (ccw) relative to travel + and may be negative for a jog i the opposite direction. Won't be called if jog=0. + """ + + in_port_name: str + out_port_name: str + @dataclass(frozen=True, slots=True) class Bend: """ Description of a pre-rendered bend """ @@ -445,11 +459,26 @@ class AutoTool(Tool, metaclass=ABCMeta): b_transition: 'AutoTool.Transition | None' out_transition: 'AutoTool.Transition | None' + @dataclass(frozen=True, slots=True) + class SData: + """ Data for planS """ + straight_length: float + straight: 'AutoTool.Straight' + gen_kwargs: dict[str, Any] + jog_remaining: float + sbend: 'AutoTool.SBend' + in_transition: 'AutoTool.Transition | None' + b_transition: 'AutoTool.Transition | None' + out_transition: 'AutoTool.Transition | None' + straights: list[Straight] """ List of straight-generators to choose from, in order of priority """ bends: list[Bend] - """ List of bends to choose from, in order of priority. """ + """ List of bends to choose from, in order of priority """ + + sbends: list[SBend] + """ List of S-bend generators to choose from, in order of priority """ transitions: dict[tuple[str, str], Transition] """ `{(external_ptype, internal_ptype): Transition, ...}` """ @@ -481,6 +510,20 @@ class AutoTool(Tool, metaclass=ABCMeta): bend_angle *= -1 return bend_dxy, bend_angle + @staticmethod + def _sbend2dxy(sbend: SBend, jog: float) -> NDArray[numpy.float64]: + if numpy.isclose(jog, 0): + return numpy.zeros(2) + + sbend_pat_or_tree = sbend.fn(jog) + sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() + + angle_in = sbpat[sbend.in_port_name].rotation + assert angle_in is not None + + dxy = rotation_matrix_2d(-angle_in) @ (sbpat[sbend.out_port_name].offset - sbpat[sbend.in_port_name].offset) + return dxy + @staticmethod def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: if in_transition is None: @@ -610,6 +653,122 @@ class AutoTool(Tool, metaclass=ABCMeta): self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) return tree + def planS( + self, + length: float, + jog: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, + ) -> tuple[Port, Any]: + + success = False + for straight in self.straights: + for sbend in self.sbends: + out_ptype_pair = ( + 'unk' if out_ptype is None else out_ptype, + straight.ptype if numpy.isclose(jog, 0) else sbend.ptype + ) + out_transition = self.transitions.get(out_ptype_pair, None) + otrans_dxy = self._otransition2dxy(out_transition, pi) + + # Assume we'll need a straight segment with transitions, then discard them if they don't fit + # We do this before generating the s-bend because the transitions might have some dy component + in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) + in_transition = self.transitions.get(in_ptype_pair, None) + itrans_dxy = self._itransition2dxy(in_transition) + + b_transition = None + if not numpy.isclose(jog, 0) and sbend.ptype != straight.ptype: + b_transition = self.transitions.get((sbend.ptype, straight.ptype), None) + btrans_dxy = self._itransition2dxy(b_transition) + + if length > itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0]: + # `if` guard to avoid unnecessary calls to `_sbend2dxy()`, which calls `sbend.fn()` + # note some S-bends may have 0 length, so we can't be more restrictive + jog_remaining = jog - itrans_dxy[1] - btrans_dxy[1] - otrans_dxy[1] + sbend_dxy = self._sbend2dxy(sbend, jog_remaining) + straight_length = length - sbend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] + success = straight.length_range[0] <= straight_length < straight.length_range[1] + if success: + break + + # Straight didn't work, see if just the s-bend is enough + if sbend.ptype != straight.ptype: + # Need to use a different in-transition for sbend (vs straight) + in_ptype_pair = ('unk' if in_ptype is None else in_ptype, sbend.ptype) + in_transition = self.transitions.get(in_ptype_pair, None) + itrans_dxy = self._itransition2dxy(in_transition) + + jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] + sbend_dxy = self._sbend2dxy(sbend, jog_remaining) + success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) + if success: + b_transition = None + straight_length = 0 + break + if success: + break + else: + # Failed to break + raise BuildError( + f'Asked to draw S-path with total length {length:,g}, shorter than required bends and transitions:\n' + f'sbend: {sbend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' + f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' + ) + + if out_transition is not None: + out_ptype_actual = out_transition.their_port.ptype + elif not numpy.isclose(jog_remaining, 0): + out_ptype_actual = sbend.ptype + else: + out_ptype_actual = self.default_out_ptype + + data = self.SData(straight_length, straight, kwargs, jog_remaining, sbend, in_transition, b_transition, out_transition) + out_port = Port((length, jog), rotation=pi, ptype=out_ptype_actual) + return out_port, data + + def _renderS( + self, + data: SData, + tree: ILibrary, + port_names: tuple[str, str], + gen_kwargs: dict[str, Any], + ) -> ILibrary: + """ + Render an L step into a preexisting tree + """ + pat = tree.top_pattern() + if data.in_transition: + pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) + if not numpy.isclose(data.straight_length, 0): + straight_pat_or_tree = data.straight.fn(data.straight_length, **(gen_kwargs | data.gen_kwargs)) + pmap = {port_names[1]: data.straight.in_port_name} + if isinstance(straight_pat_or_tree, Pattern): + straight_pat = straight_pat_or_tree + pat.plug(straight_pat, pmap, append=True) + else: + straight_tree = straight_pat_or_tree + top = straight_tree.top() + straight_tree.flatten(top) + pat.plug(straight_tree[top], pmap, append=True) + if data.b_transition: + pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) + if not numpy.isclose(data.jog_remaining, 0): + sbend_pat_or_tree = data.sbend.fn(data.jog_remaining, **(gen_kwargs | data.gen_kwargs)) + pmap = {port_names[1]: data.sbend.in_port_name} + if isinstance(sbend_pat_or_tree, Pattern): + pat.plug(sbend_pat_or_tree, pmap, append=True) + else: + sbend_tree = sbend_pat_or_tree + top = sbend_tree.top() + sbend_tree.flatten(top) + pat.plug(sbend_tree[top], pmap, append=True) + if data.out_transition: + pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) + return tree + def render( self, batch: Sequence[RenderStep], @@ -625,6 +784,8 @@ class AutoTool(Tool, metaclass=ABCMeta): assert step.tool == self if step.opcode == 'L': self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) + elif step.opcode == 'S': + self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) return tree From 70559308a195ad8bce446b4263bf4c44230c7c70 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 16 Nov 2025 22:57:20 -0800 Subject: [PATCH 071/100] [Pather] clarify a variable name --- masque/builder/pather.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index e9cd125..4542b95 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -296,11 +296,11 @@ class Pather(Builder, PatherMixin): tool = self.tools.get(portspec, self.tools[None]) in_ptype = self.pattern[portspec].ptype tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - abstract = self.library << tree # TODO this seems like a name, not an abstract + tname = self.library << tree if plug_into is not None: output = {plug_into: tool_port_names[1]} else: output = {} - self.plug(abstract, {portspec: tool_port_names[0], **output}) + self.plug(tname, {portspec: tool_port_names[0], **output}) return self From feba7c699d2579455e08ef52dd4a2e999781fcd5 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 16 Nov 2025 22:58:02 -0800 Subject: [PATCH 072/100] [Tool / AutoTool] clarify some docstings --- masque/builder/tools.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 2b88d24..0ebc345 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -135,7 +135,7 @@ class Tool: kwargs: Custom tool-specific parameters. Returns: - The calculated output `Port` for the wire. + The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Raises: @@ -173,7 +173,7 @@ class Tool: kwargs: Custom tool-specific parameters. Returns: - The calculated output `Port` for the wire. + The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Raises: @@ -211,7 +211,7 @@ class Tool: kwargs: Custom tool-specific parameters. Returns: - The calculated output `Port` for the wire. + The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Raises: @@ -382,6 +382,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) return tree + @dataclass class AutoTool(Tool, metaclass=ABCMeta): """ @@ -407,7 +408,7 @@ class AutoTool(Tool, metaclass=ABCMeta): fn: Callable[[float], Pattern] | Callable[[float], Library] """ Generator function. `jog` (only argument) is assumed to be left (ccw) relative to travel - and may be negative for a jog i the opposite direction. Won't be called if jog=0. + and may be negative for a jog in the opposite direction. Won't be called if jog=0. """ in_port_name: str From e8e630bb2f1c0e77b64357e433aae991277cc7f5 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 17 Nov 2025 22:11:04 -0800 Subject: [PATCH 073/100] [Tool / Pather] fix some doc typos --- masque/builder/pather.py | 2 +- masque/builder/tools.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 4542b95..4b9c5b3 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -266,7 +266,7 @@ class Pather(Builder, PatherMixin): Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim of traveling exactly `length` distance. - The wire will travel `length` distance along the port's axis, an an unspecified + The wire will travel `length` distance along the port's axis, and an unspecified (tool-dependent) distance in the perpendicular direction. The output port will be rotated (or not) based on the `ccw` parameter. diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 0ebc345..befe198 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -70,7 +70,7 @@ class Tool: Create a wire or waveguide that travels exactly `length` distance along the axis of its input port. - Used by `Pather`. + Used by `Pather` and `RenderPather`. The output port must be exactly `length` away along the input port's axis, but may be placed an additional (unspecified) distance away along the perpendicular From 3593b4aec7c892891a07c19d107f007d35797901 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 17 Nov 2025 22:11:55 -0800 Subject: [PATCH 074/100] [Port] add `Port.measure_travel()` --- masque/ports.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/masque/ports.py b/masque/ports.py index b56ad70..17431c2 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -11,7 +11,7 @@ from numpy import pi from numpy.typing import ArrayLike, NDArray from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable -from .utils import rotate_offsets_around +from .utils import rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace @@ -143,6 +143,28 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): and self.rotation == other.rotation ) + def measure_travel(self, destination: 'Port') -> tuple[NDArray[numpy.float64], float | None]: + """ + Find the (travel, jog) distances and rotation angle from the current port to the provided + `destination` port. + + Travel is along the source port's axis (into the device interior), and jog is perpendicular, + with left of the travel direction corresponding to a positive jog. + + Args: + (self): Source `Port` + destination: Destination `Port` + + Returns + [travel, jog], rotation + """ + angle_in = self.rotation + angle_out = destination.rotation + assert angle_in is not None + dxy = rotation_matrix_2d(-angle_in) @ (destination.offset - self.offset) + angle = ((angle_out - angle_in) % (2 * pi)) if angle_out is not None else None + return dxy, angle + class PortList(metaclass=ABCMeta): __slots__ = () # Allow subclasses to use __slots__ @@ -552,3 +574,4 @@ class PortList(metaclass=ABCMeta): raise PortError(msg) return translations[0], rotations[0], o_offsets[0] + From d3216c680ce2c29e30f366885f8947332b780f60 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 17 Nov 2025 22:12:24 -0800 Subject: [PATCH 075/100] [Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends --- masque/builder/pather.py | 68 ++++++++++++++++ masque/builder/pather_mixin.py | 38 ++++----- masque/builder/renderpather.py | 75 +++++++++++++++++ masque/builder/tools.py | 144 ++++++++++++++++++++++++--------- 4 files changed, 265 insertions(+), 60 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 4b9c5b3..3a0dee4 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -304,3 +304,71 @@ class Pather(Builder, PatherMixin): self.plug(tname, {portspec: tool_port_names[0], **output}) return self + def pathS( + self, + portspec: str, + length: float, + jog: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is + left of direction of travel). + + The output port will have the same orientation as the source port (`portspec`). + + This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former + raises a NotImplementedError. + + Args: + portspec: The name of the port into which the wire will be plugged. + jog: Total manhattan distance perpendicular to the direction of travel. + Positive values are to the left of the direction of travel. + length: The total manhattan distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `distance` is too small to fit the s-bend (for nonzero jog). + LibraryError if no valid name could be picked for the pattern. + """ + if self._dead: + logger.error('Skipping pathS() since device is dead') + return self + + tool_port_names = ('A', 'B') + + tool = self.tools.get(portspec, self.tools[None]) + in_ptype = self.pattern[portspec].ptype + try: + tree = tool.pathS(length, jog, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + except NotImplementedError: + # Fall back to drawing two L-bends + ccw0 = jog > 0 + kwargs_no_out = (kwargs | {'out_ptype': None}) + t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) + t_pat0 = t_tree0.top_pattern() + (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) + t_tree1 = tool.path(not ccw0, jog - jog0, port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs) + t_pat1 = t_tree1.top_pattern() + (_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]]) + + self.path(portspec, ccw0, length - jog1, **kwargs_no_out) + self.path(portspec, not ccw0, jog - jog0, **kwargs) + return self + + tname = self.library << tree + if plug_into is not None: + output = {plug_into: tool_port_names[1]} + else: + output = {} + self.plug(tname, {portspec: tool_port_names[0], **output}) + return self + diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index e2c7eee..477c8aa 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -48,6 +48,18 @@ class PatherMixin(metaclass=ABCMeta): ) -> Self: pass + @abstractmethod + def pathS( + self, + portspec: str, + length: float, + jog: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + pass + def retool( self, tool: Tool, @@ -266,16 +278,6 @@ class PatherMixin(metaclass=ABCMeta): angle = (port_dst.rotation - port_src.rotation) % (2 * pi) - src_ne = port_src.rotation % (2 * pi) > (3 * pi / 4) # path from src will go north or east - - def get_jog(ccw: SupportsBool, length: float) -> float: - tool = self.tools.get(portspec_src, self.tools[None]) - in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already... - tree2 = tool.path(ccw, length, in_ptype=in_ptype, port_names=('A', 'B'), out_ptype=out_ptype, **kwargs) - top2 = tree2.top_pattern() - jog = rotation_matrix_2d(top2['A'].rotation) @ (top2['B'].offset - top2['A'].offset) - return jog[1] * [-1, 1][int(bool(ccw))] - dst_extra_args = {'out_ptype': out_ptype} if plug_destination: dst_extra_args['plug_into'] = portspec_dst @@ -297,20 +299,10 @@ class PatherMixin(metaclass=ABCMeta): elif not src_is_horizontal and xs == xd: # straight connector self.path_to(portspec_src, None, y=yd, **dst_args) - elif src_is_horizontal: - # figure out how much x our y-segment (2nd) takes up, then path based on that - y_len = numpy.abs(yd - ys) - ccw2 = src_ne != (yd > ys) - jog = get_jog(ccw2, y_len) * numpy.sign(xd - xs) - self.path_to(portspec_src, not ccw2, x=xd - jog, **src_args) - self.path_to(portspec_src, ccw2, y=yd, **dst_args) else: - # figure out how much y our x-segment (2nd) takes up, then path based on that - x_len = numpy.abs(xd - xs) - ccw2 = src_ne != (xd < xs) - jog = get_jog(ccw2, x_len) * numpy.sign(yd - ys) - self.path_to(portspec_src, not ccw2, y=yd - jog, **src_args) - self.path_to(portspec_src, ccw2, x=xd, **dst_args) + # S-bend, delegate to implementations + (travel, jog), _ = port_src.measure_travel(port_dst) + self.pathS(portspec_src, -travel, jog, **dst_args) elif numpy.isclose(angle, 0): raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') else: diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index c546f8b..eb78cb8 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -420,6 +420,81 @@ class RenderPather(PortList, PatherMixin): return self + def pathS( + self, + portspec: str, + length: float, + jog: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is + left of direction of travel). + + The output port will have the same orientation as the source port (`portspec`). + + `RenderPather.render` must be called after all paths have been fully planned. + + This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former + raises a NotImplementedError. + + Args: + portspec: The name of the port into which the wire will be plugged. + jog: Total manhattan distance perpendicular to the direction of travel. + Positive values are to the left of the direction of travel. + length: The total manhattan distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `distance` is too small to fit the s-bend (for nonzero jog). + LibraryError if no valid name could be picked for the pattern. + """ + if self._dead: + logger.error('Skipping pathS() since device is dead') + return self + + port = self.pattern[portspec] + in_ptype = port.ptype + port_rot = port.rotation + assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? + + tool = self.tools.get(portspec, self.tools[None]) + + # check feasibility, get output port and data + try: + out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs) + except NotImplementedError: + # Fall back to drawing two L-bends + ccw0 = jog > 0 + kwargs_no_out = (kwargs | {'out_ptype': None}) + t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) + (_, jog0), _ = Port((0, 0), 0).measure_travel(t_port0) + t_port1, _ = tool.planL(not ccw0, jog - jog0, in_ptype=t_port0.ptype, **kwargs) + (_, jog1), _ = Port((0, 0), 0).measure_travel(t_port1) + + self.path(portspec, ccw0, length - jog1, **kwargs_no_out) + self.path(portspec, not ccw0, jog - jog0, **kwargs) + return self + + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + step = RenderStep('S', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) + self.pattern.ports[portspec] = out_port.copy() + + if plug_into is not None: + self.plugged({portspec: plug_into}) + return self + + def render( self, append: bool = True, diff --git a/masque/builder/tools.py b/masque/builder/tools.py index befe198..1db156b 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -101,6 +101,48 @@ class Tool: """ raise NotImplementedError(f'path() not implemented for {type(self)}') + def pathS( + self, + length: float, + jog: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, + ) -> Library: + """ + Create a wire or waveguide that travels exactly `length` distance along the axis + of its input port, and `jog` distance on the perpendicular axis. + `jog` is positive when moving left of the direction of travel (from input to ouput port). + + Used by `Pather` and `RenderPather`. + + The output port should be rotated to face the input port (i.e. plugging the device + into a port will move that port but keep its orientation). + + The input and output ports should be compatible with `in_ptype` and + `out_ptype`, respectively. They should also be named `port_names[0]` and + `port_names[1]`, respectively. + + Args: + length: The total distance from input to output, along the input's axis only. + jog: The total distance from input to output, along the second axis. Positive indicates + a leftward shift when moving from input to output port. + in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. + out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. + port_names: The output pattern will have its input port named `port_names[0]` and + its output named `port_names[1]`. + kwargs: Custom tool-specific parameters. + + Returns: + A pattern tree containing the requested S-shaped (or straight) wire or waveguide + + Raises: + BuildError if an impossible or unsupported geometry is requested. + """ + raise NotImplementedError(f'path() not implemented for {type(self)}') + def planL( self, ccw: SupportsBool | None, @@ -204,8 +246,8 @@ class Tool: Args: jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a rightwards shift (i.e. clockwise bend followed - by a counterclockwise bend) + A positive number implies a leftwards shift (i.e. counterclockwise bend + followed by a clockwise bend) in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. kwargs: Custom tool-specific parameters. @@ -413,6 +455,7 @@ class AutoTool(Tool, metaclass=ABCMeta): in_port_name: str out_port_name: str + jog_range: tuple[float, float] = (0, numpy.inf) @dataclass(frozen=True, slots=True) class Bend: @@ -446,7 +489,7 @@ class AutoTool(Tool, metaclass=ABCMeta): return self.abstract.ports[self.their_port_name] def reversed(self) -> Self: - return type(self)(self.abstract, self.their_port_name, self.our_port_name) + return type(self)(self.abstract, self.our_port_name, self.their_port_name) @dataclass(frozen=True, slots=True) class LData: @@ -497,15 +540,8 @@ class AutoTool(Tool, metaclass=ABCMeta): def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: if ccw is None: return numpy.zeros(2), pi - - angle_in = bend.in_port.rotation - angle_out = bend.out_port.rotation - assert angle_in is not None - assert angle_out is not None - - bend_dxy = rotation_matrix_2d(-angle_in) @ (bend.out_port.offset - bend.in_port.offset) - bend_angle = angle_out - angle_in - + bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) + assert bend_angle is not None if bool(ccw): bend_dxy[1] *= -1 bend_angle *= -1 @@ -516,23 +552,17 @@ class AutoTool(Tool, metaclass=ABCMeta): if numpy.isclose(jog, 0): return numpy.zeros(2) - sbend_pat_or_tree = sbend.fn(jog) + sbend_pat_or_tree = sbend.fn(abs(jog)) sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() - - angle_in = sbpat[sbend.in_port_name].rotation - assert angle_in is not None - - dxy = rotation_matrix_2d(-angle_in) @ (sbpat[sbend.out_port_name].offset - sbpat[sbend.in_port_name].offset) + dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name]) return dxy @staticmethod def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: if in_transition is None: return numpy.zeros(2) - irot = in_transition.their_port.rotation - assert irot is not None - itrans_dxy = rotation_matrix_2d(-irot) @ (in_transition.our_port.offset - in_transition.their_port.offset) - return itrans_dxy + dxy, _ = in_transition.their_port.measure_travel(in_transition.our_port) + return dxy @staticmethod def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]: @@ -550,7 +580,7 @@ class AutoTool(Tool, metaclass=ABCMeta): *, in_ptype: str | None = None, out_ptype: str | None = None, - **kwargs, # noqa: ARG002 (unused) + **kwargs, ) -> tuple[Port, LData]: success = False @@ -593,6 +623,8 @@ class AutoTool(Tool, metaclass=ABCMeta): out_ptype_actual = out_transition.their_port.ptype elif ccw is not None: out_ptype_actual = bend.out_port.ptype + elif not numpy.isclose(straight_length, 0): + out_ptype_actual = straight.ptype else: out_ptype_actual = self.default_out_ptype @@ -703,26 +735,43 @@ class AutoTool(Tool, metaclass=ABCMeta): itrans_dxy = self._itransition2dxy(in_transition) jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) - if success: - b_transition = None - straight_length = 0 - break + if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]: + sbend_dxy = self._sbend2dxy(sbend, jog_remaining) + success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) + if success: + b_transition = None + straight_length = 0 + break if success: break - else: + + if not success: + try: + ccw0 = jog > 0 + p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype) + p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype) + + dx = p_test1.x - length / 2 + p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype) + p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype) + success = True + except BuildError as err: + l2_err: BuildError | None = err + else: + l2_err = None + + if not success: # Failed to break raise BuildError( - f'Asked to draw S-path with total length {length:,g}, shorter than required bends and transitions:\n' - f'sbend: {sbend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' - f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' - ) + f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}' + ) from l2_err if out_transition is not None: out_ptype_actual = out_transition.their_port.ptype elif not numpy.isclose(jog_remaining, 0): out_ptype_actual = sbend.ptype + elif not numpy.isclose(straight_length, 0): + out_ptype_actual = straight.ptype else: out_ptype_actual = self.default_out_ptype @@ -757,19 +806,40 @@ class AutoTool(Tool, metaclass=ABCMeta): if data.b_transition: pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) if not numpy.isclose(data.jog_remaining, 0): - sbend_pat_or_tree = data.sbend.fn(data.jog_remaining, **(gen_kwargs | data.gen_kwargs)) + sbend_pat_or_tree = data.sbend.fn(abs(data.jog_remaining), **(gen_kwargs | data.gen_kwargs)) pmap = {port_names[1]: data.sbend.in_port_name} if isinstance(sbend_pat_or_tree, Pattern): - pat.plug(sbend_pat_or_tree, pmap, append=True) + pat.plug(sbend_pat_or_tree, pmap, append=True, mirrored=data.jog_remaining < 0) else: sbend_tree = sbend_pat_or_tree top = sbend_tree.top() sbend_tree.flatten(top) - pat.plug(sbend_tree[top], pmap, append=True) + pat.plug(sbend_tree[top], pmap, append=True, mirrored=data.jog_remaining < 0) if data.out_transition: pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) return tree + def pathS( + self, + length: float, + jog: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, + ) -> Library: + _out_port, data = self.planS( + length, + jog, + in_ptype = in_ptype, + out_ptype = out_ptype, + ) + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS') + pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) + self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + return tree + def render( self, batch: Sequence[RenderStep], From bc8c0ee5809bbf0bd126ad315b9afb74df2c7738 Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 18 Nov 2025 23:01:42 -0800 Subject: [PATCH 076/100] add some whitespace --- masque/builder/builder.py | 32 ++++++++++++++++---------------- masque/pattern.py | 20 ++++++++++---------- masque/ports.py | 12 ++++++------ 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/masque/builder/builder.py b/masque/builder/builder.py index fed839a..4494751 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -292,14 +292,14 @@ class Builder(PortList): other = self.library[other.name] self.pattern.plug( - other=other, - map_in=map_in, - map_out=map_out, - mirrored=mirrored, - inherit_name=inherit_name, - set_rotation=set_rotation, - append=append, - ok_connections=ok_connections, + other = other, + map_in = map_in, + map_out = map_out, + mirrored = mirrored, + inherit_name = inherit_name, + set_rotation = set_rotation, + append = append, + ok_connections = ok_connections, ) return self @@ -365,14 +365,14 @@ class Builder(PortList): other = self.library[other.name] self.pattern.place( - other=other, - offset=offset, - rotation=rotation, - pivot=pivot, - mirrored=mirrored, - port_map=port_map, - skip_port_check=skip_port_check, - append=append, + other = other, + offset = offset, + rotation = rotation, + pivot = pivot, + mirrored = mirrored, + port_map = port_map, + skip_port_check = skip_port_check, + append = append, ) return self diff --git a/masque/pattern.py b/masque/pattern.py index 01ddf6a..fd29d5f 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1311,9 +1311,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): translation, rotation, pivot = self.find_transform( other, map_in, - mirrored=mirrored, - set_rotation=set_rotation, - ok_connections=ok_connections, + mirrored = mirrored, + set_rotation = set_rotation, + ok_connections = ok_connections, ) # get rid of plugged ports @@ -1326,13 +1326,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self.place( other, - offset=translation, - rotation=rotation, - pivot=pivot, - mirrored=mirrored, - port_map=map_out, - skip_port_check=True, - append=append, + offset = translation, + rotation = rotation, + pivot = pivot, + mirrored = mirrored, + port_map = map_out, + skip_port_check = True, + append = append, ) return self diff --git a/masque/ports.py b/masque/ports.py index 17431c2..b8137c0 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -472,12 +472,12 @@ class PortList(metaclass=ABCMeta): s_ports = self[map_in.keys()] o_ports = other[map_in.values()] return self.find_port_transform( - s_ports=s_ports, - o_ports=o_ports, - map_in=map_in, - mirrored=mirrored, - set_rotation=set_rotation, - ok_connections=ok_connections, + s_ports = s_ports, + o_ports = o_ports, + map_in = map_in, + mirrored = mirrored, + set_rotation = set_rotation, + ok_connections = ok_connections, ) @staticmethod From 7c928a59fa155d12ceb18133f46261fb8c7b2aee Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 00:06:57 -0800 Subject: [PATCH 077/100] [plug()] rename `inherit_name` arg to `thru` and allow passing a string Breaking change Affects Pattern, Builder, Pather, RenderPather --- masque/builder/builder.py | 20 +++++++++------ masque/builder/renderpather.py | 16 +++++++----- masque/pattern.py | 45 +++++++++++++++++++++------------- 3 files changed, 50 insertions(+), 31 deletions(-) diff --git a/masque/builder/builder.py b/masque/builder/builder.py index 4494751..ee1d277 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -67,7 +67,7 @@ class Builder(PortList): - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, - argument is provided, and the `inherit_name` argument is not explicitly + argument is provided, and the `thru` argument is not explicitly set to `False`, the unconnected port of `wire` is automatically renamed to 'myport'. This allows easy extension of existing ports without changing their names or having to provide `map_out` each time `plug` is called. @@ -223,7 +223,7 @@ class Builder(PortList): map_out: dict[str, str | None] | None = None, *, mirrored: bool = False, - inherit_name: bool = True, + thru: bool | str = True, set_rotation: bool | None = None, append: bool = False, ok_connections: Iterable[tuple[str, str]] = (), @@ -246,11 +246,15 @@ class Builder(PortList): new names for ports in `other`. mirrored: Enables mirroring `other` across the x axis prior to connecting any ports. - inherit_name: If `True`, and `map_in` specifies only a single port, - and `map_out` is `None`, and `other` has only two ports total, - then automatically renames the output port of `other` to the - name of the port from `self` that appears in `map_in`. This - makes it easy to extend a device with simple 2-port devices + thru: If map_in specifies only a single port, `thru` provides a mechainsm + to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, + - If True (default), and `other` has only two ports total, and map_out + doesn't specify a name for the other port, its name is set to the key + in `map_in`, i.e. 'myport'. + - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). + An error is raised if that entry already exists. + + This makes it easy to extend a pattern with simple 2-port devices (e.g. wires) without providing `map_out` each time `plug` is called. See "Examples" above for more info. Default `True`. set_rotation: If the necessary rotation cannot be determined from @@ -296,7 +300,7 @@ class Builder(PortList): map_in = map_in, map_out = map_out, mirrored = mirrored, - inherit_name = inherit_name, + thru = thru, set_rotation = set_rotation, append = append, ok_connections = ok_connections, diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index eb78cb8..23c466a 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -193,7 +193,7 @@ class RenderPather(PortList, PatherMixin): map_out: dict[str, str | None] | None = None, *, mirrored: bool = False, - inherit_name: bool = True, + thru: bool | str = True, set_rotation: bool | None = None, append: bool = False, ) -> Self: @@ -210,11 +210,15 @@ class RenderPather(PortList, PatherMixin): new names for ports in `other`. mirrored: Enables mirroring `other` across the x axis prior to connecting any ports. - inherit_name: If `True`, and `map_in` specifies only a single port, - and `map_out` is `None`, and `other` has only two ports total, - then automatically renames the output port of `other` to the - name of the port from `self` that appears in `map_in`. This - makes it easy to extend a device with simple 2-port devices + thru: If map_in specifies only a single port, `thru` provides a mechainsm + to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, + - If True (default), and `other` has only two ports total, and map_out + doesn't specify a name for the other port, its name is set to the key + in `map_in`, i.e. 'myport'. + - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). + An error is raised if that entry already exists. + + This makes it easy to extend a pattern with simple 2-port devices (e.g. wires) without providing `map_out` each time `plug` is called. See "Examples" above for more info. Default `True`. set_rotation: If the necessary rotation cannot be determined from diff --git a/masque/pattern.py b/masque/pattern.py index fd29d5f..4a401b6 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1202,7 +1202,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # map_out: dict[str, str | None] | None, # *, # mirrored: bool, -# inherit_name: bool, +# thru: bool | str, # set_rotation: bool | None, # append: Literal[False], # ) -> Self: @@ -1216,7 +1216,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # map_out: dict[str, str | None] | None, # *, # mirrored: bool, -# inherit_name: bool, +# thru: bool | str, # set_rotation: bool | None, # append: bool, # ) -> Self: @@ -1229,7 +1229,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_out: dict[str, str | None] | None = None, *, mirrored: bool = False, - inherit_name: bool = True, + thru: bool | str = True, set_rotation: bool | None = None, append: bool = False, ok_connections: Iterable[tuple[str, str]] = (), @@ -1250,7 +1250,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): - `my_pat.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' of `my_pat`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out` argument is - provided, and the `inherit_name` argument is not explicitly set to `False`, + provided, and the `thru` argument is not explicitly set to `False`, the unconnected port of `wire` is automatically renamed to 'myport'. This allows easy extension of existing ports without changing their names or having to provide `map_out` each time `plug` is called. @@ -1263,11 +1263,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): new names for ports in `other`. mirrored: Enables mirroring `other` across the x axis prior to connecting any ports. - inherit_name: If `True`, and `map_in` specifies only a single port, - and `map_out` is `None`, and `other` has only two ports total, - then automatically renames the output port of `other` to the - name of the port from `self` that appears in `map_in`. This - makes it easy to extend a pattern with simple 2-port devices + thru: If map_in specifies only a single port, `thru` provides a mechainsm + to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, + - If True (default), and `other` has only two ports total, and map_out + doesn't specify a name for the other port, its name is set to the key + in `map_in`, i.e. 'myport'. + - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). + An error is raised if that entry already exists. + + This makes it easy to extend a pattern with simple 2-port devices (e.g. wires) without providing `map_out` each time `plug` is called. See "Examples" above for more info. Default `True`. set_rotation: If the necessary rotation cannot be determined from @@ -1295,18 +1299,25 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): `PortError` if the specified port mapping is not achieveable (the ports do not line up) """ - # If asked to inherit a name, check that all conditions are met - if (inherit_name - and not map_out - and len(map_in) == 1 - and len(other.ports) == 2): - out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values()))) - map_out = {out_port_name: next(iter(map_in.keys()))} - if map_out is None: map_out = {} map_out = copy.deepcopy(map_out) + # If asked to inherit a name, check that all conditions are met + if isinstance(thru, str): + if not len(map_in) == 1: + raise PatternError(f'Got {thru=} but have multiple map_in entries; don\'t know which one to use') + if thru in map_out: + raise PatternError(f'Got {thru=} but tha port already exists in map_out') + map_out[thru] = next(iter(map_in.keys())) + elif (bool(thru) + and len(map_in) == 1 + and not map_out + and len(other.ports) == 2 + ): + out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values()))) + map_out = {out_port_name: next(iter(map_in.keys()))} + self.check_ports(other.ports.keys(), map_in, map_out) translation, rotation, pivot = self.find_transform( other, From 1c7ee9bef4ef79c16074d0acaaaa0cfe03e519fd Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 00:07:07 -0800 Subject: [PATCH 078/100] [RenderPather] whitespace --- masque/builder/renderpather.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 23c466a..682f9ec 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -265,13 +265,13 @@ class RenderPather(PortList, PatherMixin): self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) self.pattern.plug( - other=other_tgt, - map_in=map_in, - map_out=map_out, - mirrored=mirrored, - inherit_name=inherit_name, - set_rotation=set_rotation, - append=append, + other = other_tgt, + map_in = map_in, + map_out = map_out, + mirrored = mirrored, + thru = thru, + set_rotation = set_rotation, + append = append, ) return self @@ -337,14 +337,14 @@ class RenderPather(PortList, PatherMixin): self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) self.pattern.place( - other=other_tgt, - offset=offset, - rotation=rotation, - pivot=pivot, - mirrored=mirrored, - port_map=port_map, - skip_port_check=skip_port_check, - append=append, + other = other_tgt, + offset = offset, + rotation = rotation, + pivot = pivot, + mirrored = mirrored, + port_map = port_map, + skip_port_check = skip_port_check, + append = append, ) return self From 54cddaddd93418bea0774ef8b05045f763a523e0 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 00:16:34 -0800 Subject: [PATCH 079/100] [PortPather] add PortPather --- masque/__init__.py | 1 + masque/builder/__init__.py | 1 + masque/builder/pather_mixin.py | 95 ++++++++++++++++++++++++++++++++-- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/masque/__init__.py b/masque/__init__.py index 9dbf233..4ad7e69 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -80,6 +80,7 @@ from .builder import ( SimpleTool as SimpleTool, AutoTool as AutoTool, PathTool as PathTool, + PortPather as PortPather, ) from .utils import ( ports2data as ports2data, diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index eb5047f..2fd00a4 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,6 +1,7 @@ from .builder import Builder as Builder from .pather import Pather as Pather from .renderpather import RenderPather as RenderPather +from .pather_mixin import PortPather as PortPather from .utils import ell as ell from .tools import ( Tool as Tool, diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 477c8aa..571f0bb 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Self, TYPE_CHECKING from collections.abc import Sequence, Iterator import logging from contextlib import contextmanager @@ -11,11 +11,15 @@ from numpy.typing import ArrayLike from ..pattern import Pattern from ..library import ILibrary from ..error import PortError, BuildError -from ..utils import rotation_matrix_2d, SupportsBool -#from ..abstract import Abstract +from ..utils import SupportsBool +from ..abstract import Abstract from .tools import Tool from .utils import ell +if TYPE_CHECKING: + from .pather import Pather + from .renderpather import RenderPather + logger = logging.getLogger(__name__) @@ -435,3 +439,88 @@ class PatherMixin(metaclass=ABCMeta): """ self.pattern.flatten(self.library) return self + + + +class PortPather: + """ + Single-port state manager + + This class provides a convenient way to perform multiple pathing operations on a + port without needing to repeatedly pass its name. + """ + port: str + pather: 'Pather | RenderPather' + + def __init__(self, port: str, pather: 'Pather | RenderPather') -> None: + self.port = port + self.pather = pather + + # + # Delegate to pather + # + def retool(self, tool: Tool) -> Self: + self.pather.retool(tool, keys=[self.port]) + return self + + @contextmanager + def toolctx(self, tool: Tool) -> Iterator[Self]: + with self.pather.toolctx(tool, keys=[self.port]): + yield self + + def path(self, *args, **kwargs) -> Self: + self.pather.path(self.port, *args, **kwargs) + return self + + def pathS(self, *args, **kwargs) -> Self: + self.pather.pathS(self.port, *args, **kwargs) + return self + + def path_to(self, *args, **kwargs) -> Self: + self.pather.path_to(self.port, *args, **kwargs) + return self + + def path_into(self, *args, **kwargs) -> Self: + self.pather.path_into(self.port, *args, **kwargs) + return self + + def path_from(self, *args, **kwargs) -> Self: + self.pather.path_into(args[0], self.port, *args[1:], **kwargs) + return self + + def mpath(self, *args, **kwargs) -> Self: + self.pather.mpath([self.port], *args, **kwargs) + return self + + def plug( + self, + other: Abstract | str, + other_port: str, + *args, + **kwargs, + ) -> Self: + self.pather.plug(other, {self.port: other_port}, *args, **kwargs) + return self + + def plugged(self, other_port: str) -> Self: + self.pather.plugged({self.port: other_port}) + return self + + # + # Delegate to port + # + def set_ptype(self, ptype: str) -> Self: + self.pather[self.port].set_ptype(ptype) + return self + + def mirror(self, *args, **kwargs) -> Self: + self.pather[self.port].mirror(*args, **kwargs) + return self + + def rotate(self, rotation: float) -> Self: + self.pather[self.port].rotate(rotation) + return self + + def set_rotation(self, rotation: float | None) -> Self: + self.pather[self.port].set_rotation(rotation) + return self From d46be245c654dc35f48cdc26ecca1fc78e987126 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 00:17:38 -0800 Subject: [PATCH 080/100] add missing float_raster dep for manhattanize_slow --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 062098d..9a29065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dxf = ["ezdxf~=1.0.2"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] +manhatanize_slow = ["float_raster"] [tool.ruff] From 7ca3dd5b09065d03f0cf368c2c44fba8501cee5b Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 00:32:14 -0800 Subject: [PATCH 081/100] [PatherMixin] add at() for generating PortPather --- masque/builder/pather_mixin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 571f0bb..9319a59 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -440,6 +440,9 @@ class PatherMixin(metaclass=ABCMeta): self.pattern.flatten(self.library) return self + def at(self, portspec: str) -> 'PortPather': + return PortPather(portspec, self) + class PortPather: From 62a030dd14255da2879e57ab9b248e4763b90bf8 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 00:58:35 -0800 Subject: [PATCH 082/100] [PortPather] add rename_to and rename_from --- masque/builder/pather_mixin.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 9319a59..abf665b 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -488,7 +488,10 @@ class PortPather: return self def path_from(self, *args, **kwargs) -> Self: + thru = kwargs.pop('thru', None) self.pather.path_into(args[0], self.port, *args[1:], **kwargs) + if thru is not None: + self.rename_from(thru) return self def mpath(self, *args, **kwargs) -> Self: @@ -527,3 +530,11 @@ class PortPather: def set_rotation(self, rotation: float | None) -> Self: self.pather[self.port].set_rotation(rotation) return self + + def rename_to(self, new_name: str) -> Self: + self.pather.rename_ports({self.port: new_name}) + return self + + def rename_from(self, old_name: str) -> Self: + self.pather.rename_ports({old_name: self.port}) + return self From f8a82336f66daf0d27a5a6d54c58c5b5fc835d5e Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 01:24:02 -0800 Subject: [PATCH 083/100] [PatherMixin] add `thru` arg to path_into and rework portlist inheritance --- masque/builder/pather_mixin.py | 36 +++++++++++++++++++++++++--------- masque/builder/renderpather.py | 15 ++++++++++---- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index abf665b..f2b8229 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -1,5 +1,5 @@ from typing import Self, TYPE_CHECKING -from collections.abc import Sequence, Iterator +from collections.abc import Sequence, Iterator, Iterable import logging from contextlib import contextmanager from abc import abstractmethod, ABCMeta @@ -9,22 +9,19 @@ from numpy import pi from numpy.typing import ArrayLike from ..pattern import Pattern -from ..library import ILibrary +from ..library import ILibrary, TreeView from ..error import PortError, BuildError from ..utils import SupportsBool from ..abstract import Abstract from .tools import Tool from .utils import ell - -if TYPE_CHECKING: - from .pather import Pather - from .renderpather import RenderPather +from ..ports import PortList logger = logging.getLogger(__name__) -class PatherMixin(metaclass=ABCMeta): +class PatherMixin(PortList, metaclass=ABCMeta): pattern: Pattern """ Layout of this device """ @@ -64,6 +61,21 @@ class PatherMixin(metaclass=ABCMeta): ) -> Self: pass + @abstractmethod + def plug( + self, + other: Abstract | str | Pattern | TreeView, + map_in: dict[str, str], + map_out: dict[str, str | None] | None = None, + *, + mirrored: bool = False, + thru: bool | str = True, + set_rotation: bool | None = None, + append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), + ) -> Self: + pass + def retool( self, tool: Tool, @@ -221,6 +233,7 @@ class PatherMixin(metaclass=ABCMeta): *, out_ptype: str | None = None, plug_destination: bool = True, + thru: str | None = None, **kwargs, ) -> Self: """ @@ -244,6 +257,8 @@ class PatherMixin(metaclass=ABCMeta): out_ptype: Passed to the pathing tool in order to specify the desired port type to be generated at the destination end. If `None` (default), the destination port's `ptype` will be used. + thru: If not `None`, the port by this name will be rename to `portspec_src`. + This can be used when routing a signal through a pre-placed 2-port device. Returns: self @@ -312,6 +327,9 @@ class PatherMixin(metaclass=ABCMeta): else: raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') + if thru is not None: + self.rename_ports({thru: portspec_src}) + return self def mpath( @@ -453,9 +471,9 @@ class PortPather: port without needing to repeatedly pass its name. """ port: str - pather: 'Pather | RenderPather' + pather: PatherMixin - def __init__(self, port: str, pather: 'Pather | RenderPather') -> None: + def __init__(self, port: str, pather: PatherMixin) -> None: self.port = port self.pather = pather diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 682f9ec..1665ed0 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -2,7 +2,7 @@ Pather with batched (multi-step) rendering """ from typing import Self -from collections.abc import Sequence, Mapping, MutableMapping +from collections.abc import Sequence, Mapping, MutableMapping, Iterable import copy import logging from collections import defaultdict @@ -13,7 +13,7 @@ from numpy import pi from numpy.typing import ArrayLike from ..pattern import Pattern -from ..library import ILibrary +from ..library import ILibrary, TreeView from ..error import BuildError from ..ports import PortList, Port from ..abstract import Abstract @@ -25,7 +25,7 @@ from .pather_mixin import PatherMixin logger = logging.getLogger(__name__) -class RenderPather(PortList, PatherMixin): +class RenderPather(PatherMixin): """ `RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath` functions to plan out wire paths without incrementally generating the layout. Instead, @@ -188,7 +188,7 @@ class RenderPather(PortList, PatherMixin): def plug( self, - other: Abstract | str, + other: Abstract | str | Pattern | TreeView, map_in: dict[str, str], map_out: dict[str, str | None] | None = None, *, @@ -196,6 +196,7 @@ class RenderPather(PortList, PatherMixin): thru: bool | str = True, set_rotation: bool | None = None, append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), ) -> Self: """ Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P' @@ -229,6 +230,12 @@ class RenderPather(PortList, PatherMixin): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. + Returns: self From c1c83afc9821e152b259ff2c0aa527101dce5961 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 23:08:17 -0800 Subject: [PATCH 084/100] [Library.flatten] add dangling_ok param --- masque/builder/tools.py | 8 ++++---- masque/library.py | 10 +++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 1db156b..6a58b70 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -378,7 +378,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): else: straight_tree = straight_pat_or_tree top = straight_tree.top() - straight_tree.flatten(top) + straight_tree.flatten(top, dangling_ok=True) pat.plug(straight_tree[top], pmap, append=True) if data.ccw is not None: bend, bport_in, bport_out = self.bend @@ -653,7 +653,7 @@ class AutoTool(Tool, metaclass=ABCMeta): else: straight_tree = straight_pat_or_tree top = straight_tree.top() - straight_tree.flatten(top) + straight_tree.flatten(top, dangling_ok=True) pat.plug(straight_tree[top], pmap, append=True) if data.b_transition: pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) @@ -801,7 +801,7 @@ class AutoTool(Tool, metaclass=ABCMeta): else: straight_tree = straight_pat_or_tree top = straight_tree.top() - straight_tree.flatten(top) + straight_tree.flatten(top, dangling_ok=True) pat.plug(straight_tree[top], pmap, append=True) if data.b_transition: pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) @@ -813,7 +813,7 @@ class AutoTool(Tool, metaclass=ABCMeta): else: sbend_tree = sbend_pat_or_tree top = sbend_tree.top() - sbend_tree.flatten(top) + sbend_tree.flatten(top, dangling_ok=True) pat.plug(sbend_tree[top], pmap, append=True, mirrored=data.jog_remaining < 0) if data.out_transition: pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) diff --git a/masque/library.py b/masque/library.py index b52da74..9e7c133 100644 --- a/masque/library.py +++ b/masque/library.py @@ -264,6 +264,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): self, tops: str | Sequence[str], flatten_ports: bool = False, + dangling_ok: bool = False, ) -> dict[str, 'Pattern']: """ Returns copies of all `tops` patterns with all refs @@ -276,6 +277,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): tops: The pattern(s) to flattern. flatten_ports: If `True`, keep ports from any referenced patterns; otherwise discard them. + dangling_ok: If `True`, no error will be thrown if any + ref points to a name which is not present in the library. + Default False. Returns: {name: flat_pattern} mapping for all flattened patterns. @@ -292,6 +296,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): for target in pat.refs: if target is None: continue + if dangling_ok and target not in self: + continue if target not in flattened: flatten_single(target) @@ -307,7 +313,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): p.ports.clear() pat.append(p) - pat.refs.clear() + for target in set(pat.refs.keys()) & set(self.keys()): + del pat.refs[target] + flattened[name] = pat for top in tops: From 88a3d261aac1af69d4c331761e7e35e22311e9df Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 23:14:02 -0800 Subject: [PATCH 085/100] [AutoTool / SimpleTool] allow choice between rotating or mirroring bends --- masque/builder/tools.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 6a58b70..9c27a41 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -302,6 +302,9 @@ class SimpleTool(Tool, metaclass=ABCMeta): default_out_ptype: str """ Default value for out_ptype """ + mirror_bend: bool = True + """ Whether a clockwise bend should be mirrored (vs rotated) to get a ccw bend """ + @dataclass(frozen=True, slots=True) class LData: """ Data for planL """ @@ -382,7 +385,9 @@ class SimpleTool(Tool, metaclass=ABCMeta): pat.plug(straight_tree[top], pmap, append=True) if data.ccw is not None: bend, bport_in, bport_out = self.bend - pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(data.ccw)) + mirrored = self.mirror_bend and bool(data.ccw) + inport = bport_in if (self.mirror_bend or not data.ccw) else bport_out + pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored) return tree def path( @@ -463,7 +468,8 @@ class AutoTool(Tool, metaclass=ABCMeta): abstract: Abstract in_port_name: str out_port_name: str - clockwise: bool = True + clockwise: bool = True # Is in-to-out clockwise? + mirror: bool = True # Should we mirror to get the other rotation? @property def in_port(self) -> Port: @@ -658,8 +664,11 @@ class AutoTool(Tool, metaclass=ABCMeta): if data.b_transition: pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) if data.ccw is not None: - assert data.bend is not None - pat.plug(data.bend.abstract, {port_names[1]: data.bend.in_port_name}, mirrored=bool(data.ccw) == data.bend.clockwise) + bend = data.bend + assert bend is not None + mirrored = bend.mirror and (bool(data.ccw) == bend.clockwise) + inport = bend.in_port_name if (bend.mirror or bool(data.ccw) != bend.clockwise) else bend.out_port_name + pat.plug(bend.abstract, {port_names[1]: inport}, mirrored=mirrored) if data.out_transition: pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) return tree From 48ccf3e148343f0ca8f3b71fc17caed97a1469c6 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 23:14:36 -0800 Subject: [PATCH 086/100] [PortPather] add some more port-related convenience functions --- masque/builder/pather_mixin.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index f2b8229..79053db 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -537,6 +537,10 @@ class PortPather: self.pather[self.port].set_ptype(ptype) return self + def translate(self, *args, **kwargs) -> Self: + self.pather[self.port].translate(*args, **kwargs) + return self + def mirror(self, *args, **kwargs) -> Self: self.pather[self.port].mirror(*args, **kwargs) return self @@ -556,3 +560,13 @@ class PortPather: def rename_from(self, old_name: str) -> Self: self.pather.rename_ports({old_name: self.port}) return self + + def into_copy(self, new_name: str) -> Self: + self.pather.ports[new_name] = self.pather[self.port].copy() + self.port = new_name + return self + + def save_copy(self, new_name: str) -> Self: + self.pather.ports[new_name] = self.pather[self.port].copy() + return self + From 0f8078127d8aec7f8abad51c3eee01d8504c7255 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 23:14:54 -0800 Subject: [PATCH 087/100] cleanup --- masque/builder/pather_mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 79053db..bcf9d5e 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -1,4 +1,4 @@ -from typing import Self, TYPE_CHECKING +from typing import Self from collections.abc import Sequence, Iterator, Iterable import logging from contextlib import contextmanager From fdd776f4d76d1e8e7652cc7a77935495bfdbf554 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 23:15:22 -0800 Subject: [PATCH 088/100] [RenderPather.plug] fix ok_connections param --- masque/builder/renderpather.py | 1 + 1 file changed, 1 insertion(+) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 1665ed0..747a098 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -279,6 +279,7 @@ class RenderPather(PatherMixin): thru = thru, set_rotation = set_rotation, append = append, + ok_connections = ok_connections, ) return self From 84629ea61493343fc5be273e3d91cbe683c5a9cd Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 19 Nov 2025 23:19:29 -0800 Subject: [PATCH 089/100] minor readme cleanup --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2ed7489..565b8f0 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ tree = make_tree(...) # To reference this cell in our layout, we have to add all its children to our `library` first: top_name = tree.top() # get the name of the topcell -name_mapping = library.add(tree) # add all patterns from `tree`, renaming elgible conflicting patterns +name_mapping = library.add(tree) # add all patterns from `tree`, renaming eligible conflicting patterns new_name = name_mapping.get(top_name, top_name) # get the new name for the cell (in case it was auto-renamed) my_pattern.ref(new_name, ...) # instantiate the cell @@ -176,7 +176,7 @@ my_pattern.place(library << make_tree(...), ...) ### Quickly add geometry, labels, or refs: -The long form for adding elements can be overly verbose: +Adding elements can be overly verbose: ```python3 my_pattern.shapes[layer].append(Polygon(vertices, ...)) my_pattern.labels[layer] += [Label('my text')] From c366add952c7971f415b685e3e3055168c6ec399 Mon Sep 17 00:00:00 2001 From: jan Date: Thu, 20 Nov 2025 12:59:20 -0800 Subject: [PATCH 090/100] [Port] mirror() should not mirror port position, only orientation --- masque/pattern.py | 11 ++++++----- masque/ports.py | 1 - 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index 4a401b6..d870a43 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1169,12 +1169,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ports[new_name] = port for name, port in ports.items(): - p = port.deepcopy() + pp = port.deepcopy() if mirrored: - p.mirror() - p.rotate_around(pivot, rotation) - p.translate(offset) - self.ports[name] = p + pp.mirror() + pp.offset[1] *= -1 + pp.rotate_around(pivot, rotation) + pp.translate(offset) + self.ports[name] = pp if append: if isinstance(other, Abstract): diff --git a/masque/ports.py b/masque/ports.py index b8137c0..0211723 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -100,7 +100,6 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): return self def mirror(self, axis: int = 0) -> Self: - self.offset[1 - axis] *= -1 if self.rotation is not None: self.rotation *= -1 self.rotation += axis * pi From fc963cfbfcef122172ce896824cb8e1e4add1378 Mon Sep 17 00:00:00 2001 From: jan Date: Thu, 20 Nov 2025 13:00:57 -0800 Subject: [PATCH 091/100] [Pather / RenderPather] Fix handling of jog polarity --- masque/builder/pather.py | 9 +++++---- masque/builder/pather_mixin.py | 2 +- masque/builder/renderpather.py | 11 ++++++----- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 3a0dee4..9af473d 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -352,16 +352,17 @@ class Pather(Builder, PatherMixin): except NotImplementedError: # Fall back to drawing two L-bends ccw0 = jog > 0 - kwargs_no_out = (kwargs | {'out_ptype': None}) + kwargs_no_out = kwargs | {'out_ptype': None} t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) t_pat0 = t_tree0.top_pattern() (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) - t_tree1 = tool.path(not ccw0, jog - jog0, port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs) + t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs) t_pat1 = t_tree1.top_pattern() (_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]]) - self.path(portspec, ccw0, length - jog1, **kwargs_no_out) - self.path(portspec, not ccw0, jog - jog0, **kwargs) + kwargs_plug = kwargs | {'plug_into': plug_into} + self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) return self tname = self.library << tree diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index bcf9d5e..6acd7c6 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -321,7 +321,7 @@ class PatherMixin(PortList, metaclass=ABCMeta): else: # S-bend, delegate to implementations (travel, jog), _ = port_src.measure_travel(port_dst) - self.pathS(portspec_src, -travel, jog, **dst_args) + self.pathS(portspec_src, -travel, -jog, **dst_args) elif numpy.isclose(angle, 0): raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') else: diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 747a098..303a59d 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -488,12 +488,13 @@ class RenderPather(PatherMixin): ccw0 = jog > 0 kwargs_no_out = (kwargs | {'out_ptype': None}) t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) - (_, jog0), _ = Port((0, 0), 0).measure_travel(t_port0) - t_port1, _ = tool.planL(not ccw0, jog - jog0, in_ptype=t_port0.ptype, **kwargs) - (_, jog1), _ = Port((0, 0), 0).measure_travel(t_port1) + jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1] + t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs) + jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1] - self.path(portspec, ccw0, length - jog1, **kwargs_no_out) - self.path(portspec, not ccw0, jog - jog0, **kwargs) + kwargs_plug = kwargs | {'plug_into': plug_into} + self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) return self out_port.rotate_around((0, 0), pi + port_rot) From c18249c4d5943b1af0074fb58d2bc93eec47d7ff Mon Sep 17 00:00:00 2001 From: jan Date: Thu, 20 Nov 2025 13:01:51 -0800 Subject: [PATCH 092/100] [AutoTool] S-bend to L-bend fallback does not work yet, should throw an error --- masque/builder/tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 9c27a41..6bd7547 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -768,6 +768,7 @@ class AutoTool(Tool, metaclass=ABCMeta): l2_err: BuildError | None = err else: l2_err = None + raise NotImplementedError('TODO need to handle ldata below') if not success: # Failed to break From dca0df940bc27eaad6d4eb76bc6ba4e8349cba3c Mon Sep 17 00:00:00 2001 From: jan Date: Thu, 20 Nov 2025 13:05:30 -0800 Subject: [PATCH 093/100] [Pattern] use 1-axis instead of axis-1 --- masque/pattern.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/pattern.py b/masque/pattern.py index d870a43..7e0a79e 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -745,7 +745,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ 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[1 - across_axis] *= -1 return self def mirror_elements(self, across_axis: int = 0) -> Self: From f7138ee8e4a9967ff7e2cf622acbdaeaccd72f97 Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 21 Nov 2025 01:04:55 -0800 Subject: [PATCH 094/100] [PortPather] generalize to multi-port functions where possible --- masque/builder/pather_mixin.py | 179 ++++++++++++++++++++++++++------- 1 file changed, 142 insertions(+), 37 deletions(-) diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 6acd7c6..1655329 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Self, overload from collections.abc import Sequence, Iterator, Iterable import logging from contextlib import contextmanager @@ -458,62 +458,91 @@ class PatherMixin(PortList, metaclass=ABCMeta): self.pattern.flatten(self.library) return self - def at(self, portspec: str) -> 'PortPather': + def at(self, portspec: str | Iterable[str]) -> 'PortPather': return PortPather(portspec, self) - class PortPather: """ - Single-port state manager + Port state manager This class provides a convenient way to perform multiple pathing operations on a - port without needing to repeatedly pass its name. + set of ports without needing to repeatedly pass their names. """ - port: str + ports: list[str] pather: PatherMixin - def __init__(self, port: str, pather: PatherMixin) -> None: - self.port = port + def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None: + self.ports = [ports] if isinstance(ports, str) else list(ports) self.pather = pather # # Delegate to pather # def retool(self, tool: Tool) -> Self: - self.pather.retool(tool, keys=[self.port]) + self.pather.retool(tool, keys=self.ports) return self @contextmanager def toolctx(self, tool: Tool) -> Iterator[Self]: - with self.pather.toolctx(tool, keys=[self.port]): + with self.pather.toolctx(tool, keys=self.ports): yield self def path(self, *args, **kwargs) -> Self: - self.pather.path(self.port, *args, **kwargs) + if len(self.ports) > 1: + logger.warning('Use path_each() when pathing multiple ports independently') + for port in self.ports: + self.pather.path(port, *args, **kwargs) + return self + + def path_each(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather.path(port, *args, **kwargs) return self def pathS(self, *args, **kwargs) -> Self: - self.pather.pathS(self.port, *args, **kwargs) + if len(self.ports) > 1: + logger.warning('Use pathS_each() when pathing multiple ports independently') + for port in self.ports: + self.pather.pathS(port, *args, **kwargs) + return self + + def pathS_each(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather.pathS(port, *args, **kwargs) return self def path_to(self, *args, **kwargs) -> Self: - self.pather.path_to(self.port, *args, **kwargs) + if len(self.ports) > 1: + logger.warning('Use path_each_to() when pathing multiple ports independently') + for port in self.ports: + self.pather.path_to(port, *args, **kwargs) return self - def path_into(self, *args, **kwargs) -> Self: - self.pather.path_into(self.port, *args, **kwargs) - return self - - def path_from(self, *args, **kwargs) -> Self: - thru = kwargs.pop('thru', None) - self.pather.path_into(args[0], self.port, *args[1:], **kwargs) - if thru is not None: - self.rename_from(thru) + def path_each_to(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather.path_to(port, *args, **kwargs) return self def mpath(self, *args, **kwargs) -> Self: - self.pather.mpath([self.port], *args, **kwargs) + self.pather.mpath(self.ports, *args, **kwargs) + return self + + def path_into(self, *args, **kwargs) -> Self: + """ Path_into, using the current port as the source """ + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.') + self.pather.path_into(self.ports[0], *args, **kwargs) + return self + + def path_from(self, *args, **kwargs) -> Self: + """ Path_into, using the current port as the destination """ + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.') + thru = kwargs.pop('thru', None) + self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs) + if thru is not None: + self.rename_from(thru) return self def plug( @@ -523,50 +552,126 @@ class PortPather: *args, **kwargs, ) -> Self: - self.pather.plug(other, {self.port: other_port}, *args, **kwargs) + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' + 'Use the pather or pattern directly to plug multiple ports.') + self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs) return self def plugged(self, other_port: str) -> Self: - self.pather.plugged({self.port: other_port}) + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.') + self.pather.plugged({self.ports[0]: other_port}) return self # # Delegate to port # def set_ptype(self, ptype: str) -> Self: - self.pather[self.port].set_ptype(ptype) + for port in self.ports: + self.pather[port].set_ptype(ptype) return self def translate(self, *args, **kwargs) -> Self: - self.pather[self.port].translate(*args, **kwargs) + for port in self.ports: + self.pather[port].translate(*args, **kwargs) return self def mirror(self, *args, **kwargs) -> Self: - self.pather[self.port].mirror(*args, **kwargs) + for port in self.ports: + self.pather[port].mirror(*args, **kwargs) return self def rotate(self, rotation: float) -> Self: - self.pather[self.port].rotate(rotation) + for port in self.ports: + self.pather[port].rotate(rotation) return self def set_rotation(self, rotation: float | None) -> Self: - self.pather[self.port].set_rotation(rotation) + for port in self.ports: + self.pather[port].set_rotation(rotation) return self def rename_to(self, new_name: str) -> Self: - self.pather.rename_ports({self.port: new_name}) + if len(self.ports) > 1: + BuildError('Use rename_ports() for >1 port') + self.pather.rename_ports({self.ports[0]: new_name}) + self.ports[0] = new_name return self def rename_from(self, old_name: str) -> Self: - self.pather.rename_ports({old_name: self.port}) + if len(self.ports) > 1: + BuildError('Use rename_ports() for >1 port') + self.pather.rename_ports({old_name: self.ports[0]}) return self - def into_copy(self, new_name: str) -> Self: - self.pather.ports[new_name] = self.pather[self.port].copy() - self.port = new_name + def rename_ports(self, name_map: dict[str, str | None]) -> Self: + self.pather.rename_ports(name_map) + self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None] return self - def save_copy(self, new_name: str) -> Self: - self.pather.ports[new_name] = self.pather[self.port].copy() + def add_ports(self, ports: Iterable[str]) -> Self: + ports = list(ports) + conflicts = set(ports) & set(self.ports) + if conflicts: + raise BuildError(f'ports {conflicts} already selected') + self.ports += ports + return self + + def add_port(self, port: str, index: int | None = None) -> Self: + if port in self.ports: + raise BuildError(f'{port=} already selected') + if index is not None: + self.ports.insert(index, port) + else: + self.ports.append(port) + return self + + def drop_port(self, port: str) -> Self: + if port not in self.ports: + raise BuildError(f'{port=} already not selected') + self.ports = [pp for pp in self.ports if pp != port] + return self + + def into_copy(self, new_name: str, src: str | None = None) -> Self: + """ Copy a port and replace it with the copy """ + if not self.ports: + raise BuildError('Have no ports to copy') + if len(self.ports) == 1: + src = self.ports[0] + elif src is None: + raise BuildError('Must specify src when >1 port is available') + if src not in self.ports: + raise BuildError(f'{src=} not available') + self.pather.ports[new_name] = self.pather[src].copy() + self.ports = [(new_name if pp == src else pp) for pp in self.ports] + return self + + def save_copy(self, new_name: str, src: str | None = None) -> Self: + """ Copy a port and but keep using the original """ + if not self.ports: + raise BuildError('Have no ports to copy') + if len(self.ports) == 1: + src = self.ports[0] + elif src is None: + raise BuildError('Must specify src when >1 port is available') + if src not in self.ports: + raise BuildError(f'{src=} not available') + self.pather.ports[new_name] = self.pather[src].copy() + return self + + @overload + def delete(self, name: None) -> None: ... + + @overload + def delete(self, name: str) -> Self: ... + + def delete(self, name: str | None = None) -> Self | None: + if name is None: + for pp in self.ports: + del self.pather.ports[pp] + return None + del self.pather.ports[name] + self.ports = [pp for pp in self.ports if pp != name] return self From 05b73066ea0846a229a4396bbd294a7b8187d834 Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 21 Nov 2025 01:09:38 -0800 Subject: [PATCH 095/100] update TODO in readme --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 565b8f0..250eef3 100644 --- a/README.md +++ b/README.md @@ -228,9 +228,11 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...) ## TODO +* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath) +* PolyCollection & arrow-based read/write +* pather and renderpather examples, including .at() (PortPather) +* Bus-to-bus connections? +* Tests tests tests * Better interface for polygon operations (e.g. with `pyclipper`) - de-embedding - boolean ops -* Tests tests tests -* check renderpather -* pather and renderpather examples From ba2bc2b4448d72491a396463b45e06f7c7e16d13 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 10 Dec 2025 21:22:38 -0800 Subject: [PATCH 096/100] [dxf] don't need to add polygon offset since it's zero --- masque/file/dxf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 078245b..0d6006e 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -351,7 +351,7 @@ def _shapes_to_elements( ) for polygon in shape.to_polygons(): - xy_open = polygon.vertices + polygon.offset + xy_open = polygon.vertices xy_closed = numpy.vstack((xy_open, xy_open[0, :])) block.add_lwpolyline(xy_closed, dxfattribs=attribs) From 28e2864ce155c7611bae91d9d1ace632bbfb99b0 Mon Sep 17 00:00:00 2001 From: jan Date: Wed, 10 Dec 2025 21:22:56 -0800 Subject: [PATCH 097/100] [dxf] make sure layer tuple contents are ints --- masque/file/dxf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0d6006e..0f6dd32 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -376,5 +376,5 @@ def _mlayer2dxf(layer: layer_t) -> str: if isinstance(layer, int): return str(layer) if isinstance(layer, tuple): - return f'{layer[0]}.{layer[1]}' + return f'{layer[0]:d}.{layer[1]:d}' raise PatternError(f'Unknown layer type: {layer} ({type(layer)})') From 48034b7a3005fca5708977c095d7e63a91b8e0a7 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 19 Jan 2026 18:35:13 -0800 Subject: [PATCH 098/100] [Polygon.rect] raise a PatternError when given the wrong number of args instead of assert --- masque/shapes/polygon.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index fc17f61..c8c3ddd 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -256,6 +256,11 @@ class Polygon(Shape): Returns: A Polygon object containing the requested rectangle """ + if sum(int(pp is None) for pp in (xmin, xmax, xctr, lx)) != 2: + raise PatternError('Exactly two of xmin, xctr, xmax, lx must be provided!') + if sum(int(pp is None) for pp in (ymin, ymax, yctr, ly)) != 2: + raise PatternError('Exactly two of ymin, yctr, ymax, ly must be provided!') + if lx is None: if xctr is None: assert xmin is not None From 49e3917a6ed51a01e72c4259a42c18c110a3f1cb Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 19 Jan 2026 18:35:48 -0800 Subject: [PATCH 099/100] [remove_duplicate_vertices] remove the last vertex rather than the first to better match docs --- masque/utils/vertices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 23fb601..5fddd52 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -18,9 +18,9 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> `vertices` with no consecutive duplicates. This may be a view into the original array. """ vertices = numpy.asarray(vertices) - duplicates = (vertices == numpy.roll(vertices, 1, axis=0)).all(axis=1) + duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1) if not closed_path: - duplicates[0] = False + duplicates[-1] = False return vertices[~duplicates] From d8702af5b94ab38aedcd61606499bbfbe8f1e44e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Feb 2026 15:04:34 -0800 Subject: [PATCH 100/100] misc doc updates --- README.md | 50 ++++++++++++++++++++++++++++++++++ masque/abstract.py | 5 ++-- masque/builder/builder.py | 3 +- masque/builder/renderpather.py | 2 +- masque/library.py | 3 +- masque/pattern.py | 2 +- 6 files changed, 57 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 250eef3..62b13bb 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,55 @@ A layout consists of a hierarchy of `Pattern`s stored in a single `Library`. Each `Pattern` can contain `Ref`s pointing at other patterns, `Shape`s, `Label`s, and `Port`s. +Library / Pattern hierarchy: +``` + +-----------------------------------------------------------------------+ + | Library | + | | + | Name: "MyChip" ...> Name: "Transistor" | + | +---------------------------+ : +---------------------------+ | + | | [Pattern] | : | [Pattern] | | + | | | : | | | + | | shapes: {...} | : | shapes: { | | + | | ports: {...} | : | "Si": [, ...] | | + | | | : | "M1": [, ...]}| | + | | refs: | : | ports: {G, S, D} | | + | | "Transistor": [Ref, Ref]|..: +---------------------------+ | + | +---------------------------+ | + | | + | # (`refs` keys resolve to Patterns within the Library) | + +-----------------------------------------------------------------------+ +``` + + +Pattern internals: +``` + +---------------------------------------------------------------+ + | [Pattern] | + | | + | shapes: { | + | (1, 0): [Polygon, Circle, ...], # Geometry by layer | + | (2, 0): [Path, ...] | + | "M1" : [Path, ...] | + | "M2" : [Polygon, ...] | + | } | + | | + | refs: { # Key sets target name, Ref sets transform | + | "my_cell": [ | + | Ref(offset=(0,0), rotation=0), | + | Ref(offset=(10,0), rotation=R90, repetition=Grid(...)) | + | ] | + | } | + | | + | ports: { | + | "in": Port(offset=(0,0), rotation=0, ptype="M1"), | + | "out": Port(offset=(10,0), rotation=R180, ptype="wg") | + | } | + | | + +---------------------------------------------------------------+ +``` + + `masque` departs from several "classic" GDSII paradigms: - A `Pattern` object does not store its own name. A name is only assigned when the pattern is placed into a `Library`, which is effectively a name->`Pattern` mapping. @@ -236,3 +285,4 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...) * Better interface for polygon operations (e.g. with `pyclipper`) - de-embedding - boolean ops +* tuple / string layer auto-translation diff --git a/masque/abstract.py b/masque/abstract.py index 248c8a5..7135eba 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -24,6 +24,7 @@ class Abstract(PortList): When snapping a sub-component to an existing pattern, only the name (not contained in a `Pattern` object) and port info is needed, and not the geometry itself. """ + # Alternate design option: do we want to store a Ref instead of just a name? then we can translate/rotate/mirror... __slots__ = ('name', '_ports') name: str @@ -48,8 +49,6 @@ class Abstract(PortList): self.name = name self.ports = copy.deepcopy(ports) - # TODO do we want to store a Ref instead of just a name? then we can translate/rotate/mirror... - def __repr__(self) -> str: s = f' Self: """ - Rotate the Abstract around the a location. + Rotate the Abstract around a pivot point. Args: pivot: (x, y) location to rotate around diff --git a/masque/builder/builder.py b/masque/builder/builder.py index ee1d277..1b534b5 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -210,7 +210,8 @@ class Builder(PortList): self.pattern.rect(*args, **kwargs) return self - # Note: We're a superclass of `Pather`, where path() means something different... + # Note: We're a superclass of `Pather`, where path() means something different, + # so we shouldn't wrap Pattern.path() #@wraps(Pattern.path) #def path(self, *args, **kwargs) -> Self: # self.pattern.path(*args, **kwargs) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 303a59d..7f18e77 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -487,7 +487,7 @@ class RenderPather(PatherMixin): # Fall back to drawing two L-bends ccw0 = jog > 0 kwargs_no_out = (kwargs | {'out_ptype': None}) - t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) + t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1] t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs) jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1] diff --git a/masque/library.py b/masque/library.py index 9e7c133..0ed5271 100644 --- a/masque/library.py +++ b/masque/library.py @@ -141,7 +141,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): Args: tops: Name(s) of the pattern(s) to check. Default is all patterns in the library. - skip: Memo, set patterns which have already been traversed. Returns: Set of all referenced pattern names @@ -274,7 +273,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): For an in-place variant, see `Pattern.flatten`. Args: - tops: The pattern(s) to flattern. + tops: The pattern(s) to flatten. flatten_ports: If `True`, keep ports from any referenced patterns; otherwise discard them. dangling_ok: If `True`, no error will be thrown if any diff --git a/masque/pattern.py b/masque/pattern.py index 7e0a79e..dc7d058 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1241,7 +1241,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ports specified by `map_out`. Examples: - ======list, === + ========= - `my_pat.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` instantiates `subdevice` into `my_pat`, plugging ports 'A' and 'B' of `my_pat` into ports 'C' and 'B' of `subdevice`. The connected ports