Below are the contents of a python package. Please write new files, to be placed in masque/test/, to test all functionality. The tests should use pytest but not add more dependencies beyond numpy.



masque/README.md
---
# Masque README

Masque is a Python module for designing lithography masks.

The general idea is to implement something resembling the GDSII file-format, but
with some vectorized element types (eg. circles, not just polygons) and the ability
to output to multiple formats.

- [Source repository](https://mpxd.net/code/jan/masque)
- [PyPI](https://pypi.org/project/masque)
- [Github mirror](https://github.com/anewusername/masque)


## Installation

Requirements:
* python >= 3.11
* numpy
* klamath (used for GDSII i/o)

Optional requirements:
* `ezdxf` (DXF i/o): ezdxf
* `oasis` (OASIS i/o): fatamorgana
* `svg` (SVG output): svgwrite
* `visualization` (shape plotting): matplotlib
* `text` (`Text` shape): matplotlib, freetype


Install with pip:
```bash
pip install 'masque[oasis,dxf,svg,visualization,text]'
```

## Overview

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.


`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.
- Layer info for `Shape`ss and `Label`s is not stored in the individual shape and label objects.
    Instead, the layer is determined by the key for the container dict (e.g. `pattern.shapes[layer]`).
    * This simplifies many common tasks: filtering `Shape`s by layer, remapping layers, and checking if
        a layer is empty.
    * Technically, this allows reusing the same shape or label object across multiple layers. This isn't
        part of the standard workflow since a mixture of single-use and multi-use shapes could be confusing.
    * This is similar to the approach used in [KLayout](https://www.klayout.de)
- `Ref` target names are also determined in the key of the container dict (e.g. `pattern.refs[target_name]`).
    * This similarly simplifies filtering `Ref`s by target name, updating to a new target, and checking
        if a given `Pattern` is referenced.
- `Pattern` names are set by their containing `Library` and are not stored in the `Pattern` objects.
    * This guarantees that there are no duplicate pattern names within any given `Library`.
    * Likewise, enumerating all the names (and all the `Pattern`s) in a `Library` is straightforward.
- Each `Ref`, `Shape`, or `Label` can be repeated multiple times by attaching a `repetition` object to it.
    * This is similar to how OASIS reptitions are handled, and provides extra flexibility over the GDSII
        approach of only allowing arrays through AREF (`Ref` + `repetition`).
- `Label`s do not have an orientation or presentation
    * This is in line with how they are used in practice, and how they are represented in OASIS.
- Non-polygonal `Shape`s are allowed. For example, elliptical arcs are a basic shape type.
    * This enables compatibility with OASIS (e.g. circles) and other formats.
    * `Shape`s provide a `.to_polygons()` method for GDSII compatibility.
- Most coordinate values are stored as 64-bit floats internally.
    * 1 earth radii in nanometers (6e15) is still represented without approximation (53 bit mantissa -> 2^53 > 9e15)
    * Operations that would otherwise clip/round on are still represented approximately.
    * Memory usage is usually dominated by other Python overhead.
- `Pattern` objects also contain `Port` information, which can be used to "snap" together
    multiple sub-components by matching up the requested port offsets and rotations.
    * Port rotations are defined as counter-clockwise angles from the +x axis.
    * Ports point into the interior of their associated device.
    * Port rotations may be `None` in the case of non-oriented ports.
    * Ports have a `ptype` string which is compared in order to catch mismatched connections at build time.
    * Ports can be exported into/imported from `Label`s stored directly in the layout,
        editable from standard tools (e.g. KLayout). A default format is provided.

In one important way, `masque` stays very orthodox:
References are accomplished by listing the target's name, not its `Pattern` object.

- The main downside of this is that any operations that traverse the hierarchy require
    both the `Pattern` and the `Library` which is contains its reference targets.
- This guarantees that names within a `Library` remain unique at all times.
    * Since this can be tedious in cases where you don't actually care about the name of a
        pattern, patterns whose names start with `SINGLE_USE_PREFIX` (default: an underscore)
        may be silently renamed in order to maintain uniqueness.
        See `masque.library.SINGLE_USE_PREFIX`, `masque.library._rename_patterns()`,
        and `ILibrary.add()` for more details.
- Having all patterns accessible through the `Library` avoids having to perform a
    tree traversal for every operation which needs to touch all `Pattern` objects
    (e.g. deleting a layer everywhere or scaling all patterns).
- Since `Pattern` doesn't know its own name, you can't create a reference by passing in
    a `Pattern` object -- you need to know its name.
- You *can* reference a `Pattern` before it is created, so long as you have already decided
    on its name.
- Functions like `Pattern.place()` and `Pattern.plug()` need to receive a pattern's name
    in order to create a reference, but they also need to access the pattern's ports.
    * One way to provide this data is through an `Abstract`, generated via
        `Library.abstract()` or through a `Library.abstract_view()`.
    * Another way is use `Builder.place()` or `Builder.plug()`, which automatically creates
        an `Abstract` from its internally-referenced `Library`.


## Glossary
- `Library`: A collection of named cells. OASIS or GDS "library" or file.
- `Tree`: Any `{name: pattern}` mapping which has only one topcell.
- `Pattern`: A collection of geometry, text labels, and reference to other patterns.
        OASIS or GDS "Cell", DXF "Block".
- `Ref`: A reference to another pattern. GDS "AREF/SREF", OASIS "Placement".
- `Shape`: Individual geometric entity. OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline".
- `repetition`: Repetition operation. OASIS "repetition".
        GDS "AREF" is a `Ref` combined with a `Grid` repetition.
- `Label`: Text label. Not rendered into geometry. OASIS, GDS, DXF "Text".
- `annotation`: Additional metadata. OASIS or GDS "property".


## Syntax, shorthand, and design patterns
Most syntax and behavior should follow normal python conventions.
There are a few exceptions, either meant to catch common mistakes or to provide a shorthand for common operations:

### `Library` objects don't allow overwriting already-existing patterns
```python3
library['mycell'] = pattern0
library['mycell'] = pattern1   # Error! 'mycell' already exists and can't be overwritten
del library['mycell']          # We can explicitly delete it
library['mycell'] = pattern1   # And now it's ok to assign a new value
library.delete('mycell')       # This also deletes all refs pointing to 'mycell' by default
```

### Insert a newly-made hierarchical pattern (with children) into a layout
```python3
# Let's say we have a function which returns a new library containing one topcell (and possibly children)
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
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

# This can be accomplished as follows
new_name = library << tree       # Add `tree` into `library` and return the top cell's new name
my_pattern.ref(new_name, ...)       # instantiate the cell

# In practice, you may do lots of
my_pattern.ref(lib << make_tree(...), ...)

# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit:
my_builder = Builder(library=lib, ...)
...
my_builder.place(make_tree(...))
```

We can also use this shorthand to quickly add and reference a single flat (as yet un-named) pattern:
```python3
anonymous_pattern = Pattern(...)
my_pattern.ref(lib << {'_tentative_name': anonymous_pattern}, ...)
```

### Place a hierarchical pattern into a layout, preserving its port info
```python3
# As above, we have a function that makes a new library containing one topcell (and possibly children)
tree = make_tree(...)

# We need to go get its port info to `place()` it into our existing layout,
new_name = library << tree          # Add the tree to the library and return its name (see `<<` above)
abstract = library.abstract(tree)   # An `Abstract` stores a pattern's name and its ports (but no geometry)
my_pattern.place(abstract, ...)

# With shorthand,
abstract = library <= tree
my_pattern.place(abstract, ...)

# or
my_pattern.place(library << make_tree(...), ...)
```


### Quickly add geometry, labels, or refs:
The long form for adding elements can be overly verbose:
```python3
my_pattern.shapes[layer].append(Polygon(vertices, ...))
my_pattern.labels[layer] += [Label('my text')]
my_pattern.refs[target_name].append(Ref(offset=..., ...))
```

There is shorthand for the most common elements:
```python3
my_pattern.polygon(layer=layer, vertices=vertices, ...)
my_pattern.rect(layer=layer, xctr=..., xmin=..., ymax=..., ly=...)  # rectangle; pick 4 of 6 constraints
my_pattern.rect(layer=layer, ymin=..., ymax=..., xctr=..., lx=...)
my_pattern.path(...)
my_pattern.label(layer, 'my_text')
my_pattern.ref(target_name, offset=..., ...)
```

### Accessing ports
```python3
# Square brackets pull from the underlying `.ports` dict:
assert pattern['input'] is pattern.ports['input']

# And you can use them to read multiple ports at once:
assert pattern[('input', 'output')] == {
    'input': pattern.ports['input'],
    'output': pattern.ports['output'],
    }

# But you shouldn't use them for anything except reading
pattern['input'] = Port(...)   # Error!
has_input = ('input' in pattern)   # Error!
```

### Building patterns
```python3
library = Library(...)
my_pattern_name, my_pattern = library.mkpat(some_name_generator())
...
def _make_my_subpattern() -> str:
    #   This function can draw from the outer scope (e.g. `library`) but will not pollute the outer scope
    # (e.g. the variable `subpattern` will not be accessible from outside the function; you must load it
    # from within `library`).
    subpattern_name, subpattern = library.mkpat(...)
    subpattern.rect(...)
    ...
    return subpattern_name
my_pattern.ref(_make_my_subpattern(), offset=..., ...)
```


## TODO

* Better interface for polygon operations (e.g. with `pyclipper`)
    - de-embedding
    - boolean ops
* Tests tests tests
* check renderpather
* pather and renderpather examples


---
masque/__init__.py
---
'''
 masque 2D CAD library

 masque is an attempt to make a relatively compact library for designing lithography
  masks. The general idea is to implement something resembling the GDSII and OASIS file-formats,
  but with some additional vectorized element types (eg. ellipses, not just polygons), and the
  ability to interface with multiple file formats.

 `Pattern` is a basic object containing a 2D lithography mask, composed of a list of `Shape`
  objects, a list of `Label` objects, and a list of references to other `Patterns` (using
  `Ref`).

 `Ref` provides basic support for nesting `Pattern` objects within each other, by adding
  offset, rotation, scaling, repetition, and other such properties to a Pattern reference.

 Note that the methods for these classes try to avoid copying wherever possible, so unless
  otherwise noted, assume that arguments are stored by-reference.


 NOTES ON INTERNALS
 ==========================
 - Many of `masque`'s classes make use of `__slots__` to make them faster / smaller.
    Since `__slots__` doesn't play well with multiple inheritance, often they are left
    empty for superclasses and it is the subclass's responsibility to set them correctly.
 - File I/O submodules are not imported by `masque.file` to avoid creating hard dependencies
    on external file-format reader/writers
- Try to accept the broadest-possible inputs: e.g., don't demand an `ILibraryView` if you
    can accept a `Mapping[str, Pattern]` and wrap it in a `LibraryView` internally.
'''

from .utils import (
    layer_t as layer_t,
    annotations_t as annotations_t,
    SupportsBool as SupportsBool,
    )
from .error import (
    MasqueError as MasqueError,
    PatternError as PatternError,
    LibraryError as LibraryError,
    BuildError as BuildError,
    )
from .shapes import (
    Shape as Shape,
    Polygon as Polygon,
    Path as Path,
    Circle as Circle,
    Arc as Arc,
    Ellipse as Ellipse,
    )
from .label import Label as Label
from .ref import Ref as Ref
from .pattern import (
    Pattern as Pattern,
    map_layers as map_layers,
    map_targets as map_targets,
    chain_elements as chain_elements,
    )

from .library import (
    ILibraryView as ILibraryView,
    ILibrary as ILibrary,
    LibraryView as LibraryView,
    Library as Library,
    LazyLibrary as LazyLibrary,
    AbstractView as AbstractView,
    TreeView as TreeView,
    Tree as Tree,
    )
from .ports import (
    Port as Port,
    PortList as PortList,
    )
from .abstract import Abstract as Abstract
from .builder import (
    Builder as Builder,
    Tool as Tool,
    Pather as Pather,
    RenderPather as RenderPather,
    RenderStep as RenderStep,
    BasicTool as BasicTool,
    PathTool as PathTool,
    )
from .utils import (
    ports2data as ports2data,
    oneshot as oneshot,
    R90 as R90,
    R180 as R180,
    )


__author__ = 'Jan Petykiewicz'

__version__ = '3.3'
version = __version__       # legacy


---
masque/abstract.py
---
from typing import Self
import copy
import logging

import numpy
from numpy.typing import ArrayLike

from .ref import Ref
from .ports import PortList, Port
from .utils import rotation_matrix_2d

#if TYPE_CHECKING:
#    from .builder import Builder, Tool
#    from .library import ILibrary


logger = logging.getLogger(__name__)


class Abstract(PortList):
    '''
    An `Abstract` is a container for a name and associated ports.

    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.
    '''
    __slots__ = ('name', '_ports')

    name: str
    ''' Name of the pattern this device references '''

    _ports: dict[str, Port]
    ''' Uniquely-named ports which can be used to instances together'''

    @property
    def ports(self) -> dict[str, Port]:
        return self._ports

    @ports.setter
    def ports(self, value: dict[str, Port]) -> None:
        self._ports = value

    def __init__(
            self,
            name: str,
            ports: dict[str, Port],
            ) -> None:
        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'<Abstract {self.name} ['
        for name, port in self.ports.items():
            s += f'\n\t{name}: {port}'
        s += ']>'
        return s

    def translate_ports(self, offset: ArrayLike) -> Self:
        '''
        Translates all ports by the given offset.

        Args:
            offset: (x, y) to translate by

        Returns:
            self
        '''
        for port in self.ports.values():
            port.translate(offset)
        return self

    def scale_by(self, c: float) -> Self:
        '''
        Scale this Abstract by the given value
         (all port offsets are scaled)

        Args:
            c: factor to scale by

        Returns:
            self
        '''
        for port in self.ports.values():
            port.offset *= c
        return self

    def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
        '''
        Rotate the Abstract around the a location.

        Args:
            pivot: (x, y) location to rotate around
            rotation: Angle to rotate by (counter-clockwise, radians)

        Returns:
            self
        '''
        pivot = numpy.asarray(pivot, dtype=float)
        self.translate_ports(-pivot)
        self.rotate_ports(rotation)
        self.rotate_port_offsets(rotation)
        self.translate_ports(+pivot)
        return self

    def rotate_port_offsets(self, rotation: float) -> Self:
        '''
        Rotate the offsets of all ports around (0, 0)

        Args:
            rotation: Angle to rotate by (counter-clockwise, radians)

        Returns:
            self
        '''
        for port in self.ports.values():
            port.offset = rotation_matrix_2d(rotation) @ port.offset
        return self

    def rotate_ports(self, rotation: float) -> Self:
        '''
        Rotate each port around its offset (i.e. in place)

        Args:
            rotation: Angle to rotate by (counter-clockwise, radians)

        Returns:
            self
        '''
        for port in self.ports.values():
            port.rotate(rotation)
        return self

    def mirror_port_offsets(self, across_axis: int = 0) -> Self:
        '''
        Mirror the offsets of all shapes, labels, and refs across an axis

        Args:
            across_axis: Axis to mirror across
                (0: mirror across x axis, 1: mirror across y axis)

        Returns:
            self
        '''
        for port in self.ports.values():
            port.offset[across_axis - 1] *= -1
        return self

    def mirror_ports(self, across_axis: int = 0) -> Self:
        '''
        Mirror each port's rotation across an axis, relative to its
          offset

        Args:
            across_axis: Axis to mirror across
                (0: mirror across x axis, 1: mirror across y axis)

        Returns:
            self
        '''
        for port in self.ports.values():
            port.mirror(across_axis)
        return self

    def mirror(self, across_axis: int = 0) -> Self:
        '''
        Mirror the Pattern across an axis

        Args:
            axis: Axis to mirror across
                (0: mirror across x axis, 1: mirror across y axis)

        Returns:
            self
        '''
        self.mirror_ports(across_axis)
        self.mirror_port_offsets(across_axis)
        return self

    def apply_ref_transform(self, ref: Ref) -> Self:
        '''
        Apply the transform from a `Ref` to the ports of this `Abstract`.
        This changes the port locations to where they would be in the Ref's parent pattern.

        Args:
            ref: The ref whose transform should be applied.

        Returns:
            self
        '''
        if ref.mirrored:
            self.mirror()
        self.rotate_ports(ref.rotation)
        self.rotate_port_offsets(ref.rotation)
        self.translate_ports(ref.offset)
        return self

    def undo_ref_transform(self, ref: Ref) -> Self:
        '''
        Apply the inverse transform from a `Ref` to the ports of this `Abstract`.
        This changes the port locations to where they would be in the Ref's target (from the parent).

        Args:
            ref: The ref whose (inverse) transform should be applied.

        Returns:
            self

        # TODO test undo_ref_transform
        '''
        self.translate_ports(-ref.offset)
        self.rotate_port_offsets(-ref.rotation)
        self.rotate_ports(-ref.rotation)
        if ref.mirrored:
            self.mirror(0)
        return self


---
masque/error.py
---
class MasqueError(Exception):
    '''
    Parent exception for all Masque-related Exceptions
    '''
    pass


class PatternError(MasqueError):
    '''
    Exception for Pattern objects and their contents
    '''
    pass


class LibraryError(MasqueError):
    '''
    Exception raised by Library classes
    '''
    pass


class BuildError(MasqueError):
    '''
    Exception raised by builder-related functions
    '''
    pass

class PortError(MasqueError):
    '''
    Exception raised by builder-related functions
    '''
    pass

class OneShotError(MasqueError):
    '''
    Exception raised when a function decorated with `@oneshot` is called more than once
    '''
    def __init__(self, func_name: str) -> None:
        Exception.__init__(self, f'Function "{func_name}" with @oneshot was called more than once')


---
masque/label.py
---
from typing import Self, Any
import copy
import functools

import numpy
from numpy.typing import ArrayLike, NDArray

from .repetition import Repetition
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded
from .traits import AnnotatableImpl


@functools.total_ordering
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
    '''
    A text annotation with a position (but no size; it is not drawn)
    '''
    __slots__ = (
        '_string',
        # Inherited
        '_offset', '_repetition', '_annotations',
        )

    _string: str
    ''' Label string '''

    '''
    ---- Properties
    '''
    # string property
    @property
    def string(self) -> str:
        '''
        Label string (str)
        '''
        return self._string

    @string.setter
    def string(self, val: str) -> None:
        self._string = val

    def __init__(
            self,
            string: str,
            *,
            offset: ArrayLike = (0.0, 0.0),
            repetition: Repetition | None = None,
            annotations: annotations_t | None = None,
            ) -> None:
        self.string = string
        self.offset = numpy.array(offset, dtype=float)
        self.repetition = repetition
        self.annotations = annotations if annotations is not None else {}

    def __copy__(self) -> Self:
        return type(self)(
            string=self.string,
            offset=self.offset.copy(),
            repetition=self.repetition,
            )

    def __deepcopy__(self, memo: dict | None = None) -> Self:
        memo = {} if memo is None else memo
        new = copy.copy(self)
        new._offset = self._offset.copy()
        return new

    def __lt__(self, other: 'Label') -> bool:
        if self.string != other.string:
            return self.string < other.string
        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 __eq__(self, other: Any) -> bool:
        return (
            self.string == other.string
            and numpy.array_equal(self.offset, other.offset)
            and self.repetition == other.repetition
            and annotations_eq(self.annotations, other.annotations)
            )

    def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
        '''
        Rotate the label around a point.

        Args:
            pivot: Point (x, y) to rotate around
            rotation: Angle to rotate by (counterclockwise, radians)

        Returns:
            self
        '''
        pivot = numpy.asarray(pivot, dtype=float)
        self.translate(-pivot)
        self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
        self.translate(+pivot)
        return self

    def get_bounds_single(self) -> NDArray[numpy.float64]:
        '''
        Return the bounds of the label.

        Labels are assumed to take up 0 area, i.e.
        bounds = [self.offset,
                  self.offset]

        Returns:
            Bounds [[xmin, xmax], [ymin, ymax]]
        '''
        return numpy.array([self.offset, self.offset])


---
masque/library.py
---
'''
Library classes for managing unique name->pattern mappings and deferred loading or execution.

Classes include:
- `ILibraryView`: Defines a general interface for read-only name->pattern mappings.
- `LibraryView`: An implementation of `ILibraryView` backed by an arbitrary `Mapping`.
    Can be used to wrap any arbitrary `Mapping` to give it all the functionality in `ILibraryView`
- `ILibrary`: Defines a general interface for mutable name->pattern mappings.
- `Library`: An implementation of `ILibrary` backed by an arbitrary `MutableMapping`.
    Can be used to wrap any arbitrary `MutableMapping` to give it all the functionality in `ILibrary`.
    By default, uses a `dict` as the underylingmapping.
- `LazyLibrary`: An implementation of `ILibrary` which enables on-demand loading or generation
    of patterns.
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
    library. Generated with `ILibraryView.abstract_view()`.
'''
from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
import logging
import re
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 layer_t, apply_transforms
from .shapes import Shape, Polygon
from .label import Label
from .abstract import Abstract
from .pattern import map_layers

if TYPE_CHECKING:
    from .pattern import Pattern


logger = logging.getLogger(__name__)


class visitor_function_t(Protocol):
    ''' Signature for `Library.dfs()` visitor functions. '''
    def __call__(
            self,
            pattern: 'Pattern',
            hierarchy: tuple[str | None, ...],
            memo: dict,
            transform: NDArray[numpy.float64] | Literal[False],
            ) -> 'Pattern':
        ...


TreeView: TypeAlias = Mapping[str, 'Pattern']
''' A name-to-`Pattern` mapping which is expected to have only one top-level cell '''

Tree: TypeAlias = MutableMapping[str, 'Pattern']
''' A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell '''


SINGLE_USE_PREFIX = '_'
'''
Names starting with this prefix are assumed to refer to single-use patterns,
which may be renamed automatically by `ILibrary.add()` (via
`rename_theirs=_rename_patterns()` )
'''
# TODO what are the consequences of making '_' special?  maybe we can make this decision everywhere?


def _rename_patterns(lib: 'ILibraryView', name: str) -> str:
    '''
    The default `rename_theirs` function for `ILibrary.add`.

      Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as
    "one-offs" for which name conflicts should be automatically resolved.
    Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)`
    where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`.
    Names lacking the prefix are directly returned (not renamed).

    Args:
        lib: The library into which `name` is to be added (but is presumed to conflict)
        name: The original name, to be modified

    Returns:
        The new name, not guaranteed to be conflict-free!
    '''
    if not name.startswith(SINGLE_USE_PREFIX):
        return name

    stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]
    return lib.get_name(SINGLE_USE_PREFIX + stem)


class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
    '''
    Interface for a read-only library.

    A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
    '''
    # inherited abstract functions
    #def __getitem__(self, key: str) -> 'Pattern':
    #def __iter__(self) -> Iterator[str]:
    #def __len__(self) -> int:

    #__contains__, keys, items, values, get, __eq__, __ne__ supplied by Mapping

    def __repr__(self) -> str:
        return '<ILibraryView with keys\n' + pformat(list(self.keys())) + '>'

    def abstract_view(self) -> 'AbstractView':
        '''
        Returns:
            An AbstractView into this library
        '''
        return AbstractView(self)

    def abstract(self, name: str) -> Abstract:
        '''
        Return an `Abstract` (name & ports) for the pattern in question.

        Args:
            name: The pattern name

        Returns:
            An `Abstract` object for the pattern
        '''
        return Abstract(name=name, ports=self[name].ports)

    def dangling_refs(
            self,
            tops: str | Sequence[str] | None = None,
            ) -> set[str | None]:
        '''
        Get the set of all pattern names not present in the library but referenced
        by `tops`, recursively traversing any refs.

        If `tops` are not given, all patterns in the library are checked.

        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
        '''
        if tops is None:
            tops = tuple(self.keys())

        referenced = self.referenced_patterns(tops)
        return referenced - set(self.keys())

    def referenced_patterns(
            self,
            tops: str | Sequence[str] | None = None,
            skip: set[str | None] | None = None,
            ) -> set[str | None]:
        '''
        Get the set of all pattern names referenced by `tops`. Recursively traverses into any refs.

        If `tops` are not given, all patterns in the library are checked.

        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
        '''
        if tops is None:
            tops = tuple(self.keys())

        if skip is None:
            skip = {None}

        if isinstance(tops, str):
            tops = (tops,)

        # Get referenced patterns for all tops
        targets = set()
        for top in set(tops):
            targets |= self[top].referenced_patterns()

        # Perform recursive lookups, but only once for each name
        for target in targets - skip:
            assert target is not None
            if target in self:
                targets |= self.referenced_patterns(target, skip=skip)
            skip.add(target)

        return targets

    def subtree(
            self,
            tops: str | Sequence[str],
            ) -> 'ILibraryView':
        '''
         Return a new `ILibraryView`, containing only the specified patterns and the patterns they
        reference (recursively).
        Dangling references do not cause an error.

        Args:
            tops: Name(s) of patterns to keep

        Returns:
            A `LibraryView` containing only `tops` and the patterns they reference.
        '''
        if isinstance(tops, str):
            tops = (tops,)

        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}
        new = LibraryView(filtered)
        return new

    def polygonize(
            self,
            num_vertices: int | None = None,
            max_arclen: float | None = None,
            ) -> Self:
        '''
        Calls `.polygonize(...)` on each pattern in this library.
        Arguments are passed on to `shape.to_polygons(...)`.

        Args:
            num_vertices: Number of points to use for each polygon. Can be overridden by
                `max_arclen` if that results in more points. Optional, defaults to shapes'
                internal defaults.
            max_arclen: Maximum arclength which can be approximated by a single line
             segment. Optional, defaults to shapes' internal defaults.

        Returns:
            self
        '''
        for pat in self.values():
            pat.polygonize(num_vertices, max_arclen)
        return self

    def manhattanize(
            self,
            grid_x: ArrayLike,
            grid_y: ArrayLike,
            ) -> Self:
        '''
        Calls `.manhattanize(grid_x, grid_y)` on each pattern in this library.

        Args:
            grid_x: List of allowed x-coordinates for the Manhattanized polygon edges.
            grid_y: List of allowed y-coordinates for the Manhattanized polygon edges.

        Returns:
            self
        '''
        for pat in self.values():
            pat.manhattanize(grid_x, grid_y)
        return self

    def flatten(
            self,
            tops: str | Sequence[str],
            flatten_ports: bool = False,
            ) -> dict[str, 'Pattern']:
        '''
        Returns copies of all `tops` patterns with all refs
         removed and replaced with equivalent shapes.
        Also returns flattened copies of all referenced patterns.
        The originals in the calling `Library` are not modified.
        For an in-place variant, see `Pattern.flatten`.

        Args:
            tops: The pattern(s) to flattern.
            flatten_ports: If `True`, keep ports from any referenced
                patterns; otherwise discard them.

        Returns:
            {name: flat_pattern} mapping for all flattened patterns.
        '''
        if isinstance(tops, str):
            tops = (tops,)

        flattened: dict[str, Pattern | None] = {}

        def flatten_single(name: str) -> None:
            flattened[name] = None
            pat = self[name].deepcopy()

            for target in pat.refs:
                if target is None:
                    continue
                if target not in flattened:
                    flatten_single(target)

                target_pat = flattened[target]
                if target_pat is None:
                    raise PatternError(f'Circular reference in {name} to {target}')
                if target_pat.is_empty():        # avoid some extra allocations
                    continue

                for ref in pat.refs[target]:
                    p = ref.as_pattern(pattern=target_pat)
                    if not flatten_ports:
                        p.ports.clear()
                    pat.append(p)

            pat.refs.clear()
            flattened[name] = pat

        for top in tops:
            flatten_single(top)

        assert None not in flattened.values()
        return cast('dict[str, Pattern]', flattened)

    def get_name(
            self,
            name: str = SINGLE_USE_PREFIX * 2,
            sanitize: bool = True,
            max_length: int = 32,
            quiet: bool | None = None,
            ) -> str:
        '''
        Find a unique name for the pattern.

        This function may be overridden in a subclass or monkey-patched to fit the caller's requirements.

        Args:
            name: Preferred name for the pattern. Default is `SINGLE_USE_PREFIX * 2`.
            sanitize: Allows only alphanumeric charaters and _?$. Replaces invalid characters with underscores.
            max_length: Names longer than this will be truncated.
            quiet: If `True`, suppress log messages. Default `None` suppresses messages only if
                the name starts with `SINGLE_USE_PREFIX`.

        Returns:
            Name, unique within this library.
        '''
        if quiet is None:
            quiet = name.startswith(SINGLE_USE_PREFIX)

        if sanitize:
            # Remove invalid characters
            sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', name)
        else:
            sanitized_name = name

        suffixed_name = sanitized_name
        if sanitized_name in self:
            ii = sum(1 for nn in self.keys() if nn.startswith(sanitized_name))
        else:
            ii = 0
        while suffixed_name in self or suffixed_name == '':
            suffixed_name = sanitized_name + b64suffix(ii)
            ii += 1

        if len(suffixed_name) > max_length:
            if name == '':
                raise LibraryError(f'No valid pattern names remaining within the specified {max_length=}')

            cropped_name = self.get_name(sanitized_name[:-1], sanitize=sanitize, max_length=max_length, quiet=True)
        else:
            cropped_name = suffixed_name

        if not quiet:
            logger.info(f'Requested name "{name}" changed to "{cropped_name}"')

        return cropped_name

    def tops(self) -> list[str]:
        '''
        Return the list of all patterns that are not referenced by any other pattern in the library.

        Returns:
            A list of pattern names in which no pattern is referenced by any other pattern.
        '''
        names = set(self.keys())
        not_toplevel: set[str | None] = set()
        for name in names:
            not_toplevel |= set(self[name].refs.keys())

        toplevel = list(names - not_toplevel)
        return toplevel

    def top(self) -> str:
        '''
        Return the name of the topcell, or raise an exception if there isn't a single topcell

        Raises:
            LibraryError if there is not exactly one topcell.
        '''
        tops = self.tops()
        if len(tops) != 1:
            raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}')
        return tops[0]

    def top_pattern(self) -> 'Pattern':
        '''
        Shorthand for self[self.top()]

        Raises:
            LibraryError if there is not exactly one topcell.
        '''
        return self[self.top()]

    def dfs(
            self,
            pattern: 'Pattern',
            visit_before: visitor_function_t | None = None,
            visit_after: visitor_function_t | None = None,
            *,
            hierarchy: tuple[str | None, ...] = (None,),
            transform: ArrayLike | bool | None = False,
            memo: dict | None = None,
            ) -> Self:
        '''
        Convenience function.
        Performs a depth-first traversal of a pattern and its referenced patterns.
        At each pattern in the tree, the following sequence is called:
            ```
            current_pattern = visit_before(current_pattern, **vist_args)
            for target in current_pattern.refs:
                for ref in pattern.refs[target]:
                    self.dfs(target, visit_before, visit_after,
                             hierarchy + (sp.target,), updated_transform, memo)
            current_pattern = visit_after(current_pattern, **visit_args)
            ```
          where `visit_args` are
            `hierarchy`:  (top_pattern_or_None, L1_pattern, L2_pattern, ..., parent_pattern, target_pattern)
                          tuple of all parent-and-higher pattern names. Top pattern name may be
                          `None` if not provided in first call to .dfs()
            `transform`:  numpy.ndarray containing cumulative
                          [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)]
                          for the instance being visited
            `memo`:  Arbitrary dict (not altered except by `visit_before()` and `visit_after()`)

        Args:
            pattern: Pattern object to start at ("top"/root node of the tree).
            visit_before: Function to call before traversing refs.
                Should accept a `Pattern` and `**visit_args`, and return the (possibly modified)
                pattern. Default `None` (not called).
            visit_after: Function to call after traversing refs.
                Should accept a `Pattern` and `**visit_args`, and return the (possibly modified)
                pattern. Default `None` (not called).
            transform: Initial value for `visit_args['transform']`.
                Can be `False`, in which case the transform is not calculated.
                `True` or `None` is interpreted as `[0, 0, 0, 0]`.
            memo: Arbitrary dict for use by `visit_*()` functions. Default `None` (empty dict).
            hierarchy: Tuple of patterns specifying the hierarchy above the current pattern.
                Default is (None,), which will be used as a placeholder for the top pattern's
                name if not overridden.

        Returns:
            self
        '''
        if memo is None:
            memo = {}

        if transform is None or transform is True:
            transform = numpy.zeros(4)
        elif transform is not False:
            transform = numpy.asarray(transform, dtype=float)

        original_pattern = pattern

        if visit_before is not None:
            pattern = visit_before(pattern, hierarchy=hierarchy, memo=memo, transform=transform)

        for target in pattern.refs:
            if target is None:
                continue
            if target in hierarchy:
                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:
                    ref_transforms = apply_transforms(transform, ref.as_transforms())
                else:
                    ref_transforms = [False]

                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)

        if pattern is not original_pattern:
            name = hierarchy[-1]
            if not isinstance(self, ILibrary):
                raise LibraryError('visit_* functions returned a new `Pattern` object'
                                   ' but the library is immutable')
            if name is None:
                # The top pattern is not the original pattern, but we don't know what to call it!
                raise LibraryError('visit_* functions returned a new `Pattern` object'
                                   ' but no top-level name was provided in `hierarchy`')

            cast('ILibrary', self)[name] = pattern

        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 cast('list[str]', 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):
    '''
    Interface for a writeable library.

    A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
    '''
    # inherited abstract functions
    #def __getitem__(self, key: str) -> 'Pattern':
    #def __iter__(self) -> Iterator[str]:
    #def __len__(self) -> int:
    #def __setitem__(self, key: str, value: 'Pattern | Callable[[], Pattern]') -> None:
    #def __delitem__(self, key: str) -> None:

    @abstractmethod
    def __setitem__(
            self,
            key: str,
            value: 'Pattern | Callable[[], Pattern]',
            ) -> None:
        pass

    @abstractmethod
    def __delitem__(self, key: str) -> None:
        pass

    @abstractmethod
    def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
        pass

    def rename(
            self,
            old_name: str,
            new_name: str,
            move_references: bool = False,
            ) -> Self:
        '''
        Rename a pattern.

        Args:
            old_name: Current name for the pattern
            new_name: New name for the pattern
            move_references: If `True`, any refs in this library pointing to `old_name`
                will be updated to point to `new_name`.

        Returns:
            self
        '''
        self[new_name] = self[old_name]
        del self[old_name]
        if move_references:
            self.move_references(old_name, new_name)
        return self

    def rename_top(self, name: str) -> Self:
        '''
        Rename the (single) top pattern
        '''
        self.rename(self.top(), name, move_references=True)
        return self

    def move_references(self, old_target: str, new_target: str) -> Self:
        '''
        Change all references pointing at `old_target` into references pointing at `new_target`.

        Args:
            old_target: Current reference target
            new_target: New target for the reference

        Returns:
            self
        '''
        for pattern in self.values():
            if old_target in pattern.refs:
                pattern.refs[new_target].extend(pattern.refs[old_target])
                del pattern.refs[old_target]
        return self

    def map_layers(
            self,
            map_layer: Callable[[layer_t], layer_t],
            ) -> Self:
        '''
        Move all the elements in all patterns from one layer onto a different layer.
        Can also handle multiple such mappings simultaneously.

        Args:
            map_layer: Callable which may be called with each layer present in `elements`,
                and should return the new layer to which it will be mapped.
                A simple example which maps `old_layer` to `new_layer` and leaves all others
                as-is would look like `lambda layer: {old_layer: new_layer}.get(layer, layer)`

        Returns:
            self
        '''
        for pattern in self.values():
            pattern.shapes = map_layers(pattern.shapes, map_layer)
            pattern.labels = map_layers(pattern.labels, map_layer)
        return self

    def mkpat(self, name: str) -> tuple[str, 'Pattern']:
        '''
        Convenience method to create an empty pattern, add it to the library,
        and return both the pattern and name.

        Args:
            name: Name for the pattern

        Returns:
            (name, pattern) tuple
        '''
        from .pattern import Pattern
        pat = Pattern()
        self[name] = pat
        return name, pat

    def add(
            self,
            other: Mapping[str, 'Pattern'],
            rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
            mutate_other: bool = False,
            ) -> dict[str, str]:
        '''
        Add items from another library into this one.

        If any name in `other` is already present in `self`, `rename_theirs(self, name)` is called
          to pick a new name for the newly-added pattern. If the new name still conflicts with a name
          in `self` a `LibraryError` is raised. All references to the original name (within `other)`
          are updated to the new name.
        If `mutate_other=False` (default), all changes are made to a deepcopy of `other`.

        By default, `rename_theirs` makes no changes to the name (causing a `LibraryError`) unless the
          name starts with `SINGLE_USE_PREFIX`. Prefixed names are truncated to before their first
          non-prefix '$' and then passed to `self.get_name()` to create a new unique name.

        Args:
            other: The library to insert keys from.
            rename_theirs: Called as rename_theirs(self, name) for each duplicate name
                encountered in `other`. Should return the new name for the pattern in
                `other`. See above for default behavior.
            mutate_other: If `True`, modify the original library and its contained patterns
                (e.g. when renaming patterns and updating refs). Otherwise, operate on a deepcopy
                (default).

        Returns:
            A mapping of `{old_name: new_name}` for all `old_name`s in `other`. Unchanged
            names map to themselves.

        Raises:
            `LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`.
        '''
        from .pattern import map_targets
        duplicates = set(self.keys()) & set(other.keys())

        if not duplicates:
            for key in other:
                self._merge(key, other, key)
            return {}

        if mutate_other:
            if isinstance(other, Library):
                temp = other
            else:
                temp = Library(dict(other))
        else:
            temp = Library(copy.deepcopy(dict(other)))
        rename_map = {}
        for old_name in temp:
            if old_name in self:
                new_name = rename_theirs(self, old_name)
                if new_name in self:
                    raise LibraryError(f'Unresolved duplicate key encountered in library merge: {old_name} -> {new_name}')
                rename_map[old_name] = new_name
            else:
                new_name = old_name

            self._merge(new_name, temp, old_name)

        # Update references in the newly-added cells
        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))

        return rename_map

    def __lshift__(self, other: TreeView) -> str:
        '''
          `add()` items from a tree (single-topcell name: pattern mapping) into this one,
        and return the name of the tree's topcell (in this library; it may have changed
        based on `add()`'s default `rename_theirs` argument).

        Raises:
            LibraryError if there is more than one topcell in `other`.
        '''
        if len(other) == 1:
            name = next(iter(other))
        else:
            if not isinstance(other, ILibraryView):
                other = LibraryView(other)

            tops = other.tops()
            if len(tops) > 1:
                raise LibraryError('Received a library containing multiple topcells!')

            name = tops[0]

        rename_map = self.add(other)
        new_name = rename_map.get(name, name)
        return new_name

    def __le__(self, other: Mapping[str, 'Pattern']) -> Abstract:
        '''
          Perform the same operation as `__lshift__` / `<<`, but return an `Abstract` instead
        of just the pattern's name.

        Raises:
            LibraryError if there is more than one topcell in `other`.
        '''
        new_name = self << other
        return self.abstract(new_name)

    def dedup(
            self,
            norm_value: int = int(1e6),
            exclude_types: tuple[type] = (Polygon,),
            label2name: Callable[[tuple], str] | None = None,
            threshold: int = 2,
            ) -> Self:
        '''
        Iterates through all `Pattern`s. Within each `Pattern`, it iterates
         over all shapes, calling `.normalized_form(norm_value)` on them to retrieve a scale-,
         offset-, and rotation-independent form. Each shape whose normalized form appears
         more than once is removed and re-added using `Ref` objects referencing a newly-created
         `Pattern` containing only the normalized form of the shape.

        Note:
            The default norm_value was chosen to give a reasonable precision when using
            integer values for coordinates.

        Args:
            norm_value: Passed to `shape.normalized_form(norm_value)`. Default `1e6` (see function
                note)
            exclude_types: Shape types passed in this argument are always left untouched, for
                speed or convenience. Default: `(shapes.Polygon,)`
            label2name: Given a label tuple as returned by `shape.normalized_form(...)`, pick
                a name for the generated pattern.
                Default `self.get_name(SINGLE_USE_PREIX + 'shape')`.
            threshold: Only replace shapes with refs if there will be at least this many
                instances.

        Returns:
            self
        '''
        # This currently simplifies globally (same shape in different patterns is
        # merged into the same ref target).

        from .pattern import Pattern

        if exclude_types is None:
            exclude_types = ()

        if label2name is None:
            def label2name(label: tuple) -> str:        # noqa: ARG001
                return self.get_name(SINGLE_USE_PREFIX + 'shape')

        shape_counts: MutableMapping[tuple, int] = defaultdict(int)
        shape_funcs = {}

        # ## First pass ##
        #  Using the label tuple from `.normalized_form()` as a key, check how many of each shape
        # are present and store the shape function for each one
        for pat in tuple(self.values()):
            for layer, sseq in pat.shapes.items():
                for shape in sseq:
                    if not any(isinstance(shape, t) for t in exclude_types):
                        base_label, _values, func = shape.normalized_form(norm_value)
                        label = (*base_label, layer)
                        shape_funcs[label] = func
                        shape_counts[label] += 1

        shape_pats = {}
        for label, count in shape_counts.items():
            if count < threshold:
                continue

            shape_func = shape_funcs[label]
            shape_pat = Pattern()
            shape_pat.shapes[label[-1]] += [shape_func()]
            shape_pats[label] = shape_pat

        # ## Second pass ##
        for pat in tuple(self.values()):
            #  Store `[(index_in_shapes, values_from_normalized_form), ...]` for all shapes which
            # are to be replaced.
            # The `values` are `(offset, scale, rotation)`.

            shape_table: dict[tuple, list] = defaultdict(list)
            for layer, sseq in pat.shapes.items():
                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)
                    label = (*base_label, layer)

                    if label not in shape_pats:
                        continue

                    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, shape_entries in shape_table.items():
                layer = label[-1]
                target = label2name(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))
                    shapes_to_remove.append(ii)

                # Remove any shapes for which we have created refs.
                for ii in sorted(shapes_to_remove, reverse=True):
                    del pat.shapes[layer][ii]

        for ll, pp in shape_pats.items():
            self[label2name(ll)] = pp

        return self

    def wrap_repeated_shapes(
            self,
            name_func: Callable[['Pattern', Shape | Label], str] | None = None,
            ) -> Self:
        '''
        Wraps all shapes and labels with a non-`None` `repetition` attribute
          into a `Ref`/`Pattern` combination, and applies the `repetition`
          to each `Ref` instead of its contained shape.

        Args:
            name_func: Function f(this_pattern, shape) which generates a name for the
                        wrapping pattern.
                        Default is `self.get_name(SINGLE_USE_PREFIX + 'rep')`.

        Returns:
            self
        '''
        from .pattern import Pattern

        if name_func is None:
            def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
                return self.get_name(SINGLE_USE_PREFIX + 'rep')

        for pat in tuple(self.values()):
            for layer in pat.shapes:
                new_shapes = []
                for shape in pat.shapes[layer]:
                    if shape.repetition is None:
                        new_shapes.append(shape)
                        continue

                    name = name_func(pat, shape)
                    self[name] = Pattern(shapes={layer: [shape]})
                    pat.ref(name, repetition=shape.repetition)
                    shape.repetition = None
                pat.shapes[layer] = new_shapes

            for layer in pat.labels:
                new_labels = []
                for label in pat.labels[layer]:
                    if label.repetition is None:
                        new_labels.append(label)
                        continue
                    name = name_func(pat, label)
                    self[name] = Pattern(labels={layer: [label]})
                    pat.ref(name, repetition=label.repetition)
                    label.repetition = None
                pat.labels[layer] = new_labels

        return self

    def subtree(
            self,
            tops: str | Sequence[str],
            ) -> Self:
        '''
         Return a new `ILibraryView`, containing only the specified patterns and the patterns they
        reference (recursively).
        Dangling references do not cause an error.

        Args:
            tops: Name(s) of patterns to keep

        Returns:
            An object of the same type as `self` containing only `tops` and the patterns they reference.
        '''
        if isinstance(tops, str):
            tops = (tops,)

        keep = cast('set[str]', self.referenced_patterns(tops) - {None})
        keep |= set(tops)

        new = type(self)()
        for key in keep & set(self.keys()):
            new._merge(key, self, key)
        return new

    def prune_empty(
            self,
            repeat: bool = True,
            ) -> set[str]:
        '''
        Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).

        Args:
            repeat: Also recursively delete any patterns which only contain(ed) empty patterns.

        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:
            parents = set()
            for name in empty:
                del self[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(
            self,
            key: str,
            delete_refs: bool = True,
            ) -> Self:
        '''
        Delete a pattern and (optionally) all refs pointing to that pattern.

        Args:
            key: Name of the pattern to be deleted.
            delete_refs: If `True` (default), also delete all refs pointing to the pattern.
        '''
        del self[key]
        if delete_refs:
            for pat in self.values():
                if key in pat.refs:
                    del pat.refs[key]
        return self


class LibraryView(ILibraryView):
    '''
    Default implementation for a read-only library.

    A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
    This library is backed by an arbitrary python object which implements the `Mapping` interface.
    '''
    mapping: Mapping[str, 'Pattern']

    def __init__(
            self,
            mapping: Mapping[str, 'Pattern'],
            ) -> None:
        self.mapping = mapping

    def __getitem__(self, key: str) -> 'Pattern':
        return self.mapping[key]

    def __iter__(self) -> Iterator[str]:
        return iter(self.mapping)

    def __len__(self) -> int:
        return len(self.mapping)

    def __contains__(self, key: object) -> bool:
        return key in self.mapping

    def __repr__(self) -> str:
        return f'<LibraryView ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'


class Library(ILibrary):
    '''
    Default implementation for a writeable library.

    A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
    This library is backed by an arbitrary python object which implements the `MutableMapping` interface.
    '''
    mapping: MutableMapping[str, 'Pattern']

    def __init__(
            self,
            mapping: MutableMapping[str, 'Pattern'] | None = None,
            ) -> None:
        if mapping is None:
            self.mapping = {}
        else:
            self.mapping = mapping

    def __getitem__(self, key: str) -> 'Pattern':
        return self.mapping[key]

    def __iter__(self) -> Iterator[str]:
        return iter(self.mapping)

    def __len__(self) -> int:
        return len(self.mapping)

    def __contains__(self, key: object) -> bool:
        return key in self.mapping

    def __setitem__(
            self,
            key: str,
            value: 'Pattern | Callable[[], Pattern]',
            ) -> None:
        if key in self.mapping:
            raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')

        value = value() if callable(value) else value
        self.mapping[key] = value

    def __delitem__(self, key: str) -> None:
        del self.mapping[key]

    def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
        self[key_self] = other[key_other]

    def __repr__(self) -> str:
        return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'

    @classmethod
    def mktree(cls: type[Self], name: str) -> tuple[Self, 'Pattern']:
        '''
        Create a new Library and immediately add a pattern

        Args:
            name: The name for the new pattern (usually the name of the topcell).

        Returns:
            The newly created `Library` and the newly created `Pattern`
        '''
        from .pattern import Pattern
        tree = cls()
        pat = Pattern()
        tree[name] = pat
        return tree, pat


class LazyLibrary(ILibrary):
    '''
    This class is usually used to create a library of Patterns by mapping names to
     functions which generate or load the relevant `Pattern` object as-needed.

    TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid?
    '''
    mapping: dict[str, Callable[[], 'Pattern']]
    cache: dict[str, 'Pattern']
    _lookups_in_progress: set[str]

    def __init__(self) -> None:
        self.mapping = {}
        self.cache = {}
        self._lookups_in_progress = set()

    def __setitem__(
            self,
            key: str,
            value: 'Pattern | Callable[[], Pattern]',
            ) -> None:
        if key in self.mapping:
            raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')

        if callable(value):
            value_func = value
        else:
            value_func = lambda: cast('Pattern', value)      # noqa: E731

        self.mapping[key] = value_func
        if key in self.cache:
            del self.cache[key]

    def __delitem__(self, key: str) -> None:
        del self.mapping[key]
        if key in self.cache:
            del self.cache[key]

    def __getitem__(self, key: str) -> 'Pattern':
        logger.debug(f'loading {key}')
        if key in self.cache:
            logger.debug(f'found {key} in cache')
            return self.cache[key]

        if key in self._lookups_in_progress:
            raise LibraryError(
                f'Detected multiple simultaneous lookups of "{key}".\n'
                'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
                'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'        # TODO give advice on finding cycles
                )

        self._lookups_in_progress.add(key)
        func = self.mapping[key]
        pat = func()
        self._lookups_in_progress.remove(key)
        self.cache[key] = pat
        return pat

    def __iter__(self) -> Iterator[str]:
        return iter(self.mapping)

    def __len__(self) -> int:
        return len(self.mapping)

    def __contains__(self, key: object) -> bool:
        return key in self.mapping

    def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
        if isinstance(other, LazyLibrary):
            self.mapping[key_self] = other.mapping[key_other]
            if key_other in other.cache:
                self.cache[key_self] = other.cache[key_other]
        else:
            self[key_self] = other[key_other]

    def __repr__(self) -> str:
        return '<LazyLibrary with keys\n' + pformat(list(self.keys())) + '>'

    def rename(
            self,
            old_name: str,
            new_name: str,
            move_references: bool = False,
            ) -> Self:
        '''
        Rename a pattern.

        Args:
            old_name: Current name for the pattern
            new_name: New name for the pattern
            move_references: Whether to scan all refs in the pattern and
                move them to point to `new_name` as necessary.
                Default `False`.

        Returns:
            self
        '''
        self[new_name] = self.mapping[old_name]        # copy over function
        if old_name in self.cache:
            self.cache[new_name] = self.cache[old_name]
        del self[old_name]

        if move_references:
            self.move_references(old_name, new_name)

        return self

    def move_references(self, old_target: str, new_target: str) -> Self:
        '''
        Change all references pointing at `old_target` into references pointing at `new_target`.

        Args:
            old_target: Current reference target
            new_target: New target for the reference

        Returns:
            self
        '''
        self.precache()
        for pattern in self.cache.values():
            if old_target in pattern.refs:
                pattern.refs[new_target].extend(pattern.refs[old_target])
                del pattern.refs[old_target]
        return self

    def precache(self) -> Self:
        '''
        Force all patterns into the cache

        Returns:
            self
        '''
        for key in self.mapping:
            _ = self[key]       # want to trigger our own __getitem__
        return self

    def __deepcopy__(self, memo: dict | None = None) -> 'LazyLibrary':
        raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)')


class AbstractView(Mapping[str, Abstract]):
    '''
    A read-only mapping from names to `Abstract` objects.

    This is usually just used as a shorthand for repeated calls to `library.abstract()`.
    '''
    library: ILibraryView

    def __init__(self, library: ILibraryView) -> None:
        self.library = library

    def __getitem__(self, key: str) -> Abstract:
        return self.library.abstract(key)

    def __iter__(self) -> Iterator[str]:
        return self.library.__iter__()

    def __len__(self) -> int:
        return self.library.__len__()


def b64suffix(ii: int) -> str:
    '''
    Turn an integer into a base64-equivalent suffix.

    This could be done with base64.b64encode, but this way is faster for many small `ii`.
    '''
    def i2a(nn: int) -> str:
        return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn]

    parts = ['$', i2a(ii % 64)]
    ii >>= 6
    while ii:
        parts.append(i2a(ii % 64))
        ii >>= 6
    return ''.join(parts)


---
masque/pattern.py
---
'''
  Object representing a one multi-layer lithographic layout.
  A single level of hierarchical references is included.
'''
from typing import cast, Self, Any, TypeVar
from collections.abc import Sequence, Mapping, MutableMapping, Iterable, Callable
import copy
import logging
import functools
from itertools import chain
from collections import defaultdict

import numpy
from numpy import inf, pi, nan
from numpy.typing import NDArray, ArrayLike
# .visualize imports matplotlib and matplotlib.collections

from .ref import Ref
from .abstract import Abstract
from .shapes import Shape, Polygon, Path, DEFAULT_POLY_NUM_VERTICES
from .label import Label
from .utils import rotation_matrix_2d, annotations_t, layer_t, annotations_eq, annotations_lt, layer2key
from .utils import ports_eq, ports_lt
from .error import PatternError, PortError
from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable, Bounded
from .ports import Port, PortList


logger = logging.getLogger(__name__)


@functools.total_ordering
class Pattern(PortList, AnnotatableImpl, Mirrorable):
    '''
      2D layout consisting of some set of shapes, labels, and references to other
    Pattern objects (via Ref). Shapes are assumed to inherit from `masque.shapes.Shape`
    or provide equivalent functions.

    `Pattern` also stores a dict of `Port`s, which can be used to "snap" together points.
    See `Pattern.plug()` and `Pattern.place()`, as well as the helper classes
    `builder.Builder`, `builder.Pather`, `builder.RenderPather`, and `ports.PortsList`.

    For convenience, ports can be read out using square brackets:
    - `pattern['A'] == Port((0, 0), 0)`
    - `pattern[['A', 'B']] == {'A': Port((0, 0), 0), 'B': Port((0, 0), pi)}`


    Examples: Making a Pattern
    ==========================
    - `pat = Pattern()` just creates an empty pattern, with no geometry or ports

    - To immediately set some of the pattern's contents,
      ```
        pat = Pattern(
            shapes={'layer1': [shape0, ...], 'layer2': [shape,...], ...},
            labels={'layer1': [...], ...},
            refs={'name1': [ref0, ...], 'name2': [ref, ...], ...},
            ports={'in': Port(...), 'out': Port(...)},
            )
      ```

    - `Pattern.interface(other_pat, port_map=['A', 'B'])` makes a new
        (empty) pattern, copies over ports 'A' and 'B' from `other_pat`, and
        creates additional ports 'in_A' and 'in_B' facing in the opposite
        directions. This can be used to build a device which can plug into
        `other_pat` (using the 'in_*' ports) but which does not itself include
        `other_pat` as a subcomponent.


    Examples: Adding to a pattern
    =============================
    - `pat.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
        instantiates `subdevice` into `pat`, plugging ports 'A' and 'B'
        of `pat` into ports 'C' and 'B' of `subdevice`. The connected ports
        are removed and any unconnected ports from `subdevice` are added to
        `pat`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'.

    - `pat.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
        of `pat`. If `wire` has only two ports (e.g. 'A' and 'B'), since no `map_out`
        argument is provided and the `inherit_name` 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.

    - `pat.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
        instantiates `pad` at the specified (x, y) offset and with the specified
        rotation, adding its ports to those of `pat`. Port 'A' of `pad` is
        renamed to 'gnd' so that further routing can use this signal or net name
        rather than the port name on the original `pad` device.
    '''
    __slots__ = (
        'shapes', 'labels', 'refs', '_ports',
        # inherited
        '_annotations',
        )

    shapes: defaultdict[layer_t, list[Shape]]
    ''' Stores of all shapes in this Pattern, indexed by layer.
    Elements in this list are assumed to inherit from Shape or provide equivalent functions.
    '''

    labels: defaultdict[layer_t, list[Label]]
    ''' List of all labels in this Pattern. '''

    refs: defaultdict[str | None, list[Ref]]
    ''' List of all references to other patterns (`Ref`s) in this `Pattern`.
    Multiple objects in this list may reference the same Pattern object
      (i.e. multiple instances of the same object).
    '''

    _ports: dict[str, Port]
    ''' Uniquely-named ports which can be used to snap to other Pattern instances'''

    @property
    def ports(self) -> dict[str, Port]:
        return self._ports

    @ports.setter
    def ports(self, value: dict[str, Port]) -> None:
        self._ports = value

    def __init__(
            self,
            *,
            shapes: Mapping[layer_t, Sequence[Shape]] | None = None,
            labels: Mapping[layer_t, Sequence[Label]] | None = None,
            refs: Mapping[str | None, Sequence[Ref]] | None = None,
            annotations: annotations_t | None = None,
            ports: Mapping[str, 'Port'] | None = None
            ) -> None:
        '''
        Basic init; arguments get assigned to member variables.
         Non-list inputs for shapes and refs get converted to lists.

        Args:
            shapes: Initial shapes in the Pattern
            labels: Initial labels in the Pattern
            refs: Initial refs in the Pattern
            annotations: Initial annotations for the pattern
            ports: Any ports in the pattern
        '''
        self.shapes = defaultdict(list)
        self.labels = defaultdict(list)
        self.refs = defaultdict(list)
        if shapes:
            for layer, sseq in shapes.items():
                self.shapes[layer].extend(sseq)
        if labels:
            for layer, lseq in labels.items():
                self.labels[layer].extend(lseq)
        if refs:
            for target, rseq in refs.items():
                self.refs[target].extend(rseq)

        if ports is not None:
            self.ports = dict(copy.deepcopy(ports))
        else:
            self.ports = {}

        self.annotations = annotations if annotations is not None else {}

    def __repr__(self) -> str:
        nshapes = sum(len(seq) for seq in self.shapes.values())
        nrefs = sum(len(seq) for seq in self.refs.values())
        nlabels = sum(len(seq) for seq in self.labels.values())

        s = f'<Pattern: s{nshapes} r{nrefs} l{nlabels} ['
        for name, port in self.ports.items():
            s += f'\n\t{name}: {port}'
        s += ']>'
        return s

    def __copy__(self) -> 'Pattern':
        logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!')
        new = Pattern(
            annotations=copy.deepcopy(self.annotations),
            ports=copy.deepcopy(self.ports),
            )
        for target, rseq in self.refs.items():
            new.refs[target].extend(rseq)
        for layer, sseq in self.shapes.items():
            new.shapes[layer].extend(sseq)
        for layer, lseq in self.labels.items():
            new.labels[layer].extend(lseq)

        return new

#    def __deepcopy__(self, memo: dict | None = None) -> 'Pattern':
#        memo = {} if memo is None else memo
#        new = Pattern(
#            shapes=copy.deepcopy(self.shapes, memo),
#            labels=copy.deepcopy(self.labels, memo),
#            refs=copy.deepcopy(self.refs, memo),
#            annotations=copy.deepcopy(self.annotations, memo),
#            ports=copy.deepcopy(self.ports),
#            )
#        return new

    def __lt__(self, other: 'Pattern') -> bool:
        self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
        other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
        self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
        other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))

        if self_tgtkeys != other_tgtkeys:
            return self_tgtkeys < other_tgtkeys

        for _, target in self_tgtkeys:
            refs_ours = tuple(sorted(self.refs[target]))
            refs_theirs = tuple(sorted(other.refs[target]))
            if refs_ours != refs_theirs:
                return refs_ours < refs_theirs

        self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
        other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
        self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
        other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))

        if self_layerkeys != other_layerkeys:
            return self_layerkeys < other_layerkeys

        for _, _, layer in self_layerkeys:
            shapes_ours = tuple(sorted(self.shapes[layer]))
            shapes_theirs = tuple(sorted(self.shapes[layer]))
            if shapes_ours != shapes_theirs:
                return shapes_ours < shapes_theirs

        self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
        other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
        self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
        other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))

        if self_txtlayerkeys != other_txtlayerkeys:
            return self_txtlayerkeys < other_txtlayerkeys

        for _, _, layer in self_layerkeys:
            labels_ours = tuple(sorted(self.labels[layer]))
            labels_theirs = tuple(sorted(self.labels[layer]))
            if labels_ours != labels_theirs:
                return labels_ours < labels_theirs

        if not annotations_eq(self.annotations, other.annotations):
            return annotations_lt(self.annotations, other.annotations)

        if not ports_eq(self.ports, other.ports):
            return ports_lt(self.ports, other.ports)

        return False

    def __eq__(self, other: Any) -> bool:
        if type(self) is not type(other):
            return False

        self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
        other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
        self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
        other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))

        if self_tgtkeys != other_tgtkeys:
            return False

        for _, target in self_tgtkeys:
            refs_ours = tuple(sorted(self.refs[target]))
            refs_theirs = tuple(sorted(other.refs[target]))
            if refs_ours != refs_theirs:
                return False

        self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
        other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
        self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
        other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))

        if self_layerkeys != other_layerkeys:
            return False

        for _, _, layer in self_layerkeys:
            shapes_ours = tuple(sorted(self.shapes[layer]))
            shapes_theirs = tuple(sorted(self.shapes[layer]))
            if shapes_ours != shapes_theirs:
                return False

        self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
        other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
        self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
        other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))

        if self_txtlayerkeys != other_txtlayerkeys:
            return False

        for _, _, layer in self_layerkeys:
            labels_ours = tuple(sorted(self.labels[layer]))
            labels_theirs = tuple(sorted(self.labels[layer]))
            if labels_ours != labels_theirs:
                return False

        if not annotations_eq(self.annotations, other.annotations):
            return False

        if not ports_eq(self.ports, other.ports):   # noqa: SIM103
            return False

        return True

    def sort(self, sort_elements: bool = True) -> Self:
        '''
        Sort the element dicts (shapes, labels, refs) and (optionally) their contained lists.
        This is primarily useful for making builds more reproducible.

        Args:
            sort_elements: Whether to sort all the shapes/labels/refs within each layer/target.

        Returns:
            self
        '''
        if sort_elements:
            def maybe_sort(xx):         # noqa:ANN001,ANN202
                return sorted(xx)
        else:
            def maybe_sort(xx):         # noqa:ANN001,ANN202
                return xx

        self.refs = defaultdict(list, sorted(
            (tgt, maybe_sort(rrs)) for tgt, rrs in self.refs.items()
            ))
        self.labels = defaultdict(list, sorted(
            ((layer, maybe_sort(lls)) for layer, lls in self.labels.items()),
            key=lambda tt: layer2key(tt[0]),
            ))
        self.shapes = defaultdict(list, sorted(
            ((layer, maybe_sort(sss)) for layer, sss in self.shapes.items()),
            key=lambda tt: layer2key(tt[0]),
            ))

        self.ports = dict(sorted(self.ports.items()))
        self.annotations = dict(sorted(self.annotations.items())) if self.annotations is not None else None

        return self

    def append(self, other_pattern: 'Pattern') -> Self:
        '''
        Appends all shapes, labels and refs from other_pattern to self's shapes,
          labels, and supbatterns.

        Args:
           other_pattern: The Pattern to append

        Returns:
            self
        '''
        for target, rseq in other_pattern.refs.items():
            self.refs[target].extend(rseq)
        for layer, sseq in other_pattern.shapes.items():
            self.shapes[layer].extend(sseq)
        for layer, lseq in other_pattern.labels.items():
            self.labels[layer].extend(lseq)

        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:
            raise PatternError(f'Port names overlap: {port_conflicts}')
        self.ports.update(other_pattern.ports)

        return self

    def subset(
            self,
            shapes: Callable[[layer_t, Shape], bool] | None = None,
            labels: Callable[[layer_t, Label], bool] | None = None,
            refs: Callable[[str | None, Ref], bool] | None = None,
            annotations: Callable[[str, list[int | float | str]], bool] | None = None,
            ports: Callable[[str, Port], bool] | None = None,
            default_keep: bool = False
            ) -> 'Pattern':
        '''
        Returns a Pattern containing only the entities (e.g. shapes) for which the
          given entity_func returns True.
        Self is _not_ altered, but shapes, labels, and refs are _not_ copied, just referenced.

        Args:
            shapes: Given a layer and shape, returns a boolean denoting whether the shape is a
                member of the subset.
            labels: Given a layer and label, returns a boolean denoting whether the label is a
                member of the subset.
            refs: Given a target and ref, returns a boolean denoting if it is a member of the subset.
            annotations: Given an annotation, returns a boolean denoting if it is a member of the subset.
            ports: Given a port, returns a boolean denoting if it is a member of the subset.
            default_keep: If `True`, keeps all elements of a given type if no function is supplied.
                Default `False` (discards all elements).

        Returns:
            A Pattern containing all the shapes and refs for which the parameter
                functions return True
        '''
        pat = Pattern()

        if shapes is not None:
            for layer in self.shapes:
                pat.shapes[layer] = [ss for ss in self.shapes[layer] if shapes(layer, ss)]
        elif default_keep:
            pat.shapes = copy.copy(self.shapes)

        if labels is not None:
            for layer in self.labels:
                pat.labels[layer] = [ll for ll in self.labels[layer] if labels(layer, ll)]
        elif default_keep:
            pat.labels = copy.copy(self.labels)

        if refs is not None:
            for target in self.refs:
                pat.refs[target] = [rr for rr in self.refs[target] if refs(target, rr)]
        elif default_keep:
            pat.refs = copy.copy(self.refs)

        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)

        if ports is not None:
            pat.ports = {k: v for k, v in self.ports.items() if ports(k, v)}
        elif default_keep:
            pat.ports = copy.copy(self.ports)

        return pat

    def polygonize(
            self,
            num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
            max_arclen: float | None = None,
            ) -> Self:
        '''
        Calls `.to_polygons(...)` on all the shapes in this Pattern, replacing them with the returned polygons.
        Arguments are passed directly to `shape.to_polygons(...)`.

        Args:
            num_vertices: Number of points to use for each polygon. Can be overridden by
                `max_arclen` if that results in more points. Optional, defaults to shapes'
                internal defaults.
            max_arclen: Maximum arclength which can be approximated by a single line
             segment. Optional, defaults to shapes' internal defaults.

        Returns:
            self
        '''
        for layer in self.shapes:
            self.shapes[layer] = list(chain.from_iterable(
                ss.to_polygons(num_vertices, max_arclen)
                for ss in self.shapes[layer]
                ))
        return self

    def manhattanize(
            self,
            grid_x: ArrayLike,
            grid_y: ArrayLike,
            ) -> Self:
        '''
        Calls `.polygonize()` on the pattern, then calls `.manhattanize()` on all the
         resulting shapes, replacing them with the returned Manhattan polygons.

        Args:
            grid_x: List of allowed x-coordinates for the Manhattanized polygon edges.
            grid_y: List of allowed y-coordinates for the Manhattanized polygon edges.

        Returns:
            self
        '''

        self.polygonize()
        for layer in self.shapes:
            self.shapes[layer] = list(chain.from_iterable(
                ss.manhattanize(grid_x, grid_y)
                for ss in self.shapes[layer]
                ))
        return self

    def as_polygons(self, library: Mapping[str, 'Pattern']) -> list[NDArray[numpy.float64]]:
        '''
        Represents the pattern as a list of polygons.

        Deep-copies the pattern, then calls `.polygonize()` and `.flatten()` on the copy in order to
         generate the list of polygons.

        Returns:
            A list of `(Ni, 2)` `numpy.ndarray`s specifying vertices of the polygons. Each ndarray
             is of the form `[[x0, y0], [x1, y1],...]`.
        '''
        pat = self.deepcopy().polygonize().flatten(library=library)
        polys = [
            cast('Polygon', shape).vertices + cast('Polygon', shape).offset
            for shape in chain_elements(pat.shapes)
            ]
        return polys

    def referenced_patterns(self) -> set[str | None]:
        '''
        Get all pattern namers referenced by this pattern. Non-recursive.

        Returns:
            A set of all pattern names referenced by this pattern.
        '''
        return set(self.refs.keys())

    def get_bounds(
            self,
            library: Mapping[str, 'Pattern'] | None = None,
            recurse: bool = True,
            cache: MutableMapping[str, NDArray[numpy.float64] | None] | None = None,
            ) -> NDArray[numpy.float64] | None:
        '''
        Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
         extent of the Pattern's contents in each dimension.
        Returns `None` if the Pattern is empty.

        Args:
            library: If `recurse=True`, any referenced patterns are loaded from this library.
            recurse: If `False`, do not evaluate the bounds of any refs (i.e. assume they are empty).
                If `True`, evaluate the bounds of all refs and their conained geometry recursively.
                Default `True`.
            cache: Mapping of `{name: bounds}` for patterns for which the bounds have already been calculated.
                Modified during the run (any referenced pattern's bounds are added).

        Returns:
            `[[x_min, y_min], [x_max, y_max]]` or `None`
        '''
        if self.is_empty():
            return None

        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()
            if maybe_ebounds is not None:
                ebounds[ee] = maybe_ebounds
        mask = ~numpy.isnan(ebounds[:, 0, 0])

        if mask.any():
            cbounds = numpy.vstack((
                numpy.min(ebounds[mask, 0, :], axis=0),
                numpy.max(ebounds[mask, 1, :], axis=0),
                ))
        else:
            cbounds = numpy.array((
                (+inf, +inf),
                (-inf, -inf),
                ))

        if recurse and self.has_refs():
            if library is None:
                raise PatternError('Must provide a library to get_bounds() to resolve refs')

            if cache is None:
                cache = {}

            for target, refs in self.refs.items():
                if target is None:
                    continue
                if not refs:
                    continue

                if target in cache:
                    unrot_bounds = cache[target]
                elif any(numpy.isclose(ref.rotation % (pi / 2), 0) for ref in refs):
                    unrot_bounds = library[target].get_bounds(library=library, recurse=recurse, cache=cache)
                    cache[target] = unrot_bounds

                for ref in refs:
                    if numpy.isclose(ref.rotation % (pi / 2), 0):
                        if unrot_bounds is None:
                            bounds = None
                        else:
                            ubounds = unrot_bounds.copy()
                            if ref.mirrored:
                                ubounds[:, 1] *= -1

                            corners = (rotation_matrix_2d(ref.rotation) @ ubounds.T).T
                            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()

                    else:
                        # Non-manhattan rotation, have to figure out bounds by rotating the pattern
                        bounds = ref.get_bounds(library[target], library=library)

                    if bounds is None:
                        continue

                    cbounds[0] = numpy.minimum(cbounds[0], bounds[0])
                    cbounds[1] = numpy.maximum(cbounds[1], bounds[1])

        if (cbounds[1] < cbounds[0]).any():
            return None
        return cbounds

    def get_bounds_nonempty(
            self,
            library: Mapping[str, 'Pattern'] | None = None,
            recurse: bool = True,
            ) -> NDArray[numpy.float64]:
        '''
        Convenience wrapper for `get_bounds()` which asserts that the Pattern as non-None bounds.

        Args:
            library: If `recurse=True`, any referenced patterns are loaded from this library.
            recurse: If `False`, do not evaluate the bounds of any refs (i.e. assume they are empty).
                If `True`, evaluate the bounds of all refs and their conained geometry recursively.
                Default `True`.
            cache: Mapping of `{name: bounds}` for patterns for which the bounds have already been calculated.
                Modified during the run (any referenced pattern's bounds are added).

        Returns:
            `[[x_min, y_min], [x_max, y_max]]`
        '''
        bounds = self.get_bounds(library, recurse=recurse)
        assert bounds is not None
        return bounds

    def translate_elements(self, offset: ArrayLike) -> Self:
        '''
        Translates all shapes, label, refs, and ports by the given offset.

        Args:
            offset: (x, y) to translate by

        Returns:
            self
        '''
        for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
            cast('Positionable', entry).translate(offset)
        return self

    def scale_elements(self, c: float) -> Self:
        '''"
        Scales all shapes and refs by the given value.

        Args:
            c: factor to scale by

        Returns:
            self
        '''
        for entry in chain_elements(self.shapes, self.refs):
            cast('Scalable', entry).scale_by(c)
        return self

    def scale_by(self, c: float, scale_refs: bool = True) -> Self:
        '''
        Scale this Pattern by the given value
        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
            if scale_refs or not isinstance(entry, Ref):
                cast('Scalable', entry).scale_by(c)

            rep = cast('Repeatable', entry).repetition
            if rep:
                rep.scale_by(c)

        for label in chain_elements(self.labels):
            cast('Positionable', label).offset *= c

            rep = cast('Repeatable', label).repetition
            if rep:
                rep.scale_by(c)

        for port in self.ports.values():
            port.offset *= c
        return self

    def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
        '''
        Rotate the Pattern around the a location.

        Args:
            pivot: (x, y) location to rotate around
            rotation: Angle to rotate by (counter-clockwise, radians)

        Returns:
            self
        '''
        pivot = numpy.asarray(pivot, dtype=float)
        self.translate_elements(-pivot)
        self.rotate_elements(rotation)
        self.rotate_element_centers(rotation)
        self.translate_elements(+pivot)
        return self

    def rotate_element_centers(self, rotation: float) -> Self:
        '''
        Rotate the offsets of all shapes, labels, refs, and ports around (0, 0)

        Args:
            rotation: Angle to rotate by (counter-clockwise, radians)

        Returns:
            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)
        return self

    def rotate_elements(self, rotation: float) -> Self:
        '''
        Rotate each shape, ref, and port around its origin (offset)

        Args:
            rotation: Angle to rotate by (counter-clockwise, radians)

        Returns:
            self
        '''
        for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
            cast('Rotatable', entry).rotate(rotation)
        return self

    def mirror_element_centers(self, across_axis: int = 0) -> Self:
        '''
        Mirror the offsets of all shapes, labels, and refs across an axis

        Args:
            across_axis: Axis to mirror across
                (0: mirror across x axis, 1: mirror across y axis)

        Returns:
            self
        '''
        for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
            cast('Positionable', entry).offset[across_axis - 1] *= -1
        return self

    def mirror_elements(self, across_axis: int = 0) -> Self:
        '''
        Mirror each shape, ref, and pattern across an axis, relative
          to its offset

        Args:
            across_axis: Axis to mirror across
                (0: mirror across x axis, 1: mirror across y axis)

        Returns:
            self
        '''
        for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
            cast('Mirrorable', entry).mirror(across_axis)
        return self

    def mirror(self, across_axis: int = 0) -> Self:
        '''
        Mirror the Pattern across an axis

        Args:
            across_axis: Axis to mirror across
                (0: mirror across x axis, 1: mirror across y axis)

        Returns:
            self
        '''
        self.mirror_elements(across_axis)
        self.mirror_element_centers(across_axis)
        return self

    def copy(self) -> Self:
        '''
        Convenience method for `copy.deepcopy(pattern)` (same as `Pattern.deepcopy()`).
        See also: `Pattern.deepcopy()`

        Returns:
            A deep copy of the current Pattern.
        '''
        return copy.deepcopy(self)

    def deepcopy(self) -> Self:
        '''
        Convenience method for `copy.deepcopy(pattern)`

        Returns:
            A deep copy of the current Pattern.
        '''
        return copy.deepcopy(self)

    def is_empty(self) -> bool:
        '''
        Returns:
            True if the pattern is contains no shapes, labels, or refs.
        '''
        return not (self.has_refs() or self.has_shapes() or self.has_labels())

    def has_refs(self) -> bool:
        '''
        Returns:
            True if the pattern contains any refs.
        '''
        return any(True for _ in chain.from_iterable(self.refs.values()))

    def has_shapes(self) -> bool:
        '''
        Returns:
            True if the pattern contains any shapes.
        '''
        return any(True for _ in chain.from_iterable(self.shapes.values()))

    def has_labels(self) -> bool:
        '''
        Returns:
            True if the pattern contains any labels.
        '''
        return any(True for _ in chain.from_iterable(self.labels.values()))

    def has_ports(self) -> bool:
        '''
        Returns:
            True if the pattern contains any ports.
        '''
        return bool(self.ports)

    def ref(self, target: str | Abstract | None, *args: Any, **kwargs: Any) -> Self:
        '''
        Convenience function which constructs a `Ref` object and adds it
         to this pattern.

        Args:
            target: Target for the ref
            *args: Passed to `Ref()`
            **kwargs: Passed to `Ref()`

        Returns:
            self
        '''
        if isinstance(target, Abstract):
            target = target.name
        self.refs[target].append(Ref(*args, **kwargs))
        return self

    def polygon(self, layer: layer_t, *args: Any, **kwargs: Any) -> Self:
        '''
        Convenience function which constructs a `Polygon` object and adds it
         to this pattern.

        Args:
            layer: Layer for the polygon
            *args: Passed to `Polygon()`
            **kwargs: Passed to `Polygon()`

        Returns:
            self
        '''
        self.shapes[layer].append(Polygon(*args, **kwargs))
        return self

    def rect(self, layer: layer_t, *args: Any, **kwargs: Any) -> Self:
        '''
        Convenience function which calls `Polygon.rect` to construct a
         rectangle and adds it to this pattern.

        Args:
            layer: Layer for the rectangle
            *args: Passed to `Polygon.rect()`
            **kwargs: Passed to `Polygon.rect()`

        Returns:
            self
        '''
        self.shapes[layer].append(Polygon.rect(*args, **kwargs))
        return self

    def path(self, layer: layer_t, *args: Any, **kwargs: Any) -> Self:
        '''
        Convenience function which constructs a `Path` object and adds it
         to this pattern.

        Args:
            layer: Layer for the path
            *args: Passed to `Path()`
            **kwargs: Passed to `Path()`

        Returns:
            self
        '''
        self.shapes[layer].append(Path(*args, **kwargs))
        return self

    def label(self, layer: layer_t, *args: Any, **kwargs: Any) -> Self:
        '''
        Convenience function which constructs a `Label` object
         and adds it to this pattern.

        Args:
            layer: Layer for the label
            *args: Passed to `Label()`
            **kwargs: Passed to `Label()`

        Returns:
            self
        '''
        self.labels[layer].append(Label(*args, **kwargs))
        return self

    def prune_layers(self) -> Self:
        '''
        Remove empty layers (empty lists) in `self.shapes` and `self.labels`.

        Returns:
            self
        '''
        for layer in list(self.shapes):
            if not self.shapes[layer]:
                del self.shapes[layer]
        for layer in list(self.labels):
            if not self.labels[layer]:
                del self.labels[layer]
        return self

    def prune_refs(self) -> Self:
        '''
        Remove empty ref lists in `self.refs`.

        Returns:
            self
        '''
        for target in list(self.refs):
            if not self.refs[target]:
                del self.refs[target]
        return self

    def flatten(
            self,
            library: Mapping[str, 'Pattern'],
            flatten_ports: bool = False,
            ) -> 'Pattern':
        '''
        Removes all refs (recursively) and adds equivalent shapes.
        Alters the current pattern in-place.
        For a version which creates copies, see `Library.flatten`.

        Args:
            library: Source for referenced patterns.
            flatten_ports: If `True`, keep ports from any referenced
                patterns; otherwise discard them.

        Returns:
            self
        '''
        flattened: dict[str | None, Pattern | None] = {}

        def flatten_single(name: str | None) -> None:
            if name is None:
                pat = self
            else:
                pat = library[name].deepcopy()
                flattened[name] = None

            for target, refs in pat.refs.items():
                if target is None:
                    continue
                if not refs:
                    continue

                if target not in flattened:
                    flatten_single(target)
                target_pat = flattened[target]

                if target_pat is None:
                    raise PatternError(f'Circular reference in {name} to {target}')
                if target_pat.is_empty():        # avoid some extra allocations
                    continue

                for ref in refs:
                    p = ref.as_pattern(pattern=target_pat)
                    if not flatten_ports:
                        p.ports.clear()
                    pat.append(p)

            pat.refs.clear()
            flattened[name] = pat

        flatten_single(None)
        return self

    def visualize(
            self,
            library: Mapping[str, 'Pattern'] | None = None,
            offset: ArrayLike = (0., 0.),
            line_color: str = 'k',
            fill_color: str = 'none',
            overdraw: bool = False,
            ) -> None:
        '''
        Draw a picture of the Pattern and wait for the user to inspect it

        Imports `matplotlib`.

        Note that this can be slow; it is often faster to export to GDSII and use
         klayout or a different GDS viewer!

        Args:
            offset: Coordinates to offset by before drawing
            line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
            fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
            overdraw: Whether to create a new figure or draw on a pre-existing one
        '''
        # TODO: add text labels to visualize()
        try:
            from matplotlib import pyplot       # type: ignore
            import matplotlib.collections       # type: ignore
        except ImportError:
            logger.exception('Pattern.visualize() depends on matplotlib!\n'
                + 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
            raise

        if self.has_refs() and library is None:
            raise PatternError('Must provide a library when visualizing a pattern with refs')

        offset = numpy.asarray(offset, dtype=float)

        if not overdraw:
            figure = pyplot.figure()
            pyplot.axis('equal')
        else:
            figure = pyplot.gcf()

        axes = figure.gca()

        polygons = []
        for shape in chain.from_iterable(self.shapes.values()):
            polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()]

        mpl_poly_collection = matplotlib.collections.PolyCollection(
            polygons,
            facecolors=fill_color,
            edgecolors=line_color,
            )
        axes.add_collection(mpl_poly_collection)
        pyplot.axis('equal')

        for target, refs in self.refs.items():
            if target is None:
                continue
            if not refs:
                continue
            assert library is not None
            target_pat = library[target]
            for ref in refs:
                ref.as_pattern(target_pat).visualize(
                    library=library,
                    offset=offset,
                    overdraw=True,
                    line_color=line_color,
                    fill_color=fill_color,
                    )

        if not overdraw:
            pyplot.xlabel('x')
            pyplot.ylabel('y')
            pyplot.show()

#    @overload
#    def place(
#            self,
#            other: 'Pattern',
#            *,
#            offset: ArrayLike,
#            rotation: float,
#            pivot: ArrayLike,
#            mirrored: bool,
#            port_map: dict[str, str | None] | None,
#            skip_port_check: bool,
#            append: bool,
#            ) -> Self:
#        pass
#
#    @overload
#    def place(
#            self,
#            other: Abstract,
#            *,
#            offset: ArrayLike,
#            rotation: float,
#            pivot: ArrayLike,
#            mirrored: bool,
#            port_map: dict[str, str | None] | None,
#            skip_port_check: bool,
#            append: Literal[False],
#            ) -> Self:
#        pass

    def place(
            self,
            other: 'Abstract | Pattern',
            *,
            offset: ArrayLike = (0, 0),
            rotation: float = 0,
            pivot: ArrayLike = (0, 0),
            mirrored: bool = False,
            port_map: dict[str, str | None] | None = None,
            skip_port_check: bool = False,
            append: bool = False,
            ) -> Self:
        '''
        Instantiate or append the pattern `other` into the current pattern, adding its
          ports to those of the current pattern (but not connecting/removing any ports).

        Mirroring is applied before rotation; translation (`offset`) is applied last.

        Examples:
        =========
        - `my_pat.place(pad_pat, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
            instantiates `pad` at the specified (x, y) offset and with the specified
            rotation, adding its ports to those of `my_pat`. Port 'A' of `pad` is
            renamed to 'gnd' so that further routing can use this signal or net name
            rather than the port name on the original `pad_pat` pattern.

        Args:
            other: An `Abstract` or `Pattern` describing the device to be instatiated.
            offset: Offset at which to place the instance. Default (0, 0).
            rotation: Rotation applied to the instance before placement. Default 0.
            pivot: Rotation is applied around this pivot point (default (0, 0)).
                Rotation is applied prior to translation (`offset`).
            mirrored: Whether theinstance should be mirrored across the x axis.
                Mirroring is applied before translation and rotation.
            port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
                new names for ports in the instantiated pattern. New names can be
                `None`, which will delete those ports.
            skip_port_check: Can be used to skip the internal call to `check_ports`,
                in case it has already been performed elsewhere.
            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`).

        Returns:
            self

        Raises:
            `PortError` if any ports specified in `map_in` or `map_out` do not
                exist in `self.ports` or `other.ports`.
            `PortError` if there are any duplicate names after `map_in` and `map_out`
                are applied.
        '''
        if port_map is None:
            port_map = {}

        if not skip_port_check:
            self.check_ports(other.ports.keys(), map_in=None, map_out=port_map)

        ports = {}
        for name, port in other.ports.items():
            new_name = port_map.get(name, name)
            if new_name is None:
                continue
            ports[new_name] = port

        for name, port in ports.items():
            p = port.deepcopy()
            if mirrored:
                p.mirror()
            p.rotate_around(pivot, rotation)
            p.translate(offset)
            self.ports[name] = p

        if append:
            if isinstance(other, Abstract):
                raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
            other_copy = other.deepcopy()
            other_copy.ports.clear()
            if mirrored:
                other_copy.mirror()
            other_copy.rotate_around(pivot, rotation)
            other_copy.translate_elements(offset)
            self.append(other_copy)
        else:
            assert not isinstance(other, Pattern)
            ref = Ref(mirrored=mirrored)
            ref.rotate_around(pivot, rotation)
            ref.translate(offset)
            self.refs[other.name].append(ref)
        return self

#    @overload
#    def plug(
#            self,
#            other: Abstract,
#            map_in: dict[str, str],
#            map_out: dict[str, str | None] | None,
#            *,
#            mirrored: bool,
#            inherit_name: bool,
#            set_rotation: bool | None,
#            append: Literal[False],
#            ) -> Self:
#        pass
#
#    @overload
#    def plug(
#            self,
#            other: 'Pattern',
#            map_in: dict[str, str],
#            map_out: dict[str, str | None] | None,
#            *,
#            mirrored: bool,
#            inherit_name: bool,
#            set_rotation: bool | None,
#            append: bool,
#            ) -> Self:
#        pass

    def plug(
            self,
            other: 'Abstract | Pattern',
            map_in: dict[str, str],
            map_out: dict[str, str | None] | None = None,
            *,
            mirrored: bool = False,
            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
          the ports specified by `map_in` and renaming the unconnected
          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
            are removed and any unconnected ports from `subdevice` are added to
            `my_pat`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'.

        - `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`,
            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.

        Args:
            other: A `Pattern` or `Abstract` describing the subdevice to be instatiated.
            map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
                port connections between the current pattern and the subdevice.
            map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
                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
                (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
                the ports being connected (i.e. all pairs have at least one
                port with `rotation=None`), `set_rotation` must be provided
                to indicate how much `other` should be rotated. Otherwise,
                `set_rotation` must remain `None`.
            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

        Raises:
            `PortError` if any ports specified in `map_in` or `map_out` do not
                exist in `self.ports` or `other_names`.
            `PortError` if there are any duplicate names after `map_in` and `map_out`
                are applied.
            `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)

        self.check_ports(other.ports.keys(), map_in, map_out)
        translation, rotation, pivot = self.find_transform(
            other,
            map_in,
            mirrored=mirrored,
            set_rotation=set_rotation,
            ok_connections=ok_connections,
            )

        # get rid of plugged ports
        for ki, vi in map_in.items():
            del self.ports[ki]
            map_out[vi] = None

        if isinstance(other, Pattern):
            assert append, 'Got a name (not an abstract) but was asked to reference (not append)'

        self.place(
            other,
            offset=translation,
            rotation=rotation,
            pivot=pivot,
            mirrored=mirrored,
            port_map=map_out,
            skip_port_check=True,
            append=append,
            )
        return self

    @classmethod
    def interface(
            cls: type['Pattern'],
            source: PortList | Mapping[str, Port],
            *,
            in_prefix: str = 'in_',
            out_prefix: str = '',
            port_map: dict[str, str] | Sequence[str] | None = None,
            ) -> 'Pattern':
        '''
        Generate an empty pattern with ports based on all or some of the ports
          in the `source`. Do not include the source device istelf; instead use
          it to define ports (the "interface") for the new device.

        The ports specified by `port_map` (default: all ports) are copied to
          new device, and additional (input) ports are created facing in the
          opposite directions. The specified `in_prefix` and `out_prefix` are
          prepended to the port names to differentiate them.

        By default, the flipped ports are given an 'in_' prefix and unflipped
          ports keep their original names, enabling intuitive construction of
          a device that will "plug into" the current device; the 'in_*' ports
          are used for plugging the devices together while the original port
          names are used for building the new device.

        Another use-case could be to build the new device using the 'in_'
          ports, creating a new device which could be used in place of the
          current device.

        Args:
            source: A collection of ports (e.g. Pattern, Builder, or dict)
                from which to create the interface.
            in_prefix: Prepended to port names for newly-created ports with
                reversed directions compared to the current device.
            out_prefix: Prepended to port names for ports which are directly
                copied from the current device.
            port_map: Specification for ports to copy into the new device:
                - If `None`, all ports are copied.
                - If a sequence, only the listed ports are copied
                - If a mapping, the listed ports (keys) are copied and
                    renamed (to the values).

        Returns:
            The new empty pattern, with 2x as many ports as listed in port_map.

        Raises:
            `PortError` if `port_map` contains port names not present in the
                current device.
            `PortError` if applying the prefixes results in duplicate port
                names.
        '''
        if isinstance(source, PortList):
            orig_ports = source.ports
        elif isinstance(source, dict):
            orig_ports = source
        else:
            raise PatternError(f'Unable to get ports from {type(source)}: {source}')

        if port_map:
            if isinstance(port_map, dict):
                missing_inkeys = set(port_map.keys()) - set(orig_ports.keys())
                mapped_ports = {port_map[k]: v for k, v in orig_ports.items() if k in port_map}
            else:
                port_set = set(port_map)
                missing_inkeys = port_set - set(orig_ports.keys())
                mapped_ports = {k: v for k, v in orig_ports.items() if k in port_set}

            if missing_inkeys:
                raise PortError(f'`port_map` keys not present in source: {missing_inkeys}')
        else:
            mapped_ports = orig_ports

        ports_in = {f'{in_prefix}{name}': port.deepcopy().rotate(pi)
                    for name, port in mapped_ports.items()}
        ports_out = {f'{out_prefix}{name}': port.deepcopy()
                     for name, port in mapped_ports.items()}

        duplicates = set(ports_out.keys()) & set(ports_in.keys())
        if duplicates:
            raise PortError(f'Duplicate keys after prefixing, try a different prefix: {duplicates}')

        new = Pattern(ports={**ports_in, **ports_out})
        return new


TT = TypeVar('TT')


def chain_elements(*args: Mapping[Any, Iterable[TT]]) -> Iterable[TT]:
    '''
    Iterate over each element in one or more {layer: elements} mappings.

    Useful when you want to do some operation on all shapes and/or labels,
    disregarding which layer they are on.

    Args:
        *args: One or more {layer: [element0, ...]} mappings.
            Can also be applied to e.g. {target: [ref0, ...]} mappings.

    Returns:
        An iterable containing all elements, regardless of layer.
    '''
    return chain(*(chain.from_iterable(aa.values()) for aa in args))


def map_layers(
        elements: Mapping[layer_t, Sequence[TT]],
        map_layer: Callable[[layer_t], layer_t],
        ) -> defaultdict[layer_t, list[TT]]:
    '''
    Move all the elements from one layer onto a different layer.
    Can also handle multiple such mappings simultaneously.

    Args:
        elements: Mapping of {old_layer: geometry_or_labels}.
        map_layer: Callable which may be called with each layer present in `elements`,
            and should return the new layer to which it will be mapped.
            A simple example which maps `old_layer` to `new_layer` and leaves all others
            as-is would look like `lambda layer: {old_layer: new_layer}.get(layer, layer)`

    Returns:
        Mapping of {new_layer: geometry_or_labels}
    '''
    new_elements: defaultdict[layer_t, list[TT]] = defaultdict(list)
    for old_layer, seq in elements.items():
        new_layer = map_layer(old_layer)
        new_elements[new_layer].extend(seq)
    return new_elements


def map_targets(
        refs: Mapping[str | None, Sequence[Ref]],
        map_target: Callable[[str | None], str | None],
        ) -> defaultdict[str | None, list[Ref]]:
    '''
    Change the target of all references to a given cell.
    Can also handle multiple such mappings simultaneously.

    Args:
        refs: Mapping of {old_target: ref_objects}.
        map_target: Callable which may be called with each target present in `refs`,
            and should return the new target to which it will be mapped.
            A simple example which maps `old_target` to `new_target` and leaves all others
            as-is would look like `lambda target: {old_target: new_target}.get(target, target)`

    Returns:
        Mapping of {new_target: ref_objects}
    '''
    new_refs: defaultdict[str | None, list[Ref]] = defaultdict(list)
    for old_target, seq in refs.items():
        new_target = map_target(old_target)
        new_refs[new_target].extend(seq)
    return new_refs


---
masque/ports.py
---
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
from abc import ABCMeta, abstractmethod
from itertools import chain

import numpy
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 .error import PortError


logger = logging.getLogger(__name__)


@functools.total_ordering
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
    '''
    A point at which a `Device` can be snapped to another `Device`.

    Each port has an `offset` ((x, y) position) and may also have a
      `rotation` (orientation) and a `ptype` (port type).

    The `rotation` is an angle, in radians, measured counterclockwise
      from the +x axis, pointing inwards into the device which owns the port.
      The rotation may be set to `None`, indicating that any orientation is
      allowed (e.g. for a DC electrical port). It is stored modulo 2pi.

    The `ptype` is an arbitrary string, default of `unk` (unknown).
    '''
    __slots__ = (
        'ptype', '_rotation',
        # inherited:
        '_offset',
        )

    _rotation: float | None
    ''' radians counterclockwise from +x, pointing into device body.
        Can be `None` to signify undirected port '''

    ptype: str
    ''' Port types must match to be plugged together if both are non-zero '''

    def __init__(
            self,
            offset: ArrayLike,
            rotation: float | None,
            ptype: str = 'unk',
            ) -> None:
        self.offset = offset
        self.rotation = rotation
        self.ptype = ptype

    @property
    def rotation(self) -> float | None:
        ''' Rotation, radians counterclockwise, pointing into device body. Can be None. '''
        return self._rotation

    @rotation.setter
    def rotation(self, val: float) -> None:
        if val is None:
            self._rotation = None
        else:
            if not numpy.size(val) == 1:
                raise PortError('Rotation must be a scalar')
            self._rotation = val % (2 * pi)

    @property
    def x(self) -> float:
        ''' Alias for offset[0] '''
        return self.offset[0]

    @x.setter
    def x(self, val: float) -> None:
        self.offset[0] = val

    @property
    def y(self) -> float:
        ''' Alias for offset[1] '''
        return self.offset[1]

    @y.setter
    def y(self, val: float) -> None:
        self.offset[1] = val

    def copy(self) -> Self:
        return self.deepcopy()

    def get_bounds(self) -> NDArray[numpy.float64]:
        return numpy.vstack((self.offset, self.offset))

    def set_ptype(self, ptype: str) -> Self:
        ''' Chainable setter for `ptype` '''
        self.ptype = ptype
        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
        return self

    def rotate(self, rotation: float) -> Self:
        if self.rotation is not None:
            self.rotation += rotation
        return self

    def set_rotation(self, rotation: float | None) -> Self:
        self.rotation = rotation
        return self

    def __repr__(self) -> str:
        if self.rotation is None:
            rot = 'any'
        else:
            rot = str(numpy.rad2deg(self.rotation))
        return f'<{self.offset}, {rot}, [{self.ptype}]>'

    def __lt__(self, other: 'Port') -> bool:
        if self.ptype != other.ptype:
            return self.ptype < other.ptype
        if not numpy.array_equal(self.offset, other.offset):
            return tuple(self.offset) < tuple(other.offset)
        if self.rotation != other.rotation:
            if self.rotation is None:
                return True
            if other.rotation is None:
                return False
            return self.rotation < other.rotation
        return False

    def __eq__(self, other: Any) -> bool:
        return (
            type(self) is type(other)
            and self.ptype == other.ptype
            and numpy.array_equal(self.offset, other.offset)
            and self.rotation == other.rotation
            )


class PortList(metaclass=ABCMeta):
    __slots__ = ()      # Allow subclasses to use __slots__

    @property
    @abstractmethod
    def ports(self) -> dict[str, Port]:
        ''' Uniquely-named ports which can be used to snap to other Device instances'''
        pass

    @ports.setter
    @abstractmethod
    def ports(self, value: dict[str, Port]) -> None:
        pass

    @overload
    def __getitem__(self, key: str) -> Port:
        pass

    @overload
    def __getitem__(self, key: list[str] | tuple[str, ...] | KeysView[str] | ValuesView[str]) -> dict[str, Port]:
        pass

    def __getitem__(self, key: str | Iterable[str]) -> Port | dict[str, Port]:
        '''
        For convenience, ports can be read out using square brackets:
        - `pattern['A'] == Port((0, 0), 0)`
        - ```
          pattern[['A', 'B']] == {
              'A': Port((0, 0), 0),
              'B': Port((0, 0), pi),
              }
          ```
        '''
        if isinstance(key, str):
            return self.ports[key]
        else:                                       # noqa: RET505
            return {k: self.ports[k] for k in key}

    def __contains__(self, key: str) -> NoReturn:
        raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.')

    # NOTE: Didn't add keys(), items(), values(), __contains__(), etc.
    # because it's weird on stuff like Pattern that contains other lists
    # and because you can just grab .ports and use that instead

    def mkport(
            self,
            name: str,
            value: Port,
            ) -> Self:
        '''
        Create a port, raising a `PortError` if a port with the same name already exists.

        Args:
            name: Name for the port. A port with this name should not already exist.
            value: The `Port` object to which `name` will refer.

        Returns:
            self

        Raises:
            `PortError` if the name already exists.
        '''
        if name in self.ports:
            raise PortError(f'Port {name} already exists.')
        assert name not in self.ports
        self.ports[name] = value
        return self

    def rename_ports(
            self,
            mapping: dict[str, str | None],
            overwrite: bool = False,
            ) -> Self:
        '''
        Renames ports as specified by `mapping`.
        Ports can be explicitly deleted by mapping them to `None`.

        Args:
            mapping: dict of `{'old_name': 'new_name'}` pairs. Names can be mapped
                to `None` to perform an explicit deletion. `'new_name'` can also
                overwrite an existing non-renamed port to implicitly delete it if
                `overwrite` is set to `True`.
            overwrite: Allows implicit deletion of ports if set to `True`; see `mapping`.

        Returns:
            self
        '''
        if not overwrite:
            duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
            if duplicates:
                raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')

        renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
        if None in renamed:
            del renamed[None]

        self.ports.update(renamed)      # type: ignore
        return self

    def add_port_pair(
            self,
            offset: ArrayLike = (0, 0),
            rotation: float = 0.0,
            names: tuple[str, str] = ('A', 'B'),
            ptype: str = 'unk',
            ) -> Self:
        '''
        Add a pair of ports with opposing directions at the specified location.

        Args:
            offset: Location at which to add the ports
            rotation: Orientation of the first port. Radians, counterclockwise.
                Default 0.
            names: Names for the two ports. Default 'A' and 'B'
            ptype: Sets the port type for both ports.

        Returns:
            self
        '''
        new_ports = {
            names[0]: Port(offset, rotation=rotation, ptype=ptype),
            names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
            }
        self.check_ports(names)
        self.ports.update(new_ports)
        return self

    def plugged(
            self,
            connections: dict[str, str],
            ) -> Self:
        '''
        Verify that the ports specified by `connections` are coincident and have opposing
        rotations, then remove the ports.

        This is used when ports have been "manually" aligned as part of some other routing,
        but for whatever reason were not eliminated via `plug()`.

        Args:
            connections: Pairs of ports which "plug" each other (same offset, opposing directions)

        Returns:
            self

        Raises:
            `PortError` if the ports are not properly aligned.
        '''
        a_names, b_names = list(zip(*connections.items(), strict=True))
        a_ports = [self.ports[pp] for pp in a_names]
        b_ports = [self.ports[pp] for pp in b_names]

        a_types = [pp.ptype for pp in a_ports]
        b_types = [pp.ptype for pp in b_ports]
        type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt)
                                      for at, bt in zip(a_types, b_types, strict=True)])

        if type_conflicts.any():
            msg = 'Ports have conflicting types:\n'
            for nn, (k, v) 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)

        a_offsets = numpy.array([pp.offset for pp in a_ports])
        b_offsets = numpy.array([pp.offset for pp in b_ports])
        a_rotations = numpy.array([pp.rotation if pp.rotation is not None else 0 for pp in a_ports])
        b_rotations = numpy.array([pp.rotation if pp.rotation is not None else 0 for pp in b_ports])
        a_has_rot = numpy.array([pp.rotation is not None for pp in a_ports], dtype=bool)
        b_has_rot = numpy.array([pp.rotation is not None for pp in b_ports], dtype=bool)
        has_rot = a_has_rot & b_has_rot

        if has_rot.any():
            rotations = numpy.mod(a_rotations - b_rotations - pi, 2 * pi)
            rotations[~has_rot] = rotations[has_rot][0]

            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()):
                    if not numpy.isclose(rot_deg[nn], 0):
                        msg += f'{k} | {rot_deg[nn]:g} | {v}\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()):
                if not numpy.allclose(translations[nn], 0):
                    msg += f'{k} | {translations[nn]} | {v}\n'
            raise PortError(msg)

        for pp in chain(a_names, b_names):
            del self.ports[pp]
        return self

    def check_ports(
            self,
            other_names: Iterable[str],
            map_in: dict[str, str] | None = None,
            map_out: dict[str, str | None] | None = None,
            ) -> Self:
        '''
        Given the provided port mappings, check that:
            - All of the ports specified in the mappings exist
            - There are no duplicate port names after all the mappings are performed

        Args:
            other_names: List of port names being considered for inclusion into
                `self.ports` (before mapping)
            map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
                port connections between the two devices.
            map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
                new names for unconnected `other_names` ports.

        Returns:
            self

        Raises:
            `PortError` if any ports specified in `map_in` or `map_out` do not
                exist in `self.ports` or `other_names`.
            `PortError` if there are any duplicate names after `map_in` and `map_out`
                are applied.
        '''
        if map_in is None:
            map_in = {}

        if map_out is None:
            map_out = {}

        other = set(other_names)

        missing_inkeys = set(map_in.keys()) - set(self.ports.keys())
        if missing_inkeys:
            raise PortError(f'`map_in` keys not present in device: {missing_inkeys}')

        missing_invals = set(map_in.values()) - other
        if missing_invals:
            raise PortError(f'`map_in` values not present in other device: {missing_invals}')

        missing_outkeys = set(map_out.keys()) - other
        if missing_outkeys:
            raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}')

        orig_remaining = set(self.ports.keys()) - set(map_in.keys())
        other_remaining = other - set(map_out.keys()) - set(map_in.values())
        mapped_vals = set(map_out.values())
        mapped_vals.discard(None)

        conflicts_final = orig_remaining & (other_remaining | mapped_vals)
        if conflicts_final:
            raise PortError(f'Device ports conflict with existing ports: {conflicts_final}')

        conflicts_partial = other_remaining & mapped_vals
        if conflicts_partial:
            raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}')

        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}
        if conflicts_out:
            raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}')

        return self

    def find_transform(
            self,
            other: 'PortList',
            map_in: dict[str, str],
            *,
            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,
          find the transform which will correctly align the specified ports.

        Args:
            other: a device
            map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
                port connections between the two devices.
            mirrored: Mirrors `other` across the x axis prior to
                connecting any ports.
            set_rotation: If the necessary rotation cannot be determined from
                the ports being connected (i.e. all pairs have at least one
                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)
            - The rotation (radians, counterclockwise)
            - The (x, y) pivot point for the rotation

            The rotation should be performed before the translation.
        '''
        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,
            )

    @staticmethod
    def find_port_transform(
            s_ports: Mapping[str, Port],
            o_ports: Mapping[str, Port],
            map_in: dict[str, str],
            *,
            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`
          specifying port connections, find the transform which will correctly
          align the specified o_ports onto their respective s_ports.

        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
                port connections.
            mirrored: Mirrors `o_ports` across the x axis prior to
                connecting any ports.
            set_rotation: If the necessary rotation cannot be determined from
                the ports being connected (i.e. all pairs have at least one
                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)
            - The rotation (radians, counterclockwise)
            - The (x, y) pivot point for the rotation

            The rotation should be performed before the translation.
        '''
        s_offsets = numpy.array([p.offset for p in s_ports.values()])
        o_offsets = numpy.array([p.offset for p in o_ports.values()])
        s_types = [p.ptype for p in s_ports.values()]
        o_types = [p.ptype for p in o_ports.values()]

        s_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in s_ports.values()])
        o_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in o_ports.values()])
        s_has_rot = numpy.array([p.rotation is not None for p in s_ports.values()], dtype=bool)
        o_has_rot = numpy.array([p.rotation is not None for p in o_ports.values()], dtype=bool)
        has_rot = s_has_rot & o_has_rot

        if mirrored:
            o_offsets[:, 1] *= -1
            o_rotations *= -1

        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'
            for nn, (k, v) 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)

        rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
        if not has_rot.any():
            if set_rotation is None:
                PortError('Must provide set_rotation if rotation is indeterminate')
            rotations[:] = set_rotation
        else:
            rotations[~has_rot] = rotations[has_rot][0]

        if not numpy.allclose(rotations[:1], rotations):
            rot_deg = numpy.rad2deg(rotations)
            msg = 'Port orientations do not match:\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()
        rotate_offsets_around(o_offsets, pivot, rotations[0])
        translations = s_offsets - o_offsets
        if not numpy.allclose(translations[:1], translations):
            msg = 'Port translations do not match:\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]


---
masque/py.typed
---


---
masque/ref.py
---
'''
 Ref provides basic support for nesting Pattern objects within each other.
 It carries offset, rotation, mirroring, and scaling data for each individual instance.
'''
from typing import TYPE_CHECKING, Self, Any
from collections.abc import Mapping
import copy
import functools

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 .repetition import Repetition
from .traits import (
    PositionableImpl, RotatableImpl, ScalableImpl,
    Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
    )


if TYPE_CHECKING:
    from . import Pattern


@functools.total_ordering
class Ref(
        PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
        PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
        ):
    '''
    `Ref` provides basic support for nesting Pattern objects within each other.

    It containts the transformation (mirror, rotation, scale, offset, repetition)
    and annotations for a single instantiation of a `Pattern`.

    Note that the target (i.e. which pattern a `Ref` instantiates) is not stored within the
    `Ref` itself, but is specified by the containing `Pattern`.

    Order of operations is (mirror, rotate, scale, translate, repeat).
    '''
    __slots__ = (
        '_mirrored',
        # inherited
        '_offset', '_rotation', 'scale', '_repetition', '_annotations',
        )

    _mirrored: bool
    ''' Whether to mirror the instance across the x axis (new_y = -old_y)ubefore rotating. '''

    # Mirrored property
    @property
    def mirrored(self) -> bool:     # mypy#3004, setter should be SupportsBool
        return self._mirrored

    @mirrored.setter
    def mirrored(self, val: bool) -> None:
        self._mirrored = bool(val)

    def __init__(
            self,
            *,
            offset: ArrayLike = (0.0, 0.0),
            rotation: float = 0.0,
            mirrored: bool = False,
            scale: float = 1.0,
            repetition: Repetition | None = None,
            annotations: annotations_t | None = None,
            ) -> None:
        '''
        Note: Order is (mirror, rotate, scale, translate, repeat)

        Args:
            offset: (x, y) offset applied to the referenced pattern. Not affected by rotation etc.
            rotation: Rotation (radians, counterclockwise) relative to the referenced pattern's (0, 0).
            mirrored: Whether to mirror the referenced pattern across its x axis before rotating.
            scale: Scaling factor applied to the pattern's geometry.
            repetition: `Repetition` object, default `None`
        '''
        self.offset = offset
        self.rotation = rotation
        self.scale = scale
        self.mirrored = mirrored
        self.repetition = repetition
        self.annotations = annotations if annotations is not None else {}

    def __copy__(self) -> 'Ref':
        new = Ref(
            offset=self.offset.copy(),
            rotation=self.rotation,
            scale=self.scale,
            mirrored=self.mirrored,
            repetition=copy.deepcopy(self.repetition),
            annotations=copy.deepcopy(self.annotations),
            )
        return new

    def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
        memo = {} if memo is None else memo
        new = copy.copy(self)
        #new.repetition = copy.deepcopy(self.repetition, memo)
        #new.annotations = copy.deepcopy(self.annotations, memo)
        return new

    def __lt__(self, other: 'Ref') -> bool:
        if (self.offset != other.offset).any():
            return tuple(self.offset) < tuple(other.offset)
        if self.mirrored != other.mirrored:
            return self.mirrored < other.mirrored
        if self.rotation != other.rotation:
            return self.rotation < other.rotation
        if self.scale != other.scale:
            return self.scale < other.scale
        if self.repetition != other.repetition:
            return rep2key(self.repetition) < rep2key(other.repetition)
        return annotations_lt(self.annotations, other.annotations)

    def __eq__(self, other: Any) -> bool:
        return (
            numpy.array_equal(self.offset, other.offset)
            and self.mirrored == other.mirrored
            and self.rotation == other.rotation
            and self.scale == other.scale
            and self.repetition == other.repetition
            and annotations_eq(self.annotations, other.annotations)
            )

    def as_pattern(
            self,
            pattern: 'Pattern',
            ) -> 'Pattern':
        '''
        Args:
            pattern: Pattern object to transform

        Returns:
            A copy of the referenced Pattern which has been scaled, rotated, etc.
             according to this `Ref`'s properties.
        '''
        pattern = pattern.deepcopy()

        if self.scale != 1:
            pattern.scale_by(self.scale)
        if self.mirrored:
            pattern.mirror()
        if self.rotation % (2 * pi) != 0:
            pattern.rotate_around((0.0, 0.0), self.rotation)
        if numpy.any(self.offset):
            pattern.translate_elements(self.offset)

        if self.repetition is not None:
            combined = type(pattern)()
            for dd in self.repetition.displacements:
                temp_pat = pattern.deepcopy()
                temp_pat.ports = {}
                temp_pat.translate_elements(dd)
                combined.append(temp_pat)
            pattern = combined

        return pattern

    def rotate(self, rotation: float) -> Self:
        self.rotation += rotation
        if self.repetition is not None:
            self.repetition.rotate(rotation)
        return self

    def mirror(self, axis: int = 0) -> Self:
        self.mirror_target(axis)
        self.rotation *= -1
        if self.repetition is not None:
            self.repetition.mirror(axis)
        return self

    def mirror_target(self, axis: int = 0) -> Self:
        self.mirrored = not self.mirrored
        self.rotation += axis * pi
        return self

    def mirror2d_target(self, across_x: bool = False, across_y: bool = False) -> Self:
        self.mirrored = bool((self.mirrored + across_x + across_y) % 2)
        if across_y:
            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',
            *,
            library: Mapping[str, 'Pattern'] | None = None,
            ) -> NDArray[numpy.float64] | None:
        '''
        Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
         extent of the `Ref` in each dimension.
        Returns `None` if the contained `Pattern` is empty.

        Args:
            library: Name-to-Pattern mapping for resul

        Returns:
            `[[x_min, y_min], [x_max, y_max]]` or `None`
        '''
        if pattern.is_empty():
            # no need to run as_pattern()
            return None

        # if rotation is manhattan, can take pattern's bounds and transform them
        if numpy.isclose(self.rotation % (pi / 2), 0):
            unrot_bounds = pattern.get_bounds(library)
            if unrot_bounds is None:
                return None

            if self.mirrored:
                unrot_bounds[:, 1] *= -1

            corners = (rotation_matrix_2d(self.rotation) @ unrot_bounds.T).T
            bounds = numpy.vstack((numpy.min(corners, axis=0),
                                   numpy.max(corners, axis=0))) * self.scale + [self.offset]
            return bounds
        return self.as_pattern(pattern=pattern).get_bounds(library)

    def __repr__(self) -> str:
        rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
        scale = f' d{self.scale:g}' if self.scale != 1 else ''
        mirrored = ' m' if self.mirrored else ''
        return f'<Ref {self.offset}{rotation}{scale}{mirrored}>'


---
masque/repetition.py
---
'''
    Repetitions provide support for efficiently representing multiple identical
     instances of an object .
'''
from typing import Any, Self, TypeVar, cast
import copy
import functools
from abc import ABCMeta, abstractmethod

import numpy
from numpy.typing import ArrayLike, NDArray

from .traits import Copyable, Scalable, Rotatable, Mirrorable, Bounded
from .error import PatternError
from .utils import rotation_matrix_2d


GG = TypeVar('GG', bound='Grid')


@functools.total_ordering
class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=ABCMeta):
    '''
    Interface common to all objects which specify repetitions
    '''
    __slots__ = ()      # Allow subclasses to use __slots__

    @property
    @abstractmethod
    def displacements(self) -> NDArray[numpy.float64]:
        '''
        An Nx2 ndarray specifying all offsets generated by this repetition
        '''
        pass

    @abstractmethod
    def __le__(self, other: 'Repetition') -> bool:
        pass

    @abstractmethod
    def __eq__(self, other: Any) -> bool:
        pass


class Grid(Repetition):
    '''
    `Grid` describes a 2D grid formed by two basis vectors and two 'counts' (sizes).

    The second basis vector and count (`b_vector` and `b_count`) may be omitted,
      which makes the grid describe a 1D array.

    Note that the offsets in either the 2D or 1D grids do not have to be axis-aligned.
    '''
    __slots__ = (
        '_a_vector', '_b_vector',
        '_a_count', '_b_count',
        )

    _a_vector: NDArray[numpy.float64]
    ''' Vector `[x, y]` specifying the first lattice vector of the grid.
        Specifies center-to-center spacing between adjacent elements.
    '''

    _a_count: int
    ''' Number of instances along the direction specified by the `a_vector` '''

    _b_vector: NDArray[numpy.float64] | None
    ''' Vector `[x, y]` specifying a second lattice vector for the grid.
        Specifies center-to-center spacing between adjacent elements.
        Can be `None` for a 1D array.
    '''

    _b_count: int
    ''' Number of instances along the direction specified by the `b_vector` '''

    def __init__(
            self,
            a_vector: ArrayLike,
            a_count: int,
            b_vector: ArrayLike | None = None,
            b_count: int | None = 1,
            ) -> None:
        '''
        Args:
            a_vector: First lattice vector, of the form `[x, y]`.
                Specifies center-to-center spacing between adjacent instances.
            a_count: Number of elements in the a_vector direction.
            b_vector: Second lattice vector, of the form `[x, y]`.
                Specifies center-to-center spacing between adjacent instances.
                Can be omitted when specifying a 1D array.
            b_count: Number of elements in the `b_vector` direction.
                Should be omitted if `b_vector` was omitted.

        Raises:
            PatternError if `b_*` inputs conflict with each other
            or `a_count < 1`.
        '''
        if b_count is None:
            b_count = 1

        if b_vector is None:
            if b_count > 1:
                raise PatternError('Repetition has b_count > 1 but no b_vector')
            b_vector = numpy.array([0.0, 0.0])

        if a_count < 1:
            raise PatternError(f'Repetition has too-small a_count: {a_count}')
        if b_count < 1:
            raise PatternError(f'Repetition has too-small b_count: {b_count}')

        self.a_vector = a_vector        # type: ignore     # setter handles type conversion
        self.b_vector = b_vector        # type: ignore     # setter handles type conversion
        self.a_count = a_count
        self.b_count = b_count

    @classmethod
    def aligned(
            cls: type[GG],
            x: float,
            y: float,
            x_count: int,
            y_count: int,
            ) -> GG:
        '''
        Simple constructor for an axis-aligned 2D grid

        Args:
            x: X-step
            y: Y-step
            x_count: count of columns
            y_count: count of rows

        Returns:
            An Grid instance with the requested values
        '''
        return cls(a_vector=(x, 0), b_vector=(0, y), a_count=x_count, b_count=y_count)

    def __copy__(self) -> 'Grid':
        new = Grid(
            a_vector=self.a_vector.copy(),
            b_vector=copy.copy(self.b_vector),
            a_count=self.a_count,
            b_count=self.b_count,
            )
        return new

    def __deepcopy__(self, memo: dict | None = None) -> Self:
        memo = {} if memo is None else memo
        new = copy.copy(self)
        return new

    # a_vector property
    @property
    def a_vector(self) -> NDArray[numpy.float64]:
        return self._a_vector

    @a_vector.setter
    def a_vector(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float)

        if val.size != 2:
            raise PatternError('a_vector must be convertible to size-2 ndarray')
        self._a_vector = val.flatten()

    # b_vector property
    @property
    def b_vector(self) -> NDArray[numpy.float64] | None:
        return self._b_vector

    @b_vector.setter
    def b_vector(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float)

        if val.size != 2:
            raise PatternError('b_vector must be convertible to size-2 ndarray')
        self._b_vector = val.flatten()

    # a_count property
    @property
    def a_count(self) -> int:
        return self._a_count

    @a_count.setter
    def a_count(self, val: int) -> None:
        if val != int(val):
            raise PatternError('a_count must be convertable to an int!')
        self._a_count = int(val)

    # b_count property
    @property
    def b_count(self) -> int:
        return self._b_count

    @b_count.setter
    def b_count(self, val: int) -> None:
        if val != int(val):
            raise PatternError('b_count must be convertable to an int!')
        self._b_count = int(val)

    @property
    def displacements(self) -> NDArray[numpy.float64]:
        if self.b_vector is None:
            return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :]

        aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
        return (aa.flatten()[:, None] * self.a_vector[None, :]
              + bb.flatten()[:, None] * self.b_vector[None, :])             # noqa

    def rotate(self, rotation: float) -> Self:
        '''
        Rotate lattice vectors (around (0, 0))

        Args:
            rotation: Angle to rotate by (counterclockwise, radians)

        Returns:
            self
        '''
        self.a_vector = numpy.dot(rotation_matrix_2d(rotation), self.a_vector)
        if self.b_vector is not None:
            self.b_vector = numpy.dot(rotation_matrix_2d(rotation), self.b_vector)
        return self

    def mirror(self, axis: int = 0) -> Self:
        '''
        Mirror the Grid across an axis.

        Args:
            axis: Axis to mirror across.
                (0: mirror across x-axis, 1: mirror across y-axis)

        Returns:
            self
        '''
        self.a_vector[1 - axis] *= -1
        if self.b_vector is not None:
            self.b_vector[1 - axis] *= -1
        return self

    def get_bounds(self) -> NDArray[numpy.float64] | None:
        '''
        Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
         extent of the `Grid` in each dimension.

        Returns:
            `[[x_min, y_min], [x_max, y_max]]` or `None`
        '''
        a_extent = self.a_vector * (self.a_count - 1)
        if self.b_count is None:
            b_extent = numpy.zeros(2)
        else:
            assert self.b_vector is not None
            b_extent = self.b_vector * (self.b_count - 1)

        corners = numpy.stack(((0, 0), a_extent, b_extent, a_extent + b_extent))
        xy_min = numpy.min(corners, axis=0)
        xy_max = numpy.max(corners, axis=0)
        return numpy.array((xy_min, xy_max))

    def scale_by(self, c: float) -> Self:
        '''
        Scale the Grid by a factor

        Args:
            c: scaling factor

        Returns:
            self
        '''
        self.a_vector *= c
        if self.b_vector is not None:
            self.b_vector *= c
        return self

    def __repr__(self) -> str:
        bv = f', {self.b_vector}' if self.b_vector is not None else ''
        return (f'<Grid {self.a_count}x{self.b_count} ({self.a_vector}{bv})>')

    def __eq__(self, other: Any) -> bool:
        if type(other) is not type(self):
            return False
        if self.a_count != other.a_count or self.b_count != other.b_count:
            return False
        if any(self.a_vector[ii] != other.a_vector[ii] for ii in range(2)):
            return False
        if self.b_vector is None and other.b_vector is None:
            return True
        if self.b_vector is None or other.b_vector is None:
            return False
        if any(self.b_vector[ii] != other.b_vector[ii] for ii in range(2)):     # noqa: SIM103
            return False
        return True

    def __le__(self, other: Repetition) -> bool:
        if type(self) is not type(other):
            return repr(type(self)) < repr(type(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:
            return self.b_count < other.b_count
        if not numpy.array_equal(self.a_vector, other.a_vector):
            return tuple(self.a_vector) < tuple(other.a_vector)
        if self.b_vector is None:
            return other.b_vector is not None
        if other.b_vector is None:
            return False
        if not numpy.array_equal(self.b_vector, other.b_vector):
            return tuple(self.a_vector) < tuple(other.a_vector)
        return False


class Arbitrary(Repetition):
    '''
    `Arbitrary` is a simple list of (absolute) displacements for instances.

    Attributes:
        displacements (numpy.ndarray): absolute displacements of all elements
                                       `[[x0, y0], [x1, y1], ...]`
    '''

    __slots__ = ('_displacements',)

    _displacements: NDArray[numpy.float64]
    ''' List of vectors `[[x0, y0], [x1, y1], ...]` specifying the offsets
          of the instances.
    '''

    @property
    def displacements(self) -> Any:     # mypy#3004   NDArray[numpy.float64]:
        return self._displacements

    @displacements.setter
    def displacements(self, val: ArrayLike) -> None:
        vala = numpy.array(val, dtype=float)
        order = numpy.lexsort(vala.T[::-1])     # sortrows
        self._displacements = vala[order]

    def __init__(
            self,
            displacements: ArrayLike,
            ) -> None:
        '''
        Args:
            displacements: List of vectors (Nx2 ndarray) specifying displacements.
        '''
        self.displacements = displacements

    def __repr__(self) -> str:
        return (f'<Arbitrary {len(self.displacements)}pts >')

    def __eq__(self, other: Any) -> bool:
        if not type(other) is not type(self):
            return False
        return numpy.array_equal(self.displacements, other.displacements)

    def __le__(self, other: Repetition) -> bool:
        if type(self) is not type(other):
            return repr(type(self)) < repr(type(other))
        other = cast('Arbitrary', other)
        if self.displacements.size != other.displacements.size:
            return self.displacements.size < other.displacements.size

        neq = (self.displacements != other.displacements)
        if neq.any():
            return self.displacements[neq][0] < other.displacements[neq][0]

        return False

    def rotate(self, rotation: float) -> Self:
        '''
        Rotate dispacements (around (0, 0))

        Args:
            rotation: Angle to rotate by (counterclockwise, radians)

        Returns:
            self
        '''
        self.displacements = numpy.dot(rotation_matrix_2d(rotation), self.displacements.T).T
        return self

    def mirror(self, axis: int = 0) -> Self:
        '''
        Mirror the displacements across an axis.

        Args:
            axis: Axis to mirror across.
                (0: mirror across x-axis, 1: mirror across y-axis)

        Returns:
            self
        '''
        self.displacements[1 - axis] *= -1
        return self

    def get_bounds(self) -> NDArray[numpy.float64] | None:
        '''
        Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
         extent of the `displacements` in each dimension.

        Returns:
            `[[x_min, y_min], [x_max, y_max]]` or `None`
        '''
        xy_min = numpy.min(self.displacements, axis=0)
        xy_max = numpy.max(self.displacements, axis=0)
        return numpy.array((xy_min, xy_max))

    def scale_by(self, c: float) -> Self:
        '''
        Scale the displacements by a factor

        Args:
            c: scaling factor

        Returns:
            self
        '''
        self.displacements *= c
        return self



---
masque/file/__init__.py
---
'''
Functions for reading from and writing to various file formats.
'''



---
masque/file/dxf.py
---
'''
DXF file format readers and writers

Notes:
 * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
 * ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID
    to unique values, so byte-for-byte reproducibility is not achievable for now
'''
from typing import Any, cast, TextIO, IO
from collections.abc import Mapping, Callable
import io
import logging
import pathlib
import gzip

import numpy
import ezdxf
from ezdxf.enums import TextEntityAlignment
from ezdxf.entities import LWPolyline, Polyline, Text, Insert

from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, Label
from ..library import ILibraryView, LibraryView, Library
from ..shapes import Shape, Polygon, Path
from ..repetition import Grid
from ..utils import rotation_matrix_2d, layer_t, normalize_mirror


logger = logging.getLogger(__name__)


logger.warning('DXF support is experimental!')


DEFAULT_LAYER = 'DEFAULT'


def write(
        library: Mapping[str, Pattern],    # TODO could allow library=None for flat DXF
        top_name: str,
        stream: TextIO,
        *,
        dxf_version: str = 'AC1024',
        ) -> None:
    '''
    Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes
     into polygons, and then writing patterns as DXF `Block`s, polygons as `LWPolyline`s,
     and refs as `Insert`s.

    The top level pattern's name is not written to the DXF file. Nested patterns keep their
     names.

    Layer numbers are translated as follows:
        int: 1 -> '1'
        tuple: (1, 2) -> '1.2'
        str: '1.2' -> '1.2' (no change)

    DXF does not support shape repetition (only block repeptition). Please call
    library.wrap_repeated_shapes() before writing to file.

    Other functions you may want to call:
        - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
        - `library.dangling_refs()` to check for references to missing patterns
        - `pattern.polygonize()` for any patterns with shapes other
            than `masque.shapes.Polygon` or `masque.shapes.Path`

    Only `Grid` repetition objects with manhattan basis vectors are preserved as arrays. Since DXF
     rotations apply to basis vectors while `masque`'s rotations do not, the basis vectors of an
     array with rotated instances must be manhattan _after_ having a compensating rotation applied.

    Args:
        library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced
            by it are written.
        top_name: Name of the top-level pattern to write.
        stream: Stream object to write to.
    '''
    #TODO consider supporting DXF arcs?
    if not isinstance(library, ILibraryView):
        if isinstance(library, dict):
            library = LibraryView(library)
        else:
            library = LibraryView(dict(library))

    pattern = library[top_name]
    subtree = library.subtree(top_name)

    # Create library
    lib = ezdxf.new(dxf_version, setup=True)
    msp = lib.modelspace()
    _shapes_to_elements(msp, pattern.shapes)
    _labels_to_texts(msp, pattern.labels)
    _mrefs_to_drefs(msp, pattern.refs)

    # Now create a block for each referenced pattern, and add in any shapes
    for name, pat in subtree.items():
        assert pat is not None
        if name == top_name:
            continue

        block = lib.blocks.new(name=name)

        _shapes_to_elements(block, pat.shapes)
        _labels_to_texts(block, pat.labels)
        _mrefs_to_drefs(block, pat.refs)

    lib.write(stream)


def writefile(
        library: Mapping[str, Pattern],
        top_name: str,
        filename: str | pathlib.Path,
        *args,
        **kwargs,
        ) -> None:
    '''
    Wrapper for `dxf.write()` that takes a filename or path instead of a stream.

    Will automatically compress the file if it has a .gz suffix.

    Args:
        library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced
            by it are written.
        top_name: Name of the top-level pattern to write.
        filename: Filename to save to.
        *args: passed to `dxf.write`
        **kwargs: passed to `dxf.write`
    '''
    path = pathlib.Path(filename)

    gz_stream: IO[bytes]
    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'))
            streams = (gz_stream,) + streams
        else:
            gz_stream = base_stream
        stream = io.TextIOWrapper(gz_stream)        # type: ignore
        streams = (stream,) + streams

        try:
            write(library, top_name, stream, *args, **kwargs)
        finally:
            for ss in streams:
                ss.close()


def readfile(
        filename: str | pathlib.Path,
        *args,
        **kwargs,
        ) -> tuple[Library, dict[str, Any]]:
    '''
    Wrapper for `dxf.read()` that takes a filename or path instead of a stream.

    Will automatically decompress gzipped files.

    Args:
        filename: Filename to save to.
        *args: passed to `dxf.read`
        **kwargs: passed to `dxf.read`
    '''
    path = pathlib.Path(filename)
    if is_gzipped(path):
        open_func: Callable = gzip.open
    else:
        open_func = open

    with open_func(path, mode='rt') as stream:
        results = read(stream, *args, **kwargs)
    return results


def read(
        stream: TextIO,
        ) -> tuple[Library, dict[str, Any]]:
    '''
    Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are
     translated into `Pattern` objects; `LWPolyline`s are translated into polygons, and `Insert`s
     are translated into `Ref` objects.

    If an object has no layer it is set to this module's `DEFAULT_LAYER` ("DEFAULT").

    Args:
        stream: Stream to read from.

    Returns:
        - Top level pattern
    '''
    lib = ezdxf.read(stream)
    msp = lib.modelspace()

    top_name, top_pat = _read_block(msp)
    mlib = Library({top_name: top_pat})
    for bb in lib.blocks:
        if bb.name == '*Model_Space':
            continue
        name, pat = _read_block(bb)
        mlib[name] = pat

    library_info = dict(
        layers=[ll.dxfattribs() for ll in lib.layers],
        )

    return mlib, library_info


def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> tuple[str, Pattern]:
    name = block.name
    pat = Pattern()
    for element in block:
        if isinstance(element, LWPolyline | Polyline):
            if isinstance(element, LWPolyline):
                points = numpy.asarray(element.get_points())
            elif isinstance(element, Polyline):
                points = numpy.asarray([pp.xyz for pp in element.points()])
            attr = element.dxfattribs()
            layer = attr.get('layer', DEFAULT_LAYER)

            if points.shape[1] == 2:
                raise PatternError('Invalid or unimplemented polygon?')

            if points.shape[1] > 2:
                if (points[0, 2] != points[:, 2]).any():
                    raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
                if points.shape[1] == 4 and (points[:, 3] != 0).any():
                    raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')

                width = points[0, 2]
                if width == 0:
                    width = attr.get('const_width', 0)

                shape: Path | Polygon
                if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
                    shape = Polygon(vertices=points[:-1, :2])
                else:
                    shape = Path(width=width, vertices=points[:, :2])

            pat.shapes[layer].append(shape)

        elif isinstance(element, Text):
            args = dict(
                offset=numpy.asarray(element.get_placement()[1])[:2],
                layer=element.dxfattribs().get('layer', DEFAULT_LAYER),
                )
            string = element.dxfattribs().get('text', '')
#            height = element.dxfattribs().get('height', 0)
#            if height != 0:
#                logger.warning('Interpreting DXF TEXT as a label despite nonzero height. '
#                               'This could be changed in the future by setting a font path in the masque DXF code.')
            pat.label(string=string, **args)
#            else:
#                pat.shapes[args['layer']].append(Text(string=string, height=height, font_path=????))
        elif isinstance(element, Insert):
            attr = element.dxfattribs()
            xscale = attr.get('xscale', 1)
            yscale = attr.get('yscale', 1)
            if abs(xscale) != abs(yscale):
                logger.warning('Masque does not support per-axis scaling; using x-scaling only!')
            scale = abs(xscale)
            mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0))
            rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle

            offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2]

            args = dict(
                target=attr.get('name', None),
                offset=offset,
                scale=scale,
                mirrored=mirrored,
                rotation=rotation,
                )

            if 'column_count' in attr:
                args['repetition'] = Grid(
                    a_vector=(attr['column_spacing'], 0),
                    b_vector=(0, attr['row_spacing']),
                    a_count=attr['column_count'],
                    b_count=attr['row_count'],
                    )
            pat.ref(**args)
        else:
            logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
    return name, pat


def _mrefs_to_drefs(
        block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
        refs: dict[str | None, list[Ref]],
        ) -> None:
    def mk_blockref(encoded_name: str, ref: Ref) -> None:
        rotation = numpy.rad2deg(ref.rotation) % 360
        attribs = dict(
            xscale=ref.scale,
            yscale=ref.scale * (-1 if ref.mirrored else 1),
            rotation=rotation,
            )

        rep = ref.repetition
        if rep is None:
            block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
        elif isinstance(rep, Grid):
            a = rep.a_vector
            b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
            rotated_a = rotation_matrix_2d(-ref.rotation) @ a
            rotated_b = rotation_matrix_2d(-ref.rotation) @ b
            if rotated_a[1] == 0 and rotated_b[0] == 0:
                attribs['column_count'] = rep.a_count
                attribs['row_count'] = rep.b_count
                attribs['column_spacing'] = rotated_a[0]
                attribs['row_spacing'] = rotated_b[1]
                block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
            elif rotated_a[0] == 0 and rotated_b[1] == 0:
                attribs['column_count'] = rep.b_count
                attribs['row_count'] = rep.a_count
                attribs['column_spacing'] = rotated_b[0]
                attribs['row_spacing'] = rotated_a[1]
                block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
            else:
                #NOTE: We could still do non-manhattan (but still orthogonal) grids by getting
                #       creative with counter-rotated nested patterns, but probably not worth it.
                # Instead, just break appart the grid into individual elements:
                for dd in rep.displacements:
                    block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs)
        else:
            for dd in rep.displacements:
                block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs)

    for target, rseq in refs.items():
        if target is None:
            continue
        for ref in rseq:
            mk_blockref(target, ref)


def _shapes_to_elements(
        block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
        shapes: dict[layer_t, list[Shape]],
        ) -> None:
    # Add `LWPolyline`s for each shape.
    #   Could set do paths with width setting, but need to consider endcaps.
    # TODO: can DXF do paths?
    for layer, sseq in shapes.items():
        attribs = dict(layer=_mlayer2dxf(layer))
        for shape in sseq:
            if shape.repetition is not None:
                raise PatternError(
                    'Shape repetitions are not supported by DXF.'
                    ' Please call library.wrap_repeated_shapes() before writing to file.'
                    )

            for polygon in shape.to_polygons():
                xy_open = polygon.vertices + polygon.offset
                xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
                block.add_lwpolyline(xy_closed, dxfattribs=attribs)


def _labels_to_texts(
        block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
        labels: dict[layer_t, list[Label]],
        ) -> None:
    for layer, lseq in labels.items():
        attribs = dict(layer=_mlayer2dxf(layer))
        for label in lseq:
            xy = label.offset
            block.add_text(
                label.string,
                dxfattribs=attribs
                ).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)


def _mlayer2dxf(layer: layer_t) -> str:
    if isinstance(layer, str):
        return layer
    if isinstance(layer, int):
        return str(layer)
    if isinstance(layer, tuple):
        return f'{layer[0]}.{layer[1]}'
    raise PatternError(f'Unknown layer type: {layer} ({type(layer)})')


---
masque/file/gdsii.py
---
'''
GDSII file format readers and writers using the `klamath` library.

Note that GDSII references follow the same convention as `masque`,
  with this order of operations:
   1. Mirroring
   2. Rotation
   3. Scaling
   4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)

  Scaling, rotation, and mirroring apply to individual instances, not grid
   vectors or offsets.

Notes:
 * absolute positioning is not supported
 * PLEX is not supported
 * ELFLAGS are not supported
 * GDS does not support library- or structure-level annotations
 * GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
 * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
'''
from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
from types import MappingProxyType
import io
import mmap
import logging
import pathlib
import gzip
import string
from pprint import pformat

import numpy
from numpy.typing import ArrayLike, NDArray
import klamath
from klamath import records

from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..shapes import Polygon, Path
from ..repetition import Grid
from ..utils import layer_t, annotations_t
from ..library import LazyLibrary, Library, ILibrary, ILibraryView


logger = logging.getLogger(__name__)


path_cap_map = {
    0: Path.Cap.Flush,
    1: Path.Cap.Circle,
    2: Path.Cap.Square,
    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)


def write(
        library: Mapping[str, Pattern],
        stream: IO[bytes],
        meters_per_unit: float,
        logical_units_per_unit: float = 1,
        library_name: str = 'masque-klamath',
        ) -> None:
    '''
    Convert a library to a GDSII stream, mapping data as follows:
         Pattern -> GDSII structure
         Ref -> GDSII SREF or AREF
         Path -> GSDII path
         Shape (other than path) -> GDSII boundary/ies
         Label -> GDSII text
         annnotations -> properties, where possible

     For each shape,
        layer is chosen to be equal to `shape.layer` if it is an int,
            or `shape.layer[0]` if it is a tuple
        datatype is chosen to be `shape.layer[1]` if available,
            otherwise `0`

    GDS does not support shape repetition (only cell repeptition). Please call
    `library.wrap_repeated_shapes()` before writing to file.

    Other functions you may want to call:
        - `masque.file.gdsii.check_valid_names(library.keys())` to check for invalid names
        - `library.dangling_refs()` to check for references to missing patterns
        - `pattern.polygonize()` for any patterns with shapes other
            than `masque.shapes.Polygon` or `masque.shapes.Path`

    Args:
        library: A {name: Pattern} mapping of patterns to write.
        meters_per_unit: Written into the GDSII file, meters per (database) length unit.
            All distances are assumed to be an integer multiple of this unit, and are stored as such.
        logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a
            "logical" unit which is different from the "database" unit, for display purposes.
            Default `1`.
        library_name: Library name written into the GDSII file.
            Default 'masque-klamath'.
    '''
    if not isinstance(library, ILibrary):
        if isinstance(library, dict):
            library = Library(library)
        else:
            library = Library(dict(library))

    # Create library
    header = klamath.library.FileHeader(
        name=library_name.encode('ASCII'),
        user_units_per_db_unit=logical_units_per_unit,
        meters_per_db_unit=meters_per_unit,
        )
    header.write(stream)

    # Now create a structure for each pattern, and add in any Boundary and SREF elements
    for name, pat in library.items():
        elements: list[klamath.elements.Element] = []
        elements += _shapes_to_elements(pat.shapes)
        elements += _labels_to_texts(pat.labels)
        elements += _mrefs_to_grefs(pat.refs)

        klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
    records.ENDLIB.write(stream, None)


def writefile(
        library: Mapping[str, Pattern],
        filename: str | pathlib.Path,
        *args,
        **kwargs,
        ) -> None:
    '''
    Wrapper for `write()` that takes a filename or path instead of a stream.

    Will automatically compress the file if it has a .gz suffix.

    Args:
        library: {name: Pattern} pairs to save.
        filename: Filename to save to.
        *args: passed to `write()`
        **kwargs: passed to `write()`
    '''
    path = pathlib.Path(filename)

    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))
            streams = (stream,) + streams
        else:
            stream = base_stream

        try:
            write(library, stream, *args, **kwargs)
        finally:
            for ss in streams:
                ss.close()


def readfile(
        filename: str | pathlib.Path,
        *args,
        **kwargs,
        ) -> tuple[Library, dict[str, Any]]:
    '''
    Wrapper for `read()` that takes a filename or path instead of a stream.

    Will automatically decompress gzipped files.

    Args:
        filename: Filename to save to.
        *args: passed to `read()`
        **kwargs: passed to `read()`
    '''
    path = pathlib.Path(filename)
    if is_gzipped(path):
        open_func: Callable = gzip.open
    else:
        open_func = open

    with open_func(path, mode='rb') as stream:
        results = read(stream, *args, **kwargs)
    return results


def read(
        stream: IO[bytes],
        raw_mode: bool = True,
        ) -> tuple[Library, dict[str, Any]]:
    '''
    # TODO check GDSII file for cycles!
    Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are
     translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
     are translated into Ref objects.

    Additional library info is returned in a dict, containing:
      'name': name of the library
      'meters_per_unit': number of meters per database unit (all values are in database units)
      'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns)
                                per database unit

    Args:
        stream: Stream to read from.
        raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True.

    Returns:
        - dict of pattern_name:Patterns generated from GDSII structures
        - dict of GDSII library info
    '''
    library_info = _read_header(stream)

    mlib = Library()
    found_struct = records.BGNSTR.skip_past(stream)
    while found_struct:
        name = records.STRNAME.skip_and_read(stream)
        pat = read_elements(stream, raw_mode=raw_mode)
        mlib[name.decode('ASCII')] = pat
        found_struct = records.BGNSTR.skip_past(stream)

    return mlib, library_info


def _read_header(stream: IO[bytes]) -> dict[str, Any]:
    '''
    Read the file header and create the library_info dict.
    '''
    header = klamath.library.FileHeader.read(stream)

    library_info = {'name': header.name.decode('ASCII'),
                    'meters_per_unit': header.meters_per_db_unit,
                    'logical_units_per_unit': header.user_units_per_db_unit,
                    }
    return library_info


def read_elements(
        stream: IO[bytes],
        raw_mode: bool = True,
        ) -> Pattern:
    '''
    Read elements from a GDS structure and build a Pattern from them.

    Args:
        stream: Seekable stream, positioned at a record boundary.
                Will be read until an ENDSTR record is consumed.
        name: Name of the resulting Pattern
        raw_mode: If True, bypass per-shape data validation. Default True.

    Returns:
        A pattern containing the elements that were read.
    '''
    pat = Pattern()

    elements = klamath.library.read_elements(stream)
    for element in elements:
        if isinstance(element, klamath.elements.Boundary):
            layer, poly = _boundary_to_polygon(element, raw_mode)
            pat.shapes[layer].append(poly)
        elif isinstance(element, klamath.elements.Path):
            layer, path = _gpath_to_mpath(element, raw_mode)
            pat.shapes[layer].append(path)
        elif isinstance(element, klamath.elements.Text):
            pat.label(
                layer=element.layer,
                offset=element.xy.astype(float),
                string=element.string.decode('ASCII'),
                annotations=_properties_to_annotations(element.properties),
                )
        elif isinstance(element, klamath.elements.Reference):
            target, ref = _gref_to_mref(element)
            pat.refs[target].append(ref)
    return pat


def _mlayer2gds(mlayer: layer_t) -> tuple[int, int]:
    ''' Helper to turn a layer tuple-or-int into a layer and datatype'''
    if isinstance(mlayer, int):
        layer = mlayer
        data_type = 0
    elif isinstance(mlayer, tuple):
        layer = mlayer[0]
        if len(mlayer) > 1:
            data_type = mlayer[1]
        else:
            data_type = 0
    else:
        raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.')
    return layer, data_type


def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]:
    '''
    Helper function to create a Ref from an SREF or AREF. Sets ref.target to struct_name.
    '''
    xy = ref.xy.astype(float)
    offset = xy[0]
    repetition = None
    if ref.colrow is not None:
        a_count, b_count = ref.colrow
        a_vector = (xy[1] - offset) / a_count
        b_vector = (xy[2] - offset) / b_count
        repetition = Grid(a_vector=a_vector, b_vector=b_vector,
                          a_count=a_count, b_count=b_count)

    target = ref.struct_name.decode('ASCII')
    mref = Ref(
        offset=offset,
        rotation=numpy.deg2rad(ref.angle_deg),
        scale=ref.mag,
        mirrored=ref.invert_y,
        annotations=_properties_to_annotations(ref.properties),
        repetition=repetition,
        )
    return target, mref


def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_t, Path]:
    if gpath.path_type in path_cap_map:
        cap = path_cap_map[gpath.path_type]
    else:
        raise PatternError(f'Unrecognized path type: {gpath.path_type}')

    mpath = Path(
        vertices=gpath.xy.astype(float),
        width=gpath.width,
        cap=cap,
        offset=numpy.zeros(2),
        annotations=_properties_to_annotations(gpath.properties),
        raw=raw_mode,
        )
    if cap == Path.Cap.SquareCustom:
        mpath.cap_extensions = gpath.extension
    return gpath.layer, mpath


def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
    return boundary.layer, Polygon(
        vertices=boundary.xy[:-1].astype(float),
        offset=numpy.zeros(2),
        annotations=_properties_to_annotations(boundary.properties),
        raw=raw_mode,
        )


def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
    grefs = []
    for target, rseq in refs.items():
        if target is None:
            continue
        encoded_name = target.encode('ASCII')
        for ref in rseq:
            # Note: GDS also mirrors first and rotates second
            rep = ref.repetition
            angle_deg = numpy.rad2deg(ref.rotation) % 360
            properties = _annotations_to_properties(ref.annotations, 512)

            if isinstance(rep, Grid):
                b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
                b_count = rep.b_count if rep.b_count is not None else 1
                xy = numpy.asarray(ref.offset) + numpy.array([
                    [0.0, 0.0],
                    rep.a_vector * rep.a_count,
                    b_vector * b_count,
                    ])
                aref = klamath.library.Reference(
                    struct_name=encoded_name,
                    xy=rint_cast(xy),
                    colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)),
                    angle_deg=angle_deg,
                    invert_y=ref.mirrored,
                    mag=ref.scale,
                    properties=properties,
                    )
                grefs.append(aref)
            elif rep is None:
                sref = klamath.library.Reference(
                    struct_name=encoded_name,
                    xy=rint_cast([ref.offset]),
                    colrow=None,
                    angle_deg=angle_deg,
                    invert_y=ref.mirrored,
                    mag=ref.scale,
                    properties=properties,
                    )
                grefs.append(sref)
            else:
                new_srefs = [
                    klamath.library.Reference(
                        struct_name=encoded_name,
                        xy=rint_cast([ref.offset + dd]),
                        colrow=None,
                        angle_deg=angle_deg,
                        invert_y=ref.mirrored,
                        mag=ref.scale,
                        properties=properties,
                        )
                    for dd in rep.displacements]
                grefs += new_srefs
    return grefs


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) -> Mapping[int, bytes]:
    if annotations is None:
        return RO_EMPTY_DICT
    cum_len = 0
    props = {}
    for key, vals in annotations.items():
        try:
            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])')

        val_strings = ' '.join(str(val) for val in vals)
        b = val_strings.encode()
        if len(b) > 126:
            raise PatternError(f'Annotation value {b!r} is longer than 126 characters!')
        cum_len += numpy.ceil(len(b) / 2) * 2 + 2
        if cum_len > max_len:
            raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}')
        props[i] = b
    return props


def _shapes_to_elements(
        shapes: dict[layer_t, list[Shape]],
        polygonize_paths: bool = False,
        ) -> list[klamath.elements.Element]:
    elements: list[klamath.elements.Element] = []
    # Add a Boundary element for each shape, and Path elements if necessary
    for mlayer, sseq in shapes.items():
        layer, data_type = _mlayer2gds(mlayer)
        for shape in sseq:
            if shape.repetition is not None:
                raise PatternError('Shape repetitions are not supported by GDS.'
                                   ' Please call library.wrap_repeated_shapes() before writing to file.')

            properties = _annotations_to_properties(shape.annotations, 128)
            if isinstance(shape, Path) and not polygonize_paths:
                xy = rint_cast(shape.vertices + shape.offset)
                width = rint_cast(shape.width)
                path_type = next(k for k, v in path_cap_map.items() if v == shape.cap)    # reverse lookup

                extension: tuple[int, int]
                if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
                    extension = tuple(shape.cap_extensions)     # type: ignore
                else:
                    extension = (0, 0)

                path = klamath.elements.Path(
                    layer=(layer, data_type),
                    xy=xy,
                    path_type=path_type,
                    width=int(width),
                    extension=extension,
                    properties=properties,
                    )
                elements.append(path)
            elif isinstance(shape, Polygon):
                polygon = shape
                xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
                numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
                xy_closed[-1] = xy_closed[0]
                boundary = klamath.elements.Boundary(
                    layer=(layer, data_type),
                    xy=xy_closed,
                    properties=properties,
                    )
                elements.append(boundary)
            else:
                for polygon in shape.to_polygons():
                    xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
                    numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
                    xy_closed[-1] = xy_closed[0]
                    boundary = klamath.elements.Boundary(
                        layer=(layer, data_type),
                        xy=xy_closed,
                        properties=properties,
                        )
                    elements.append(boundary)
    return elements


def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.elements.Text]:
    texts = []
    for mlayer, lseq in labels.items():
        layer, text_type = _mlayer2gds(mlayer)
        for label in lseq:
            properties = _annotations_to_properties(label.annotations, 128)
            xy = rint_cast([label.offset])
            text = klamath.elements.Text(
                layer=(layer, text_type),
                xy=xy,
                string=label.string.encode('ASCII'),
                properties=properties,
                presentation=0,  # font number & alignment -- unused by us
                angle_deg=0,     # rotation -- unused by us
                invert_y=False,  # inversion -- unused by us
                width=0,         # stroke width -- unused by us
                path_type=0,     # text path endcaps, unused
                mag=1,           # size -- unused by us
                )
            texts.append(text)
    return texts


def load_library(
        stream: IO[bytes],
        *,
        full_load: bool = False,
        postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
        ) -> tuple[LazyLibrary, dict[str, Any]]:
    '''
    Scan a GDSII stream to determine what structures are present, and create
        a library from them. This enables deferred reading of structures
        on an as-needed basis.
    All structures are loaded as secondary

    Args:
        stream: Seekable stream. Position 0 should be the start of the file.
            The caller should leave the stream open while the library
            is still in use, since the library will need to access it
            in order to read the structure contents.
        full_load: If True, force all structures to be read immediately rather
            than as-needed. Since data is read sequentially from the file, this
            will be faster than using the resulting library's `precache` method.
        postprocess: If given, this function is used to post-process each
            pattern *upon first load only*.

    Returns:
        LazyLibrary object, allowing for deferred load of structures.
        Additional library info (dict, same format as from `read`).
    '''
    stream.seek(0)
    lib = LazyLibrary()

    if full_load:
        # Full load approach (immediately load everything)
        patterns, library_info = read(stream)
        for name, pattern in patterns.items():
            if postprocess is not None:
                lib[name] = postprocess(lib, name, pattern)
            else:
                lib[name] = pattern
        return lib, library_info

    # Normal approach (scan and defer load)
    library_info = _read_header(stream)
    structs = klamath.library.scan_structs(stream)

    for name_bytes, pos in structs.items():
        name = name_bytes.decode('ASCII')

        def mkstruct(pos: int = pos, name: str = name) -> Pattern:
            stream.seek(pos)
            pat = read_elements(stream, raw_mode=True)
            if postprocess is not None:
                pat = postprocess(lib, name, pat)
            return pat

        lib[name] = mkstruct

    return lib, library_info


def load_libraryfile(
        filename: str | pathlib.Path,
        *,
        use_mmap: bool = True,
        full_load: bool = False,
        postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
        ) -> tuple[LazyLibrary, dict[str, Any]]:
    '''
    Wrapper for `load_library()` that takes a filename or path instead of a stream.

    Will automatically decompress the file if it is gzipped.

    NOTE that any streams/mmaps opened will remain open until ALL of the
     `PatternGenerator` objects in the library are garbage collected.

    Args:
        path: filename or path to read from
        use_mmap: If `True`, will attempt to memory-map the file instead
                  of buffering. In the case of gzipped files, the file
                  is decompressed into a python `bytes` object in memory
                  and reopened as an `io.BytesIO` stream.
        full_load: If `True`, immediately loads all data. See `load_library`.
        postprocess: Passed to `load_library`

    Returns:
        LazyLibrary object, allowing for deferred load of structures.
        Additional library info (dict, same format as from `read`).
    '''
    path = pathlib.Path(filename)
    stream: IO[bytes]
    if is_gzipped(path):
        if use_mmap:
            logger.info('Asked to mmap a gzipped file, reading into memory instead...')
            gz_stream = gzip.open(path, mode='rb')      # noqa: SIM115
            stream = io.BytesIO(gz_stream.read())       # type: ignore
        else:
            gz_stream = gzip.open(path, mode='rb')      # noqa: SIM115
            stream = io.BufferedReader(gz_stream)       # type: ignore
    else:       # noqa: PLR5501
        if use_mmap:
            base_stream = path.open(mode='rb', buffering=0)                         # noqa: SIM115
            stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ)    # type: ignore
        else:
            stream = path.open(mode='rb')          # noqa: SIM115
    return load_library(stream, full_load=full_load, postprocess=postprocess)


def check_valid_names(
        names: Iterable[str],
        max_length: int = 32,
        ) -> None:
    '''
    Check all provided names to see if they're valid GDSII cell names.

    Args:
        names: Collection of names to check
        max_length: Max allowed length

    '''
    allowed_chars = set(string.ascii_letters + string.digits + '_?$')

    bad_chars = [
        name for name in names
        if not set(name).issubset(allowed_chars)
        ]

    bad_lengths = [
        name for name in names
        if len(name) > max_length
        ]

    if bad_chars:
        logger.error('Names contain invalid characters:\n' + pformat(bad_chars))

    if bad_lengths:
        logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))

    if bad_chars or bad_lengths:
        raise LibraryError('Library contains invalid names, see log above')


---
masque/file/gdsii_arrow.py
---
'''
GDSII file format readers and writers using the `TODO` library.

Note that GDSII references follow the same convention as `masque`,
  with this order of operations:
   1. Mirroring
   2. Rotation
   3. Scaling
   4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)

  Scaling, rotation, and mirroring apply to individual instances, not grid
   vectors or offsets.

Notes:
 * absolute positioning is not supported
 * PLEX is not supported
 * ELFLAGS are not supported
 * GDS does not support library- or structure-level annotations
 * GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
 * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)

 TODO writing
 TODO warn on boxes, nodes
'''
from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
import io
import mmap
import logging
import pathlib
import gzip
import string
from pprint import pformat

import numpy
from numpy.typing import ArrayLike, NDArray
from numpy.testing import assert_equal
import pyarrow
from pyarrow.cffi import ffi

from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..shapes import Polygon, Path, PolyCollection
from ..repetition import Grid
from ..utils import layer_t, annotations_t
from ..library import LazyLibrary, Library, ILibrary, ILibraryView


logger = logging.getLogger(__name__)

clib = ffi.dlopen('/home/jan/projects/klamath-rs/target/release/libklamath_rs_ext.so')
ffi.cdef('void read_path(char* path, struct ArrowArray* array, struct ArrowSchema* schema);')


path_cap_map = {
    0: Path.Cap.Flush,
    1: Path.Cap.Circle,
    2: Path.Cap.Square,
    4: Path.Cap.SquareCustom,
    }


def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
    return numpy.rint(val).astype(numpy.int32)


def _read_to_arrow(
        filename: str | pathlib.Path,
        *args,
        **kwargs,
        ) -> pyarrow.Array:
    path = pathlib.Path(filename)
    path.resolve()
    ptr_array = ffi.new('struct ArrowArray[]', 1)
    ptr_schema = ffi.new('struct ArrowSchema[]', 1)
    clib.read_path(str(path).encode(), ptr_array, ptr_schema)

    iptr_schema = int(ffi.cast('uintptr_t', ptr_schema))
    iptr_array = int(ffi.cast('uintptr_t', ptr_array))
    arrow_arr = pyarrow.Array._import_from_c(iptr_array, iptr_schema)

    return arrow_arr


def readfile(
        filename: str | pathlib.Path,
        *args,
        **kwargs,
        ) -> tuple[Library, dict[str, Any]]:
    '''
    Wrapper for `read()` that takes a filename or path instead of a stream.

    Will automatically decompress gzipped files.

    Args:
        filename: Filename to save to.
        *args: passed to `read()`
        **kwargs: passed to `read()`
    '''
    arrow_arr = _read_to_arrow(filename)
    assert len(arrow_arr) == 1

    results = read_arrow(arrow_arr[0])

    return results


def read_arrow(
        libarr: pyarrow.Array,
        raw_mode: bool = True,
        ) -> tuple[Library, dict[str, Any]]:
    '''
    # TODO check GDSII file for cycles!
    Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are
     translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
     are translated into Ref objects.

    Additional library info is returned in a dict, containing:
      'name': name of the library
      'meters_per_unit': number of meters per database unit (all values are in database units)
      'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns)
                                per database unit

    Args:
        stream: Stream to read from.
        raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True.

    Returns:
        - dict of pattern_name:Patterns generated from GDSII structures
        - dict of GDSII library info
    '''
    library_info = _read_header(libarr)

    layer_names_np = libarr['layers'].values.to_numpy().view('i2').reshape((-1, 2))
    layer_tups = [tuple(pair) for pair in layer_names_np]

    cell_ids = libarr['cells'].values.field('id').to_numpy()
    cell_names = libarr['cell_names'].as_py()

    def get_geom(libarr: pyarrow.Array, geom_type: str) -> dict[str, Any]:
        el = libarr['cells'].values.field(geom_type)
        elem = dict(
            offsets = el.offsets.to_numpy(),
            xy_arr = el.values.field('xy').values.to_numpy().reshape((-1, 2)),
            xy_off = el.values.field('xy').offsets.to_numpy() // 2,
            layer_inds = el.values.field('layer').to_numpy(),
            prop_off = el.values.field('properties').offsets.to_numpy(),
            prop_key = el.values.field('properties').values.field('key').to_numpy(),
            prop_val = el.values.field('properties').values.field('value').to_pylist(),
            )
        return elem

    rf = libarr['cells'].values.field('refs')
    refs = dict(
        offsets = rf.offsets.to_numpy(),
        targets = rf.values.field('target').to_numpy(),
        xy = rf.values.field('xy').to_numpy().view('i4').reshape((-1, 2)),
        invert_y = rf.values.field('invert_y').fill_null(False).to_numpy(zero_copy_only=False),
        angle_rad = numpy.rad2deg(rf.values.field('angle_deg').fill_null(0).to_numpy()),
        scale = rf.values.field('mag').fill_null(1).to_numpy(),
        rep_valid = rf.values.field('repetition').is_valid().to_numpy(zero_copy_only=False),
        rep_xy0 = rf.values.field('repetition').field('xy0').fill_null(0).to_numpy().view('i4').reshape((-1, 2)),
        rep_xy1 = rf.values.field('repetition').field('xy1').fill_null(0).to_numpy().view('i4').reshape((-1, 2)),
        rep_counts = rf.values.field('repetition').field('counts').fill_null(0).to_numpy().view('i2').reshape((-1, 2)),
        prop_off = rf.values.field('properties').offsets.to_numpy(),
        prop_key = rf.values.field('properties').values.field('key').to_numpy(),
        prop_val = rf.values.field('properties').values.field('value').to_pylist(),
        )

    txt = libarr['cells'].values.field('texts')
    texts = dict(
        offsets = txt.offsets.to_numpy(),
        layer_inds = txt.values.field('layer').to_numpy(),
        xy = txt.values.field('xy').to_numpy().view('i4').reshape((-1, 2)),
        string = txt.values.field('string').to_pylist(),
        prop_off = txt.values.field('properties').offsets.to_numpy(),
        prop_key = txt.values.field('properties').values.field('key').to_numpy(),
        prop_val = txt.values.field('properties').values.field('value').to_pylist(),
        )

    elements = dict(
        boundaries = get_geom(libarr, 'boundaries'),
        paths = get_geom(libarr, 'paths'),
        boxes = get_geom(libarr, 'boxes'),
        nodes = get_geom(libarr, 'nodes'),
        texts = texts,
        refs = refs,
        )

    paths = libarr['cells'].values.field('paths')
    elements['paths'].update(dict(
        width = paths.values.field('width').fill_null(0).to_numpy(),
        path_type = paths.values.field('path_type').fill_null(0).to_numpy(),
        extensions = numpy.stack((
            paths.values.field('extension_start').fill_null(0).to_numpy(),
            paths.values.field('extension_end').fill_null(0).to_numpy(),
            ), axis=-1),
        ))

    global_args = dict(
        cell_names = cell_names,
        layer_tups = layer_tups,
        raw_mode = raw_mode,
        )

    mlib = Library()
    for cc in range(len(libarr['cells'])):
        name = cell_names[cell_ids[cc]]
        pat = Pattern()
        _boundaries_to_polygons(pat, global_args, elements['boundaries'], cc)
        _gpaths_to_mpaths(pat, global_args, elements['paths'], cc)
        _grefs_to_mrefs(pat, global_args, elements['refs'], cc)
        _texts_to_labels(pat, global_args, elements['texts'], cc)
        mlib[name] = pat

    return mlib, library_info


def _read_header(libarr: pyarrow.Array) -> dict[str, Any]:
    '''
    Read the file header and create the library_info dict.
    '''
    library_info = dict(
        name = libarr['lib_name'],
        meters_per_unit = libarr['meters_per_db_unit'],
        logical_units_per_unit = libarr['user_units_per_db_unit'],
        )
    return library_info


def _grefs_to_mrefs(
        pat: Pattern,
        global_args: dict[str, Any],
        elem: dict[str, Any],
        cc: int,
        ) -> None:
    cell_names = global_args['cell_names']
    elem_off = elem['offsets']      # which elements belong to each cell
    xy = elem['xy']
    prop_key = elem['prop_key']
    prop_val = elem['prop_val']
    targets = elem['targets']

    elem_count = elem_off[cc + 1] - elem_off[cc]
    elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)   # +1 to capture ending location for last elem
    prop_offs = elem['prop_off'][elem_slc]  # which props belong to each element
    elem_invert_y = elem['invert_y'][elem_slc][:elem_count]
    elem_angle_rad = elem['angle_rad'][elem_slc][:elem_count]
    elem_scale = elem['scale'][elem_slc][:elem_count]
    elem_rep_xy0 = elem['rep_xy0'][elem_slc][:elem_count]
    elem_rep_xy1 = elem['rep_xy1'][elem_slc][:elem_count]
    elem_rep_counts = elem['rep_counts'][elem_slc][:elem_count]
    rep_valid = elem['rep_valid'][elem_slc][:elem_count]


    for ee in range(elem_count):
        target = cell_names[targets[ee]]
        offset = xy[ee]
        mirr = elem_invert_y[ee]
        rot = elem_angle_rad[ee]
        mag = elem_scale[ee]

        rep: None | Grid = None
        if rep_valid[ee]:
            a_vector = elem_rep_xy0[ee]
            b_vector = elem_rep_xy1[ee]
            a_count, b_count = elem_rep_counts[ee]
            rep = Grid(a_vector=a_vector, b_vector=b_vector, a_count=a_count, b_count=b_count)

        annotations: None | dict[str, list[int | float | str]] = None
        prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
        if prop_ii < prop_ff:
            annotations = {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}

        ref = Ref(offset=offset, mirrored=mirr, rotation=rot, scale=mag, repetition=rep, annotations=annotations)
        pat.refs[target].append(ref)


def _texts_to_labels(
        pat: Pattern,
        global_args: dict[str, Any],
        elem: dict[str, Any],
        cc: int,
        ) -> None:
    elem_off = elem['offsets']      # which elements belong to each cell
    xy = elem['xy']
    layer_tups = global_args['layer_tups']
    layer_inds = elem['layer_inds']
    prop_key = elem['prop_key']
    prop_val = elem['prop_val']

    elem_count = elem_off[cc + 1] - elem_off[cc]
    elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)   # +1 to capture ending location for last elem
    prop_offs = elem['prop_off'][elem_slc]  # which props belong to each element
    elem_layer_inds = layer_inds[elem_slc][:elem_count]
    elem_strings = elem['string'][elem_slc][:elem_count]

    for ee in range(elem_count):
        layer = layer_tups[elem_layer_inds[ee]]
        offset = xy[ee]
        string = elem_strings[ee]

        annotations: None | dict[str, list[int | float | str]] = None
        prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
        if prop_ii < prop_ff:
            annotations = {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}

        mlabel = Label(string=string, offset=offset, annotations=annotations)
        pat.labels[layer].append(mlabel)


def _gpaths_to_mpaths(
        pat: Pattern,
        global_args: dict[str, Any],
        elem: dict[str, Any],
        cc: int,
        ) -> None:
    elem_off = elem['offsets']      # which elements belong to each cell
    xy_val = elem['xy_arr']
    layer_tups = global_args['layer_tups']
    layer_inds = elem['layer_inds']
    prop_key = elem['prop_key']
    prop_val = elem['prop_val']

    elem_count = elem_off[cc + 1] - elem_off[cc]
    elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)   # +1 to capture ending location for last elem
    xy_offs = elem['xy_off'][elem_slc]      # which xy coords belong to each element
    prop_offs = elem['prop_off'][elem_slc]  # which props belong to each element
    elem_layer_inds = layer_inds[elem_slc][:elem_count]
    elem_widths = elem['width'][elem_slc][:elem_count]
    elem_path_types = elem['path_type'][elem_slc][:elem_count]
    elem_extensions = elem['extensions'][elem_slc][:elem_count]

    zeros = numpy.zeros((elem_count, 2))
    raw_mode = global_args['raw_mode']
    for ee in range(elem_count):
        layer = layer_tups[elem_layer_inds[ee]]
        vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1]]
        width = elem_widths[ee]
        cap_int = elem_path_types[ee]
        cap = path_cap_map[cap_int]
        if cap_int == 4:
            cap_extensions = elem_extensions[ee]
        else:
            cap_extensions = None

        annotations: None | dict[str, list[int | float | str]] = None
        prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
        if prop_ii < prop_ff:
            annotations = {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}

        path = Path(vertices=vertices, offset=zeros[ee], annotations=annotations, raw=raw_mode,
            width=width, cap=cap,cap_extensions=cap_extensions)
        pat.shapes[layer].append(path)


def _boundaries_to_polygons(
        pat: Pattern,
        global_args: dict[str, Any],
        elem: dict[str, Any],
        cc: int,
        ) -> None:
    elem_off = elem['offsets']      # which elements belong to each cell
    xy_val = elem['xy_arr']
    layer_inds = elem['layer_inds']
    layer_tups = global_args['layer_tups']
    prop_key = elem['prop_key']
    prop_val = elem['prop_val']

    elem_count = elem_off[cc + 1] - elem_off[cc]
    elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)   # +1 to capture ending location for last elem
    xy_offs = elem['xy_off'][elem_slc]      # which xy coords belong to each element
    xy_counts = xy_offs[1:] - xy_offs[:-1]
    prop_offs = elem['prop_off'][elem_slc]  # which props belong to each element
    prop_counts = prop_offs[1:] - prop_offs[:-1]
    elem_layer_inds = layer_inds[elem_slc][:elem_count]

    order = numpy.argsort(elem_layer_inds, stable=True)
    unilayer_inds, unilayer_first, unilayer_count = numpy.unique(elem_layer_inds, return_index=True, return_counts=True)

    zeros = numpy.zeros((elem_count, 2))
    raw_mode = global_args['raw_mode']
    for layer_ind, ff, cc in zip(unilayer_inds, unilayer_first, unilayer_count, strict=True):
        ee_inds = order[ff:ff + cc]
        layer = layer_tups[layer_ind]
        propless_mask = prop_counts[ee_inds] == 0

        poly_count_on_layer = propless_mask.sum()
        if poly_count_on_layer == 1:
            propless_mask[:] = 0        # Never make a 1-element collection
        elif poly_count_on_layer > 1:
            propless_vert_counts = xy_counts[ee_inds[propless_mask]] - 1        # -1 to drop closing point
            vertex_lists = numpy.empty((propless_vert_counts.sum(), 2), dtype=numpy.float64)
            vertex_offsets = numpy.cumsum(numpy.concatenate([[0], propless_vert_counts]))

            for ii, ee in enumerate(ee_inds[propless_mask]):
                vo = vertex_offsets[ii]
                vertex_lists[vo:vo + propless_vert_counts[ii]] = xy_val[xy_offs[ee]:xy_offs[ee + 1] - 1]

            polys = PolyCollection(vertex_lists=vertex_lists, vertex_offsets=vertex_offsets, offset=zeros[ee])
            pat.shapes[layer].append(polys)

        # Handle single polygons
        for ee in ee_inds[~propless_mask]:
            layer = layer_tups[elem_layer_inds[ee]]
            vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1] - 1]    # -1 to drop closing point

            annotations: None | dict[str, list[int | float | str]] = None
            prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
            if prop_ii < prop_ff:
                annotations = {str(prop_key[off]): prop_val[off] for off in range(prop_ii, prop_ff)}

            poly = Polygon(vertices=vertices, offset=zeros[ee], annotations=annotations, raw=raw_mode)
            pat.shapes[layer].append(poly)


#def _properties_to_annotations(properties: pyarrow.Array) -> annotations_t:
#    return {prop['key'].as_py(): prop['value'].as_py() for prop in properties}


def check_valid_names(
        names: Iterable[str],
        max_length: int = 32,
        ) -> None:
    '''
    Check all provided names to see if they're valid GDSII cell names.

    Args:
        names: Collection of names to check
        max_length: Max allowed length

    '''
    allowed_chars = set(string.ascii_letters + string.digits + '_?$')

    bad_chars = [
        name for name in names
        if not set(name).issubset(allowed_chars)
        ]

    bad_lengths = [
        name for name in names
        if len(name) > max_length
        ]

    if bad_chars:
        logger.error('Names contain invalid characters:\n' + pformat(bad_chars))

    if bad_lengths:
        logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))

    if bad_chars or bad_lengths:
        raise LibraryError('Library contains invalid names, see log above')


---
masque/file/oasis.py
---
'''
OASIS file format readers and writers

Note that OASIS references follow the same convention as `masque`,
  with this order of operations:
   1. Mirroring
   2. Rotation
   3. Scaling
   4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)

  Scaling, rotation, and mirroring apply to individual instances, not grid
   vectors or offsets.

Notes:
 * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
'''
from typing import Any, IO, cast
from collections.abc import Sequence, Iterable, Mapping, Callable
import logging
import pathlib
import gzip
import string
from pprint import pformat

import numpy
from numpy.typing import ArrayLike, NDArray
import fatamorgana
import fatamorgana.records as fatrec
from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringReference

from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..library import Library, ILibrary
from ..shapes import Path, Circle
from ..repetition import Grid, Arbitrary, Repetition
from ..utils import layer_t, annotations_t


logger = logging.getLogger(__name__)


logger.warning('OASIS support is experimental!')


path_cap_map = {
    PathExtensionScheme.Flush: Path.Cap.Flush,
    PathExtensionScheme.HalfWidth: Path.Cap.Square,
    PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom,
    }

#TODO implement more shape types in OASIS?

def rint_cast(val: ArrayLike) -> NDArray[numpy.int64]:
    return numpy.rint(val).astype(numpy.int64)


def build(
        library: Mapping[str, Pattern],           # NOTE: Pattern here should be treated as immutable!
        units_per_micron: int,
        layer_map: dict[str, int | tuple[int, int]] | None = None,
        *,
        annotations: annotations_t | None = None,
        ) -> fatamorgana.OasisLayout:
    '''
    Convert a collection of {name: Pattern} pairs to an OASIS stream, writing patterns
     as OASIS cells, refs as Placement records, and mapping other shapes and labels
     to equivalent record types (Polygon, Path, Circle, Text).
     Other shape types may be converted to polygons if no equivalent
     record type exists (or is not implemented here yet).

     For each shape,
        layer is chosen to be equal to `shape.layer` if it is an int,
            or `shape.layer[0]` if it is a tuple
        datatype is chosen to be `shape.layer[1]` if available,
            otherwise `0`
        If a layer map is provided, layer strings will be converted
            automatically, and layer names will be written to the file.

    Other functions you may want to call:
        - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
        - `library.dangling_refs()` to check for references to missing patterns
        - `pattern.polygonize()` for any patterns with shapes other
            than `masque.shapes.Polygon`, `masque.shapes.Path`, or `masque.shapes.Circle`

    Args:
        library: A {name: Pattern} mapping of patterns to write.
        units_per_micron: Written into the OASIS file, number of grid steps per micrometer.
            All distances are assumed to be an integer multiple of the grid step, and are stored as such.
        layer_map: dictionary which translates layer names into layer numbers. If this argument is
            provided, input shapes and labels are allowed to have layer names instead of numbers.
            It is assumed that geometry and text share the same layer names, and each name is
            assigned only to a single layer (not a range).
            If more fine-grained control is needed, manually pre-processing shapes' layer names
            into numbers, omit this argument, and manually generate the required
            `fatamorgana.records.LayerName` entries.
            Default is an empty dict (no names provided).
        annotations: dictionary of key-value pairs which are saved as library-level properties

    Returns:
        `fatamorgana.OasisLayout`
    '''
    if not isinstance(library, ILibrary):
        if isinstance(library, dict):
            library = Library(library)
        else:
            library = Library(dict(library))

    if layer_map is None:
        layer_map = {}

    if annotations is None:
        annotations = {}

    # Create library
    lib = fatamorgana.OasisLayout(unit=units_per_micron, validation=None)
    lib.properties = annotations_to_properties(annotations)

    if layer_map:
        for name, layer_num in layer_map.items():
            layer, data_type = _mlayer2oas(layer_num)
            lib.layers += [
                fatrec.LayerName(
                    nstring=name,
                    layer_interval=(layer, layer),
                    type_interval=(data_type, data_type),
                    is_textlayer=tt,
                    )
                for tt in (True, False)]

        def layer2oas(mlayer: layer_t) -> tuple[int, int]:
            assert layer_map is not None
            layer_num = layer_map[mlayer] if isinstance(mlayer, str) else mlayer
            return _mlayer2oas(layer_num)
    else:
        layer2oas = _mlayer2oas

    # Now create a structure for each pattern
    for name, pat in library.items():
        structure = fatamorgana.Cell(name=name)
        lib.cells.append(structure)

        structure.properties += annotations_to_properties(pat.annotations)

        structure.geometry += _shapes_to_elements(pat.shapes, layer2oas)
        structure.geometry += _labels_to_texts(pat.labels, layer2oas)
        structure.placements += _refs_to_placements(pat.refs)

    return lib


def write(
        library: Mapping[str, Pattern],           # NOTE: Pattern here should be treated as immutable!
        stream: IO[bytes],
        *args,
        **kwargs,
        ) -> None:
    '''
    Write a `Pattern` or list of patterns to a OASIS file. See `oasis.build()`
      for details.

    Args:
        library: A {name: Pattern} mapping of patterns to write.
        stream: Stream to write to.
        *args: passed to `oasis.build()`
        **kwargs: passed to `oasis.build()`
    '''
    lib = build(library, *args, **kwargs)
    lib.write(stream)


def writefile(
        library: Mapping[str, Pattern],           # NOTE: Pattern here should be treated as immutable!
        filename: str | pathlib.Path,
        *args,
        **kwargs,
        ) -> None:
    '''
    Wrapper for `oasis.write()` that takes a filename or path instead of a stream.

    Will automatically compress the file if it has a .gz suffix.

    Args:
        library: A {name: Pattern} mapping of patterns to write.
        filename: Filename to save to.
        *args: passed to `oasis.write`
        **kwargs: passed to `oasis.write`
    '''
    path = pathlib.Path(filename)

    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'))
            streams += (stream,)
        else:
            stream = base_stream

        try:
            write(library, stream, *args, **kwargs)
        finally:
            for ss in streams:
                ss.close()


def readfile(
        filename: str | pathlib.Path,
        *args,
        **kwargs,
        ) -> tuple[Library, dict[str, Any]]:
    '''
    Wrapper for `oasis.read()` that takes a filename or path instead of a stream.

    Will automatically decompress gzipped files.

    Args:
        filename: Filename to save to.
        *args: passed to `oasis.read`
        **kwargs: passed to `oasis.read`
    '''
    path = pathlib.Path(filename)
    if is_gzipped(path):
        open_func: Callable = gzip.open
    else:
        open_func = open

    with open_func(path, mode='rb') as stream:
        results = read(stream, *args, **kwargs)
    return results


def read(
        stream: IO[bytes],
        ) -> tuple[Library, dict[str, Any]]:
    '''
    Read a OASIS file and translate it into a dict of Pattern objects. OASIS cells are
     translated into Pattern objects; Polygons are translated into polygons, and Placements
     are translated into Ref objects.

    Additional library info is returned in a dict, containing:
      'units_per_micrometer': number of database units per micrometer (all values are in database units)
      'layer_map': Mapping from layer names to fatamorgana.LayerName objects
      'annotations': Mapping of {key: value} pairs from library's properties

    Args:
        stream: Stream to read from.

    Returns:
        - dict of `pattern_name`:`Pattern`s generated from OASIS cells
        - dict of OASIS library info
    '''

    lib = fatamorgana.OasisLayout.read(stream)

    library_info: dict[str, Any] = {
        'units_per_micrometer': lib.unit,
        'annotations': properties_to_annotations(lib.properties, lib.propnames, lib.propstrings),
        }

    layer_map = {}
    for layer_name in lib.layers:
        layer_map[str(layer_name.nstring)] = layer_name
    library_info['layer_map'] = layer_map

    mlib = Library()
    for cell in lib.cells:
        if isinstance(cell.name, int):
            cell_name = lib.cellnames[cell.name].nstring.string
        else:
            cell_name = cell.name.string

        pat = Pattern()
        for element in cell.geometry:
            if isinstance(element, fatrec.XElement):
                logger.warning('Skipping XElement record')
                # note XELEMENT has no repetition
                continue

            assert not isinstance(element.repetition, fatamorgana.ReuseRepetition)
            repetition = repetition_fata2masq(element.repetition)

            # Switch based on element type:
            if isinstance(element, fatrec.Polygon):
                # Drop last point (`fatamorgana` returns explicity closed list; we use implicit close)
                # also need `cumsum` to convert from deltas to locations
                vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list()[:-1])), axis=0)

                annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
                pat.polygon(
                    vertices=vertices,
                    layer=element.get_layer_tuple(),
                    offset=element.get_xy(),
                    annotations=annotations,
                    repetition=repetition,
                    )
            elif isinstance(element, fatrec.Path):
                vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)

                cap_start = path_cap_map[element.get_extension_start()[0]]
                cap_end   = path_cap_map[element.get_extension_end()[0]]
                if cap_start != cap_end:
                    raise PatternError('masque does not support multiple cap types on a single path.')      # TODO handle multiple cap types
                cap = cap_start

                path_args: dict[str, Any] = {}
                if cap == Path.Cap.SquareCustom:
                    path_args['cap_extensions'] = numpy.array((
                        element.get_extension_start()[1],
                        element.get_extension_end()[1],
                        ))

                annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
                pat.path(
                    vertices=vertices,
                    layer=element.get_layer_tuple(),
                    offset=element.get_xy(),
                    repetition=repetition,
                    annotations=annotations,
                    width=element.get_half_width() * 2,
                    cap=cap,
                    **path_args,
                    )

            elif isinstance(element, fatrec.Rectangle):
                width = element.get_width()
                height = element.get_height()
                annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
                pat.polygon(
                    layer=element.get_layer_tuple(),
                    offset=element.get_xy(),
                    repetition=repetition,
                    vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
                    annotations=annotations,
                    )

            elif isinstance(element, fatrec.Trapezoid):
                vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (element.get_width(), element.get_height())
                a = element.get_delta_a()
                b = element.get_delta_b()
                if element.get_is_vertical():
                    if a > 0:
                        vertices[0, 1] += a
                    else:
                        vertices[3, 1] += a

                    if b > 0:
                        vertices[2, 1] -= b
                    else:
                        vertices[1, 1] -= b
                else:
                    if a > 0:
                        vertices[1, 0] += a
                    else:
                        vertices[0, 0] += a

                    if b > 0:
                        vertices[3, 0] -= b
                    else:
                        vertices[2, 0] -= b

                annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
                pat.polygon(
                    layer=element.get_layer_tuple(),
                    offset=element.get_xy(),
                    repetition=repetition,
                    vertices=vertices,
                    annotations=annotations,
                    )

            elif isinstance(element, fatrec.CTrapezoid):
                cttype = element.get_ctrapezoid_type()
                height = element.get_height()
                width = element.get_width()

                vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height)

                if cttype in (0, 4, 7):
                    vertices[2, 0] -= height
                if cttype in (1, 5, 6):
                    vertices[3, 0] -= height
                if cttype in (2, 4, 6):
                    vertices[1, 0] += height
                if cttype in (3, 5, 7):
                    vertices[0, 0] += height

                if cttype in (8, 12, 15):
                    vertices[2, 0] -= width
                if cttype in (9, 13, 14):
                    vertices[1, 0] -= width
                if cttype in (10, 12, 14):
                    vertices[3, 0] += width
                if cttype in (11, 13, 15):
                    vertices[0, 0] += width

                if cttype == 16:
                    vertices = vertices[[0, 1, 3], :]
                elif cttype == 17:
                    vertices = vertices[[0, 1, 2], :]
                elif cttype == 18:
                    vertices = vertices[[0, 2, 3], :]
                elif cttype == 19:
                    vertices = vertices[[1, 2, 3], :]
                elif cttype == 20:
                    vertices = vertices[[0, 1, 3], :]
                    vertices[1, 0] += height
                elif cttype == 21:
                    vertices = vertices[[0, 1, 2], :]
                    vertices[0, 0] += height
                elif cttype == 22:
                    vertices = vertices[[0, 1, 3], :]
                    vertices[3, 1] += width
                elif cttype == 23:
                    vertices = vertices[[0, 2, 3], :]
                    vertices[0, 1] += width

                annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
                pat.polygon(
                    layer=element.get_layer_tuple(),
                    offset=element.get_xy(),
                    repetition=repetition,
                    vertices=vertices,
                    annotations=annotations,
                    )

            elif isinstance(element, fatrec.Circle):
                annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
                layer = element.get_layer_tuple()
                circle = Circle(
                    offset=element.get_xy(),
                    repetition=repetition,
                    annotations=annotations,
                    radius=float(element.get_radius()),
                    )
                pat.shapes[layer].append(circle)

            elif isinstance(element, fatrec.Text):
                annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
                str_or_ref = element.get_string()
                if isinstance(str_or_ref, int):
                    string = lib.textstrings[str_or_ref].string
                else:
                    string = str_or_ref.string
                pat.label(
                    layer=element.get_layer_tuple(),
                    offset=element.get_xy(),
                    repetition=repetition,
                    annotations=annotations,
                    string=string,
                    )

            else:
                logger.warning(f'Skipping record {element} (unimplemented)')
                continue

        for placement in cell.placements:
            target, ref = _placement_to_ref(placement, lib)
            if isinstance(target, int):
                target = lib.cellnames[target].nstring.string
            pat.refs[target].append(ref)

        mlib[cell_name] = pat

    return mlib, library_info


def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]:
    ''' Helper to turn a layer tuple-or-int into a layer and datatype'''
    if isinstance(mlayer, int):
        layer = mlayer
        data_type = 0
    elif isinstance(mlayer, tuple):
        layer = mlayer[0]
        if len(mlayer) > 1:
            data_type = mlayer[1]
        else:
            data_type = 0
    else:
        raise PatternError(f'Invalid layer for OASIS: {mlayer}. Note that OASIS layers cannot be '
                           f'strings unless a layer map is provided.')
    return layer, data_type


def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> tuple[int | str, Ref]:
    '''
    Helper function to create a Ref from a placment. Also returns the placement name (or id).
    '''
    assert not isinstance(placement.repetition, fatamorgana.ReuseRepetition)
    xy = numpy.array((placement.x, placement.y))
    mag = placement.magnification if placement.magnification is not None else 1

    pname = placement.get_name()
    name: int | str = pname if isinstance(pname, int) else pname.string       # TODO deal with referenced names

    annotations = properties_to_annotations(placement.properties, lib.propnames, lib.propstrings)
    if placement.angle is None:
        rotation = 0
    else:
        rotation = numpy.deg2rad(float(placement.angle))
    ref = Ref(
        offset=xy,
        mirrored=placement.flip,
        rotation=rotation,
        scale=float(mag),
        repetition=repetition_fata2masq(placement.repetition),
        annotations=annotations,
        )
    return name, ref


def _refs_to_placements(
        refs: dict[str | None, list[Ref]],
        ) -> list[fatrec.Placement]:
    placements = []
    for target, rseq in refs.items():
        if target is None:
            continue
        for ref in rseq:
            # Note: OASIS also mirrors first and rotates second
            frep, rep_offset = repetition_masq2fata(ref.repetition)

            offset = rint_cast(ref.offset + rep_offset)
            angle = numpy.rad2deg(ref.rotation) % 360
            placement = fatrec.Placement(
                name=target,
                flip=ref.mirrored,
                angle=angle,
                magnification=ref.scale,
                properties=annotations_to_properties(ref.annotations),
                x=offset[0],
                y=offset[1],
                repetition=frep,
                )

            placements.append(placement)
    return placements


def _shapes_to_elements(
        shapes: dict[layer_t, list[Shape]],
        layer2oas: Callable[[layer_t], tuple[int, int]],
        ) -> list[fatrec.Polygon | fatrec.Path | fatrec.Circle]:
    # Add a Polygon record for each shape, and Path elements if necessary
    elements: list[fatrec.Polygon | fatrec.Path | fatrec.Circle] = []
    for mlayer, sseq in shapes.items():
        layer, datatype = layer2oas(mlayer)
        for shape in sseq:
            repetition, rep_offset = repetition_masq2fata(shape.repetition)
            properties = annotations_to_properties(shape.annotations)
            if isinstance(shape, Circle):
                offset = rint_cast(shape.offset + rep_offset)
                radius = rint_cast(shape.radius)
                circle = fatrec.Circle(
                    layer=layer,
                    datatype=datatype,
                    radius=cast('int', radius),
                    x=offset[0],
                    y=offset[1],
                    properties=properties,
                    repetition=repetition,
                    )
                elements.append(circle)
            elif isinstance(shape, Path):
                xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset)
                deltas = rint_cast(numpy.diff(shape.vertices, axis=0))
                half_width = rint_cast(shape.width / 2)
                path_type = next(k for k, v in path_cap_map.items() if v == shape.cap)    # reverse lookup
                extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
                extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
                path = fatrec.Path(
                    layer=layer,
                    datatype=datatype,
                    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?
                    extension_end=extension_end,
                    properties=properties,
                    repetition=repetition,
                    )
                elements.append(path)
            else:
                for polygon in shape.to_polygons():
                    xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
                    points = rint_cast(numpy.diff(polygon.vertices, axis=0))
                    elements.append(fatrec.Polygon(
                        layer=layer,
                        datatype=datatype,
                        x=xy[0],
                        y=xy[1],
                        point_list=cast('list[list[int]]', points),
                        properties=properties,
                        repetition=repetition,
                        ))
    return elements


def _labels_to_texts(
        labels: dict[layer_t, list[Label]],
        layer2oas: Callable[[layer_t], tuple[int, int]],
        ) -> list[fatrec.Text]:
    texts = []
    for mlayer, lseq in labels.items():
        layer, datatype = layer2oas(mlayer)
        for label in lseq:
            repetition, rep_offset = repetition_masq2fata(label.repetition)
            xy = rint_cast(label.offset + rep_offset)
            properties = annotations_to_properties(label.annotations)
            texts.append(fatrec.Text(
                layer=layer,
                datatype=datatype,
                x=xy[0],
                y=xy[1],
                string=label.string,
                properties=properties,
                repetition=repetition,
                ))
    return texts


def repetition_fata2masq(
        rep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None,
        ) -> Repetition | None:
    mrep: Repetition | None
    if isinstance(rep, fatamorgana.GridRepetition):
        mrep = Grid(a_vector=rep.a_vector,
                    b_vector=rep.b_vector,
                    a_count=rep.a_count,
                    b_count=rep.b_count)
    elif isinstance(rep, fatamorgana.ArbitraryRepetition):
        displacements = numpy.cumsum(numpy.column_stack((
            rep.x_displacements,
            rep.y_displacements,
            )), axis=0)
        displacements = numpy.vstack(([0, 0], displacements))
        mrep = Arbitrary(displacements)
    elif rep is None:
        mrep = None
    return mrep


def repetition_masq2fata(
        rep: Repetition | None,
        ) -> tuple[
            fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None,
            tuple[int, int]
            ]:
    frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
    if isinstance(rep, Grid):
        a_vector = rint_cast(rep.a_vector)
        b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None
        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),
            )
        offset = (0, 0)
    elif isinstance(rep, Arbitrary):
        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, :]
    else:
        assert rep is None
        frep = None
        offset = (0, 0)
    return frep, offset


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
                for v in values]
        properties.append(fatrec.Property(key, vals, is_standard=False))        # type: ignore
    return properties


def properties_to_annotations(
        properties: list[fatrec.Property],
        propnames: dict[int, NString],
        propstrings: dict[int, AString],
        ) -> annotations_t:
    annotations = {}
    for proprec in properties:
        assert proprec.name is not None
        if isinstance(proprec.name, int):
            key = propnames[proprec.name].string
        else:
            key = proprec.name.string
        values: list[str | float | int] = []

        assert proprec.values is not None
        for value in proprec.values:
            if isinstance(value, float | int):
                values.append(value)
            elif isinstance(value, NString | AString):
                values.append(value.string)
            elif isinstance(value, PropStringReference):
                values.append(propstrings[value.ref].string)  # dereference
            else:
                string = repr(value)
                logger.warning(f'Converting property value for key ({key}) to string ({string})')
                values.append(string)
        annotations[key] = values
    return annotations

    properties = [fatrec.Property(key, vals, is_standard=False)
                  for key, vals in annotations.items()]
    return properties


def check_valid_names(
        names: Iterable[str],
        ) -> None:
    '''
    Check all provided names to see if they're valid GDSII cell names.

    Args:
        names: Collection of names to check
        max_length: Max allowed length

    '''
    allowed_chars = set(string.ascii_letters + string.digits + string.punctuation + ' ')

    bad_chars = [
        name for name in names
        if not set(name).issubset(allowed_chars)
        ]

    if bad_chars:
        raise LibraryError('Names contain invalid characters:\n' + pformat(bad_chars))


---
masque/file/svg.py
---
'''
SVG file format readers and writers
'''
from collections.abc import Mapping
import warnings

import numpy
from numpy.typing import ArrayLike
import svgwrite     # type: ignore

from .utils import mangle_name
from .. import Pattern


def writefile(
        library: Mapping[str, Pattern],
        top: str,
        filename: str,
        custom_attributes: bool = False,
        ) -> None:
    '''
    Write a Pattern to an SVG file, by first calling .polygonize() on it
     to change the shapes into polygons, and then writing patterns as SVG
     groups (<g>, inside <defs>), polygons as paths (<path>), and refs
     as <use> elements.

    Note that this function modifies the Pattern.

    If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
     is written to the relevant elements.

    It is often a good idea to run `pattern.dedup()` on pattern prior to
     calling this function, especially if calling `.polygonize()` will result in very
     many vertices.

    If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
     prior to calling this function.

    Args:
        pattern: Pattern to write to file. Modified by this function.
        filename: Filename to write to.
        custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
            SVG elements.
    '''
    pattern = library[top]

    # Polygonize pattern
    pattern.polygonize()

    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)
    else:
        bounds_min, bounds_max = bounds

    viewbox = numpy.hstack((bounds_min - 1, (bounds_max - bounds_min) + 2))
    viewbox_string = '{:g} {:g} {:g} {:g}'.format(*viewbox)

    # Create file
    svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
                           debug=(not custom_attributes))

    # Now create a group for each pattern and add in any Boundary and Use elements
    for name, pat in library.items():
        svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red')

        for layer, shapes in pat.shapes.items():
            for shape in shapes:
                for polygon in shape.to_polygons():
                    path_spec = poly2path(polygon.vertices + polygon.offset)

                    path = svg.path(d=path_spec)
                    if custom_attributes:
                        path['pattern_layer'] = layer

                    svg_group.add(path)

        for target, refs in pat.refs.items():
            if target is None:
                continue
            for ref in refs:
                transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})'
                use = svg.use(href='#' + mangle_name(target), transform=transform)
                svg_group.add(use)

        svg.defs.add(svg_group)
    svg.add(svg.use(href='#' + mangle_name(top)))
    svg.save()


def writefile_inverted(
        library: Mapping[str, Pattern],
        top: str,
        filename: str,
        ) -> None:
    '''
    Write an inverted Pattern to an SVG file, by first calling `.polygonize()` and
     `.flatten()` on it to change the shapes into polygons, then drawing a bounding
     box and drawing the polygons with reverse vertex order inside it, all within
     one `<path>` element.

    Note that this function modifies the Pattern.

    If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
     prior to calling this function.

    Args:
        pattern: Pattern to write to file. Modified by this function.
        filename: Filename to write to.
    '''
    pattern = library[top]

    # Polygonize and flatten pattern
    pattern.polygonize().flatten(library)

    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)
    else:
        bounds_min, bounds_max = bounds

    viewbox = numpy.hstack((bounds_min - 1, (bounds_max - bounds_min) + 2))
    viewbox_string = '{:g} {:g} {:g} {:g}'.format(*viewbox)

    # Create file
    svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string)

    # Draw bounding box
    slab_edge = [[bounds_min[0] - 1, bounds_max[1] + 1],
                 [bounds_max[0] + 1, bounds_max[1] + 1],
                 [bounds_max[0] + 1, bounds_min[1] - 1],
                 [bounds_min[0] - 1, bounds_min[1] - 1]]
    path_spec = poly2path(slab_edge)

    # Draw polygons with reversed vertex order
    for _layer, shapes in pattern.shapes.items():
        for shape in shapes:
            for polygon in shape.to_polygons():
                path_spec += poly2path(polygon.vertices[::-1] + polygon.offset)

    svg.add(svg.path(d=path_spec, fill='blue', stroke='red'))
    svg.save()


def poly2path(vertices: ArrayLike) -> str:
    '''
    Create an SVG path string from an Nx2 list of vertices.

    Args:
        vertices: Nx2 array of vertices.

    Returns:
        SVG path-string.
    '''
    verts = numpy.asarray(vertices)
    commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1])      # noqa: UP032
    for vertex in verts[1:]:
        commands += 'L{:g},{:g}'.format(vertex[0], vertex[1])      # noqa: UP032
    commands += ' Z   '
    return commands


---
masque/file/utils.py
---
'''
Helper functions for file reading and writing
'''
from typing import IO
from collections.abc import Iterator, Mapping
import re
import pathlib
import logging
import tempfile
import shutil
from collections import defaultdict
from contextlib import contextmanager
from pprint import pformat
from itertools import chain

from .. import Pattern, PatternError, Library, LibraryError
from ..shapes import Polygon, Path


logger = logging.getLogger(__name__)


def preflight(
        lib: Library,
        sort: bool = True,
        sort_elements: bool = False,
        allow_dangling_refs: bool | None = None,
        allow_named_layers: bool = True,
        prune_empty_patterns: bool = False,
        wrap_repeated_shapes: bool = False,
        ) -> Library:
    '''
    Run a standard set of useful operations and checks, usually done immediately prior
    to writing to a file (or immediately after reading).

    Args:
        sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents.
            Default True. Useful for reproducible builds.
        sort_elements: Whether to sort the pattern contents. Requires sort=True to run.
        allow_dangling_refs: If `None` (default), warns about any refs to patterns that are not
            in the provided library. If `True`, no check is performed; if `False`, a `LibraryError`
            is raised instead.
        allow_named_layers: If `False`, raises a `PatternError` if any layer is referred to by
            a string instead of a number (or tuple).
        prune_empty_patterns: Runs `Library.prune_empty()`, recursively deleting any empty patterns.
        wrap_repeated_shapes: Runs `Library.wrap_repeated_shapes()`, turning repeated shapes into
            repeated refs containing non-repeated shapes.

    Returns:
        `lib` or an equivalent sorted library
    '''
    if sort:
        lib = Library(dict(sorted(
            (nn, pp.sort(sort_elements=sort_elements)) for nn, pp in lib.items()
            )))

    if not allow_dangling_refs:
        refs = lib.referenced_patterns()
        dangling = refs - set(lib.keys())
        if dangling:
            msg = 'Dangling refs found: ' + pformat(dangling)
            if allow_dangling_refs is None:
                logger.warning(msg)
            else:
                raise LibraryError(msg)

    if not allow_named_layers:
        named_layers: Mapping[str, set] = defaultdict(set)
        for name, pat in lib.items():
            for layer in chain(pat.shapes.keys(), pat.labels.keys()):
                if isinstance(layer, str):
                    named_layers[name].add(layer)
        named_layers = dict(named_layers)
        if named_layers:
            raise PatternError('Non-numeric layers found:' + pformat(named_layers))

    if prune_empty_patterns:
        pruned = lib.prune_empty()
        if pruned:
            logger.info(f'Preflight pruned {len(pruned)} empty patterns')
            logger.debug('Pruned: ' + pformat(pruned))
        else:
            logger.debug('Preflight found no empty patterns')

    if wrap_repeated_shapes:
        lib.wrap_repeated_shapes()

    return lib


def mangle_name(name: str) -> str:
    '''
    Sanitize a name.

    Args:
        name: Name we want to mangle.

    Returns:
        Mangled name.
    '''
    expression = re.compile(r'[^A-Za-z0-9_\?\$]')
    sanitized_name = expression.sub('_', name)
    return sanitized_name


def clean_pattern_vertices(pat: Pattern) -> Pattern:
    '''
    Given a pattern, remove any redundant vertices in its polygons and paths.
    The cleaning process completely removes any polygons with zero area or <3 vertices.

    Args:
        pat: Pattern to clean

    Returns:
        pat
    '''
    for shapes in pat.shapes.values():
        remove_inds = []
        for ii, shape in enumerate(shapes):
            if not isinstance(shape, Polygon | Path):
                continue
            try:
                shape.clean_vertices()
            except PatternError:
                remove_inds.append(ii)
        for ii in sorted(remove_inds, reverse=True):
            del shapes[ii]
    return pat


def is_gzipped(path: pathlib.Path) -> bool:
    with path.open('rb') as stream:
        magic_bytes = stream.read(2)
        return magic_bytes == b'\x1f\x8b'


@contextmanager
def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]:
    '''
    Context manager which allows you to write to a temporary file,
    and move that file into its final location only after the write
    has finished.
    '''
    path = pathlib.Path(path)
    suffixes = ''.join(path.suffixes)
    with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream:
        yield tmp_stream

    try:
        shutil.move(tmp_stream.name, path)
    finally:
        pathlib.Path(tmp_stream.name).unlink(missing_ok=True)


---
masque/shapes/__init__.py
---
'''
Shapes for use with the Pattern class, as well as the Shape abstract class from
 which they are derived.
'''

from .shape import (
    Shape as Shape,
    normalized_shape_tuple as normalized_shape_tuple,
    DEFAULT_POLY_NUM_VERTICES as DEFAULT_POLY_NUM_VERTICES,
    )

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
from .text import Text as Text
from .path import Path as Path


---
masque/shapes/arc.py
---
from typing import Any, cast
import copy
import functools

import numpy
from numpy import pi
from numpy.typing import NDArray, ArrayLike

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


@functools.total_ordering
class Arc(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.

    The radii define an ellipse; the ring is formed with radii +/- width/2.
    The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
    The start and stop angle are measured counterclockwise from the first (x) radius.
    '''
    __slots__ = (
        '_radii', '_angles', '_width', '_rotation',
        # Inherited
        '_offset', '_repetition', '_annotations',
        )

    _radii: NDArray[numpy.float64]
    ''' Two radii for defining an ellipse '''

    _rotation: float
    ''' Rotation (ccw, radians) from the x axis to the first radius '''

    _angles: NDArray[numpy.float64]
    ''' Start and stop angles (ccw, radians) for choosing an arc from the ellipse, measured from the first radius '''

    _width: float
    ''' Width of the arc '''

    # radius properties
    @property
    def radii(self) -> Any:         # mypy#3004   NDArray[numpy.float64]:
        '''
        Return the radii `[rx, ry]`
        '''
        return self._radii

    @radii.setter
    def radii(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float).flatten()
        if not val.size == 2:
            raise PatternError('Radii must have length 2')
        if not val.min() >= 0:
            raise PatternError('Radii must be non-negative')
        self._radii = val

    @property
    def radius_x(self) -> float:
        return self._radii[0]

    @radius_x.setter
    def radius_x(self, val: float) -> None:
        if not val >= 0:
            raise PatternError('Radius must be non-negative')
        self._radii[0] = val

    @property
    def radius_y(self) -> float:
        return self._radii[1]

    @radius_y.setter
    def radius_y(self, val: float) -> None:
        if not val >= 0:
            raise PatternError('Radius must be non-negative')
        self._radii[1] = val

    # arc start/stop angle properties
    @property
    def angles(self) -> Any:            # mypy#3004    NDArray[numpy.float64]:
        '''
        Return the start and stop angles `[a_start, a_stop]`.
        Angles are measured from x-axis after rotation

        Returns:
            `[a_start, a_stop]`
        '''
        return self._angles

    @angles.setter
    def angles(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float).flatten()
        if not val.size == 2:
            raise PatternError('Angles must have length 2')
        self._angles = val

    @property
    def start_angle(self) -> float:
        return self.angles[0]

    @start_angle.setter
    def start_angle(self, val: float) -> None:
        self.angles = (val, self.angles[1])

    @property
    def stop_angle(self) -> float:
        return self.angles[1]

    @stop_angle.setter
    def stop_angle(self, val: float) -> None:
        self.angles = (self.angles[0], val)

    # Rotation property
    @property
    def rotation(self) -> float:
        '''
        Rotation of radius_x from x_axis, counterclockwise, in radians. Stored mod 2*pi

        Returns:
            rotation counterclockwise in radians
        '''
        return self._rotation

    @rotation.setter
    def rotation(self, val: float) -> None:
        if not is_scalar(val):
            raise PatternError('Rotation must be a scalar')
        self._rotation = val % (2 * pi)

    # Width
    @property
    def width(self) -> float:
        '''
        Width of the arc (difference between inner and outer radii)

        Returns:
            width
        '''
        return self._width

    @width.setter
    def width(self, val: float) -> None:
        if not is_scalar(val):
            raise PatternError('Width must be a scalar')
        if not val > 0:
            raise PatternError('Width must be positive')
        self._width = val

    def __init__(
            self,
            radii: ArrayLike,
            angles: ArrayLike,
            width: float,
            *,
            offset: ArrayLike = (0.0, 0.0),
            rotation: float = 0,
            repetition: Repetition | None = None,
            annotations: annotations_t = None,
            raw: bool = False,
            ) -> None:
        if raw:
            assert isinstance(radii, numpy.ndarray)
            assert isinstance(angles, numpy.ndarray)
            assert isinstance(offset, numpy.ndarray)
            self._radii = radii
            self._angles = angles
            self._width = width
            self._offset = offset
            self._rotation = rotation
            self._repetition = repetition
            self._annotations = annotations
        else:
            self.radii = radii
            self.angles = angles
            self.width = width
            self.offset = offset
            self.rotation = rotation
            self.repetition = repetition
            self.annotations = annotations

    def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
        memo = {} if memo is None else memo
        new = copy.copy(self)
        new._offset = self._offset.copy()
        new._radii = self._radii.copy()
        new._angles = self._angles.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.radii, other.radii)
            and numpy.array_equal(self.angles, other.angles)
            and self.width == other.width
            and self.rotation == other.rotation
            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('Arc', other)
        if self.width != other.width:
            return self.width < other.width
        if not numpy.array_equal(self.radii, other.radii):
            return tuple(self.radii) < tuple(other.radii)
        if not numpy.array_equal(self.angles, other.angles):
            return tuple(self.angles) < tuple(other.angles)
        if not numpy.array_equal(self.offset, other.offset):
            return tuple(self.offset) < tuple(other.offset)
        if self.rotation != other.rotation:
            return self.rotation < other.rotation
        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 = DEFAULT_POLY_NUM_VERTICES,
            max_arclen: float | None = None,
            ) -> list[Polygon]:
        if (num_vertices is None) and (max_arclen is None):
            raise PatternError('Max number of points and arclength left unspecified'
                               + ' (default was also overridden)')

        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())

        # Approximate perimeter via numerical integration

        #perimeter1 = numpy.trapz(numpy.sqrt(r0sin * r0sin + r1cos * r1cos), dx=dt)
        #from scipy.special import ellipeinc
        #m = 1 - (r1 / r0) ** 2
        #t1 = ellipeinc(a1 - pi / 2, m)
        #t0 = ellipeinc(a0 - pi / 2, m)
        #perimeter2 = r0 * (t1 - t0)

        def get_arclens(n_pts: int, a0: float, a1: float, dr: float) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
            ''' Get `n_pts` arclengths '''
            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(tt) * (arc_dl[1:] + arc_dl[:-1]) / 2
            arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2
            return arc_lengths, tt

        wh = self.width / 2.0
        if num_vertices is not None:
            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 = -wh if inner else wh

            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)
            start = 1
            while start < arc_lengths.size:
                next_to_keep = start + numpy.where(removable)[0][-1]    # TODO: any chance we haven't sampled finely enough?
                keep.append(next_to_keep)
                removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
                start = next_to_keep + 1
            if keep[-1] != thetas.size - 1:
                keep.append(thetas.size - 1)

            thetas = thetas[keep]
            if inner:
                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:
            thetas_inner = get_thetas(inner=True)
        thetas_outer = get_thetas(inner=False)

        sin_th_i, cos_th_i = (numpy.sin(thetas_inner), numpy.cos(thetas_inner))
        sin_th_o, cos_th_o = (numpy.sin(thetas_outer), numpy.cos(thetas_outer))

        xs1 = (r0 + wh) * cos_th_o
        ys1 = (r1 + wh) * sin_th_o
        xs2 = (r0 - wh) * cos_th_i
        ys2 = (r1 - wh) * sin_th_i

        xs = numpy.hstack((xs1, xs2))
        ys = numpy.hstack((ys1, ys2))
        xys = numpy.vstack((xs, ys)).T

        poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
        return [poly]

    def get_bounds_single(self) -> NDArray[numpy.float64]:
        '''
        Equation for rotated ellipse is
            `x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)`
            `y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)`
          where `t` is our parameter.

        Differentiating and solving for 0 slope wrt. `t`, we find
            `tan(t) = -+ b/a cot(phi)`
          where -+ is for x, y cases, so that's where the extrema are.

        If the extrema are innaccessible due to arc constraints, check the arc endpoints instead.
        '''
        a_ranges = cast('_array2x2_t', self._angles_to_parameters())

        mins = []
        maxs = []
        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

            if rx == 0 or ry == 0:
                # Single point, at origin
                mins.append([0, 0])
                maxs.append([0, 0])
                continue

            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(aa)
            cos_a = numpy.cos(aa)

            # Cutoff angles
            xpt = (-self.rotation) % (2 * pi) + a0_offset
            ypt = (pi / 2 - self.rotation) % (2 * pi) + a0_offset
            xnt = (xpt - pi) % (2 * pi) + a0_offset
            ynt = (ypt - pi) % (2 * pi) + a0_offset

            # Points along coordinate axes
            rx2_inv = 1 / (rx * rx)
            ry2_inv = 1 / (ry * ry)
            xr = numpy.abs(cos_r * cos_r * rx2_inv + sin_r * sin_r * ry2_inv) ** -0.5
            yr = numpy.abs(-sin_r * -sin_r * rx2_inv + cos_r * cos_r * ry2_inv) ** -0.5

            # Arc endpoints
            xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
            yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)

            # If our arc subtends a coordinate axis, use the extremum along that axis
            if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
                xp = xr

            if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
                xn = -xr

            if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
                yp = yr

            if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
                yn = -yr

            mins.append([xn, yn])
            maxs.append([xp, yp])
        return numpy.vstack((numpy.min(mins, axis=0) + self.offset,
                             numpy.max(maxs, axis=0) + self.offset))

    def rotate(self, theta: float) -> 'Arc':
        self.rotation += theta
        return self

    def mirror(self, axis: int = 0) -> 'Arc':
        self.offset[axis - 1] *= -1
        self.rotation *= -1
        self.rotation += axis * pi
        self.angles *= -1
        return self

    def scale_by(self, c: float) -> 'Arc':
        self.radii *= c
        self.width *= c
        return self

    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
        if self.radius_x < self.radius_y:
            radii = self.radii / self.radius_x
            scale = self.radius_x
            rotation = self.rotation
            angles = self.angles
        else:  # rotate by 90 degrees and swap radii
            radii = self.radii[::-1] / self.radius_y
            scale = self.radius_y
            rotation = self.rotation + pi / 2
            angles = self.angles - pi / 2

        delta_angle = angles[1] - angles[0]
        start_angle = angles[0] % (2 * pi)
        if start_angle >= pi:
            start_angle -= pi
            rotation += pi

        angles = (start_angle, start_angle + delta_angle)
        rotation %= 2 * pi
        width = self.width

        return ((type(self), radii, angles, width / norm_value),
                (self.offset, scale / norm_value, rotation, False),
                lambda: Arc(
                    radii=radii * norm_value,
                    angles=angles,
                    width=width * norm_value,
                    ))

    def get_cap_edges(self) -> NDArray[numpy.float64]:
        '''
        Returns:
            ```
            [[[x0, y0], [x1, y1]],   array of 4 points, specifying the two cuts which
             [[x2, y2], [x3, y3]]],    would create this arc from its corresponding ellipse.
            ```
        '''
        a_ranges = cast('_array2x2_t', self._angles_to_parameters())

        mins = []
        maxs = []
        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(aa)
            cos_a = numpy.cos(aa)

            # arc endpoints
            xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
            yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)

            mins.append([xn, yn])
            maxs.append([xp, yp])
        return numpy.array([mins, maxs]) + self.offset

    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]]`
        '''
        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(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

            aa.append((a0, a1))
        return numpy.array(aa, dtype=float)

    def __repr__(self) -> str:
        angles = f' a°{numpy.rad2deg(self.angles)}'
        rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
        return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'

_array2x2_t = tuple[tuple[float, float], tuple[float, float]]


---
masque/shapes/circle.py
---
from typing import Any, cast
import copy
import functools

import numpy
from numpy import pi
from numpy.typing import NDArray, ArrayLike

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


@functools.total_ordering
class Circle(Shape):
    '''
    A circle, which has a position and radius.
    '''
    __slots__ = (
        '_radius',
        # Inherited
        '_offset', '_repetition', '_annotations',
        )

    _radius: float
    ''' Circle radius '''

    # radius property
    @property
    def radius(self) -> float:
        '''
        Circle's radius (float, >= 0)
        '''
        return self._radius

    @radius.setter
    def radius(self, val: float) -> None:
        if not is_scalar(val):
            raise PatternError('Radius must be a scalar')
        if not val >= 0:
            raise PatternError('Radius must be non-negative')
        self._radius = val

    def __init__(
            self,
            radius: float,
            *,
            offset: ArrayLike = (0.0, 0.0),
            repetition: Repetition | None = None,
            annotations: annotations_t = None,
            raw: bool = False,
            ) -> None:
        if raw:
            assert isinstance(offset, numpy.ndarray)
            self._radius = radius
            self._offset = offset
            self._repetition = repetition
            self._annotations = annotations
        else:
            self.radius = radius
            self.offset = offset
            self.repetition = repetition
            self.annotations = annotations

    def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
        memo = {} if memo is None else memo
        new = copy.copy(self)
        new._offset = self._offset.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 self.radius == other.radius
            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('Circle', other)
        if not self.radius == other.radius:
            return self.radius < other.radius
        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 = DEFAULT_POLY_NUM_VERTICES,
            max_arclen: float | None = None,
            ) -> list[Polygon]:
        if (num_vertices is None) and (max_arclen is None):
            raise PatternError('Number of points and arclength left '
                               'unspecified (default was also overridden)')

        n: list[float] = []
        if num_vertices is not None:
            n += [num_vertices]
        if max_arclen is not None:
            n += [2 * pi * self.radius / max_arclen]
        num_vertices = int(round(max(n)))
        thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
        xs = numpy.cos(thetas) * self.radius
        ys = numpy.sin(thetas) * self.radius
        xys = numpy.vstack((xs, ys)).T

        return [Polygon(xys, offset=self.offset)]

    def get_bounds_single(self) -> NDArray[numpy.float64]:
        return numpy.vstack((self.offset - self.radius,
                             self.offset + self.radius))

    def rotate(self, theta: float) -> 'Circle':      # noqa: ARG002  (theta unused)
        return self

    def mirror(self, axis: int = 0) -> 'Circle':     # noqa: ARG002  (axis unused)
        self.offset *= -1
        return self

    def scale_by(self, c: float) -> 'Circle':
        self.radius *= c
        return self

    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
        rotation = 0.0
        magnitude = self.radius / norm_value
        return ((type(self),),
                (self.offset, magnitude, rotation, False),
                lambda: Circle(radius=norm_value))

    def __repr__(self) -> str:
        return f'<Circle o{self.offset} r{self.radius:g}>'


---
masque/shapes/ellipse.py
---
from typing import Any, Self, cast
import copy
import math
import functools

import numpy
from numpy import pi
from numpy.typing import ArrayLike, NDArray

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


@functools.total_ordering
class Ellipse(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.
    '''
    __slots__ = (
        '_radii', '_rotation',
        # Inherited
        '_offset', '_repetition', '_annotations',
        )

    _radii: NDArray[numpy.float64]
    ''' Ellipse radii '''

    _rotation: float
    ''' Angle from x-axis to first radius (ccw, radians) '''

    # radius properties
    @property
    def radii(self) -> Any:         # mypy#3004  NDArray[numpy.float64]:
        '''
        Return the radii `[rx, ry]`
        '''
        return self._radii

    @radii.setter
    def radii(self, val: ArrayLike) -> None:
        val = numpy.array(val).flatten()
        if not val.size == 2:
            raise PatternError('Radii must have length 2')
        if not val.min() >= 0:
            raise PatternError('Radii must be non-negative')
        self._radii = val

    @property
    def radius_x(self) -> float:
        return self.radii[0]

    @radius_x.setter
    def radius_x(self, val: float) -> None:
        if not val >= 0:
            raise PatternError('Radius must be non-negative')
        self.radii[0] = val

    @property
    def radius_y(self) -> float:
        return self.radii[1]

    @radius_y.setter
    def radius_y(self, val: float) -> None:
        if not val >= 0:
            raise PatternError('Radius must be non-negative')
        self.radii[1] = val

    # Rotation property
    @property
    def rotation(self) -> float:
        '''
        Rotation of rx from the x axis. Uses the interval [0, pi) in radians (counterclockwise
         is positive)

        Returns:
            counterclockwise rotation in radians
        '''
        return self._rotation

    @rotation.setter
    def rotation(self, val: float) -> None:
        if not is_scalar(val):
            raise PatternError('Rotation must be a scalar')
        self._rotation = val % pi

    def __init__(
            self,
            radii: ArrayLike,
            *,
            offset: ArrayLike = (0.0, 0.0),
            rotation: float = 0,
            repetition: Repetition | None = None,
            annotations: annotations_t = None,
            raw: bool = False,
            ) -> None:
        if raw:
            assert isinstance(radii, numpy.ndarray)
            assert isinstance(offset, numpy.ndarray)
            self._radii = radii
            self._offset = offset
            self._rotation = rotation
            self._repetition = repetition
            self._annotations = annotations
        else:
            self.radii = radii
            self.offset = offset
            self.rotation = rotation
            self.repetition = repetition
            self.annotations = annotations

    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._radii = self._radii.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.radii, other.radii)
            and self.rotation == other.rotation
            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('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):
            return tuple(self.offset) < tuple(other.offset)
        if self.rotation != other.rotation:
            return self.rotation < other.rotation
        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 = DEFAULT_POLY_NUM_VERTICES,
            max_arclen: float | None = None,
            ) -> list[Polygon]:
        if (num_vertices is None) and (max_arclen is None):
            raise PatternError('Number of points and arclength left unspecified'
                               ' (default was also overridden)')

        r0, r1 = self.radii

        # Approximate perimeter
        # Ramanujan, S., "Modular Equations and Approximations to ,"
        #  Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372
        h = ((r1 - r0) / (r1 + r0)) ** 2
        perimeter = pi * (r1 + r0) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))

        n = []
        if num_vertices is not None:
            n += [num_vertices]
        if max_arclen is not None:
            n += [perimeter / max_arclen]
        num_vertices = int(round(max(n)))
        thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)

        sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
        xs = r0 * cos_th
        ys = r1 * sin_th
        xys = numpy.vstack((xs, ys)).T

        poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
        return [poly]

    def get_bounds_single(self) -> NDArray[numpy.float64]:
        rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii)
        return numpy.vstack((self.offset - rot_radii[0],
                             self.offset + rot_radii[1]))

    def rotate(self, theta: float) -> Self:
        self.rotation += theta
        return self

    def mirror(self, axis: int = 0) -> Self:
        self.offset[axis - 1] *= -1
        self.rotation *= -1
        self.rotation += axis * pi
        return self

    def scale_by(self, c: float) -> Self:
        self.radii *= c
        return self

    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
        if self.radius_x < self.radius_y:
            radii = self.radii / self.radius_x
            scale = self.radius_x
            angle = self.rotation
        else:
            radii = self.radii[::-1] / self.radius_y
            scale = self.radius_y
            angle = (self.rotation + pi / 2) % pi
        return ((type(self), radii),
                (self.offset, scale / norm_value, angle, False),
                lambda: Ellipse(radii=radii * norm_value))

    def __repr__(self) -> str:
        rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
        return f'<Ellipse o{self.offset} r{self.radii}{rotation}>'


---
masque/shapes/path.py
---
from typing import Any, cast
from collections.abc import Sequence
import copy
import functools
from enum import Enum

import numpy
from numpy import pi, inf
from numpy.typing import NDArray, ArrayLike

from . import Shape, normalized_shape_tuple, Polygon, Circle
from ..error import PatternError
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


@functools.total_ordering
class PathCap(Enum):
    Flush = 0       # Path ends at final vertices
    Circle = 1      # Path extends past final vertices with a semicircle of radius width/2
    Square = 2      # Path extends past final vertices with a width-by-width/2 rectangle
    SquareCustom = 4  # Path extends past final vertices with a rectangle of length
#                     #     defined by path.cap_extensions

    def __lt__(self, other: Any) -> bool:
        return self.value == other.value


@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.

    Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates.

    A normalized_form(...) is available, but can be quite slow with lots of vertices.
    '''
    __slots__ = (
        '_vertices', '_width', '_cap', '_cap_extensions',
        # Inherited
        '_offset', '_repetition', '_annotations',
        )
    _vertices: NDArray[numpy.float64]
    _width: float
    _cap: PathCap
    _cap_extensions: NDArray[numpy.float64] | None

    Cap = PathCap

    # width property
    @property
    def width(self) -> float:
        '''
        Path width (float, >= 0)
        '''
        return self._width

    @width.setter
    def width(self, val: float) -> None:
        if not is_scalar(val):
            raise PatternError('Width must be a scalar')
        if not val >= 0:
            raise PatternError('Width must be non-negative')
        self._width = val

    # cap property
    @property
    def cap(self) -> PathCap:
        '''
        Path end-cap

        Note that `cap_extensions` will be reset to default values if
         `cap` is changed away from `PathCap.SquareCustom`.
        '''
        return self._cap

    @cap.setter
    def cap(self, val: PathCap) -> None:
        self._cap = PathCap(val)
        if self.cap != PathCap.SquareCustom:
            self.cap_extensions = None
        elif self.cap_extensions is None:
            # just got set to SquareCustom
            self.cap_extensions = numpy.zeros(2)

    # cap_extensions property
    @property
    def cap_extensions(self) -> Any | None:  # mypy#3004  NDArray[numpy.float64]]:
        '''
        Path end-cap extension

        Note that `cap_extensions` will be reset to default values if
         `cap` is changed away from `PathCap.SquareCustom`.

        Returns:
            2-element ndarray or `None`
        '''
        return self._cap_extensions

    @cap_extensions.setter
    def cap_extensions(self, vals: ArrayLike | None) -> None:
        custom_caps = (PathCap.SquareCustom,)
        if self.cap in custom_caps:
            if vals is None:
                raise PatternError('Tried to set cap extensions to None on path with custom cap type')
            self._cap_extensions = numpy.array(vals, dtype=float)
        else:
            if vals is not None:
                raise PatternError('Tried to set custom cap extensions on path with non-custom cap type')
            self._cap_extensions = vals

    # vertices property
    @property
    def vertices(self) -> Any:  # mypy#3004  NDArray[numpy.float64]]:
        '''
        Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`

        When setting, note that a copy of the provided vertices will be made.
        '''
        return self._vertices

    @vertices.setter
    def vertices(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float)
        if len(val.shape) < 2 or val.shape[1] != 2:
            raise PatternError('Vertices must be an Nx2 array')
        if val.shape[0] < 2:
            raise PatternError('Must have at least 2 vertices (Nx2 where N>1)')
        self._vertices = val

    # xs property
    @property
    def xs(self) -> NDArray[numpy.float64]:
        '''
        All vertex x coords as a 1D ndarray
        '''
        return self.vertices[:, 0]

    @xs.setter
    def xs(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float).flatten()
        if val.size != self.vertices.shape[0]:
            raise PatternError('Wrong number of vertices')
        self.vertices[:, 0] = val

    # ys property
    @property
    def ys(self) -> NDArray[numpy.float64]:
        '''
        All vertex y coords as a 1D ndarray
        '''
        return self.vertices[:, 1]

    @ys.setter
    def ys(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float).flatten()
        if val.size != self.vertices.shape[0]:
            raise PatternError('Wrong number of vertices')
        self.vertices[:, 1] = val

    def __init__(
            self,
            vertices: ArrayLike,
            width: float = 0.0,
            *,
            cap: PathCap = PathCap.Flush,
            cap_extensions: ArrayLike | None = None,
            offset: ArrayLike = (0.0, 0.0),
            rotation: float = 0,
            repetition: Repetition | None = None,
            annotations: annotations_t = None,
            raw: bool = False,
            ) -> None:
        self._cap_extensions = None     # Since .cap setter might access it

        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
            self._cap = cap
            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)

    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)
        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.vertices, other.vertices)
            and self.width == other.width
            and self.cap == other.cap
            and numpy.array_equal(self.cap_extensions, other.cap_extensions)        # type: ignore
            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('Path', other)
        if self.width != other.width:
            return self.width < other.width
        if self.cap != other.cap:
            return self.cap < other.cap
        if not numpy.array_equal(self.cap_extensions, other.cap_extensions):        # type: ignore
            if other.cap_extensions is None:
                return False
            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)

    @staticmethod
    def travel(
            travel_pairs: Sequence[tuple[float, float]],
            width: float = 0.0,
            cap: PathCap = PathCap.Flush,
            cap_extensions: tuple[float, float] | None = None,
            offset: ArrayLike = (0.0, 0.0),
            rotation: float = 0,
            ) -> 'Path':
        '''
        Build a path by specifying the turn angles and travel distances
          rather than setting the distances directly.

        Args:
            travel_pairs: A list of (angle, distance) pairs that define
                the path. Angles are counterclockwise, in radians, and are relative
                to the previous segment's direction (the initial angle is relative
                to the +x axis).
            width: Path width, default `0`
            cap: End-cap type, default `Path.Cap.Flush` (no end-cap)
            cap_extensions: End-cap extension distances, when using `Path.Cap.CustomSquare`.
                Default `(0, 0)` or `None`, depending on cap type
            offset: Offset, default `(0, 0)`
            rotation: Rotation counterclockwise, in radians. Default `0`

        Returns:
            The resulting Path object
        '''
        # TODO: Path.travel() needs testing
        direction = numpy.array([1, 0])

        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)

        return Path(vertices=verts, width=width, cap=cap, cap_extensions=cap_extensions,
                    offset=offset, rotation=rotation)

    def to_polygons(
            self,
            num_vertices: int | None = None,
            max_arclen: float | None = None,
            ) -> list['Polygon']:
        extensions = self._calculate_cap_extensions()

        v = remove_colinear_vertices(self.vertices, closed_path=False)
        dv = numpy.diff(v, axis=0)
        dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None]

        if self.width == 0:
            verts = numpy.vstack((v, v[::-1]))
            return [Polygon(offset=self.offset, vertices=verts)]

        perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2

        # add extensions
        if (extensions != 0).any():
            v[0] -= dvdir[0] * extensions[0]
            v[-1] += dvdir[-1] * extensions[1]
            dv = numpy.diff(v, axis=0)      # recalculate dv; dvdir and perp should stay the same

        # Find intersections of expanded sides
        As = numpy.stack((dv[:-1], -dv[1:]), axis=2)
        bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
        ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]

        rp = numpy.linalg.solve(As, bs[:, :, 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]

        towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0  # path bends towards previous perp?
#       straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0     # path is straight
        acute = (dv[1:] * dv[:-1]).sum(axis=1) < 0           # angle is acute?

        # Build vertices
        o0 = [v[0] + perp[0]]
        o1 = [v[0] - perp[0]]
        for i in range(dv.shape[0] - 1):
            if towards_perp[i]:
                o0.append(intersection_p[i])
                if acute[i]:
                    # Opposite is >270
                    pt0 = v[i + 1] - perp[i + 0] + dvdir[i + 0] * self.width / 2
                    pt1 = v[i + 1] - perp[i + 1] - dvdir[i + 1] * self.width / 2
                    o1 += [pt0, pt1]
                else:
                    o1.append(intersection_n[i])
            else:
                o1.append(intersection_n[i])
                if acute[i]:
                    # > 270
                    pt0 = v[i + 1] + perp[i + 0] + dvdir[i + 0] * self.width / 2
                    pt1 = v[i + 1] + perp[i + 1] - dvdir[i + 1] * self.width / 2
                    o0 += [pt0, pt1]
                else:
                    o0.append(intersection_p[i])
        o0.append(v[-1] + perp[-1])
        o1.append(v[-1] - perp[-1])
        verts = numpy.vstack((o0, o1[::-1]))

        polys = [Polygon(offset=self.offset, vertices=verts)]

        if self.cap == PathCap.Circle:
            #for vert in v:         # not sure if every vertex, or just ends?
            for vert in [v[0], v[-1]]:
                circ = Circle(offset=vert, radius=self.width / 2)
                polys += circ.to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)

        return polys

    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))
        elif self.cap in (
                PathCap.Flush,
                PathCap.Square,
                PathCap.SquareCustom,
                ):
            bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
            polys = self.to_polygons()
            for poly in polys:
                poly_bounds = poly.get_bounds_single_nonempty()
                bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
                bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
        else:
            raise PatternError(f'get_bounds_single() not implemented for endcaps: {self.cap}')

        return bounds

    def rotate(self, theta: float) -> 'Path':
        if theta != 0:
            self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
        return self

    def mirror(self, axis: int = 0) -> 'Path':
        self.vertices[:, axis - 1] *= -1
        return self

    def scale_by(self, c: float) -> 'Path':
        self.vertices *= c
        self.width *= c
        return self

    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
        # Note: this function is going to be pretty slow for many-vertexed paths, relative to
        #   other shapes
        offset = self.vertices.mean(axis=0) + self.offset
        zeroed_vertices = self.vertices - offset

        scale = zeroed_vertices.std()
        normed_vertices = zeroed_vertices / scale

        _, _, vertex_axis = numpy.linalg.svd(zeroed_vertices)
        rotation = numpy.arctan2(vertex_axis[0][1], vertex_axis[0][0]) % (2 * pi)
        rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
                                        for v in normed_vertices])

        # Reorder the vertices so that the one with lowest x, then y, comes first.
        x_min = rotated_vertices[:, 0].argmin()
        if not is_scalar(x_min):
            y_min = rotated_vertices[x_min, 1].argmin()
            x_min = cast('Sequence', x_min)[y_min]
        reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)

        width0 = self.width / norm_value

        return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap),
                (offset, scale / norm_value, rotation, False),
                lambda: Path(
                    reordered_vertices * norm_value,
                    width=self.width * norm_value,
                    cap=self.cap,
                    ))

    def clean_vertices(self) -> 'Path':
        '''
        Removes duplicate, co-linear and otherwise redundant vertices.

        Returns:
            self
        '''
        self.remove_colinear_vertices()
        return self

    def remove_duplicate_vertices(self) -> 'Path':
        '''
        Removes all consecutive duplicate (repeated) vertices.

        Returns:
            self
        '''
        self.vertices = remove_duplicate_vertices(self.vertices, closed_path=False)
        return self

    def remove_colinear_vertices(self) -> 'Path':
        '''
        Removes consecutive co-linear vertices.

        Returns:
            self
        '''
        self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
        return self

    def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:
        if self.cap == PathCap.Square:
            extensions = numpy.full(2, self.width / 2)
        elif self.cap == PathCap.SquareCustom:
            assert isinstance(self.cap_extensions, numpy.ndarray)
            extensions = self.cap_extensions
        else:
            # Flush or Circle
            extensions = numpy.zeros(2)
        return extensions

    def __repr__(self) -> str:
        centroid = self.offset + self.vertices.mean(axis=0)
        return f'<Path centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}>'


---
masque/shapes/poly_collection.py
---
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'<PolyCollection centroid {centroid} p{len(self.vertex_offsets)}>'


---
masque/shapes/polygon.py
---
from typing import Any, cast, TYPE_CHECKING
import copy
import functools

import numpy
from numpy import pi
from numpy.typing import NDArray, ArrayLike

from . import Shape, normalized_shape_tuple
from ..error import PatternError
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):
    '''
    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` creates a copy of the
      passed vertex coordinates.

    A `normalized_form(...)` is available, but can be quite slow with lots of vertices.
    '''
    __slots__ = (
        '_vertices',
        # Inherited
        '_offset', '_repetition', '_annotations',
        )

    _vertices: NDArray[numpy.float64]
    ''' Nx2 ndarray of vertices `[[x0, y0], [x1, y1], ...]` '''

    # vertices property
    @property
    def vertices(self) -> Any:        # mypy#3004   NDArray[numpy.float64]:
        '''
        Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)

        When setting, note that a copy of the provided vertices will be made,
        '''
        return self._vertices

    @vertices.setter
    def vertices(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float)
        if len(val.shape) < 2 or val.shape[1] != 2:
            raise PatternError('Vertices must be an Nx2 array')
        if val.shape[0] < 3:
            raise PatternError('Must have at least 3 vertices (Nx2 where N>2)')
        self._vertices = val

    # xs property
    @property
    def xs(self) -> NDArray[numpy.float64]:
        '''
        All vertex x coords as a 1D ndarray
        '''
        return self.vertices[:, 0]

    @xs.setter
    def xs(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float).flatten()
        if val.size != self.vertices.shape[0]:
            raise PatternError('Wrong number of vertices')
        self.vertices[:, 0] = val

    # ys property
    @property
    def ys(self) -> NDArray[numpy.float64]:
        '''
        All vertex y coords as a 1D ndarray
        '''
        return self.vertices[:, 1]

    @ys.setter
    def ys(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float).flatten()
        if val.size != self.vertices.shape[0]:
            raise PatternError('Wrong number of vertices')
        self.vertices[:, 1] = val

    def __init__(
            self,
            vertices: ArrayLike,
            *,
            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(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 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

    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)
            )

    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('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]
            eq_lt = self.vertices[:min_len] < other.vertices[:min_len]
            eq_lt_masked = eq_lt[eq_mask]
            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)

    @staticmethod
    def square(
            side_length: float,
            *,
            rotation: float = 0.0,
            offset: ArrayLike = (0.0, 0.0),
            repetition: Repetition | None = None,
            ) -> 'Polygon':
        '''
        Draw a square given side_length, centered on the origin.

        Args:
            side_length: Length of one side
            rotation: Rotation counterclockwise, in radians
            offset: Offset, default `(0, 0)`
            repetition: `Repetition` object, default `None`

        Returns:
            A Polygon object containing the requested square
        '''
        norm_square = numpy.array([[-1, -1],
                                   [-1, +1],
                                   [+1, +1],
                                   [+1, -1]], dtype=float)
        vertices = 0.5 * side_length * norm_square
        poly = Polygon(vertices, offset=offset, repetition=repetition)
        poly.rotate(rotation)
        return poly

    @staticmethod
    def rectangle(
            lx: float,
            ly: float,
            *,
            rotation: float = 0,
            offset: ArrayLike = (0.0, 0.0),
            repetition: Repetition | None = None,
            ) -> 'Polygon':
        '''
        Draw a rectangle with side lengths lx and ly, centered on the origin.

        Args:
            lx: Length along x (before rotation)
            ly: Length along y (before rotation)
            rotation: Rotation counterclockwise, in radians
            offset: Offset, default `(0, 0)`
            repetition: `Repetition` object, default `None`

        Returns:
            A Polygon object containing the requested rectangle
        '''
        vertices = 0.5 * numpy.array([[-lx, -ly],
                                      [-lx, +ly],
                                      [+lx, +ly],
                                      [+lx, -ly]], dtype=float)
        poly = Polygon(vertices, offset=offset, repetition=repetition)
        poly.rotate(rotation)
        return poly

    @staticmethod
    def rect(
            *,
            xmin: float | None = None,
            xctr: float | None = None,
            xmax: float | None = None,
            lx: float | None = None,
            ymin: float | None = None,
            yctr: float | None = None,
            ymax: float | None = None,
            ly: float | None = None,
            repetition: Repetition | None = None,
            ) -> 'Polygon':
        '''
        Draw a rectangle by specifying side/center positions.

        Must provide 2 of (xmin, xctr, xmax, lx),
        and 2 of (ymin, yctr, ymax, ly).

        Args:
            xmin: Minimum x coordinate
            xctr: Center x coordinate
            xmax: Maximum x coordinate
            lx: Length along x direction
            ymin: Minimum y coordinate
            yctr: Center y coordinate
            ymax: Maximum y coordinate
            ly: Length along y direction
            repetition: `Repetition` object, default `None`

        Returns:
            A Polygon object containing the requested rectangle
        '''
        if lx is None:
            if xctr is None:
                assert xmin is not None
                assert xmax is not None
                xctr = 0.5 * (xmax + xmin)
                lx = xmax - xmin
            elif xmax is None:
                assert xmin is not None
                assert xctr is not None
                lx = 2 * (xctr - xmin)
            elif xmin is None:
                assert xctr is not None
                assert xmax is not None
                lx = 2 * (xmax - xctr)
            else:
                raise PatternError('Two of xmin, xctr, xmax, lx must be None!')
        else:                        # noqa: PLR5501
            if xctr is not None:
                pass
            elif xmax is None:
                assert xmin is not None
                assert lx is not None
                xctr = xmin + 0.5 * lx
            elif xmin is None:
                assert xmax is not None
                assert lx is not None
                xctr = xmax - 0.5 * lx
            else:
                raise PatternError('Two of xmin, xctr, xmax, lx must be None!')

        if ly is None:
            if yctr is None:
                assert ymin is not None
                assert ymax is not None
                yctr = 0.5 * (ymax + ymin)
                ly = ymax - ymin
            elif ymax is None:
                assert ymin is not None
                assert yctr is not None
                ly = 2 * (yctr - ymin)
            elif ymin is None:
                assert yctr is not None
                assert ymax is not None
                ly = 2 * (ymax - yctr)
            else:
                raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
        else:                        # noqa: PLR5501
            if yctr is not None:
                pass
            elif ymax is None:
                assert ymin is not None
                assert ly is not None
                yctr = ymin + 0.5 * ly
            elif ymin is None:
                assert ly is not None
                assert ymax is not None
                yctr = ymax - 0.5 * ly
            else:
                raise PatternError('Two of ymin, yctr, ymax, ly must be None!')

        poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition)
        return poly

    @staticmethod
    def octagon(
            *,
            side_length: float | None = None,
            inner_radius: float | None = None,
            regular: bool = True,
            center: ArrayLike = (0.0, 0.0),
            rotation: float = 0.0,
            repetition: Repetition | None = None,
            ) -> 'Polygon':
        '''
        Draw an octagon given one of (side length, inradius, circumradius).

        Args:
            side_length: Length of one side. For an irregular octagon, this
                specifies the length of the long sides.
            inner_radius: Half of distance between opposite sides. For an irregular
                octagon, this specifies the spacing between the long sides.
            regular: If `True`, all sides have the same length. If `False`,
                a "clipped square" with vertices (+-1, +-2) and (+-2, +-1)
                is generated, avoiding irrational coordinate locations and
                guaranteeing 45 degree edges.
            center: Offset, default `(0, 0)`
            rotation: Rotation counterclockwise, in radians.
                `0` results in four axis-aligned sides (the long sides of the
                irregular octagon).
            repetition: `Repetition` object, default `None`

        Returns:
            A Polygon object containing the requested octagon
        '''
        s = (1 + numpy.sqrt(2)) if regular else 2

        norm_oct = numpy.array([
            [-1, -s],
            [-s, -1],
            [-s,  1],
            [-1,  s],
            [ 1,  s],
            [ s,  1],
            [ s, -1],
            [ 1, -s]], dtype=float)

        if side_length is None:
            if inner_radius is None:
                raise PatternError('One of `side_length` or `inner_radius` must be specified.')
            side_length = 2 * inner_radius / s

        vertices = 0.5 * side_length * norm_oct
        poly = Polygon(vertices, offset=center, repetition=repetition)
        poly.rotate(rotation)
        return poly

    def to_polygons(
            self,
            num_vertices: int | None = None,      # unused  # noqa: ARG002
            max_arclen: float | None = None,      # unused  # noqa: ARG002
            ) -> list['Polygon']:
        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)))

    def rotate(self, theta: float) -> 'Polygon':
        if theta != 0:
            self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
        return self

    def mirror(self, axis: int = 0) -> 'Polygon':
        self.vertices[:, axis - 1] *= -1
        return self

    def scale_by(self, c: float) -> 'Polygon':
        self.vertices *= 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.vertices.mean(axis=0)
        zeroed_vertices = self.vertices - 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.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
                                        for v in normed_vertices])

        # Reorder the vertices so that the one with lowest x, then y, comes first.
        x_min = rotated_vertices[:, 0].argmin()
        if not is_scalar(x_min):
            y_min = rotated_vertices[x_min, 1].argmin()
            x_min = cast('Sequence', x_min)[y_min]
        reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)

        # TODO: normalize mirroring?

        return ((type(self), reordered_vertices.data.tobytes()),
                (offset, scale / norm_value, rotation, False),
                lambda: Polygon(reordered_vertices * norm_value))

    def clean_vertices(self) -> 'Polygon':
        '''
        Removes duplicate, co-linear and otherwise redundant vertices.

        Returns:
            self
        '''
        self.remove_colinear_vertices()
        return self

    def remove_duplicate_vertices(self) -> 'Polygon':
        '''
        Removes all consecutive duplicate (repeated) vertices.

        Returns:
            self
        '''
        self.vertices = remove_duplicate_vertices(self.vertices, closed_path=True)
        return self

    def remove_colinear_vertices(self) -> 'Polygon':
        '''
        Removes consecutive co-linear vertices.

        Returns:
            self
        '''
        self.vertices = remove_colinear_vertices(self.vertices, closed_path=True)
        return self

    def __repr__(self) -> str:
        centroid = self.offset + self.vertices.mean(axis=0)
        return f'<Polygon centroid {centroid} v{len(self.vertices)}>'


---
masque/shapes/shape.py
---
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from abc import ABCMeta, abstractmethod

import numpy
from numpy.typing import NDArray, ArrayLike

from ..traits import (
    Rotatable, Mirrorable, Copyable, Scalable,
    PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
    )

if TYPE_CHECKING:
    from . import Polygon


# Type definitions
normalized_shape_tuple = tuple[
    tuple,
    tuple[NDArray[numpy.float64], float, float, bool],
    Callable[[], 'Shape'],
    ]

# ## Module-wide defaults
# Default number of points per polygon for shapes
DEFAULT_POLY_NUM_VERTICES = 24


class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
            PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
    '''
    Class specifying functions common to all shapes.
    '''
    __slots__ = ()      # Children should use AutoSlots or set slots themselves

    #def __copy__(self) -> Self:
    #    cls = self.__class__
    #    new = cls.__new__(cls)
    #    for name in self.__slots__:     # type: str
    #        object.__setattr__(new, name, getattr(self, name))
    #    return new

    #
    # Methods (abstract)
    #
    @abstractmethod
    def __eq__(self, other: Any) -> bool:
        pass

    @abstractmethod
    def __lt__(self, other: 'Shape') -> bool:
        pass

    @abstractmethod
    def to_polygons(
            self,
            num_vertices: int | None = None,
            max_arclen: float | None = None,
            ) -> list['Polygon']:
        '''
        Returns a list of polygons which approximate the shape.

        Args:
            num_vertices: Number of points to use for each polygon. Can be overridden by
                  max_arclen if that results in more points. Optional, defaults to shapes'
                  internal defaults.
            max_arclen: Maximum arclength which can be approximated by a single line
                  segment. Optional, defaults to shapes' internal defaults.

        Returns:
            List of polygons equivalent to the shape
        '''
        pass

    @abstractmethod
    def normalized_form(self, norm_value: int) -> normalized_shape_tuple:
        '''
        Writes the shape in a standardized notation, with offset, scale, and rotation
         information separated out from the remaining values.

        Args:
            norm_value: This value is used to normalize lengths intrinsic to the shape;
                eg. for a circle, the returned intrinsic radius value will be (radius / norm_value), and
                the returned callable will create a `Circle(radius=norm_value, ...)`. This is useful
                when you find it important for quantities to remain in a certain range, eg. for
                GDSII where vertex locations are stored as integers.

        Returns:
            The returned information takes the form of a 3-element tuple,
              `(intrinsic, extrinsic, constructor)`. These are further broken down as:
              `intrinsic`: A tuple of basic types containing all information about the instance that
                         is not contained in 'extrinsic'. Usually, `intrinsic[0] == type(self)`.
              `extrinsic`: `([x_offset, y_offset], scale, rotation, mirror_across_x_axis)`
              `constructor`: A callable (no arguments) which returns an instance of `type(self)` with
                           internal state equivalent to `intrinsic`.
        '''
        pass

    #
    # Non-abstract methods
    #
    def manhattanize_fast(
            self,
            grid_x: ArrayLike,
            grid_y: ArrayLike,
            ) -> list['Polygon']:
        '''
        Returns a list of polygons with grid-aligned ("Manhattan") edges approximating the shape.

        This function works by
            1) Converting the shape to polygons using `.to_polygons()`
            2) Approximating each edge with an equivalent Manhattan edge
        This process results in a reasonable Manhattan representation of the shape, but is
          imprecise near non-Manhattan or off-grid corners.

        Args:
            grid_x: List of allowed x-coordinates for the Manhattanized polygon edges.
            grid_y: List of allowed y-coordinates for the Manhattanized polygon edges.

        Returns:
            List of `Polygon` objects with grid-aligned edges.
        '''
        from . import Polygon

        gx = numpy.unique(grid_x)
        gy = numpy.unique(grid_y)

        polygon_contours = []
        for polygon in self.to_polygons():
            bounds = polygon.get_bounds_single()
            if bounds is None:
                continue

            mins, maxs = bounds

            vertex_lists = []
            p_verts = polygon.vertices + polygon.offset
            for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
                dv = v_next - v

                # Find x-index bounds for the line      # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape
                gxi_range = numpy.digitize([v[0], v_next[0]], gx)
                gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1)
                gxi_max = numpy.max(gxi_range).clip(0, len(gx))

                err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
                err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])

                if err_xmin >= 0.5:
                    gxi_min += 1
                if err_xmax >= 0.5:
                    gxi_max += 1

                if abs(dv[0]) < 1e-20:
                    # Vertical line, don't calculate slope
                    xi = [gxi_min, gxi_max - 1]
                    ys = numpy.array([v[1], v_next[1]])
                    yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1)
                    err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1])
                    yi[err_y < 0.5] -= 1

                    segment = numpy.column_stack((gx[xi], gy[yi]))
                    vertex_lists.append(segment)
                    continue

                m = dv[1] / dv[0]

                def get_grid_inds(xes: ArrayLike, m: float = m, v: NDArray = v) -> NDArray[numpy.float64]:
                    ys = m * (xes - v[0]) + v[1]

                    # (inds - 1) is the index of the y-grid line below the edge's intersection with the x-grid
                    inds = numpy.digitize(ys, gy).clip(1, len(gy) - 1)

                    # err is what fraction of the cell upwards we have to go to reach our y
                    #   (can be negative at bottom edge due to clip above)
                    err = (ys - gy[inds - 1]) / (gy[inds] - gy[inds - 1])

                    # now set inds to the index of the nearest y-grid line
                    inds[err < 0.5] -= 1
                    return inds

                # Find the y indices on all x gridlines
                xs = gx[int(gxi_min):int(gxi_max)]
                inds = get_grid_inds(xs)

                # Find y-intersections for x-midpoints
                xs2 = (xs[:-1] + xs[1:]) / 2
                inds2 = get_grid_inds(xs2)

                xinds = numpy.rint(numpy.arange(gxi_min, gxi_max - 0.99, 1 / 3)).astype(numpy.int64)

                # interleave the results
                yinds = xinds.copy()
                yinds[0::3] = inds
                yinds[1::3] = inds2
                yinds[2::3] = inds2

                vlist = numpy.column_stack((gx[xinds], gy[yinds]))
                if dv[0] < 0:
                    vlist = vlist[::-1]

                vertex_lists.append(vlist)
            polygon_contours.append(numpy.vstack(vertex_lists))

        manhattan_polygons = [Polygon(vertices=contour) for contour in polygon_contours]

        return manhattan_polygons

    def manhattanize(
            self,
            grid_x: ArrayLike,
            grid_y: ArrayLike,
            ) -> list['Polygon']:
        '''
        Returns a list of polygons with grid-aligned ("Manhattan") edges approximating the shape.

        This function works by
            1) Converting the shape to polygons using `.to_polygons()`
            2) Accurately rasterizing each polygon on a grid,
                where the edges of each grid cell correspond to the allowed coordinates
            3) Thresholding the (anti-aliased) rasterized image
            4) Finding the contours which outline the filled areas in the thresholded image
        This process results in a fairly accurate Manhattan representation of the shape. Possible
          caveats include:
            a) If high accuracy is important, perform any polygonization and clipping operations
                prior to calling this function. This allows you to specify any arguments you may
                need for `.to_polygons()`, and also avoids calling `.manhattanize()` multiple times for
                the same grid location (which causes inaccuracies in the final representation).
            b) If the shape is very large or the grid very fine, memory requirements can be reduced
                by breaking the shape apart into multiple, smaller shapes.
            c) Inaccuracies in edge shape can result from Manhattanization of edges which are
                equidistant from allowed edge location.

        Implementation notes:
            i) Rasterization is performed using `float_raster`, giving a high-precision anti-aliased
                rasterized image.
            ii) To find the exact polygon edges, the thresholded rasterized image is supersampled
                  prior to calling `skimage.measure.find_contours()`, which uses marching squares
                  to find the contours. This is done because `find_contours()` performs interpolation,
                  which has to be undone in order to regain the axis-aligned contours. A targetted
                  rewrite of `find_contours()` for this specific application, or use of a different
                  boundary tracing method could remove this requirement, but for now this seems to
                  be the most performant approach.

        Args:
            grid_x: List of allowed x-coordinates for the Manhattanized polygon edges.
            grid_y: List of allowed y-coordinates for the Manhattanized polygon edges.

        Returns:
            List of `Polygon` objects with grid-aligned edges.
        '''
        from . import Polygon
        import skimage.measure      # type: ignore
        import float_raster

        grx = numpy.unique(grid_x)
        gry = numpy.unique(grid_y)

        polygon_contours = []
        for polygon in self.to_polygons():
            # Get rid of unused gridlines (anything not within 2 lines of the polygon bounds)
            bounds = polygon.get_bounds_single()
            if bounds is None:
                continue

            mins, maxs = bounds
            keep_x = numpy.logical_and(grx > mins[0], grx < maxs[0])
            keep_y = numpy.logical_and(gry > mins[1], gry < maxs[1])
            # Flood left & rightwards by 2 cells
            for kk in (keep_x, keep_y):
                for ss in (1, 2):
                    kk[ss:] += kk[:-ss]
                    kk[:-ss] += kk[ss:]
                kk[:] = kk > 0

            gx = grx[keep_x]
            gy = gry[keep_y]

            if len(gx) == 0 or len(gy) == 0:
                continue

            offset = (numpy.where(keep_x)[0][0],
                      numpy.where(keep_y)[0][0])

            rastered = float_raster.raster((polygon.vertices + polygon.offset).T, gx, gy)
            binary_rastered = (numpy.abs(rastered) >= 0.5)
            supersampled = binary_rastered.repeat(2, axis=0).repeat(2, axis=1)

            contours = skimage.measure.find_contours(supersampled, 0.5)
            polygon_contours.append((offset, contours))

        manhattan_polygons = []
        for offset_i, contours in polygon_contours:
            for contour in contours:
                # /2 deals with supersampling
                # +.5 deals with the fact that our 0-edge becomes -.5 in the super-sampled contour output
                snapped_contour = numpy.rint((contour + .5) / 2).astype(numpy.int64)
                vertices = numpy.hstack((grx[snapped_contour[:, None, 0] + offset_i[0]],
                                         gry[snapped_contour[:, None, 1] + offset_i[1]]))

                manhattan_polygons.append(Polygon(vertices=vertices))

        return manhattan_polygons


---
masque/shapes/text.py
---
from typing import Self, Any, cast
import copy
import functools

import numpy
from numpy import pi, nan
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 ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key

# Loaded on use:
# from freetype import Face
# from matplotlib.path import Path


@functools.total_ordering
class Text(RotatableImpl, Shape):
    '''
    Text (to be printed e.g. as a set of polygons).
    This is distinct from non-printed Label objects.
    '''
    __slots__ = (
        '_string', '_height', '_mirrored', 'font_path',
        # Inherited
        '_offset', '_repetition', '_annotations', '_rotation',
        )

    _string: str
    _height: float
    _mirrored: bool
    font_path: str

    # vertices property
    @property
    def string(self) -> str:
        return self._string

    @string.setter
    def string(self, val: str) -> None:
        self._string = val

    # Height property
    @property
    def height(self) -> float:
        return self._height

    @height.setter
    def height(self, val: float) -> None:
        if not is_scalar(val):
            raise PatternError('Height must be a scalar')
        self._height = val

    @property
    def mirrored(self) -> bool:     # mypy#3004, should be bool
        return self._mirrored

    @mirrored.setter
    def mirrored(self, val: bool) -> None:
        self._mirrored = bool(val)

    def __init__(
            self,
            string: str,
            height: float,
            font_path: str,
            *,
            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(offset, numpy.ndarray)
            self._offset = offset
            self._string = string
            self._height = height
            self._rotation = rotation
            self._repetition = repetition
            self._annotations = annotations
        else:
            self.offset = offset
            self.string = string
            self.height = height
            self.rotation = rotation
            self.repetition = repetition
            self.annotations = annotations
        self.font_path = font_path

    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._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 self.string == other.string
            and self.height == other.height
            and self.font_path == other.font_path
            and self.rotation == other.rotation
            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('Text', other)
        if not self.height == other.height:
            return self.height < other.height
        if not self.string == other.string:
            return self.string < other.string
        if not self.font_path == other.font_path:
            return self.font_path < other.font_path
        if not numpy.array_equal(self.offset, other.offset):
            return tuple(self.offset) < tuple(other.offset)
        if self.rotation != other.rotation:
            return self.rotation < other.rotation
        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]:
        all_polygons = []
        total_advance = 0.0
        for char in self.string:
            raw_polys, advance = get_char_as_polygons(self.font_path, char)

            # Move these polygons to the right of the previous letter
            for xys in raw_polys:
                poly = Polygon(xys)
                if self.mirrored:
                    poly.mirror()
                poly.scale_by(self.height)
                poly.offset = self.offset + [total_advance, 0]
                poly.rotate_around(self.offset, self.rotation)
                all_polygons += [poly]

            # Update the list of all polygons and how far to advance
            total_advance += advance * self.height

        return all_polygons

    def mirror(self, axis: int = 0) -> Self:
        self.mirrored = not self.mirrored
        if axis == 1:
            self.rotation += pi
        return self

    def scale_by(self, c: float) -> Self:
        self.height *= c
        return self

    def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
        rotation = self.rotation % (2 * pi)
        return ((type(self), self.string, self.font_path),
                (self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
                lambda: Text(
                    string=self.string,
                    height=self.height * norm_value,
                    font_path=self.font_path,
                    rotation=rotation,
                    ).mirror2d(across_x=self.mirrored),
                )

    def get_bounds_single(self) -> NDArray[numpy.float64]:
        # rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
        #  just convert to polygons instead
        polys = self.to_polygons()
        pbounds = numpy.full((len(polys), 2, 2), nan)
        for pp, poly in enumerate(polys):
            pbounds[pp] = poly.get_bounds_nonempty()
        bounds = numpy.vstack((
            numpy.min(pbounds[: 0, :], axis=0),
            numpy.max(pbounds[: 1, :], axis=0),
            ))

        return bounds

    def __repr__(self) -> str:
        rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
        mirrored = ' m{:d}' if self.mirrored else ''
        return f'<TextShape "{self.string}" o{self.offset} h{self.height:g}{rotation}{mirrored}>'


def get_char_as_polygons(
        font_path: str,
        char: str,
        resolution: float = 48 * 64,
        ) -> tuple[list[list[list[float]]], float]:
    from freetype import Face           # type: ignore
    from matplotlib.path import Path    # type: ignore

    '''
    Get a list of polygons representing a single character.

    The output is normalized so that the font size is 1 unit.

    Args:
        font_path: File path specifying a font loadable by freetype
        char: Character to convert to polygons
        resolution: Internal resolution setting (used for freetype
            `Face.set_font_size(resolution))`. Modify at your own peril!

    Returns:
        List of polygons `[[[x0, y0], [x1, y1], ...], ...]` and
        'advance' distance (distance from the start of this glyph to the start of the next one)
    '''
    if len(char) != 1:
        raise PatternError('get_char_as_polygons called with non-char')

    face = Face(font_path)
    face.set_char_size(resolution)
    face.load_char(char)
    slot = face.glyph
    outline = slot.outline

    start = 0
    all_verts_list = []
    all_codes = []
    for end in outline.contours:
        points = outline.points[start:end + 1]
        points.append(points[0])

        tags = outline.tags[start:end + 1]
        tags.append(tags[0])

        segments: list[list[list[float]]] = []
        for j, point in enumerate(points):
            # If we already have a segment, add this point to it
            if j > 0:
                segments[-1].append(point)

            # If not bezier control point, start next segment
            if get_bit(tags[j], 0) and j < (len(points) - 1):
                segments.append([point])

        verts = [points[0]]
        codes = [Path.MOVETO]
        for segment in segments:
            if len(segment) == 2:
                verts.extend(segment[1:])
                codes.extend([Path.LINETO])
            elif len(segment) == 3:
                verts.extend(segment[1:])
                codes.extend([Path.CURVE3, Path.CURVE3])
            else:
                verts.append(segment[1])
                codes.append(Path.CURVE3)
                for i in range(1, len(segment) - 2):
                    a, b = segment[i], segment[i + 1]
                    c = ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
                    verts.extend([c, b])
                    codes.extend([Path.CURVE3, Path.CURVE3])
                verts.append(segment[-1])
                codes.append(Path.CURVE3)
        all_verts_list.extend(verts)
        all_codes.extend(codes)
        start = end + 1

    all_verts = numpy.array(all_verts_list) / resolution

    advance = slot.advance.x / resolution

    if len(all_verts) == 0:
        polygons = []
    else:
        path = Path(all_verts, all_codes)
        path.should_simplify = False
        polygons = path.to_polygons()

    return polygons, advance


---
masque/traits/__init__.py
---
'''
Traits (mixins) and default implementations

Traits and mixins should set `__slots__ = ()` to enable use of `__slots__` in subclasses.
'''
from .positionable import (
    Positionable as Positionable,
    PositionableImpl as PositionableImpl,
    Bounded as Bounded,
    )
from .layerable import (
    Layerable as Layerable,
    LayerableImpl as LayerableImpl,
    )
from .rotatable import (
    Rotatable as Rotatable,
    RotatableImpl as RotatableImpl,
    Pivotable as Pivotable,
    PivotableImpl as PivotableImpl,
    )
from .repeatable import (
    Repeatable as Repeatable,
    RepeatableImpl as RepeatableImpl,
    )
from .scalable import (
    Scalable as Scalable,
    ScalableImpl as ScalableImpl,
    )
from .mirrorable import Mirrorable as Mirrorable
from .copyable import Copyable as Copyable
from .annotatable import (
    Annotatable as Annotatable,
    AnnotatableImpl as AnnotatableImpl,
    )


---
masque/traits/annotatable.py
---
#from types import MappingProxyType
from abc import ABCMeta, abstractmethod

from ..utils import annotations_t
from ..error import MasqueError


_empty_slots = ()     # Workaround to get mypy to ignore intentionally empty slots for superclass


class Annotatable(metaclass=ABCMeta):
    '''
    Trait class for all annotatable entities
    Annotations correspond to GDS/OASIS "properties"
    '''
    __slots__ = ()

    #
    # Properties
    #
    @property
    @abstractmethod
    def annotations(self) -> annotations_t:
        '''
        Dictionary mapping annotation names to values
        '''
        pass


class AnnotatableImpl(Annotatable, metaclass=ABCMeta):
    '''
    Simple implementation of `Annotatable`.
    '''
    __slots__ = _empty_slots

    _annotations: annotations_t
    ''' Dictionary storing annotation name/value pairs '''

    #
    # Non-abstract properties
    #
    @property
    def annotations(self) -> annotations_t:
        return self._annotations

    @annotations.setter
    def annotations(self, annotations: annotations_t) -> None:
        if not isinstance(annotations, dict) and annotations is not None:
            raise MasqueError(f'annotations expected dict or None, got {type(annotations)}')
        self._annotations = annotations


---
masque/traits/copyable.py
---
from typing import Self
import copy


class Copyable:
    '''
    Trait class which adds .copy() and .deepcopy()
    '''
    __slots__ = ()

    #
    # Non-abstract methods
    #
    def copy(self) -> Self:
        '''
        Return a shallow copy of the object.

        Returns:
            `copy.copy(self)`
        '''
        return copy.copy(self)

    def deepcopy(self) -> Self:
        '''
        Return a deep copy of the object.

        Returns:
            `copy.deepcopy(self)`
        '''
        return copy.deepcopy(self)


---
masque/traits/layerable.py
---
from typing import Self
from abc import ABCMeta, abstractmethod

from ..utils import layer_t


_empty_slots = ()     # Workaround to get mypy to ignore intentionally empty slots for superclass


class Layerable(metaclass=ABCMeta):
    '''
    Trait class for all layerable entities
    '''
    __slots__ = ()

    #
    # Properties
    #
    @property
    @abstractmethod
    def layer(self) -> layer_t:
        '''
        Layer number or name (int, tuple of ints, or string)
        '''
        pass

#    @layer.setter
#    @abstractmethod
#    def layer(self, val: layer_t):
#        pass

    #
    # Methods
    #
    @abstractmethod
    def set_layer(self, layer: layer_t) -> Self:
        '''
        Set the layer

        Args:
            layer: new value for layer

        Returns:
            self
        '''
        pass


class LayerableImpl(Layerable, metaclass=ABCMeta):
    '''
    Simple implementation of Layerable
    '''
    __slots__ = _empty_slots

    _layer: layer_t
    ''' Layer number, pair, or name '''

    #
    # Non-abstract properties
    #
    @property
    def layer(self) -> layer_t:
        return self._layer

    @layer.setter
    def layer(self, val: layer_t) -> None:
        self._layer = val

    #
    # Non-abstract methods
    #
    def set_layer(self, layer: layer_t) -> Self:
        self.layer = layer
        return self


---
masque/traits/mirrorable.py
---
from typing import Self
from abc import ABCMeta, abstractmethod


class Mirrorable(metaclass=ABCMeta):
    '''
    Trait class for all mirrorable entities
    '''
    __slots__ = ()

    @abstractmethod
    def mirror(self, axis: int = 0) -> Self:
        '''
        Mirror the entity across an axis.

        Args:
            axis: Axis to mirror across.

        Returns:
            self
        '''
        pass

    def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self:
        '''
        Optionally mirror the entity across both axes

        Args:
            axes: (mirror_across_x, mirror_across_y)

        Returns:
            self
        '''
        if across_x:
            self.mirror(0)
        if across_y:
            self.mirror(1)
        return self


#class MirrorableImpl(Mirrorable, metaclass=ABCMeta):
#    '''
#    Simple implementation of `Mirrorable`
#    '''
#    __slots__ = ()
#
#    _mirrored: NDArray[numpy.bool]
#    ''' Whether to mirror the instance across the x and/or y axes. '''
#
#    #
#    # Properties
#    #
#    # Mirrored property
#    @property
#    def mirrored(self) -> NDArray[numpy.bool]:
#        ''' Whether to mirror across the [x, y] axes, respectively '''
#        return self._mirrored
#
#    @mirrored.setter
#    def mirrored(self, val: Sequence[bool]) -> None:
#        if is_scalar(val):
#            raise MasqueError('Mirrored must be a 2-element list of booleans')
#        self._mirrored = numpy.array(val, dtype=bool)
#
#    #
#    # Methods
#    #


---
masque/traits/positionable.py
---
from typing import Self, Any
from abc import ABCMeta, abstractmethod

import numpy
from numpy.typing import NDArray, ArrayLike

from ..error import MasqueError


_empty_slots = ()     # Workaround to get mypy to ignore intentionally empty slots for superclass


class Positionable(metaclass=ABCMeta):
    '''
    Trait class for all positionable entities
    '''
    __slots__ = ()

    #
    # Properties
    #
    @property
    @abstractmethod
    def offset(self) -> NDArray[numpy.float64]:
        '''
        [x, y] offset
        '''
        pass

    @offset.setter
    @abstractmethod
    def offset(self, val: ArrayLike) -> None:
        pass

    @abstractmethod
    def set_offset(self, offset: ArrayLike) -> Self:
        '''
        Set the offset

        Args:
            offset: [x_offset, y,offset]

        Returns:
            self
        '''
        pass

    @abstractmethod
    def translate(self, offset: ArrayLike) -> Self:
        '''
        Translate the entity by the given offset

        Args:
            offset: [x_offset, y,offset]

        Returns:
            self
        '''
        pass


class PositionableImpl(Positionable, metaclass=ABCMeta):
    '''
    Simple implementation of Positionable
    '''
    __slots__ = _empty_slots

    _offset: NDArray[numpy.float64]
    ''' `[x_offset, y_offset]` '''

    #
    # Properties
    #
    # offset property
    @property
    def offset(self) -> Any:  # mypy#3004  NDArray[numpy.float64]:
        '''
        [x, y] offset
        '''
        return self._offset

    @offset.setter
    def offset(self, val: ArrayLike) -> None:
        val = numpy.array(val, dtype=float)

        if val.size != 2:
            raise MasqueError('Offset must be convertible to size-2 ndarray')
        self._offset = val.flatten()

    #
    # Methods
    #
    def set_offset(self, offset: ArrayLike) -> Self:
        self.offset = offset
        return self

    def translate(self, offset: ArrayLike) -> Self:
        self._offset += offset   # type: ignore         # NDArray += ArrayLike should be fine??
        return self


class Bounded(metaclass=ABCMeta):
    @abstractmethod
    def get_bounds(self, *args, **kwargs) -> NDArray[numpy.float64] | None:
        '''
        Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity.
        Returns `None` for an empty entity.
        '''
        pass

    def get_bounds_nonempty(self, *args, **kwargs) -> NDArray[numpy.float64]:
        '''
        Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity.
        Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None).

        This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()`
        '''
        bounds = self.get_bounds(*args, **kwargs)
        assert bounds is not None
        return bounds




---
masque/traits/repeatable.py
---
from typing import Self, TYPE_CHECKING
from abc import ABCMeta, abstractmethod

import numpy
from numpy.typing import NDArray

from ..error import MasqueError
from .positionable import Bounded


_empty_slots = ()     # Workaround to get mypy to ignore intentionally empty slots for superclass


if TYPE_CHECKING:
    from ..repetition import Repetition


class Repeatable(metaclass=ABCMeta):
    '''
    Trait class for all repeatable entities
    '''
    __slots__ = ()

    #
    # Properties
    #
    @property
    @abstractmethod
    def repetition(self) -> 'Repetition | None':
        '''
        Repetition object, or None (single instance only)
        '''
        pass

#    @repetition.setter
#    @abstractmethod
#    def repetition(self, repetition: 'Repetition | None') -> None:
#        pass

    #
    # Methods
    #
    @abstractmethod
    def set_repetition(self, repetition: 'Repetition | None') -> Self:
        '''
        Set the repetition

        Args:
            repetition: new value for repetition, or None (single instance)

        Returns:
            self
        '''
        pass


class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta):
    '''
    Simple implementation of `Repeatable` and extension of `Bounded` to include repetition bounds.
    '''
    __slots__ = _empty_slots

    _repetition: 'Repetition | None'
    ''' Repetition object, or None (single instance only) '''

    @abstractmethod
    def get_bounds_single(self, *args, **kwargs) -> NDArray[numpy.float64] | None:
        pass

    #
    # Non-abstract properties
    #
    @property
    def repetition(self) -> 'Repetition | None':
        return self._repetition

    @repetition.setter
    def repetition(self, repetition: 'Repetition | None') -> None:
        from ..repetition import Repetition
        if repetition is not None and not isinstance(repetition, Repetition):
            raise MasqueError(f'{repetition} is not a valid Repetition object!')
        self._repetition = repetition

    #
    # Non-abstract methods
    #
    def set_repetition(self, repetition: 'Repetition | None') -> Self:
        self.repetition = repetition
        return self

    def get_bounds_single_nonempty(self, *args, **kwargs) -> NDArray[numpy.float64]:
        '''
        Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity.
        Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None).

        This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()`
        '''
        bounds = self.get_bounds_single(*args, **kwargs)
        assert bounds is not None
        return bounds

    def get_bounds(self, *args, **kwargs) -> NDArray[numpy.float64] | None:
        bounds = self.get_bounds_single(*args, **kwargs)

        if bounds is not None and self.repetition is not None:
            rep_bounds = self.repetition.get_bounds()
            if rep_bounds is None:
                return None
            bounds += rep_bounds
        return bounds


---
masque/traits/rotatable.py
---
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 ..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


class Rotatable(metaclass=ABCMeta):
    '''
    Trait class for all rotatable entities
    '''
    __slots__ = ()

    #
    # Methods
    #
    @abstractmethod
    def rotate(self, val: float) -> Self:
        '''
        Rotate the shape around its origin (0, 0), ignoring its offset.

        Args:
            val: Angle to rotate by (counterclockwise, radians)

        Returns:
            self
        '''
        pass


class RotatableImpl(Rotatable, metaclass=ABCMeta):
    '''
    Simple implementation of `Rotatable`
    '''
    __slots__ = _empty_slots

    _rotation: float
    ''' rotation for the object, radians counterclockwise '''

    #
    # Properties
    #
    @property
    def rotation(self) -> float:
        ''' Rotation, radians counterclockwise '''
        return self._rotation

    @rotation.setter
    def rotation(self, val: float) -> None:
        if not numpy.size(val) == 1:
            raise MasqueError('Rotation must be a scalar')
        self._rotation = val % (2 * pi)

    #
    # Methods
    #
    def rotate(self, rotation: float) -> Self:
        self.rotation += rotation
        return self

    def set_rotation(self, rotation: float) -> Self:
        '''
        Set the rotation to a value

        Args:
            rotation: radians ccw

        Returns:
            self
        '''
        self.rotation = rotation
        return self


class Pivotable(metaclass=ABCMeta):
    '''
    Trait class for entites which can be rotated around a point.
    This requires that they are `Positionable` but not necessarily `Rotatable` themselves.
    '''
    __slots__ = ()

    @abstractmethod
    def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
        '''
        Rotate the object around a point.

        Args:
            pivot: Point (x, y) to rotate around
            rotation: Angle to rotate by (counterclockwise, radians)

        Returns:
            self
        '''
        pass


class PivotableImpl(Pivotable, metaclass=ABCMeta):
    '''
    Implementation of `Pivotable` for objects which are `Rotatable`
    '''
    __slots__ = ()

    offset: Any         # TODO see if we can get around defining `offset` in  PivotableImpl
    ''' `[x_offset, y_offset]` '''

    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)
        self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)      # type: ignore # mypy#3004
        cast('Positionable', self).translate(+pivot)
        return self



---
masque/traits/scalable.py
---
from typing import Self
from abc import ABCMeta, abstractmethod

from ..error import MasqueError
from ..utils import is_scalar


_empty_slots = ()     # Workaround to get mypy to ignore intentionally empty slots for superclass


class Scalable(metaclass=ABCMeta):
    '''
    Trait class for all scalable entities
    '''
    __slots__ = ()

    #
    # Methods
    #
    @abstractmethod
    def scale_by(self, c: float) -> Self:
        '''
        Scale the entity by a factor

        Args:
            c: scaling factor

        Returns:
            self
        '''
        pass


class ScalableImpl(Scalable, metaclass=ABCMeta):
    '''
    Simple implementation of Scalable
    '''
    __slots__ = _empty_slots

    _scale: float
    ''' scale factor for the entity '''

    #
    # Properties
    #
    @property
    def scale(self) -> float:
        return self._scale

    @scale.setter
    def scale(self, val: float) -> None:
        if not is_scalar(val):
            raise MasqueError('Scale must be a scalar')
        if not val > 0:
            raise MasqueError('Scale must be positive')
        self._scale = val

    #
    # Methods
    #
    def scale_by(self, c: float) -> Self:
        self.scale *= c
        return self

    def set_scale(self, scale: float) -> Self:
        '''
        Set the sclae to a value

        Args:
            scale: absolute scale factor

        Returns:
            self
        '''
        self.scale = scale
        return self


---
masque/builder/__init__.py
---
from .builder import Builder as Builder
from .pather import Pather as Pather
from .renderpather import RenderPather as RenderPather
from .utils import ell as ell
from .tools import (
    Tool as Tool,
    RenderStep as RenderStep,
    BasicTool as BasicTool,
    PathTool as PathTool,
    )


---
masque/builder/builder.py
---
'''
Simplified Pattern assembly (`Builder`)
'''
from typing import Self
from collections.abc import Iterable, Sequence, Mapping
import copy
import logging
from functools import wraps

from numpy.typing import ArrayLike

from ..pattern import Pattern
from ..library import ILibrary, TreeView
from ..error import BuildError
from ..ports import PortList, Port
from ..abstract import Abstract


logger = logging.getLogger(__name__)


class Builder(PortList):
    '''
      A `Builder` is a helper object used for snapping together multiple
    lower-level patterns at their `Port`s.

      The `Builder` mostly just holds context, in the form of a `Library`,
    in addition to its underlying pattern. This simplifies some calls
    to `plug` and `place`, by making the library implicit.

    `Builder` can also be `set_dead()`, at which point further calls to `plug()`
    and `place()` are ignored (intended for debugging).


    Examples: Creating a Builder
    ===========================
    - `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes
        an empty pattern, adds the given ports, and places it into `library`
        under the name `'mypat'`.

    - `Builder(library)` makes an empty pattern with no ports. The pattern
        is not added into `library` and must later be added with e.g.
        `library['mypat'] = builder.pattern`

    - `Builder(library, pattern=pattern, name='mypat')` uses an existing
        pattern (including its ports) and sets `library['mypat'] = pattern`.

    - `Builder.interface(other_pat, port_map=['A', 'B'], library=library)`
        makes a new (empty) pattern, copies over ports 'A' and 'B' from
        `other_pat`, and creates additional ports 'in_A' and 'in_B' facing
        in the opposite directions. This can be used to build a device which
        can plug into `other_pat` (using the 'in_*' ports) but which does not
        itself include `other_pat` as a subcomponent.

    - `Builder.interface(other_builder, ...)` does the same thing as
        `Builder.interface(other_builder.pattern, ...)` but also uses
        `other_builder.library` as its library by default.


    Examples: Adding to a pattern
    =============================
    - `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
        instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B'
        of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports
        are removed and any unconnected ports from `subdevice` are added to
        `my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'.

    - `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
        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.

    - `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
        instantiates `pad` at the specified (x, y) offset and with the specified
        rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is
        renamed to 'gnd' so that further routing can use this signal or net name
        rather than the port name on the original `pad` device.
    '''
    __slots__ = ('pattern', 'library', '_dead')

    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)'''

    @property
    def ports(self) -> dict[str, Port]:
        return self.pattern.ports

    @ports.setter
    def ports(self, value: dict[str, Port]) -> None:
        self.pattern.ports = value

    def __init__(
            self,
            library: ILibrary,
            *,
            pattern: Pattern | None = None,
            ports: str | Mapping[str, Port] | None = None,
            name: str | None = None,
            ) -> None:
        '''
        Args:
            library: The library from which referenced patterns will be taken
            pattern: The pattern which will be modified by subsequent operations.
                If `None` (default), a new pattern is created.
            ports: Allows specifying the initial set of ports, if `pattern` does
                not already have any ports (or is not provided). May be a string,
                in which case it is interpreted as a name in `library`.
                Default `None` (no ports).
            name: If specified, `library[name]` is set to `self.pattern`.
        '''
        self._dead = False
        self.library = library
        if pattern is not None:
            self.pattern = pattern
        else:
            self.pattern = Pattern()

        if ports is not None:
            if self.pattern.ports:
                raise BuildError('Ports supplied for pattern with pre-existing ports!')
            if isinstance(ports, str):
                ports = library.abstract(ports).ports

            self.pattern.ports.update(copy.deepcopy(dict(ports)))

        if name is not None:
            library[name] = self.pattern

    @classmethod
    def interface(
            cls: type['Builder'],
            source: PortList | Mapping[str, Port] | str,
            *,
            library: ILibrary | None = None,
            in_prefix: str = 'in_',
            out_prefix: str = '',
            port_map: dict[str, str] | Sequence[str] | None = None,
            name: str | None = None,
            ) -> 'Builder':
        '''
        Wrapper for `Pattern.interface()`, which returns a Builder instead.

        Args:
            source: A collection of ports (e.g. Pattern, Builder, or dict)
                from which to create the interface. May be a pattern name if
                `library` is provided.
            library: Library from which existing patterns should be referenced,
                and to which the new one should be added (if named). If not provided,
                `source.library` must exist and will be used.
            in_prefix: Prepended to port names for newly-created ports with
                reversed directions compared to the current device.
            out_prefix: Prepended to port names for ports which are directly
                copied from the current device.
            port_map: Specification for ports to copy into the new device:
                - If `None`, all ports are copied.
                - If a sequence, only the listed ports are copied
                - If a mapping, the listed ports (keys) are copied and
                    renamed (to the values).

        Returns:
            The new builder, with an empty pattern and 2x as many ports as
              listed in port_map.

        Raises:
            `PortError` if `port_map` contains port names not present in the
                current device.
            `PortError` if applying the prefixes results in duplicate port
                names.
        '''
        if library is None:
            if hasattr(source, 'library') and isinstance(source.library, ILibrary):
                library = source.library
            else:
                raise BuildError('No library was given, and `source.library` does not have one either.')

        if isinstance(source, str):
            source = library.abstract(source).ports

        pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
        new = Builder(library=library, pattern=pat, name=name)
        return new

    @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

    # Note: We're a superclass of `Pather`, where path() means something different...
    #@wraps(Pattern.path)
    #def path(self, *args, **kwargs) -> Self:
    #    self.pattern.path(*args, **kwargs)
    #    return self

    def plug(
            self,
            other: Abstract | str | Pattern | TreeView,
            map_in: dict[str, str],
            map_out: dict[str, str | None] | None = None,
            *,
            mirrored: bool = False,
            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`.

        The `Builder`'s library is used to dereference the string (or `Abstract`, if
        one is passed with `append=True`). If a `TreeView` is passed, it is first
        added into `self.library`.

        Args:
            other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
                device to be instatiated. If it is a `TreeView`, it is first
                added into `self.library`, after which the topcell is plugged;
                an equivalent statement is `self.plug(self.library << other, ...)`.
            map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
                port connections between the two devices.
            map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
                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
                (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
                the ports being connected (i.e. all pairs have at least one
                port with `rotation=None`), `set_rotation` must be provided
                to indicate how much `other` should be rotated. Otherwise,
                `set_rotation` must remain `None`.
            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

        Raises:
            `PortError` if any ports specified in `map_in` or `map_out` do not
                exist in `self.ports` or `other_names`.
            `PortError` if there are any duplicate names after `map_in` and `map_out`
                are applied.
            `PortError` if the specified port mapping is not achieveable (the ports
                do not line up)
        '''
        if self._dead:
            logger.error('Skipping plug() since device is dead')
            return self

        if not isinstance(other, str | Abstract | Pattern):
            # We got a Tree; add it into self.library and grab an Abstract for it
            other = self.library << other

        if isinstance(other, str):
            other = self.library.abstract(other)
        if append and isinstance(other, Abstract):
            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,
            )
        return self

    def place(
            self,
            other: Abstract | str | Pattern | TreeView,
            *,
            offset: ArrayLike = (0, 0),
            rotation: float = 0,
            pivot: ArrayLike = (0, 0),
            mirrored: bool = False,
            port_map: dict[str, str | None] | None = None,
            skip_port_check: bool = False,
            append: bool = False,
            ) -> Self:
        '''
        Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`.

        The `Builder`'s library is used to dereference the string (or `Abstract`, if
        one is passed with `append=True`). If a `TreeView` is passed, it is first
        added into `self.library`.

        Args:
            other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
                device to be instatiated. If it is a `TreeView`, it is first
                added into `self.library`, after which the topcell is plugged;
                an equivalent statement is `self.plug(self.library << other, ...)`.
            offset: Offset at which to place the instance. Default (0, 0).
            rotation: Rotation applied to the instance before placement. Default 0.
            pivot: Rotation is applied around this pivot point (default (0, 0)).
                Rotation is applied prior to translation (`offset`).
            mirrored: Whether theinstance should be mirrored across the x axis.
                Mirroring is applied before translation and rotation.
            port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
                new names for ports in the instantiated device. New names can be
                `None`, which will delete those ports.
            skip_port_check: Can be used to skip the internal call to `check_ports`,
                in case it has already been performed elsewhere.
            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`).

        Returns:
            self

        Raises:
            `PortError` if any ports specified in `map_in` or `map_out` do not
                exist in `self.ports` or `other.ports`.
            `PortError` if there are any duplicate names after `map_in` and `map_out`
                are applied.
        '''
        if self._dead:
            logger.error('Skipping place() since device is dead')
            return self

        if not isinstance(other, str | Abstract | Pattern):
            # We got a Tree; add it into self.library and grab an Abstract for it
            other = self.library << other

        if isinstance(other, str):
            other = self.library.abstract(other)
        if append and isinstance(other, Abstract):
            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,
            )
        return self

    def translate(self, offset: ArrayLike) -> Self:
        '''
        Translate the pattern and all ports.

        Args:
            offset: (x, y) distance to translate by

        Returns:
            self
        '''
        self.pattern.translate_elements(offset)
        return self

    def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
        '''
        Rotate the pattern and all ports.

        Args:
            angle: angle (radians, counterclockwise) to rotate by
            pivot: location to rotate around

        Returns:
            self
        '''
        self.pattern.rotate_around(pivot, angle)
        for port in self.ports.values():
            port.rotate_around(pivot, angle)
        return self

    def mirror(self, axis: int = 0) -> Self:
        '''
        Mirror the pattern and all ports across the specified axis.

        Args:
            axis: Axis to mirror across (x=0, y=1)

        Returns:
            self
        '''
        self.pattern.mirror(axis)
        return self

    def set_dead(self) -> Self:
        '''
        Disallows further changes through `plug()` or `place()`.
        This is meant for debugging:
        ```
            dev.plug(a, ...)
            dev.set_dead()      # added for debug purposes
            dev.plug(b, ...)    # usually raises an error, but now skipped
            dev.plug(c, ...)    # also skipped
            dev.pattern.visualize()     # shows the device as of the set_dead() call
        ```

        Returns:
            self
        '''
        self._dead = True
        return self

    def __repr__(self) -> str:
        s = f'<Builder {self.pattern} L({len(self.library)})>'
        return s




---
masque/builder/pather.py
---
'''
Manual wire/waveguide routing (`Pather`)
'''
from typing import Self
from collections.abc import Sequence, MutableMapping, Mapping, Iterator
import copy
import logging
from contextlib import contextmanager
from pprint import pformat

import numpy
from numpy import pi
from numpy.typing import ArrayLike

from ..pattern import Pattern
from ..library import ILibrary, SINGLE_USE_PREFIX
from ..error import PortError, BuildError
from ..ports import PortList, Port
from ..abstract import Abstract
from ..utils import SupportsBool, rotation_matrix_2d
from .tools import Tool
from .utils import ell
from .builder import Builder


logger = logging.getLogger(__name__)


class Pather(Builder):
    '''
      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.

      `Pather` is mostly concerned with calculating how long each wire should be. It calls
    out to `Tool.path` functions provided by subclasses of `Tool` to build the actual patterns.
    `Tool`s are assigned on a per-port basis and stored in `.tools`; a key of `None` represents
    a "default" `Tool` used for all ports which do not have a port-specific `Tool` assigned.


    Examples: Creating a Pather
    ===========================
    - `Pather(library, tools=my_tool)` makes an empty pattern with no ports. The pattern
        is not added into `library` and must later be added with e.g.
        `library['mypat'] = pather.pattern`.
        The default wire/waveguide generating tool for all ports is set to `my_tool`.

    - `Pather(library, ports={'in': Port(...), 'out': ...}, name='mypat', tools=my_tool)`
        makes an empty pattern, adds the given ports, and places it into `library`
        under the name `'mypat'`. The default wire/waveguide generating tool
        for all ports is set to `my_tool`

    - `Pather(..., tools={'in': top_metal_40um, 'out': bottom_metal_1um, None: my_tool})`
        assigns specific tools to individual ports, and `my_tool` as a default for ports
        which are not specified.

    - `Pather.interface(other_pat, port_map=['A', 'B'], library=library, tools=my_tool)`
        makes a new (empty) pattern, copies over ports 'A' and 'B' from
        `other_pat`, and creates additional ports 'in_A' and 'in_B' facing
        in the opposite directions. This can be used to build a device which
        can plug into `other_pat` (using the 'in_*' ports) but which does not
        itself include `other_pat` as a subcomponent.

    - `Pather.interface(other_pather, ...)` does the same thing as
        `Builder.interface(other_builder.pattern, ...)` but also uses
        `other_builder.library` as its library by default.


    Examples: Adding to a pattern
    =============================
    - `pather.path('my_port', ccw=True, distance)` creates a "wire" for which the output
        port is `distance` units away along the axis of `'my_port'` and rotated 90 degrees
        counterclockwise (since `ccw=True`) relative to `'my_port'`. The wire is `plug`ged
        into the existing `'my_port'`, causing the port to move to the wire's output.

        There is no formal guarantee about how far off-axis the output will be located;
        there may be a significant width to the bend that is used to accomplish the 90 degree
        turn. However, an error is raised if `distance` is too small to fit the bend.

    - `pather.path('my_port', ccw=None, distance)` creates a straight wire with a length
        of `distance` and `plug`s it into `'my_port'`.

    - `pather.path_to('my_port', ccw=False, position)` creates a wire which starts at
        `'my_port'` and has its output at the specified `position`, pointing 90 degrees
        clockwise relative to the input. Again, the off-axis position or distance to the
        output is not specified, so `position` takes the form of a single coordinate. To
        ease debugging, position may be specified as `x=position` or `y=position` and an
        error will be raised if the wrong coordinate is given.

    - `pather.mpath(['A', 'B', 'C'], ..., spacing=spacing)` is a superset of `path`
        and `path_to` which can act on multiple ports simultaneously. Each port's wire is
        generated using its own `Tool` (or the default tool if left unspecified).
        The output ports are spaced out by `spacing` along the input ports' axis, unless
        `ccw=None` is specified (i.e. no bends) in which case they all end at the same
        destination coordinate.

    - `pather.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
        of `pather.pattern`. 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`, 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.

    - `pather.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
        instantiates `pad` at the specified (x, y) offset and with the specified
        rotation, adding its ports to those of `pather.pattern`. Port 'A' of `pad` is
        renamed to 'gnd' so that further routing can use this signal or net name
        rather than the port name on the original `pad` device.

    - `pather.retool(tool)` or `pather.retool(tool, ['in', 'out', None])` can change
        which tool is used for the given ports (or as the default tool). Useful
        when placing vias or using multiple waveguide types along a route.
    '''
    __slots__ = ('tools',)

    library: ILibrary
    '''
    Library from which existing patterns should be referenced, and to which
    new ones should be added
    '''

    tools: dict[str | None, Tool]
    '''
    Tool objects are used to dynamically generate new single-use `Pattern`s
    (e.g wires or waveguides) to be plugged into this device. A key of `None`
    indicates the default `Tool`.
    '''

    def __init__(
            self,
            library: ILibrary,
            *,
            pattern: Pattern | None = None,
            ports: str | Mapping[str, Port] | None = None,
            tools: Tool | MutableMapping[str | None, Tool] | None = None,
            name: str | None = None,
            ) -> None:
        '''
        Args:
            library: The library from which referenced patterns will be taken,
                and where new patterns (e.g. generated by the `tools`) will be placed.
            pattern: The pattern which will be modified by subsequent operations.
                If `None` (default), a new pattern is created.
            ports: Allows specifying the initial set of ports, if `pattern` does
                not already have any ports (or is not provided). May be a string,
                in which case it is interpreted as a name in `library`.
                Default `None` (no ports).
            tools: A mapping of {port: tool} which specifies what `Tool` should be used
                to generate waveguide or wire segments when `path`/`path_to`/`mpath`
                are called. Relies on `Tool.path` implementations.
            name: If specified, `library[name]` is set to `self.pattern`.
        '''
        self._dead = False
        self.library = library
        if pattern is not None:
            self.pattern = pattern
        else:
            self.pattern = Pattern()

        if ports is not None:
            if self.pattern.ports:
                raise BuildError('Ports supplied for pattern with pre-existing ports!')
            if isinstance(ports, str):
                ports = library.abstract(ports).ports

            self.pattern.ports.update(copy.deepcopy(dict(ports)))

        if name is not None:
            library[name] = self.pattern

        if tools is None:
            self.tools = {}
        elif isinstance(tools, Tool):
            self.tools = {None: tools}
        else:
            self.tools = dict(tools)

    @classmethod
    def from_builder(
            cls: type['Pather'],
            builder: Builder,
            *,
            tools: Tool | MutableMapping[str | None, Tool] | None = None,
            ) -> 'Pather':
        '''
        Construct a `Pather` by adding tools to a `Builder`.

        Args:
            builder: Builder to turn into a Pather
            tools: Tools for the `Pather`

        Returns:
            A new Pather object, using `builder.library` and `builder.pattern`.
        '''
        new = Pather(library=builder.library, tools=tools, pattern=builder.pattern)
        return new

    @classmethod
    def interface(
            cls: type['Pather'],
            source: PortList | Mapping[str, Port] | str,
            *,
            library: ILibrary | None = None,
            tools: Tool | MutableMapping[str | None, Tool] | None = None,
            in_prefix: str = 'in_',
            out_prefix: str = '',
            port_map: dict[str, str] | Sequence[str] | None = None,
            name: str | None = None,
            ) -> 'Pather':
        '''
        Wrapper for `Pattern.interface()`, which returns a Pather instead.

        Args:
            source: A collection of ports (e.g. Pattern, Builder, or dict)
                from which to create the interface. May be a pattern name if
                `library` is provided.
            library: Library from which existing patterns should be referenced,
                and to which the new one should be added (if named). If not provided,
                `source.library` must exist and will be used.
            tools: `Tool`s which will be used by the pather for generating new wires
                or waveguides (via `path`/`path_to`/`mpath`).
            in_prefix: Prepended to port names for newly-created ports with
                reversed directions compared to the current device.
            out_prefix: Prepended to port names for ports which are directly
                copied from the current device.
            port_map: Specification for ports to copy into the new device:
                - If `None`, all ports are copied.
                - If a sequence, only the listed ports are copied
                - If a mapping, the listed ports (keys) are copied and
                    renamed (to the values).

        Returns:
            The new pather, with an empty pattern and 2x as many ports as
              listed in port_map.

        Raises:
            `PortError` if `port_map` contains port names not present in the
                current device.
            `PortError` if applying the prefixes results in duplicate port
                names.
        '''
        if library is None:
            if hasattr(source, 'library') and isinstance(source.library, ILibrary):
                library = source.library
            else:
                raise BuildError('No library provided (and not present in `source.library`')

        if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict):
            tools = source.tools

        if isinstance(source, str):
            source = library.abstract(source).ports

        pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
        new = Pather(library=library, pattern=pat, name=name, tools=tools)
        return new

    def __repr__(self) -> str:
        s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
        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,
            portspec: str,
            ccw: SupportsBool | None,
            length: float,
            *,
            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 traveling exactly `length` distance.

        The wire will travel `length` distance along the port's axis, an an unspecified
        (tool-dependent) distance 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.
            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`.

        Returns:
            self

        Raises:
            BuildError if `distance` is too small to fit the bend (if a bend is present).
            LibraryError if no valid name could be picked for the pattern.
        '''
        if self._dead:
            logger.error('Skipping path() since device is dead')
            return self

        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
        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['bound_type'])
            bound = kwargs['bound']
            del kwargs['bound_type']
            del 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]
                del 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 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)
        return self



---
masque/builder/renderpather.py
---
'''
Pather with batched (multi-step) rendering
'''
from typing import Self
from collections.abc import Sequence, Mapping, MutableMapping
import copy
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 ..ports import PortList, Port
from ..abstract import Abstract
from ..utils import SupportsBool
from .tools import Tool, RenderStep
from .utils import ell


logger = logging.getLogger(__name__)


class RenderPather(PortList):
    '''
      `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,
    it waits until `render` is called, at which point it draws all the planned segments
    simultaneously. This allows it to e.g. draw each wire using a single `Path` or
    `Polygon` shape instead of multiple rectangles.

      `RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific
    dimensions and build the final geometry for each wire. `Tool.planL` provides the
    output port data (relative to the input) for each segment. The tool, input and output
    ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for
    each port. When `render` is called, it bundles `RenderStep`s into batches which use
    the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build
    the geometry.

    See `Pather` for routing examples. After routing is complete, `render` must be called
    to generate the final geometry.
    '''
    __slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', )

    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) '''

    paths: defaultdict[str, list[RenderStep]]
    ''' Per-port list of operations, to be used by `render` '''

    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.
    '''

    @property
    def ports(self) -> dict[str, Port]:
        return self.pattern.ports

    @ports.setter
    def ports(self, value: dict[str, Port]) -> None:
        self.pattern.ports = value

    def __init__(
            self,
            library: ILibrary,
            *,
            pattern: Pattern | None = None,
            ports: str | Mapping[str, Port] | None = None,
            tools: Tool | MutableMapping[str | None, Tool] | None = None,
            name: str | None = None,
            ) -> None:
        '''
        Args:
            library: The library from which referenced patterns will be taken,
                and where new patterns (e.g. generated by the `tools`) will be placed.
            pattern: The pattern which will be modified by subsequent operations.
                If `None` (default), a new pattern is created.
            ports: Allows specifying the initial set of ports, if `pattern` does
                not already have any ports (or is not provided). May be a string,
                in which case it is interpreted as a name in `library`.
                Default `None` (no ports).
            tools: A mapping of {port: tool} which specifies what `Tool` should be used
                to generate waveguide or wire segments when `path`/`path_to`/`mpath`
                are called. Relies on `Tool.planL` and `Tool.render` implementations.
            name: If specified, `library[name]` is set to `self.pattern`.
        '''
        self._dead = False
        self.paths = defaultdict(list)
        self.library = library
        if pattern is not None:
            self.pattern = pattern
        else:
            self.pattern = Pattern()

        if ports is not None:
            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:
            self.tools = {}
        elif isinstance(tools, Tool):
            self.tools = {None: tools}
        else:
            self.tools = dict(tools)

    @classmethod
    def interface(
            cls: type['RenderPather'],
            source: PortList | Mapping[str, Port] | str,
            *,
            library: ILibrary | None = None,
            tools: Tool | MutableMapping[str | None, Tool] | None = None,
            in_prefix: str = 'in_',
            out_prefix: str = '',
            port_map: dict[str, str] | Sequence[str] | None = None,
            name: str | None = None,
            ) -> 'RenderPather':
        '''
        Wrapper for `Pattern.interface()`, which returns a RenderPather instead.

        Args:
            source: A collection of ports (e.g. Pattern, Builder, or dict)
                from which to create the interface. May be a pattern name if
                `library` is provided.
            library: Library from which existing patterns should be referenced,
                and to which the new one should be added (if named). If not provided,
                `source.library` must exist and will be used.
            tools: `Tool`s which will be used by the pather for generating new wires
                or waveguides (via `path`/`path_to`/`mpath`).
            in_prefix: Prepended to port names for newly-created ports with
                reversed directions compared to the current device.
            out_prefix: Prepended to port names for ports which are directly
                copied from the current device.
            port_map: Specification for ports to copy into the new device:
                - If `None`, all ports are copied.
                - If a sequence, only the listed ports are copied
                - If a mapping, the listed ports (keys) are copied and
                    renamed (to the values).

        Returns:
            The new `RenderPather`, with an empty pattern and 2x as many ports as
              listed in port_map.

        Raises:
            `PortError` if `port_map` contains port names not present in the
                current device.
            `PortError` if applying the prefixes results in duplicate port
                names.
        '''
        if library is None:
            if hasattr(source, 'library') and isinstance(source.library, ILibrary):
                library = source.library
            else:
                raise BuildError('No library provided (and not present in `source.library`')

        if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict):
            tools = source.tools

        if isinstance(source, str):
            source = library.abstract(source).ports

        pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
        new = RenderPather(library=library, pattern=pat, name=name, tools=tools)
        return new

    def plug(
            self,
            other: Abstract | str,
            map_in: dict[str, str],
            map_out: dict[str, str | None] | None = None,
            *,
            mirrored: bool = False,
            inherit_name: bool = True,
            set_rotation: bool | None = None,
            append: bool = False,
            ) -> Self:
        '''
          Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P'
        for any affected ports. This separates any future `RenderStep`s on the
        same port into a new batch, since the plugged device interferes with drawing.

        Args:
            other: An `Abstract`, string, or `Pattern` describing the device to be instatiated.
            map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
                port connections between the two devices.
            map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
                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
                (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
                the ports being connected (i.e. all pairs have at least one
                port with `rotation=None`), `set_rotation` must be provided
                to indicate how much `other` should be rotated. Otherwise,
                `set_rotation` must remain `None`.
            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`).

        Returns:
            self

        Raises:
            `PortError` if any ports specified in `map_in` or `map_out` do not
                exist in `self.ports` or `other_names`.
            `PortError` if there are any duplicate names after `map_in` and `map_out`
                are applied.
            `PortError` if the specified port mapping is not achieveable (the ports
                do not line up)
        '''
        if self._dead:
            logger.error('Skipping plug() since device is dead')
            return self

        other_tgt: Pattern | Abstract
        if isinstance(other, str):
            other_tgt = self.library.abstract(other)
        if append and isinstance(other, Abstract):
            other_tgt = self.library[other.name]

        # get rid of plugged ports
        for kk in map_in:
            if kk in self.paths:
                self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))

        plugged = map_in.values()
        for name, port in other_tgt.ports.items():
            if name in plugged:
                continue
            new_name = map_out.get(name, name) if map_out is not None else name
            if new_name is not None and new_name in self.paths:
                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,
            )

        return self

    def place(
            self,
            other: Abstract | str,
            *,
            offset: ArrayLike = (0, 0),
            rotation: float = 0,
            pivot: ArrayLike = (0, 0),
            mirrored: bool = False,
            port_map: dict[str, str | None] | None = None,
            skip_port_check: bool = False,
            append: bool = False,
            ) -> Self:
        '''
          Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P'
        for any affected ports. This separates any future `RenderStep`s on the
        same port into a new batch, since the placed device interferes with drawing.

        Note that mirroring is applied before rotation; translation (`offset`) is applied last.

        Args:
            other: An `Abstract` or `Pattern` describing the device to be instatiated.
            offset: Offset at which to place the instance. Default (0, 0).
            rotation: Rotation applied to the instance before placement. Default 0.
            pivot: Rotation is applied around this pivot point (default (0, 0)).
                Rotation is applied prior to translation (`offset`).
            mirrored: Whether theinstance should be mirrored across the x axis.
                Mirroring is applied before translation and rotation.
            port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
                new names for ports in the instantiated pattern. New names can be
                `None`, which will delete those ports.
            skip_port_check: Can be used to skip the internal call to `check_ports`,
                in case it has already been performed elsewhere.
            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`).

        Returns:
            self

        Raises:
            `PortError` if any ports specified in `map_in` or `map_out` do not
                exist in `self.ports` or `other.ports`.
            `PortError` if there are any duplicate names after `map_in` and `map_out`
                are applied.
        '''
        if self._dead:
            logger.error('Skipping place() since device is dead')
            return self

        other_tgt: Pattern | Abstract
        if isinstance(other, str):
            other_tgt = self.library.abstract(other)
        if append and isinstance(other, Abstract):
            other_tgt = self.library[other.name]

        for name, port in other_tgt.ports.items():
            new_name = port_map.get(name, name) if port_map is not None else name
            if new_name is not None and new_name in self.paths:
                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,
            )

        return self

    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

    def path(
            self,
            portspec: str,
            ccw: SupportsBool | None,
            length: float,
            **kwargs,
            ) -> Self:
        '''
        Plan a "wire"/"waveguide" extending from 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
        (tool-dependent) distance 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.
            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.)

        Returns:
            self

        Raises:
            BuildError if `distance` is too small to fit the bend (if a bend is present).
            LibraryError if no valid name could be picked for the pattern.
        '''
        if self._dead:
            logger.error('Skipping path() 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])
        # ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
        out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)

        # Update port
        out_port.rotate_around((0, 0), pi + port_rot)
        out_port.translate(port.offset)

        step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
        self.paths[portspec].append(step)

        self.pattern.ports[portspec] = out_port.copy()

        return self

    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(
            self,
            append: bool = True,
            ) -> Self:
        '''
        Generate the geometry which has been planned out with `path`/`path_to`/etc.

        Args:
            append: If `True`, the rendered geometry will be directly appended to
                `self.pattern`. Note that it will not be flattened, so if only one
                layer of hierarchy is eliminated.

        Returns:
            self
        '''
        lib = self.library
        tool_port_names = ('A', 'B')
        pat = Pattern()

        def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None:
            assert batch[0].tool is not None
            name = lib << batch[0].tool.render(batch, port_names=tool_port_names)
            pat.ports[portspec] = batch[0].start_port.copy()
            if append:
                pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append)
                del lib[name]       # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened
            else:
                pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append)

        for portspec, steps in self.paths.items():
            batch: list[RenderStep] = []
            for step in steps:
                appendable_op = step.opcode in ('L', 'S', 'U')
                same_tool = batch and step.tool == batch[0].tool

                # If we can't continue a batch, render it
                if batch and (not appendable_op or not same_tool):
                    render_batch(portspec, batch, append)
                    batch = []

                # batch is emptied already if we couldn't continue it
                if appendable_op:
                    batch.append(step)

                # Opcodes which break the batch go below this line
                if not appendable_op and portspec in pat.ports:
                    del pat.ports[portspec]

            #If the last batch didn't end yet
            if batch:
                render_batch(portspec, batch, append)

        self.paths.clear()
        pat.ports.clear()
        self.pattern.append(pat)

        return self

    def translate(self, offset: ArrayLike) -> Self:
        '''
        Translate the pattern and all ports.

        Args:
            offset: (x, y) distance to translate by

        Returns:
            self
        '''
        self.pattern.translate_elements(offset)
        return self

    def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
        '''
        Rotate the pattern and all ports.

        Args:
            angle: angle (radians, counterclockwise) to rotate by
            pivot: location to rotate around

        Returns:
            self
        '''
        self.pattern.rotate_around(pivot, angle)
        return self

    def mirror(self, axis: int) -> Self:
        '''
        Mirror the pattern and all ports across the specified axis.

        Args:
            axis: Axis to mirror across (x=0, y=1)

        Returns:
            self
        '''
        self.pattern.mirror(axis)
        return self

    def set_dead(self) -> Self:
        '''
        Disallows further changes through `plug()` or `place()`.
        This is meant for debugging:
        ```
            dev.plug(a, ...)
            dev.set_dead()      # added for debug purposes
            dev.plug(b, ...)    # usually raises an error, but now skipped
            dev.plug(c, ...)    # also skipped
            dev.pattern.visualize()     # shows the device as of the set_dead() call
        ```

        Returns:
            self
        '''
        self._dead = True
        return self

    def __repr__(self) -> str:
        s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
        return s




---
masque/builder/tools.py
---
'''
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)

# TODO document all tools
'''
from typing import Literal, Any
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

import numpy
from numpy.typing import NDArray
from numpy import pi

from ..utils import SupportsBool, rotation_matrix_2d, layer_t
from ..ports import Port
from ..pattern import Pattern
from ..abstract import Abstract
from ..library import ILibrary, Library, SINGLE_USE_PREFIX
from ..error import BuildError


@dataclass(frozen=True, slots=True)
class RenderStep:
    '''
    Representation of a single saved operation, used by `RenderPather` and passed
    to `Tool.render()` when `RenderPather.render()` is called.
    '''
    opcode: Literal['L', 'S', 'U', 'P']
    ''' What operation is being performed.
        L: planL   (straight, optionally with a single bend)
        S: planS   (s-bend)
        U: planU   (u-bend)
        P: plug
    '''

    tool: 'Tool | None'
    ''' The current tool. May be `None` if `opcode='P'` '''

    start_port: Port
    end_port: Port

    data: Any
    ''' Arbitrary tool-specific data'''

    def __post_init__(self) -> None:
        if self.opcode != 'P' and self.tool is None:
            raise BuildError('Got tool=None but the opcode is not "P"')


class Tool:
    '''
    Interface for path (e.g. wire or waveguide) generation.

    Note that subclasses may implement only a subset of the methods and leave others
    unimplemented (e.g. in cases where they don't make sense or the required components
    are impractical or unavailable).
    '''
    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:
        '''
        Create a wire or waveguide that travels exactly `length` distance along the axis
        of its input port.

        Used by `Pather`.

        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
        direction. The output port should be rotated (or not) based on the value of
        `ccw`.

        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:
            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.
            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.)
            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 L-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,
            length: float,
            *,
            in_ptype: str | None = None,
            out_ptype: str | None = None,
            **kwargs,
            ) -> tuple[Port, Any]:
        '''
        Plan a wire or waveguide that travels exactly `length` distance along the axis
        of its input port.

        Used by `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
        direction. The output port should be rotated (or not) based on the value of
        `ccw`.

        The input and output ports should be compatible with `in_ptype` and
        `out_ptype`, respectively.

        Args:
            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.
            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.)
            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.

        Returns:
            The calculated output `Port` for the wire.
            Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.

        Raises:
            BuildError if an impossible or unsupported geometry is requested.
        '''
        raise NotImplementedError(f'planL() not implemented for {type(self)}')

    def planS(
            self,
            length: float,
            jog: float,
            *,
            in_ptype: str | None = None,
            out_ptype: str | None = None,
            **kwargs,
            ) -> tuple[Port, Any]:
        '''
        Plan a wire or waveguide that travels exactly `length` distance along the axis
        of its input port and `jog` distance along the perpendicular axis (i.e. an S-bend).

        Used by `RenderPather`.

        The output port must have an orientation rotated by pi from the input port.

        The input and output ports should be compatible with `in_ptype` and
        `out_ptype`, respectively.

        Args:
            length: The total distance from input to output, along the input's axis only.
            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)
            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.

        Returns:
            The calculated output `Port` for the wire.
            Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.

        Raises:
            BuildError if an impossible or unsupported geometry is requested.
        '''
        raise NotImplementedError(f'planS() not implemented for {type(self)}')

    def planU(
            self,
            jog: float,
            *,
            in_ptype: str | None = None,
            out_ptype: str | None = None,
            **kwargs,
            ) -> tuple[Port, Any]:
        '''
        # NOTE: TODO: U-bend is WIP; this interface may change in the future.

        Plan a wire or waveguide that travels exactly `jog` distance along the axis
        perpendicular to its input port (i.e. a U-bend).

        Used by `RenderPather`.

        The output port must have an orientation identical to the input port.

        The input and output ports should be compatible with `in_ptype` and
        `out_ptype`, respectively.

        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)
            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.

        Returns:
            The calculated output `Port` for the wire.
            Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.

        Raises:
            BuildError if an impossible or unsupported geometry is requested.
        '''
        raise NotImplementedError(f'planU() not implemented for {type(self)}')

    def render(
            self,
            batch: Sequence[RenderStep],
            *,
            port_names: Sequence[str] = ('A', 'B'),     # noqa: ARG002 (unused)
            **kwargs,                                   # noqa: ARG002 (unused)
            ) -> ILibrary:
        '''
        Render the provided `batch` of `RenderStep`s into geometry, returning a tree
        (a Library with a single topcell).

        Args:
            batch: A sequence of `RenderStep` objects containing the ports and data
                provided by this tool's `planL`/`planS`/`planU` functions.
            port_names: The topcell's input and output ports should be named
                `port_names[0]` and `port_names[1]` respectively.
            kwargs: Custom tool-specific parameters.
        '''
        assert not batch or batch[0].tool == self
        raise NotImplementedError(f'render() not implemented for {type(self)}')


abstract_tuple_t = tuple[Abstract, str, str]


@dataclass
class BasicTool(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.
    '''
    straight: tuple[Callable[[float], Pattern], str, str]
    ''' `create_straight(length: float), in_port_name, out_port_name` '''

    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 '''

    @dataclass(frozen=True, slots=True)
    class LData:
        ''' Data for planL '''
        straight_length: float
        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=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 = tree <= {SINGLE_USE_PREFIX + 'straight': gen_straight(data.straight_length, **kwargs)}
            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,
            **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

            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
        else:
            bend_dxy = numpy.zeros(2)
            bend_angle = 0

        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:
            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]

        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}'
                )

        data = self.LData(straight_length, ccw, in_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]))

        gen_straight, sport_in, _sport_out = self.straight
        for step in batch:
            straight_length, ccw, in_transition, out_transition = step.data
            assert step.tool == self

            if step.opcode == 'L':
                if in_transition:
                    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)
                    else:
                        straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat}
                        pat.plug(straight, {port_names[1]: sport_in}, append=True)
                if 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(opat, {port_names[1]: oport_ours})
        return tree


@dataclass
class PathTool(Tool, metaclass=ABCMeta):
    '''
    A tool which draws `Path` geometry elements.

    If `planL` / `render` are used, the `Path` elements can cover >2 vertices;
    with `path` only individual rectangles will be drawn.
    '''
    layer: layer_t
    ''' Layer to draw on '''

    width: float
    ''' `Path` width '''

    ptype: str = 'unk'
    ''' ptype for any ports in patterns generated by this tool '''

    #@dataclass(frozen=True, slots=True)
    #class LData:
    #    dxy: NDArray[numpy.float64]

    #def __init__(self, layer: layer_t, width: float, ptype: str = 'unk') -> None:
    #    Tool.__init__(self)
    #    self.layer = layer
    #    self.width = width
    #    self.ptype: str

    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,                           # noqa: ARG002 (unused)
            ) -> Library:
        out_port, dxy = self.planL(
            ccw,
            length,
            in_ptype=in_ptype,
            out_ptype=out_ptype,
            )

        tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
        pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)])

        if ccw is None:
            out_rot = pi
        elif bool(ccw):
            out_rot = -pi / 2
        else:
            out_rot = pi / 2

        pat.ports = {
            port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype),
            port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype),
            }

        return tree

    def planL(
            self,
            ccw: SupportsBool | None,
            length: float,
            *,
            in_ptype: str | None = None,        # noqa: ARG002 (unused)
            out_ptype: str | None = None,
            **kwargs,                           # noqa: ARG002 (unused)
            ) -> tuple[Port, NDArray[numpy.float64]]:
        # TODO check all the math for L-shaped bends

        if out_ptype and out_ptype != self.ptype:
            raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}')

        if ccw is not None:
            bend_dxy = numpy.array([1, -1]) * self.width / 2
            bend_angle = pi / 2

            if bool(ccw):
                bend_dxy[1] *= -1
                bend_angle *= -1
        else:
            bend_dxy = numpy.zeros(2)
            bend_angle = pi

        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 bend: {bend_dxy[0]:,g}'
                )
        data = numpy.array((length, bend_run))
        out_port = Port(data, rotation=bend_angle, ptype=self.ptype)
        return out_port, data

    def render(
            self,
            batch: Sequence[RenderStep],
            *,
            port_names: Sequence[str] = ('A', 'B'),
            **kwargs,                           # noqa: ARG002 (unused)
            ) -> ILibrary:

        path_vertices = [batch[0].start_port.offset]
        for step in batch:
            assert step.tool == self

            port_rot = step.start_port.rotation
            assert port_rot is not None

            if step.opcode == 'L':
                length, bend_run = step.data
                dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0)
                #path_vertices.append(step.start_port.offset)
                path_vertices.append(step.start_port.offset + dxy)
            else:
                raise BuildError(f'Unrecognized opcode "{step.opcode}"')

        if (path_vertices[-1] != batch[-1].end_port.offset).any():
            # If the path ends in a bend, we need to add the final vertex
            path_vertices.append(batch[-1].end_port.offset)

        tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
        pat.path(layer=self.layer, width=self.width, vertices=path_vertices)
        pat.ports = {
            port_names[0]: batch[0].start_port.copy().rotate(pi),
            port_names[1]: batch[-1].end_port.copy().rotate(pi),
            }
        return tree


---
masque/builder/utils.py
---
from typing import SupportsFloat, cast, TYPE_CHECKING
from collections.abc import Mapping, Sequence
from pprint import pformat

import numpy
from numpy import pi
from numpy.typing import ArrayLike, NDArray

from ..utils import rotation_matrix_2d, SupportsBool
from ..error import BuildError

if TYPE_CHECKING:
    from ..ports import Port


def ell(
        ports: Mapping[str, 'Port'],
        ccw: SupportsBool | None,
        bound_type: str,
        bound: float | ArrayLike,
        *,
        spacing: float | ArrayLike | None = None,
        set_rotation: float | None = None,
        ) -> dict[str, numpy.float64]:
    '''
    Calculate extension for each port in order to build a 90-degree bend with the provided
    channel spacing:

         =A>---------------------------V     turn direction: `ccw=False`
                   =B>-------------V   |
     =C>-----------------------V   |   |
       =D=>----------------V   |   |   |


                           x---x---x---x  `spacing` (can be scalar or array)

                    <-------------->      `bound_type='min_extension'`
                    <------>                         `'min_past_furthest'`
      <-------------------------------->             `'max_extension'`
                           x                         `'min_position'`
                                       x             `'max_position'`

    Args:
        ports: `name: port` mapping. All ports should have the same rotation (or `None`). If
            no port has a rotation specified, `set_rotation` must be provided.
        ccw: Turn direction. `True` means counterclockwise, `False` means clockwise,
            and `None` means no bend. If `None`, spacing must remain `None` or `0` (default),
            Otherwise, spacing must be set to a non-`None` value.
        bound_method: Method used for determining the travel distance; see diagram above.
            Valid values are:
            - 'min_extension' or 'emin':
                The total extension value for the furthest-out port (B in the diagram).
            - 'min_past_furthest':
                The distance between furthest out-port (B) and the innermost bend (D's bend).
            - 'max_extension' or 'emax':
                The total extension value for the closest-in port (C in the diagram).
            - 'min_position', 'pmin', 'xmin', 'ymin':
                The coordinate of the innermost bend (D's bend).
            - 'max_position', 'pmax', 'xmax', 'ymax':
                The coordinate of the outermost bend (A's bend).

            `bound` can also be a vector. If specifying an extension (e.g. 'min_extension',
                'max_extension', 'min_past_furthest'), it sets independent limits along
                the x- and y- axes. If specifying a position, it is projected onto
                the extension direction.

        bound_value: Value associated with `bound_type`, see above.
        spacing: Distance between adjacent channels. Can be scalar, resulting in evenly
            spaced channels, or a vector with length one less than `ports`, allowing
            non-uniform spacing.
            The ordering of the vector corresponds to the output order (DCBA in the
            diagram above), *not* the order of `ports`.
        set_rotation: If all ports have no specified rotation, this value is used
            to set the extension direction. Otherwise it must remain `None`.

    Returns:
        Dict of {port_name: distance_to_bend}

    Raises:
        `BuildError` on bad inputs
        `BuildError` if the requested bound is impossible
    '''
    if not ports:
        raise BuildError('Empty port list passed to `ell()`')

    if ccw is None:
        if spacing is not None and not numpy.isclose(spacing, 0):
            raise BuildError('Spacing must be 0 or None when ccw=None')
        spacing = 0
    elif spacing is None:
        raise BuildError('Must provide spacing if a bend direction is specified')

    has_rotation = numpy.array([p.rotation is not None for p in ports.values()], dtype=bool)
    if has_rotation.any():
        if set_rotation is not None:
            raise BuildError('set_rotation must be None when ports have rotations!')

        rotations = numpy.array([p.rotation if p.rotation is not None else 0
                                 for p in ports.values()])
        rotations[~has_rotation] = rotations[has_rotation][0]

        if not numpy.allclose(rotations[0], rotations):
            port_rotations = {k: numpy.rad2deg(p.rotation) if p.rotation is not None else None
                              for k, p in ports.items()}

            raise BuildError('Asked to find aggregation for ports that face in different directions:\n'
                             + pformat(port_rotations))
    else:
        if set_rotation is not None:
            raise BuildError('set_rotation must be specified if no ports have rotations!')
        rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)

    is_horizontal = numpy.isclose(rotations[0] % pi, 0)
    if bound_type in ('ymin', 'ymax') and is_horizontal:
        raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!')
    if bound_type in ('xmin', 'xmax') and not is_horizontal:
        raise BuildError(f'Asked for {bound_type} position but ports are pointing along the y-axis!')

    direction = rotations[0] + pi                        # direction we want to travel in (+pi relative to port)
    rot_matrix = rotation_matrix_2d(-direction)

    # Rotate so are traveling in +x
    orig_offsets = numpy.array([p.offset for p in ports.values()])
    rot_offsets = (rot_matrix @ orig_offsets.T).T

#    ordering_base = rot_offsets.T * [[1], [-1 if ccw else 1]]      # could work, but this is actually a more complex routing problem
#    y_order = numpy.lexsort(ordering_base)                         #  (need to make sure we don't collide with the next input port @ same y)
    y_order = ((-1 if ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable')
    y_ind = numpy.empty_like(y_order, dtype=int)
    y_ind[y_order] = numpy.arange(y_ind.shape[0])

    if spacing is None:
        ch_offsets = numpy.zeros_like(y_order)
    else:
        steps = numpy.zeros_like(y_order)
        steps[1:] = spacing
        ch_offsets = numpy.cumsum(steps)[y_ind]

    x_start = rot_offsets[:, 0]

    #     A---------|  `d_to_align[0]`
    #               B  `d_to_align[1]`
    # C-------------|  `d_to_align[2]`
    #   D-----------|  `d_to_align[3]`
    #
    d_to_align = x_start.max() - x_start    # distance to travel to align all
    offsets: NDArray[numpy.float64]
    if bound_type == 'min_past_furthest':
        #     A------------------V  `d_to_exit[0]`
        #               B-----V     `d_to_exit[1]`
        # C----------------V        `d_to_exit[2]`
        #   D-----------V           `d_to_exit[3]`
        offsets = d_to_align + ch_offsets
    else:
        #     A---------V  `travel[0]`   <-- Outermost port aligned to furthest-x port
        #            V--B  `travel[1]`   <-- Remaining ports follow spacing
        # C-------V        `travel[2]`
        #   D--V           `travel[3]`
        #
        #     A------------V  `offsets[0]`
        #               B     `offsets[1]`   <-- Travels adjusted to be non-negative
        # C----------V        `offsets[2]`
        #   D-----V           `offsets[3]`
        travel = d_to_align - (ch_offsets.max() - ch_offsets)
        offsets = travel - travel.min().clip(max=0)

    rot_bound: SupportsFloat
    if bound_type in ('emin', 'min_extension',
                      'emax', 'max_extension',
                      'min_past_furthest',):
        if numpy.size(bound) == 2:
            bound = cast('Sequence[float]', bound)
            rot_bound = (rot_matrix @ ((bound[0], 0),
                                       (0, bound[1])))[0, :]
        else:
            bound = cast('float', bound)
            rot_bound = numpy.array(bound)

        if rot_bound < 0:
            raise BuildError(f'Got negative bound for extension: {rot_bound}')

        if bound_type in ('emin', 'min_extension', 'min_past_furthest'):
            offsets += rot_bound.max()
        elif bound_type in ('emax', 'max_extension'):
            offsets += rot_bound.min() - offsets.max()
    else:
        if numpy.size(bound) == 2:
            bound = cast('Sequence[float]', bound)
            rot_bound = (rot_matrix @ bound)[0]
        else:
            bound = cast('float', bound)
            neg = (direction + pi / 4) % (2 * pi) > pi
            rot_bound = -bound if neg else bound

        min_possible = x_start + offsets
        if bound_type in ('pmax', 'max_position', 'xmax', 'ymax'):
            extension = rot_bound - min_possible.max()
        elif bound_type in ('pmin', 'min_position', 'xmin', 'ymin'):
            extension = rot_bound - min_possible.min()

        offsets += extension
        if extension < 0:
            ext_floor = -numpy.floor(extension)
            raise BuildError(f'Position is too close by at least {ext_floor}. Total extensions would be\n\t'
                             + '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets, strict=True)))

    result = dict(zip(ports.keys(), offsets, strict=True))
    return result


---
masque/utils/__init__.py
---
'''
Various helper functions, type definitions, etc.
'''
from .types import (
    layer_t as layer_t,
    annotations_t as annotations_t,
    SupportsBool as SupportsBool,
    )
from .array import is_scalar as is_scalar
from .autoslots import AutoSlots as AutoSlots
from .deferreddict import DeferredDict as DeferredDict
from .decorators import oneshot as oneshot

from .bitwise import (
    get_bit as get_bit,
    set_bit as set_bit,
    )
from .vertices import (
    remove_duplicate_vertices as remove_duplicate_vertices,
    remove_colinear_vertices as remove_colinear_vertices,
    poly_contains_points as poly_contains_points,
    )
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,
    R90 as R90,
    R180 as R180,
    )
from .comparisons import (
    annotation2key as annotation2key,
    annotations_lt as annotations_lt,
    annotations_eq as annotations_eq,
    layer2key as layer2key,
    ports_lt as ports_lt,
    ports_eq as ports_eq,
    rep2key as rep2key,
    )

from . import ports2data as ports2data

from . import pack2d as pack2d


---
masque/utils/array.py
---
from typing import Any


def is_scalar(var: Any) -> bool:
    '''
    Alias for 'not hasattr(var, "__len__")'

    Args:
        var: Checks if `var` has a length.
    '''
    return not hasattr(var, "__len__")


---
masque/utils/autoslots.py
---
from abc import ABCMeta


class AutoSlots(ABCMeta):
    '''
    Metaclass for automatically generating __slots__ based on superclass type annotations.

    Superclasses must set `__slots__ = ()` to make this work properly.

    This is a workaround for the fact that non-empty `__slots__` can't be used
    with multiple inheritance. Since we only use multiple inheritance with abstract
    classes, they can have empty `__slots__` and their attribute type annotations
    can be used to generate a full `__slots__` for the concrete class.
    '''
    def __new__(cls, name, bases, dctn):        # noqa: ANN001,ANN204
        parents = set()
        for base in bases:
            parents |= set(base.mro())

        slots = tuple(dctn.get('__slots__', ()))
        for parent in parents:
            if not hasattr(parent, '__annotations__'):
                continue
            slots += tuple(parent.__annotations__.keys())

        dctn['__slots__'] = slots
        return super().__new__(cls, name, bases, dctn)


---
masque/utils/bitwise.py
---
from typing import Any


def get_bit(bit_string: Any, bit_id: int) -> bool:
    '''
    Interprets bit number `bit_id` from the right (lsb) of `bit_string` as a boolean

    Args:
        bit_string: Bit string to test
        bit_id: Bit number, 0-indexed from the right (lsb)

    Returns:
        Boolean value of the requested bit
    '''
    return bit_string & (1 << bit_id) != 0


def set_bit(bit_string: Any, bit_id: int, value: bool) -> Any:
    '''
    Returns `bit_string`, with bit number `bit_id` set to boolean `value`.

    Args:
        bit_string: Bit string to alter
        bit_id: Bit number, 0-indexed from right (lsb)
        value: Boolean value to set bit to

    Returns:
        Altered `bit_string`
    '''
    mask = (1 << bit_id)
    bit_string &= ~mask
    if value:
        bit_string |= mask
    return bit_string


---
masque/utils/comparisons.py
---
from typing import Any

from .types import annotations_t, layer_t
from ..ports import Port
from ..repetition import Repetition


def annotation2key(aaa: int | float | str) -> tuple[bool, Any]:
    return (isinstance(aaa, str), aaa)


def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool:
    if aa is None:
        return bb is not None
    elif bb is None:            # noqa: RET505
        return False

    if len(aa) != len(bb):
        return len(aa) < len(bb)

    keys_a = tuple(sorted(aa.keys()))
    keys_b = tuple(sorted(bb.keys()))
    if keys_a != keys_b:
        return keys_a < keys_b

    for key in keys_a:
        va = aa[key]
        vb = bb[key]
        if len(va) != len(vb):
            return len(va) < len(vb)

        for aaa, bbb in zip(va, vb, strict=True):
            if aaa != bbb:
                return annotation2key(aaa) < annotation2key(bbb)
    return False


def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool:
    if aa is None:
        return bb is None
    elif bb is None:            # noqa: RET505
        return False

    if len(aa) != len(bb):
        return False

    keys_a = tuple(sorted(aa.keys()))
    keys_b = tuple(sorted(bb.keys()))
    if keys_a != keys_b:
        return keys_a < keys_b

    for key in keys_a:
        va = aa[key]
        vb = bb[key]
        if len(va) != len(vb):
            return False

        for aaa, bbb in zip(va, vb, strict=True):
            if aaa != bbb:
                return False

    return True


def layer2key(layer: layer_t) -> tuple[bool, bool, Any]:
    is_int = isinstance(layer, int)
    is_str = isinstance(layer, str)
    layer_tup = (layer) if (is_str or is_int) else layer
    tup = (
        is_str,
        not is_int,
        layer_tup,
        )
    return tup


def rep2key(repetition: Repetition | None) -> tuple[bool, Repetition | None]:
    return (repetition is None, repetition)


def ports_eq(aa: dict[str, Port], bb: dict[str, Port]) -> bool:
    if len(aa) != len(bb):
        return False

    keys = sorted(aa.keys())
    if keys != sorted(bb.keys()):
        return False

    return all(aa[kk] == bb[kk] for kk in keys)


def ports_lt(aa: dict[str, Port], bb: dict[str, Port]) -> bool:
    if len(aa) != len(bb):
        return len(aa) < len(bb)

    aa_keys = tuple(sorted(aa.keys()))
    bb_keys = tuple(sorted(bb.keys()))
    if aa_keys != bb_keys:
        return aa_keys < bb_keys

    for key in aa_keys:
        pa = aa[key]
        pb = bb[key]
        if pa != pb:
            return pa < pb
    return False


---
masque/utils/curves.py
---
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        # type:ignore


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, ...]`
    '''
    nodes = numpy.asarray(nodes)
    tt = numpy.asarray(tt)
    nn = nodes.shape[0]
    weights = numpy.ones(nn) if weights is None else numpy.asarray(weights)

    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]
    for kk in range(1, nn):
        hh *= umul * (nn - kk) * weights[kk]
        hh /= kk * udiv * weights[kk - 1] + hh
        qq *= 1.0 - hh[:, None]
        qq += hh[:, None] * nodes[None, kk, :]
    return qq



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).

    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.
        num_points: Number of points in the curve

    Returns:
        `[[x0, y0], ...]` for the curve
    '''
    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_spiral(ll_max: float) -> NDArray[numpy.float64]:
        xx = []
        yy = []
        for ll in numpy.linspace(0, ll_max, num_points_spiral):
            qq = numpy.linspace(0, ll, 1000)        # integrate to current arclength
            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

    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 / ll_max
        half_angle = pi / 4 - 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_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_spiral = xy_spiral[::-1, ::-1] + endpoint_xy - xy_spiral[-1, ::-1]

    xy_parts.append(second_spiral)
    xy = numpy.concatenate(xy_parts)

    # Remove any 2x-duplicate points
    xy = xy[(numpy.roll(xy, 1, axis=0) != xy).any(axis=1)]

    return xy


---
masque/utils/decorators.py
---
from collections.abc import Callable
from functools import wraps

from ..error import OneShotError


def oneshot(func: Callable) -> Callable:
    '''
    Raises a OneShotError if the decorated function is called more than once
    '''
    expired = False

    @wraps(func)
    def wrapper(*args, **kwargs):       # noqa: ANN202
        nonlocal expired
        if expired:
            raise OneShotError(func.__name__)
        expired = True
        return func(*args, **kwargs)

    return wrapper


---
masque/utils/deferreddict.py
---
from typing import TypeVar, Generic
from collections.abc import Callable
from functools import lru_cache


Key = TypeVar('Key')
Value = TypeVar('Value')


class DeferredDict(dict, Generic[Key, Value]):
    '''
    This is a modified `dict` which is used to defer loading/generating
     values until they are accessed.

    ```
    bignum = my_slow_function()         # slow function call, would like to defer this
    numbers = DeferredDict()
    numbers['big'] = my_slow_function        # no slow function call here
    assert(bignum == numbers['big'])    # first access is slow (function called)
    assert(bignum == numbers['big'])    # second access is fast (result is cached)
    ```

    The `set_const` method is provided for convenience;
     `numbers['a'] = lambda: 10` is equivalent to `numbers.set_const('a', 10)`.
    '''
    def __init__(self, *args, **kwargs) -> None:
        dict.__init__(self)
        self.update(*args, **kwargs)

    def __setitem__(self, key: Key, value: Callable[[], Value]) -> None:
        cached_fn = lru_cache(maxsize=1)(value)
        dict.__setitem__(self, key, cached_fn)

    def __getitem__(self, key: Key) -> Value:
        return dict.__getitem__(self, key)()

    def update(self, *args, **kwargs) -> None:
        for k, v in dict(*args, **kwargs).items():
            self[k] = v

    def __repr__(self) -> str:
        return '<DeferredDict with keys ' + repr(set(self.keys())) + '>'

    def set_const(self, key: Key, value: Value) -> None:
        '''
        Convenience function to avoid having to manually wrap
         constant values into callables.
        '''
        self[key] = lambda: value


---
masque/utils/pack2d.py
---
'''
2D bin-packing
'''
from collections.abc import Sequence, Mapping, Callable

import numpy
from numpy.typing import NDArray, ArrayLike

from ..error import MasqueError
from ..pattern import Pattern


def maxrects_bssf(
        rects: ArrayLike,
        containers: ArrayLike,
        presort: bool = True,
        allow_rejects: bool = True,
        ) -> tuple[NDArray[numpy.float64], set[int]]:
    '''
    Pack rectangles `rects` into regions `containers` using the "maximal rectangles best short side fit"
    algorithm (maxrects_bssf) from "A thousand ways to pack the bin", Jukka Jylanki, 2010.

    This algorithm gives the best results, but is asymptotically slower than `guillotine_bssf_sas`.

    Args:
        rects: Nx2 array of rectangle sizes `[[x_size0, y_size0], ...]`.
        containers: Mx4 array of regions into which `rects` will be placed, specified using their
            corner coordinates ` [[x_min0, y_min0, x_max0, y_max0], ...]`.
        presort: If `True` (default), largest-shortest-side rectangles will be placed
            first. Otherwise, they will be placed in the order provided.
        allow_rejects: If `False`, `MasqueError` will be raised if any rectangle cannot be placed.

    Returns:
        `[[x_min0, y_min0], ...]` placement locations for `rects`, with the same ordering.
        The second argument is a set of indicies of `rects` entries which were rejected; their
        corresponding placement locations should be ignored.

    Raises:
        MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
    '''
    regions = numpy.asarray(containers, dtype=float)
    rect_sizes = numpy.asarray(rects, dtype=float)
    rect_locs = numpy.zeros_like(rect_sizes)
    rejected_inds = set()

    if presort:
        rotated_sizes = numpy.sort(rect_sizes, axis=1)  # shortest side first
        rect_order = numpy.lexsort(rotated_sizes.T)[::-1]  # Descending shortest side
        rect_sizes = rect_sizes[rect_order]

    for rect_ind, rect_size in enumerate(rect_sizes):
        ''' Remove degenerate regions '''
        # First remove duplicate regions (but keep one; code below would drop both)
        regions = numpy.unique(regions, axis=0)

        # Now remove regions enclosed in another
        min_more = (regions[None, :, :2] >= regions[:, None, :2]).all(axis=2)   # first axis > second axis
        max_less = (regions[None, :, 2:] <= regions[:, None, 2:]).all(axis=2)   # first axis < second axis
        max_less &= ~numpy.eye(regions.shape[0], dtype=bool)    # exclude self
        degenerate = (min_more & max_less).any(axis=0)
        regions = regions[~degenerate]

        ''' Place the rect '''
        # Best short-side fit (bssf) to pick a region
        region_sizes = regions[:, 2:] - regions[:, :2]
        bssf_scores = (region_sizes - rect_size).min(axis=1).astype(float)
        bssf_scores[bssf_scores < 0] = numpy.inf        # doesn't fit!
        rr = bssf_scores.argmin()
        if numpy.isinf(bssf_scores[rr]):
            if allow_rejects:
                rejected_inds.add(rect_ind)
                continue
            raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')

        # Read out location
        loc = regions[rr, :2]
        rect_locs[rect_ind] = loc

        ''' Shatter regions '''
        # Which regions does this rectangle intersect?
        min_over = regions[:, :2] >= loc + rect_size
        max_undr = regions[:, 2:] <= loc
        intersects = ~(min_over | max_undr).any(axis=1)

        # Which sides is there excess on?
        region_past_botleft = intersects[:, None] & (regions[:, :2] < loc)
        region_past_topright = intersects[:, None] & (regions[:, 2:] > loc + rect_size)

        # Create new regions
        r_lft = regions[region_past_botleft[:, 0]].copy()
        r_bot = regions[region_past_botleft[:, 1]].copy()
        r_rgt = regions[region_past_topright[:, 0]].copy()
        r_top = regions[region_past_topright[:, 1]].copy()

        r_lft[:, 2] = loc[0]
        r_bot[:, 3] = loc[1]
        r_rgt[:, 0] = loc[0] + rect_size[0]
        r_top[:, 1] = loc[1] + rect_size[1]

        regions = numpy.vstack((regions[~intersects], r_lft, r_bot, r_rgt, r_top))

    if presort:
        unsort_order = rect_order.argsort()
        rect_locs = rect_locs[unsort_order]
        rejected_inds = set(unsort_order[list(rejected_inds)])

    return rect_locs, rejected_inds


def guillotine_bssf_sas(
        rects: ArrayLike,
        containers: ArrayLike,
        presort: bool = True,
        allow_rejects: bool = True,
        ) -> tuple[NDArray[numpy.float64], set[int]]:
    '''
    Pack rectangles `rects` into regions `containers` using the "guillotine best short side fit with
    shorter axis split rule" algorithm (guillotine-BSSF-SAS) from "A thousand ways to pack the bin",
    Jukka Jylanki, 2010.

    This algorithm gives the worse results than `maxrects_bssf`, but is asymptotically faster.

    # TODO consider adding rectangle-merge?
    # TODO guillotine could use some additional testing

    Args:
        rects: Nx2 array of rectangle sizes `[[x_size0, y_size0], ...]`.
        containers: Mx4 array of regions into which `rects` will be placed, specified using their
            corner coordinates ` [[x_min0, y_min0, x_max0, y_max0], ...]`.
        presort: If `True` (default), largest-shortest-side rectangles will be placed
            first. Otherwise, they will be placed in the order provided.
        allow_rejects: If `False`, `MasqueError` will be raised if any rectangle cannot be placed.

    Returns:
        `[[x_min0, y_min0], ...]` placement locations for `rects`, with the same ordering.
        The second argument is a set of indicies of `rects` entries which were rejected; their
        corresponding placement locations should be ignored.

    Raises:
        MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
    '''
    regions = numpy.asarray(containers, dtype=float)
    rect_sizes = numpy.asarray(rects, dtype=float)
    rect_locs = numpy.zeros_like(rect_sizes)
    rejected_inds = set()

    if presort:
        rotated_sizes = numpy.sort(rect_sizes, axis=1)  # shortest side first
        rect_order = numpy.lexsort(rotated_sizes.T)[::-1]  # Descending shortest side
        rect_sizes = rect_sizes[rect_order]

    for rect_ind, rect_size in enumerate(rect_sizes):
        ''' Place the rect '''
        # Best short-side fit (bssf) to pick a region
        region_sizes = regions[:, 2:] - regions[:, :2]
        bssf_scores = (region_sizes - rect_size).min(axis=1).astype(float)
        bssf_scores[bssf_scores < 0] = numpy.inf        # doesn't fit!
        rr = bssf_scores.argmin()
        if numpy.isinf(bssf_scores[rr]):
            if allow_rejects:
                rejected_inds.add(rect_ind)
                continue
            raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')

        # Read out location
        loc = regions[rr, :2]
        rect_locs[rect_ind] = loc

        region_size = region_sizes[rr]
        split_horiz = region_size[0] < region_size[1]

        new_region0 = regions[rr].copy()
        new_region1 = new_region0.copy()
        split_vertex = loc + rect_size
        if split_horiz:
            new_region0[2] = split_vertex[0]
            new_region0[1] = split_vertex[1]
            new_region1[0] = split_vertex[0]
        else:
            new_region0[3] = split_vertex[1]
            new_region0[0] = split_vertex[0]
            new_region1[1] = split_vertex[1]

        regions = numpy.vstack((regions[:rr], regions[rr + 1:],
                                new_region0, new_region1))

    if presort:
        unsort_order = rect_order.argsort()
        rect_locs = rect_locs[unsort_order]
        rejected_inds = set(unsort_order[list(rejected_inds)])

    return rect_locs, rejected_inds


def pack_patterns(
        library: Mapping[str, Pattern],
        patterns: Sequence[str],
        containers: ArrayLike,
        spacing: tuple[float, float],
        presort: bool = True,
        allow_rejects: bool = True,
        packer: Callable = maxrects_bssf,
        ) -> tuple[Pattern, list[str]]:
    '''
    Pick placement locations for `patterns` inside the regions specified by `containers`.
    No rotations are performed.

    Args:
        library: Library from which `Pattern` objects will be drawn.
        patterns: Sequence of pattern names which are to be placed.
        containers: Mx4 array of regions into which `patterns` will be placed, specified using their
            corner coordinates ` [[x_min0, y_min0, x_max0, y_max0], ...]`.
        spacing: (x, y) spacing between adjacent patterns. Patterns are effectively expanded outwards
            by `spacing / 2` prior to placement, so this also affects pattern position relative to
            container edges.
        presort: If `True` (default), largest-shortest-side rectangles will be placed
            first. Otherwise, they will be placed in the order provided.
        allow_rejects: If `False`, `MasqueError` will be raised if any rectangle cannot be placed.
        packer: Bin-packing method; see the other functions in this module (namely `maxrects_bssf`
            and `guillotine_bssf_sas`).

    Returns:
        A `Pattern` containing one `Ref` for each entry in `patterns`.
        A list of "rejected" pattern names, for which a valid placement location could not be found.

    Raises:
        MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
    '''

    half_spacing = numpy.asarray(spacing, dtype=float) / 2

    bounds = [library[pp].get_bounds() for pp in patterns]
    sizes = [bb[1] - bb[0] + spacing if bb is not None else spacing for bb in bounds]
    offsets = [half_spacing - bb[0] if bb is not None else (0, 0) for bb in bounds]

    locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects)

    pat = Pattern()
    for pp, oo, loc in zip(patterns, offsets, locations, strict=True):
        pat.ref(pp, offset=oo + loc)

    rejects = [patterns[ii] for ii in reject_inds]
    return pat, rejects


---
masque/utils/ports2data.py
---
'''
Functions for writing port data into Pattern geometry/annotations/labels (`ports_to_data`)
and retrieving it (`data_to_ports`).

  These use the format 'name:ptype angle_deg' written into labels, which are placed at
the port locations. This particular approach is just a sensible default; feel free to
to write equivalent functions for your own format or alternate storage methods.
'''
from collections.abc import Sequence, Mapping
import logging
from itertools import chain

import numpy

from ..pattern import Pattern
from ..utils import layer_t
from ..ports import Port
from ..error import PatternError
from ..library import ILibraryView, LibraryView


logger = logging.getLogger(__name__)


def ports_to_data(pattern: Pattern, layer: layer_t) -> Pattern:
    '''
    Place a text label at each port location, specifying the port data in the format
        'name:ptype angle_deg'

    This can be used to debug port locations or to automatically generate ports
      when reading in a GDS file.

    NOTE that `pattern` is modified by this function

    Args:
        pattern: The pattern which is to have its ports labeled. MODIFIED in-place.
        layer: The layer on which the labels will be placed.

    Returns:
        `pattern`
    '''
    for name, port in pattern.ports.items():
        if port.rotation is None:
            angle_deg = numpy.inf
        else:
            angle_deg = numpy.rad2deg(port.rotation)
        pattern.label(layer=layer, string=f'{name}:{port.ptype} {angle_deg:g}', offset=port.offset)
    return pattern


def data_to_ports(
        layers: Sequence[layer_t],
        library: Mapping[str, Pattern],
        pattern: Pattern,               # Pattern is good since we don't want to do library[name] to avoid infinite recursion.
                                        # LazyLibrary protects against library[ref.target] causing a circular lookup.
                                        # For others, maybe check for cycles up front? TODO
        name: str | None = None,     # Note: name optional, but arg order different from read(postprocess=)
        max_depth: int = 0,
        skip_subcells: bool = True,
        # TODO missing ok?
        ) -> Pattern:
    '''
    # TODO fixup documentation in ports2data
    # TODO move to utils.file?
    Examine `pattern` for labels specifying port info, and use that info
      to fill out its `ports` attribute.

    Labels are assumed to be placed at the port locations, and have the format
      'name:ptype angle_deg'

    Args:
        layers: Search for labels on all the given layers.
        pattern: Pattern object to scan for labels.
        max_depth: Maximum hierarcy depth to search. Default 999_999.
            Reduce this to 0 to avoid ever searching subcells.
        skip_subcells: If port labels are found at a given hierarcy level,
            do not continue searching at deeper levels. This allows subcells
            to contain their own port info without interfering with supercells'
            port data.
            Default True.

    Returns:
        The updated `pattern`. Port labels are not removed.
    '''
    if pattern.ports:
        logger.warning(f'Pattern {name if name else pattern} already had ports, skipping data_to_ports')
        return pattern

    if not isinstance(library, ILibraryView):
        library = LibraryView(library)

    data_to_ports_flat(layers, pattern, name)
    if (skip_subcells and pattern.ports) or max_depth == 0:
        return pattern

    # Load ports for all subpatterns, and use any we find
    found_ports = False
    for target in pattern.refs:
        if target is None:
            continue
        pp = data_to_ports(
            layers=layers,
            library=library,
            pattern=library[target],
            name=target,
            max_depth=max_depth - 1,
            skip_subcells=skip_subcells,
            )
        found_ports |= bool(pp.ports)

    if not found_ports:
        return pattern

    for target, refs in pattern.refs.items():
        if target is None:
            continue
        if not refs:
            continue

        for ref in refs:
            aa = library.abstract(target)
            if not aa.ports:
                break

            aa.apply_ref_transform(ref)
            pattern.check_ports(other_names=aa.ports.keys())
            pattern.ports.update(aa.ports)
    return pattern


def data_to_ports_flat(
        layers: Sequence[layer_t],
        pattern: Pattern,
        cell_name: str | None = None,
        ) -> Pattern:
    '''
    Examine `pattern` for labels specifying port info, and use that info
      to fill out its `ports` attribute.

    Labels are assumed to be placed at the port locations, and have the format
      'name:ptype angle_deg'

    The pattern is assumed to be flat (have no `refs`) and have no pre-existing ports.

    Args:
        layers: Search for labels on all the given layers.
        pattern: Pattern object to scan for labels.
        cell_name: optional, used for warning message only

    Returns:
        The updated `pattern`. Port labels are not removed.
    '''
    labels = list(chain.from_iterable(pattern.labels[layer] for layer in layers))
    if not labels:
        return pattern

    pstr = cell_name if cell_name is not None else repr(pattern)
    if pattern.ports:
        raise PatternError(f'Pattern "{pstr}" has pre-existing ports!')

    local_ports = {}
    for label in labels:
        name, property_string = label.string.split(':')
        properties = property_string.split(' ')
        ptype = properties[0]
        angle_deg = float(properties[1]) if len(ptype) else 0

        xy = label.offset
        angle = numpy.deg2rad(angle_deg)

        if name in local_ports:
            logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"')

        local_ports[name] = Port(offset=xy, rotation=angle, ptype=ptype)

    pattern.ports.update(local_ports)
    return pattern



---
masque/utils/transform.py
---
'''
Geometric transforms
'''
from collections.abc import Sequence
from functools import lru_cache

import numpy
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]:
    '''
    2D rotation matrix for rotating counterclockwise around the origin.

    Args:
        theta: Angle to rotate, in radians

    Returns:
        rotation matrix
    '''
    arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
                       [numpy.sin(theta), +numpy.cos(theta)]])

    # If this was a manhattan rotation, round to remove some inacuraccies in sin & cos
    if numpy.isclose(theta % (pi / 2), 0):
        arr = numpy.round(arr)

    arr.flags.writeable = False
    return arr


def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]:
    '''
    Converts 0-2 mirror operations `(mirror_across_x_axis, mirror_across_y_axis)`
    into 0-1 mirror operations and a rotation

    Args:
        mirrored: `(mirror_across_x_axis, mirror_across_y_axis)`

    Returns:
        `mirror_across_x_axis` (bool) and
        `angle_to_rotate` in radians
    '''

    mirrored_x, mirrored_y = mirrored
    mirror_x = (mirrored_x != mirrored_y)  # XOR
    angle = numpy.pi if mirrored_y else 0
    return mirror_x, angle


def rotate_offsets_around(
        offsets: NDArray[numpy.float64],
        pivot: NDArray[numpy.float64],
        angle: float,
        ) -> 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)


---
masque/utils/types.py
---
'''
Type definitions
'''
from typing import Protocol


layer_t = int | tuple[int, int] | str
annotations_t = dict[str, list[int | float | str]] | None


class SupportsBool(Protocol):
    def __bool__(self) -> bool:
        ...


---
masque/utils/vertices.py
---
'''
Vertex list operations
'''
import numpy
from numpy.typing import NDArray, ArrayLike


def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]:
    '''
    Given a list of vertices, remove any consecutive duplicates.

    Args:
        vertices: `[[x0, y0], [x1, y1], ...]`
        closed_path: If True, `vertices` is interpreted as an implicity-closed path
            (i.e. the last vertex will be removed if it is the same as the first)

    Returns:
        `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)
    if not closed_path:
        duplicates[0] = False
    return vertices[~duplicates]


def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]:
    '''
    Given a list of vertices, remove any superflous vertices (i.e.
        those which lie along the line formed by their neighbors)

    Args:
        vertices: Nx2 ndarray of vertices
        closed_path: If `True`, the vertices are assumed to represent an implicitly
           closed path. If `False`, the path is assumed to be open. Default `True`.

    Returns:
        `vertices` with colinear (superflous) vertices removed. May be a view into the original array.
    '''
    vertices = remove_duplicate_vertices(vertices)

    # Check for dx0/dy0 == dx1/dy1

    dv = numpy.roll(vertices, -1, axis=0) - vertices  # [y1-y0, y2-y1, ...]
    dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1]    # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]]

    dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0]
    err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40

    slopes_equal = (dxdy_diff / err_mult) < 1e-15
    if not closed_path:
        slopes_equal[[0, -1]] = False

    return vertices[~slopes_equal]


def poly_contains_points(
        vertices: ArrayLike,
        points: ArrayLike,
        include_boundary: bool = True,
        ) -> NDArray[numpy.int_]:
    '''
    Tests whether the provided points are inside the implicitly closed polygon
    described by the provided list of vertices.

    Args:
        vertices: Nx2 Arraylike of form [[x0, y0], [x1, y1], ...], describing an implicitly-
            closed polygon. Note that this should include any offsets.
        points: Nx2 ArrayLike of form [[x0, y0], [x1, y1], ...] containing the points to test.
        include_boundary: True if points on the boundary should be count as inside the shape.
            Default True.

    Returns:
        ndarray of booleans, [point0_is_in_shape, point1_is_in_shape, ...]
    '''
    points = numpy.asarray(points, dtype=float)
    vertices = numpy.asarray(vertices, dtype=float)

    if points.size == 0:
        return numpy.zeros(0, dtype=numpy.int8)

    min_bounds = numpy.min(vertices, axis=0)[None, :]
    max_bounds = numpy.max(vertices, axis=0)[None, :]

    trivially_outside = ((points < min_bounds).any(axis=1)
                       | (points > max_bounds).any(axis=1))     # noqa: E128

    nontrivial = ~trivially_outside
    if trivially_outside.all():
        inside = numpy.zeros_like(trivially_outside, dtype=bool)
        return inside

    ntpts = points[None, nontrivial, :]     # nontrivial points, along axis 1 of ndarray
    verts = vertices[:, :, None]

    y0_le = verts[:, 1] <= ntpts[..., 1]      # (axis 0) y_vertex <= y_point (axis 1)
    y1_le = numpy.roll(y0_le, -1, axis=0)          # rolled by 1 vertex

    upward = y0_le & ~y1_le
    downward = ~y0_le & y1_le

    dv = numpy.roll(verts, -1, axis=0) - verts
    is_left = (dv[:, 0] * (ntpts[..., 1] - verts[:, 1])        # >0 if left of dv, <0 if right, 0 if on the line
             - dv[:, 1] * (ntpts[..., 0] - verts[:, 0]))    # noqa: E128

    winding_number = ((upward & (is_left > 0)).sum(axis=0)
                  - (downward & (is_left < 0)).sum(axis=0))    # noqa: E128

    nontrivial_inside = winding_number != 0        # filter nontrivial points based on winding number
    if include_boundary:
        nontrivial_inside[(is_left == 0).any(axis=0)] = True        # check if point lies on any edge

    inside = nontrivial.copy()
    inside[nontrivial] = nontrivial_inside
    return inside


---
masque/test/__init__.py
---
'''
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
'''


---
masque/test/conftest.py
---
'''

Test fixtures

'''
# ruff: noqa: ARG001
from typing import Any
import numpy
from numpy.typing import NDArray

import pytest       # type: ignore


FixtureRequest = Any
PRNG = numpy.random.RandomState(12345)


@pytest.fixture(scope='module',
                params=[(5, 5, 1),
                        (5, 1, 5),
                        (5, 5, 5),
                        # (7, 7, 7),
                       ])
def shape(request: FixtureRequest) -> tuple[int, ...]:
    return (3, *request.param)


@pytest.fixture(scope='module', params=[1.0, 1.5])
def epsilon_bg(request: FixtureRequest) -> float:
    return request.param



---
masque/test/test_fdfd.py
---
# ruff: noqa: ARG001
import dataclasses
import pytest       # type: ignore
import numpy
from numpy import pi
from numpy.typing import NDArray
#from numpy.testing import assert_allclose, assert_array_equal

from .. import Pattern, Arc



def test_Arc_arclen() -> None:
    max_arclen = 1000

    wedge = Arc(radii=( 50,  50), angles=(-pi/4, pi/4), width=100)
    arc   = Arc(radii=(100, 100), angles=(-pi/4, pi/4), width=1)

    verts_wedge = wedge.to_polygons(max_arclen=max_arclen)[0].vertices
    verts_arc = arc.to_polygons(max_arclen=max_arclen)[0].vertices

    dxy_wedge = numpy.roll(verts_wedge, 1, axis=0) - verts_wedge
    dxy_arc   = numpy.roll(verts_arc, 1, axis=0) - verts_arc

    dl_wedge = numpy.sqrt((dxy_wedge * dxy_wedge).sum(axis=1))
    dl_arc = numpy.sqrt((dxy_arc * dxy_arc).sum(axis=1))

    assert dl_wedge.max() < max_arclen
    assert dl_arc.max() < max_arclen

    print(verts_wedge.shape[0])
    print(verts_arc.shape[0])
    print(dl_wedge, dl_arc)
    Pattern(shapes={(0, 0): [wedge, arc]}).visualize()
    assert False


---

Please start by generating tests for Polygon, Ellipse, and Arc. Please write new files, to be placed in masque/test/. The tests should use pytest.
