diff --git a/README.md b/README.md index 250eef3..2ed7489 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ tree = make_tree(...) # To reference this cell in our layout, we have to add all its children to our `library` first: top_name = tree.top() # get the name of the topcell -name_mapping = library.add(tree) # add all patterns from `tree`, renaming eligible conflicting patterns +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 @@ -176,7 +176,7 @@ my_pattern.place(library << make_tree(...), ...) ### Quickly add geometry, labels, or refs: -Adding elements can be overly verbose: +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')] @@ -228,11 +228,9 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...) ## TODO -* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath) -* PolyCollection & arrow-based read/write -* pather and renderpather examples, including .at() (PortPather) -* Bus-to-bus connections? -* Tests tests tests * Better interface for polygon operations (e.g. with `pyclipper`) - de-embedding - boolean ops +* Tests tests tests +* check renderpather +* pather and renderpather examples diff --git a/masque/__init__.py b/masque/__init__.py index 4ad7e69..86fae91 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -77,10 +77,8 @@ from .builder import ( Pather as Pather, RenderPather as RenderPather, RenderStep as RenderStep, - SimpleTool as SimpleTool, - AutoTool as AutoTool, + BasicTool as BasicTool, PathTool as PathTool, - PortPather as PortPather, ) from .utils import ( ports2data as ports2data, @@ -92,5 +90,5 @@ from .utils import ( __author__ = 'Jan Petykiewicz' -__version__ = '3.4' +__version__ = '3.3' version = __version__ # legacy diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 2fd00a4..1eea9f1 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,12 +1,10 @@ from .builder import Builder as Builder from .pather import Pather as Pather from .renderpather import RenderPather as RenderPather -from .pather_mixin import PortPather as PortPather from .utils import ell as ell from .tools import ( Tool as Tool, RenderStep as RenderStep, - SimpleTool as SimpleTool, - AutoTool as AutoTool, + BasicTool as BasicTool, PathTool as PathTool, ) diff --git a/masque/builder/builder.py b/masque/builder/builder.py index ee1d277..fed839a 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -67,7 +67,7 @@ class Builder(PortList): - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, - argument is provided, and the `thru` argument is not explicitly + 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. @@ -223,7 +223,7 @@ class Builder(PortList): map_out: dict[str, str | None] | None = None, *, mirrored: bool = False, - thru: bool | str = True, + inherit_name: bool = True, set_rotation: bool | None = None, append: bool = False, ok_connections: Iterable[tuple[str, str]] = (), @@ -246,15 +246,11 @@ class Builder(PortList): new names for ports in `other`. mirrored: Enables mirroring `other` across the x axis prior to connecting any ports. - thru: If map_in specifies only a single port, `thru` provides a mechainsm - to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, - - If True (default), and `other` has only two ports total, and map_out - doesn't specify a name for the other port, its name is set to the key - in `map_in`, i.e. 'myport'. - - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). - An error is raised if that entry already exists. - - This makes it easy to extend a pattern with simple 2-port devices + 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 @@ -296,14 +292,14 @@ class Builder(PortList): other = self.library[other.name] self.pattern.plug( - other = other, - map_in = map_in, - map_out = map_out, - mirrored = mirrored, - thru = thru, - set_rotation = set_rotation, - append = append, - ok_connections = ok_connections, + other=other, + map_in=map_in, + map_out=map_out, + mirrored=mirrored, + inherit_name=inherit_name, + set_rotation=set_rotation, + append=append, + ok_connections=ok_connections, ) return self @@ -369,14 +365,14 @@ class Builder(PortList): other = self.library[other.name] self.pattern.place( - other = other, - offset = offset, - rotation = rotation, - pivot = pivot, - mirrored = mirrored, - port_map = port_map, - skip_port_check = skip_port_check, - append = append, + other=other, + offset=offset, + rotation=rotation, + pivot=pivot, + mirrored=mirrored, + port_map=port_map, + skip_port_check=skip_port_check, + append=append, ) return self diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 9af473d..87f08a3 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -2,25 +2,31 @@ Manual wire/waveguide routing (`Pather`) """ from typing import Self -from collections.abc import Sequence, Mapping, MutableMapping +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 -from ..error import BuildError +from ..library import ILibrary, SINGLE_USE_PREFIX +from ..error import PortError, BuildError from ..ports import PortList, Port -from ..utils import SupportsBool +from ..abstract import Abstract +from ..utils import SupportsBool, rotation_matrix_2d from .tools import Tool -from .pather_mixin import PatherMixin +from .utils import ell from .builder import Builder logger = logging.getLogger(__name__) -class Pather(Builder, PatherMixin): +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. @@ -252,6 +258,60 @@ class Pather(Builder, PatherMixin): s = f'' return s + def retool( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Self: + """ + Update the `Tool` which will be used when generating `Pattern`s for the ports + given by `keys`. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + if keys is None or isinstance(keys, str): + self.tools[keys] = tool + else: + for key in keys: + self.tools[key] = tool + return self + + @contextmanager + def toolctx( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Iterator[Self]: + """ + Context manager for temporarily `retool`-ing and reverting the `retool` + upon exiting the context. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + if keys is None or isinstance(keys, str): + keys = [keys] + saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` + try: + yield self.retool(tool=tool, keys=keys) + finally: + for kk, tt in saved_tools.items(): + if tt is None: + # delete if present + self.tools.pop(kk, None) + else: + self.tools[kk] = tt def path( self, @@ -259,6 +319,7 @@ class Pather(Builder, PatherMixin): ccw: SupportsBool | None, length: float, *, + tool_port_names: tuple[str, str] = ('A', 'B'), plug_into: str | None = None, **kwargs, ) -> Self: @@ -266,7 +327,7 @@ class Pather(Builder, PatherMixin): Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim of traveling exactly `length` distance. - The wire will travel `length` distance along the port's axis, and an unspecified + 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. @@ -277,6 +338,9 @@ class Pather(Builder, PatherMixin): 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`. @@ -291,44 +355,54 @@ class Pather(Builder, PatherMixin): logger.error('Skipping path() since device is dead') return self - tool_port_names = ('A', 'B') - tool = self.tools.get(portspec, self.tools[None]) in_ptype = self.pattern[portspec].ptype tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - tname = self.library << tree + abstract = self.library << tree if plug_into is not None: output = {plug_into: tool_port_names[1]} else: output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) - return self + return self.plug(abstract, {portspec: tool_port_names[0], **output}) - def pathS( + def path_to( self, portspec: str, - length: float, - jog: float, + 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 an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is - left of direction of travel). + Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of ending exactly at a target position. - The output port will have the same orientation as the source port (`portspec`). - - This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former - raises a NotImplementedError. + 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. - jog: Total manhattan distance perpendicular to the direction of travel. - Positive values are to the left of the direction of travel. - length: The total manhattan distance from input to output, along the input's axis only. + 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`. @@ -336,40 +410,317 @@ class Pather(Builder, PatherMixin): self Raises: - BuildError if `distance` is too small to fit the s-bend (for nonzero jog). - LibraryError if no valid name could be picked for the pattern. + 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 pathS() since device is dead') + logger.error('Skipping path_to() since device is dead') return self - tool_port_names = ('A', 'B') + 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') - tool = self.tools.get(portspec, self.tools[None]) - in_ptype = self.pattern[portspec].ptype - try: - tree = tool.pathS(length, jog, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - except NotImplementedError: - # Fall back to drawing two L-bends - ccw0 = jog > 0 - kwargs_no_out = kwargs | {'out_ptype': None} - t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) - t_pat0 = t_tree0.top_pattern() - (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) - t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs) - t_pat1 = t_tree1.top_pattern() - (_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]]) + port = self.pattern[portspec] + if port.rotation is None: + raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') - kwargs_plug = kwargs | {'plug_into': plug_into} - self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) - self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) - return self + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise BuildError('path_to was asked to route from non-manhattan port') - tname = self.library << tree - if plug_into is not None: - output = {plug_into: tool_port_names[1]} + 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: - output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) + 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] + + 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 diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py deleted file mode 100644 index 1655329..0000000 --- a/masque/builder/pather_mixin.py +++ /dev/null @@ -1,677 +0,0 @@ -from typing import Self, overload -from collections.abc import Sequence, Iterator, Iterable -import logging -from contextlib import contextmanager -from abc import abstractmethod, ABCMeta - -import numpy -from numpy import pi -from numpy.typing import ArrayLike - -from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import PortError, BuildError -from ..utils import SupportsBool -from ..abstract import Abstract -from .tools import Tool -from .utils import ell -from ..ports import PortList - - -logger = logging.getLogger(__name__) - - -class PatherMixin(PortList, metaclass=ABCMeta): - pattern: Pattern - """ Layout of this device """ - - library: ILibrary - """ Library from which patterns should be referenced """ - - _dead: bool - """ If True, plug()/place() are skipped (for debugging) """ - - tools: dict[str | None, Tool] - """ - Tool objects are used to dynamically generate new single-use Devices - (e.g wires or waveguides) to be plugged into this device. - """ - - @abstractmethod - def path( - self, - portspec: str, - ccw: SupportsBool | None, - length: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - @abstractmethod - def pathS( - self, - portspec: str, - length: float, - jog: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - @abstractmethod - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - *, - mirrored: bool = False, - thru: bool | str = True, - set_rotation: bool | None = None, - append: bool = False, - ok_connections: Iterable[tuple[str, str]] = (), - ) -> Self: - pass - - def retool( - self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, - ) -> Self: - """ - Update the `Tool` which will be used when generating `Pattern`s for the ports - given by `keys`. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - self.tools[keys] = tool - else: - for key in keys: - self.tools[key] = tool - return self - - @contextmanager - def toolctx( - self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, - ) -> Iterator[Self]: - """ - Context manager for temporarily `retool`-ing and reverting the `retool` - upon exiting the context. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - keys = [keys] - saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` - try: - yield self.retool(tool=tool, keys=keys) - finally: - for kk, tt in saved_tools.items(): - if tt is None: - # delete if present - self.tools.pop(kk, None) - else: - self.tools[kk] = tt - - def path_to( - self, - portspec: str, - ccw: SupportsBool | None, - position: float | None = None, - *, - x: float | None = None, - y: float | None = None, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Build a "wire"/"waveguide" extending from the port `portspec`, with the aim - of ending exactly at a target position. - - The wire will travel so that the output port will be placed at exactly the target - position along the input port's axis. There can be an unspecified (tool-dependent) - offset in the perpendicular direction. The output port will be rotated (or not) - based on the `ccw` parameter. - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - position: The final port position, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - Only one of `position`, `x`, and `y` may be specified. - x: The final port position along the x axis. - `portspec` must refer to a horizontal port if `x` is passed, otherwise a - BuildError will be raised. - y: The final port position along the y axis. - `portspec` must refer to a vertical port if `y` is passed, otherwise a - BuildError will be raised. - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Raises: - BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend - is present). - BuildError if `x` or `y` is specified but does not match the axis of `portspec`. - BuildError if more than one of `x`, `y`, and `position` is specified. - """ - if self._dead: - logger.error('Skipping path_to() since device is dead') - return self - - pos_count = sum(vv is not None for vv in (position, x, y)) - if pos_count > 1: - raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') - if pos_count < 1: - raise BuildError('One of `position`, `x`, and `y` must be specified') - - port = self.pattern[portspec] - if port.rotation is None: - raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') - - if not numpy.isclose(port.rotation % (pi / 2), 0): - raise BuildError('path_to was asked to route from non-manhattan port') - - is_horizontal = numpy.isclose(port.rotation % pi, 0) - if is_horizontal: - if y is not None: - raise BuildError('Asked to path to y-coordinate, but port is horizontal') - if position is None: - position = x - else: - if x is not None: - raise BuildError('Asked to path to x-coordinate, but port is vertical') - if position is None: - position = y - - x0, y0 = port.offset - if is_horizontal: - if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): - raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') - length = numpy.abs(position - x0) - else: - if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): - raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') - length = numpy.abs(position - y0) - - return self.path( - portspec, - ccw, - length, - plug_into = plug_into, - **kwargs, - ) - - def path_into( - self, - portspec_src: str, - portspec_dst: str, - *, - out_ptype: str | None = None, - plug_destination: bool = True, - thru: str | None = None, - **kwargs, - ) -> Self: - """ - Create a "wire"/"waveguide" traveling between the ports `portspec_src` and - `portspec_dst`, and `plug` it into both (or just the source port). - - Only unambiguous scenarios are allowed: - - Straight connector between facing ports - - Single 90 degree bend - - Jog between facing ports - (jog is done as late as possible, i.e. only 2 L-shaped segments are used) - - By default, the destination's `pytpe` will be used as the `out_ptype` for the - wire, and the `portspec_dst` will be plugged (i.e. removed). - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec_src: The name of the starting port into which the wire will be plugged. - portspec_dst: The name of the destination port. - out_ptype: Passed to the pathing tool in order to specify the desired port type - to be generated at the destination end. If `None` (default), the destination - port's `ptype` will be used. - thru: If not `None`, the port by this name will be rename to `portspec_src`. - This can be used when routing a signal through a pre-placed 2-port device. - - Returns: - self - - 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) - - dst_extra_args = {'out_ptype': out_ptype} - if plug_destination: - dst_extra_args['plug_into'] = portspec_dst - - src_args = {**kwargs} - dst_args = {**src_args, **dst_extra_args} - if src_is_horizontal and not dst_is_horizontal: - # single bend should suffice - self.path_to(portspec_src, angle > pi, x=xd, **src_args) - self.path_to(portspec_src, None, y=yd, **dst_args) - elif dst_is_horizontal and not src_is_horizontal: - # single bend should suffice - self.path_to(portspec_src, angle > pi, y=yd, **src_args) - self.path_to(portspec_src, None, x=xd, **dst_args) - elif numpy.isclose(angle, pi): - if src_is_horizontal and ys == yd: - # straight connector - self.path_to(portspec_src, None, x=xd, **dst_args) - elif not src_is_horizontal and xs == xd: - # straight connector - self.path_to(portspec_src, None, y=yd, **dst_args) - else: - # S-bend, delegate to implementations - (travel, jog), _ = port_src.measure_travel(port_dst) - self.pathS(portspec_src, -travel, -jog, **dst_args) - elif numpy.isclose(angle, 0): - raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') - else: - raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') - - if thru is not None: - self.rename_ports({thru: portspec_src}) - - return self - - def mpath( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - set_rotation: float | None = None, - **kwargs, - ) -> Self: - """ - `mpath` is a superset of `path` and `path_to` which can act on bundles or buses - of "wires or "waveguides". - - The wires will travel so that the output ports will be placed at well-defined - locations along the axis of their input ports, but may have arbitrary (tool- - dependent) offsets in the perpendicular direction. - - If `ccw` is not `None`, the wire bundle will turn 90 degres in either the - clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the - bundle, the center-to-center wire spacings after the turn are set by `spacing`, - which is required when `ccw` is not `None`. The final position of bundle as a - whole can be set in a number of ways: - - =A>---------------------------V turn direction: `ccw=False` - =B>-------------V | - =C>-----------------------V | - =D=>----------------V | - | - - x---x---x---x `spacing` (can be scalar or array) - - <--------------> `emin=` - <------> `bound_type='min_past_furthest', bound=` - <--------------------------------> `emax=` - x `pmin=` - x `pmax=` - - - `emin=`, equivalent to `bound_type='min_extension', bound=` - The total extension value for the furthest-out port (B in the diagram). - - `emax=`, equivalent to `bound_type='max_extension', bound=`: - The total extension value for the closest-in port (C in the diagram). - - `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`: - The coordinate of the innermost bend (D's bend). - The x/y versions throw an error if they do not match the port axis (for debug) - - `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`: - The coordinate of the outermost bend (A's bend). - The x/y versions throw an error if they do not match the port axis (for debug) - - `bound_type='min_past_furthest', bound=`: - The distance between furthest out-port (B) and the innermost bend (D's bend). - - If `ccw=None`, final output positions (along the input axis) of all wires will be - identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is - required. In this case, `emin=` and `emax=` are equivalent to each other, and - `pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other. - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The names of the ports which are to be routed. - ccw: If `None`, the outputs should be along the same axis as the inputs. - Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` - and clockwise otherwise. - spacing: Center-to-center distance between output ports along the input port's axis. - Must be provided if (and only if) `ccw` is not `None`. - set_rotation: If the provided ports have `rotation=None`, this can be used - to set a rotation for them. - - Returns: - self - - Raises: - BuildError if the implied length for any wire is too close to fit the bend - (if a bend is requested). - BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not - match the axis of `portspec`. - BuildError if an incorrect bound type or spacing is specified. - """ - if self._dead: - logger.error('Skipping mpath() since device is dead') - return self - - bound_types = set() - if 'bound_type' in kwargs: - bound_types.add(kwargs.pop('bound_type')) - bound = kwargs.pop('bound') - for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): - if bt in kwargs: - bound_types.add(bt) - bound = kwargs.pop(bt) - - if not bound_types: - raise BuildError('No bound type specified for mpath') - if len(bound_types) > 1: - raise BuildError(f'Too many bound types specified for mpath: {bound_types}') - bound_type = tuple(bound_types)[0] - - if isinstance(portspec, str): - portspec = [portspec] - ports = self.pattern[tuple(portspec)] - - extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) - - #if container: - # assert not getattr(self, 'render'), 'Containers not implemented for RenderPather' - # bld = self.interface(source=ports, library=self.library, tools=self.tools) - # for port_name, length in extensions.items(): - # bld.path(port_name, ccw, length, **kwargs) - # self.library[container] = bld.pattern - # self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'? - #else: - for port_name, length in extensions.items(): - self.path(port_name, ccw, length, **kwargs) - return self - - # TODO def bus_join()? - - def flatten(self) -> Self: - """ - Flatten the contained pattern, using the contained library to resolve references. - - Returns: - self - """ - self.pattern.flatten(self.library) - return self - - def at(self, portspec: str | Iterable[str]) -> 'PortPather': - return PortPather(portspec, self) - - -class PortPather: - """ - Port state manager - - This class provides a convenient way to perform multiple pathing operations on a - set of ports without needing to repeatedly pass their names. - """ - ports: list[str] - pather: PatherMixin - - def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None: - self.ports = [ports] if isinstance(ports, str) else list(ports) - self.pather = pather - - # - # Delegate to pather - # - def retool(self, tool: Tool) -> Self: - self.pather.retool(tool, keys=self.ports) - return self - - @contextmanager - def toolctx(self, tool: Tool) -> Iterator[Self]: - with self.pather.toolctx(tool, keys=self.ports): - yield self - - def path(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use path_each() when pathing multiple ports independently') - for port in self.ports: - self.pather.path(port, *args, **kwargs) - return self - - def path_each(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.path(port, *args, **kwargs) - return self - - def pathS(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use pathS_each() when pathing multiple ports independently') - for port in self.ports: - self.pather.pathS(port, *args, **kwargs) - return self - - def pathS_each(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.pathS(port, *args, **kwargs) - return self - - def path_to(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use path_each_to() when pathing multiple ports independently') - for port in self.ports: - self.pather.path_to(port, *args, **kwargs) - return self - - def path_each_to(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.path_to(port, *args, **kwargs) - return self - - def mpath(self, *args, **kwargs) -> Self: - self.pather.mpath(self.ports, *args, **kwargs) - return self - - def path_into(self, *args, **kwargs) -> Self: - """ Path_into, using the current port as the source """ - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.') - self.pather.path_into(self.ports[0], *args, **kwargs) - return self - - def path_from(self, *args, **kwargs) -> Self: - """ Path_into, using the current port as the destination """ - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.') - thru = kwargs.pop('thru', None) - self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs) - if thru is not None: - self.rename_from(thru) - return self - - def plug( - self, - other: Abstract | str, - other_port: str, - *args, - **kwargs, - ) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' - 'Use the pather or pattern directly to plug multiple ports.') - self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs) - return self - - def plugged(self, other_port: str) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.') - self.pather.plugged({self.ports[0]: other_port}) - return self - - # - # Delegate to port - # - def set_ptype(self, ptype: str) -> Self: - for port in self.ports: - self.pather[port].set_ptype(ptype) - return self - - def translate(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather[port].translate(*args, **kwargs) - return self - - def mirror(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather[port].mirror(*args, **kwargs) - return self - - def rotate(self, rotation: float) -> Self: - for port in self.ports: - self.pather[port].rotate(rotation) - return self - - def set_rotation(self, rotation: float | None) -> Self: - for port in self.ports: - self.pather[port].set_rotation(rotation) - return self - - def rename_to(self, new_name: str) -> Self: - if len(self.ports) > 1: - BuildError('Use rename_ports() for >1 port') - self.pather.rename_ports({self.ports[0]: new_name}) - self.ports[0] = new_name - return self - - def rename_from(self, old_name: str) -> Self: - if len(self.ports) > 1: - BuildError('Use rename_ports() for >1 port') - self.pather.rename_ports({old_name: self.ports[0]}) - return self - - def rename_ports(self, name_map: dict[str, str | None]) -> Self: - self.pather.rename_ports(name_map) - self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None] - return self - - def add_ports(self, ports: Iterable[str]) -> Self: - ports = list(ports) - conflicts = set(ports) & set(self.ports) - if conflicts: - raise BuildError(f'ports {conflicts} already selected') - self.ports += ports - return self - - def add_port(self, port: str, index: int | None = None) -> Self: - if port in self.ports: - raise BuildError(f'{port=} already selected') - if index is not None: - self.ports.insert(index, port) - else: - self.ports.append(port) - return self - - def drop_port(self, port: str) -> Self: - if port not in self.ports: - raise BuildError(f'{port=} already not selected') - self.ports = [pp for pp in self.ports if pp != port] - return self - - def into_copy(self, new_name: str, src: str | None = None) -> Self: - """ Copy a port and replace it with the copy """ - if not self.ports: - raise BuildError('Have no ports to copy') - if len(self.ports) == 1: - src = self.ports[0] - elif src is None: - raise BuildError('Must specify src when >1 port is available') - if src not in self.ports: - raise BuildError(f'{src=} not available') - self.pather.ports[new_name] = self.pather[src].copy() - self.ports = [(new_name if pp == src else pp) for pp in self.ports] - return self - - def save_copy(self, new_name: str, src: str | None = None) -> Self: - """ Copy a port and but keep using the original """ - if not self.ports: - raise BuildError('Have no ports to copy') - if len(self.ports) == 1: - src = self.ports[0] - elif src is None: - raise BuildError('Must specify src when >1 port is available') - if src not in self.ports: - raise BuildError(f'{src=} not available') - self.pather.ports[new_name] = self.pather[src].copy() - return self - - @overload - def delete(self, name: None) -> None: ... - - @overload - def delete(self, name: str) -> Self: ... - - def delete(self, name: str | None = None) -> Self | None: - if name is None: - for pp in self.ports: - del self.pather.ports[pp] - return None - del self.pather.ports[name] - self.ports = [pp for pp in self.ports if pp != name] - return self - diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 303a59d..8dae18b 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -2,30 +2,30 @@ Pather with batched (multi-step) rendering """ from typing import Self -from collections.abc import Sequence, Mapping, MutableMapping, Iterable +from collections.abc import Sequence, Mapping, MutableMapping import copy import logging from collections import defaultdict -from functools import wraps from pprint import pformat +import numpy from numpy import pi from numpy.typing import ArrayLike from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import BuildError +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 .pather_mixin import PatherMixin +from .utils import ell logger = logging.getLogger(__name__) -class RenderPather(PatherMixin): +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, @@ -108,11 +108,15 @@ class RenderPather(PatherMixin): 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: @@ -182,21 +186,16 @@ class RenderPather(PatherMixin): new = RenderPather(library=library, pattern=pat, name=name, tools=tools) return new - def __repr__(self) -> str: - s = f'' - return s - def plug( self, - other: Abstract | str | Pattern | TreeView, + other: Abstract | str, map_in: dict[str, str], map_out: dict[str, str | None] | None = None, *, mirrored: bool = False, - thru: bool | str = True, + inherit_name: bool = True, set_rotation: bool | None = None, append: bool = False, - ok_connections: Iterable[tuple[str, str]] = (), ) -> Self: """ Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P' @@ -211,15 +210,11 @@ class RenderPather(PatherMixin): new names for ports in `other`. mirrored: Enables mirroring `other` across the x axis prior to connecting any ports. - thru: If map_in specifies only a single port, `thru` provides a mechainsm - to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, - - If True (default), and `other` has only two ports total, and map_out - doesn't specify a name for the other port, its name is set to the key - in `map_in`, i.e. 'myport'. - - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). - An error is raised if that entry already exists. - - This makes it easy to extend a pattern with simple 2-port devices + 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 @@ -230,12 +225,6 @@ class RenderPather(PatherMixin): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. - Returns: self @@ -272,14 +261,13 @@ class RenderPather(PatherMixin): self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) self.pattern.plug( - other = other_tgt, - map_in = map_in, - map_out = map_out, - mirrored = mirrored, - thru = thru, - set_rotation = set_rotation, - append = append, - ok_connections = ok_connections, + 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 @@ -345,28 +333,40 @@ class RenderPather(PatherMixin): self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) self.pattern.place( - other = other_tgt, - offset = offset, - rotation = rotation, - pivot = pivot, - mirrored = mirrored, - port_map = port_map, - skip_port_check = skip_port_check, - append = append, + other=other_tgt, + offset=offset, + rotation=rotation, + pivot=pivot, + mirrored=mirrored, + port_map=port_map, + skip_port_check=skip_port_check, + append=append, ) return self - def plugged( + def retool( self, - connections: dict[str, str], + tool: Tool, + keys: str | Sequence[str | None] | None = None, ) -> Self: - for aa, bb in connections.items(): - porta = self.ports[aa] - portb = self.ports[bb] - self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None)) - self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None)) - PortList.plugged(self, connections) + """ + 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( @@ -374,8 +374,6 @@ class RenderPather(PatherMixin): portspec: str, ccw: SupportsBool | None, length: float, - *, - plug_into: str | None = None, **kwargs, ) -> Self: """ @@ -395,8 +393,6 @@ class RenderPather(PatherMixin): and clockwise otherwise. length: The total distance from input to output, along the input's axis only. (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. Returns: self @@ -427,87 +423,163 @@ class RenderPather(PatherMixin): self.pattern.ports[portspec] = out_port.copy() - if plug_into is not None: - self.plugged({portspec: plug_into}) - return self - def pathS( + def path_to( self, portspec: str, - length: float, - jog: float, + ccw: SupportsBool | None, + position: float | None = None, *, - plug_into: str | None = None, + x: float | None = None, + y: float | None = None, **kwargs, ) -> Self: """ - Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is - left of direction of travel). + Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim + of ending exactly at a target position. - The output port will have the same orientation as the source port (`portspec`). + 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. - This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former - raises a NotImplementedError. - Args: portspec: The name of the port into which the wire will be plugged. - jog: Total manhattan distance perpendicular to the direction of travel. - Positive values are to the left of the direction of travel. - length: The total manhattan distance from input to output, along the input's axis only. + 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.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. + 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 `distance` is too small to fit the s-bend (for nonzero jog). - LibraryError if no valid name could be picked for the pattern. + 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 pathS() since device is 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] - in_ptype = port.ptype - port_rot = port.rotation - assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? + if port.rotation is None: + raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') - tool = self.tools.get(portspec, self.tools[None]) + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise BuildError('path_to was asked to route from non-manhattan port') - # check feasibility, get output port and data - try: - out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs) - except NotImplementedError: - # Fall back to drawing two L-bends - ccw0 = jog > 0 - kwargs_no_out = (kwargs | {'out_ptype': None}) - t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) - jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1] - t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs) - jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1] + 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 - kwargs_plug = kwargs | {'plug_into': plug_into} - self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) - self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) + 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 - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - step = RenderStep('S', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - self.pattern.ports[portspec] = out_port.copy() + 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 plug_into is not None: - self.plugged({portspec: plug_into}) + 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, @@ -624,23 +696,8 @@ class RenderPather(PatherMixin): self._dead = True return self - @wraps(Pattern.label) - def label(self, *args, **kwargs) -> Self: - self.pattern.label(*args, **kwargs) - return self + def __repr__(self) -> str: + s = f'' + return s - @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 diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 6bd7547..0e9ec33 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir # TODO document all tools """ -from typing import Literal, Any, Self +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 @@ -70,7 +70,7 @@ class Tool: Create a wire or waveguide that travels exactly `length` distance along the axis of its input port. - Used by `Pather` and `RenderPather`. + 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 @@ -101,48 +101,6 @@ class Tool: """ raise NotImplementedError(f'path() not implemented for {type(self)}') - def pathS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - """ - Create a wire or waveguide that travels exactly `length` distance along the axis - of its input port, and `jog` distance on the perpendicular axis. - `jog` is positive when moving left of the direction of travel (from input to ouput port). - - Used by `Pather` and `RenderPather`. - - The output port should be rotated to face the input port (i.e. plugging the device - into a port will move that port but keep its orientation). - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - length: The total distance from input to output, along the input's axis only. - jog: The total distance from input to output, along the second axis. Positive indicates - a leftward shift when moving from input to output port. - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested S-shaped (or straight) wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'path() not implemented for {type(self)}') - def planL( self, ccw: SupportsBool | None, @@ -177,7 +135,7 @@ class Tool: kwargs: Custom tool-specific parameters. Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. + The calculated output `Port` for the wire. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Raises: @@ -215,7 +173,7 @@ class Tool: kwargs: Custom tool-specific parameters. Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. + The calculated output `Port` for the wire. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Raises: @@ -246,14 +204,14 @@ class Tool: Args: jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftwards shift (i.e. counterclockwise bend - followed by a clockwise bend) + 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, assuming an input port at (0, 0) with rotation 0. + The calculated output `Port` for the wire. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Raises: @@ -265,7 +223,7 @@ class Tool: self, batch: Sequence[RenderStep], *, - port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused) + port_names: Sequence[str] = ('A', 'B'), # noqa: ARG002 (unused) **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: """ @@ -287,40 +245,77 @@ abstract_tuple_t = tuple[Abstract, str, str] @dataclass -class SimpleTool(Tool, metaclass=ABCMeta): +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] | Callable[[float], Library], str, str] + 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 """ - mirror_bend: bool = True - """ Whether a clockwise bend should be mirrored (vs rotated) to get a ccw bend """ - @dataclass(frozen=True, slots=True) class LData: """ Data for planL """ straight_length: float - straight_kwargs: dict[str, Any] ccw: SupportsBool | None + in_transition: abstract_tuple_t | None + out_transition: abstract_tuple_t | None + + def path( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, + ) -> Library: + _out_port, data = self.planL( + ccw, + length, + in_ptype=in_ptype, + out_ptype=out_ptype, + ) + + gen_straight, sport_in, sport_out = self.straight + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.add_port_pair(names=port_names) + 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, # noqa: ARG002 (unused) - out_ptype: str | None = None, # noqa: ARG002 (unused) + 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 @@ -341,532 +336,87 @@ class SimpleTool(Tool, metaclass=ABCMeta): bend_angle *= -1 else: bend_dxy = numpy.zeros(2) - bend_angle = pi + bend_angle = 0 - if ccw is not None: + 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] - bend_run = bend_dxy[1] + 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 L-path with total length {length:,g}, shorter than required bends ({bend_dxy[0]:,})' + 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, kwargs, ccw) + 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 _renderL( + def render( self, - data: LData, - tree: ILibrary, - port_names: tuple[str, str], - straight_kwargs: dict[str, Any], + batch: Sequence[RenderStep], + *, + port_names: Sequence[str] = ('A', 'B'), + append: bool = True, + **kwargs, ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() + + 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 - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = gen_straight(data.straight_length, **(straight_kwargs | data.straight_kwargs)) - pmap = {port_names[1]: sport_in} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - pat.plug(straight_pat, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.ccw is not None: - bend, bport_in, bport_out = self.bend - mirrored = self.mirror_bend and bool(data.ccw) - inport = bport_in if (self.mirror_bend or not data.ccw) else bport_out - pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored) - return tree - - def path( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> ILibrary: - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=(port_names[0], port_names[1])) - for step in batch: + straight_length, ccw, in_transition, out_transition = step.data assert step.tool == self + if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - -@dataclass -class AutoTool(Tool, metaclass=ABCMeta): - """ - A simple tool which relies on a single pre-rendered `bend` pattern, a function - for generating straight paths, and a table of pre-rendered `transitions` for converting - from non-native ptypes. - """ - - @dataclass(frozen=True, slots=True) - class Straight: - """ Description of a straight-path generator """ - ptype: str - fn: Callable[[float], Pattern] | Callable[[float], Library] - in_port_name: str - out_port_name: str - length_range: tuple[float, float] = (0, numpy.inf) - - @dataclass(frozen=True, slots=True) - class SBend: - """ Description of an s-bend generator """ - ptype: str - - fn: Callable[[float], Pattern] | Callable[[float], Library] - """ - Generator function. `jog` (only argument) is assumed to be left (ccw) relative to travel - and may be negative for a jog in the opposite direction. Won't be called if jog=0. - """ - - in_port_name: str - out_port_name: str - jog_range: tuple[float, float] = (0, numpy.inf) - - @dataclass(frozen=True, slots=True) - class Bend: - """ Description of a pre-rendered bend """ - abstract: Abstract - in_port_name: str - out_port_name: str - clockwise: bool = True # Is in-to-out clockwise? - mirror: bool = True # Should we mirror to get the other rotation? - - @property - def in_port(self) -> Port: - return self.abstract.ports[self.in_port_name] - - @property - def out_port(self) -> Port: - return self.abstract.ports[self.out_port_name] - - @dataclass(frozen=True, slots=True) - class Transition: - """ Description of a pre-rendered transition """ - abstract: Abstract - their_port_name: str - our_port_name: str - - @property - def our_port(self) -> Port: - return self.abstract.ports[self.our_port_name] - - @property - def their_port(self) -> Port: - return self.abstract.ports[self.their_port_name] - - def reversed(self) -> Self: - return type(self)(self.abstract, self.our_port_name, self.their_port_name) - - @dataclass(frozen=True, slots=True) - class LData: - """ Data for planL """ - straight_length: float - straight: 'AutoTool.Straight' - straight_kwargs: dict[str, Any] - ccw: SupportsBool | None - bend: 'AutoTool.Bend | None' - in_transition: 'AutoTool.Transition | None' - b_transition: 'AutoTool.Transition | None' - out_transition: 'AutoTool.Transition | None' - - @dataclass(frozen=True, slots=True) - class SData: - """ Data for planS """ - straight_length: float - straight: 'AutoTool.Straight' - gen_kwargs: dict[str, Any] - jog_remaining: float - sbend: 'AutoTool.SBend' - in_transition: 'AutoTool.Transition | None' - b_transition: 'AutoTool.Transition | None' - out_transition: 'AutoTool.Transition | None' - - straights: list[Straight] - """ List of straight-generators to choose from, in order of priority """ - - bends: list[Bend] - """ List of bends to choose from, in order of priority """ - - sbends: list[SBend] - """ List of S-bend generators to choose from, in order of priority """ - - transitions: dict[tuple[str, str], Transition] - """ `{(external_ptype, internal_ptype): Transition, ...}` """ - - default_out_ptype: str - """ Default value for out_ptype """ - - def add_complementary_transitions(self) -> Self: - for iioo in list(self.transitions.keys()): - ooii = (iioo[1], iioo[0]) - self.transitions.setdefault(ooii, self.transitions[iioo].reversed()) - return self - - @staticmethod - def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: - if ccw is None: - return numpy.zeros(2), pi - bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) - assert bend_angle is not None - if bool(ccw): - bend_dxy[1] *= -1 - bend_angle *= -1 - return bend_dxy, bend_angle - - @staticmethod - def _sbend2dxy(sbend: SBend, jog: float) -> NDArray[numpy.float64]: - if numpy.isclose(jog, 0): - return numpy.zeros(2) - - sbend_pat_or_tree = sbend.fn(abs(jog)) - sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() - dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name]) - return dxy - - @staticmethod - def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: - if in_transition is None: - return numpy.zeros(2) - dxy, _ = in_transition.their_port.measure_travel(in_transition.our_port) - return dxy - - @staticmethod - def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]: - if out_transition is None: - return numpy.zeros(2) - orot = out_transition.our_port.rotation - assert orot is not None - otrans_dxy = rotation_matrix_2d(pi - orot - bend_angle) @ (out_transition.their_port.offset - out_transition.our_port.offset) - return otrans_dxy - - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, LData]: - - success = False - for straight in self.straights: - for bend in self.bends: - bend_dxy, bend_angle = self._bend2dxy(bend, ccw) - - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if ccw is None else bend.out_port.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, bend_angle) - - b_transition = None - if ccw is not None and bend.in_port.ptype != straight.ptype: - b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) - - straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] - bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] - success = straight.length_range[0] <= straight_length < straight.length_range[1] - if success: - break - if success: - break - else: - # Failed to break - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n' - f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' - f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' - ) - - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif ccw is not None: - out_ptype_actual = bend.out_port.ptype - elif not numpy.isclose(straight_length, 0): - out_ptype_actual = straight.ptype - else: - out_ptype_actual = self.default_out_ptype - - data = self.LData(straight_length, straight, kwargs, ccw, bend, in_transition, b_transition, out_transition) - out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) - return out_port, data - - def _renderL( - self, - data: LData, - tree: ILibrary, - port_names: tuple[str, str], - straight_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **(straight_kwargs | data.straight_kwargs)) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - pat.plug(straight_pat_or_tree, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if data.ccw is not None: - bend = data.bend - assert bend is not None - mirrored = bend.mirror and (bool(data.ccw) == bend.clockwise) - inport = bend.in_port_name if (bend.mirror or bool(data.ccw) != bend.clockwise) else bend.out_port_name - pat.plug(bend.abstract, {port_names[1]: inport}, mirrored=mirrored) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) - return tree - - def path( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - def planS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - - success = False - for straight in self.straights: - for sbend in self.sbends: - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if numpy.isclose(jog, 0) else sbend.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, pi) - - # Assume we'll need a straight segment with transitions, then discard them if they don't fit - # We do this before generating the s-bend because the transitions might have some dy component - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - b_transition = None - if not numpy.isclose(jog, 0) and sbend.ptype != straight.ptype: - b_transition = self.transitions.get((sbend.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) - - if length > itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0]: - # `if` guard to avoid unnecessary calls to `_sbend2dxy()`, which calls `sbend.fn()` - # note some S-bends may have 0 length, so we can't be more restrictive - jog_remaining = jog - itrans_dxy[1] - btrans_dxy[1] - otrans_dxy[1] - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - straight_length = length - sbend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] - success = straight.length_range[0] <= straight_length < straight.length_range[1] - if success: - break - - # Straight didn't work, see if just the s-bend is enough - if sbend.ptype != straight.ptype: - # Need to use a different in-transition for sbend (vs straight) - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, sbend.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] - if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]: - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) - if success: - b_transition = None - straight_length = 0 - break - if success: - break - - if not success: - try: - ccw0 = jog > 0 - p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype) - p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype) - - dx = p_test1.x - length / 2 - p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype) - p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype) - success = True - except BuildError as err: - l2_err: BuildError | None = err - else: - l2_err = None - raise NotImplementedError('TODO need to handle ldata below') - - if not success: - # Failed to break - raise BuildError( - f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}' - ) from l2_err - - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif not numpy.isclose(jog_remaining, 0): - out_ptype_actual = sbend.ptype - elif not numpy.isclose(straight_length, 0): - out_ptype_actual = straight.ptype - else: - out_ptype_actual = self.default_out_ptype - - data = self.SData(straight_length, straight, kwargs, jog_remaining, sbend, in_transition, b_transition, out_transition) - out_port = Port((length, jog), rotation=pi, ptype=out_ptype_actual) - return out_port, data - - def _renderS( - self, - data: SData, - tree: ILibrary, - port_names: tuple[str, str], - gen_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **(gen_kwargs | data.gen_kwargs)) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - pat.plug(straight_pat, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if not numpy.isclose(data.jog_remaining, 0): - sbend_pat_or_tree = data.sbend.fn(abs(data.jog_remaining), **(gen_kwargs | data.gen_kwargs)) - pmap = {port_names[1]: data.sbend.in_port_name} - if isinstance(sbend_pat_or_tree, Pattern): - pat.plug(sbend_pat_or_tree, pmap, append=True, mirrored=data.jog_remaining < 0) - else: - sbend_tree = sbend_pat_or_tree - top = sbend_tree.top() - sbend_tree.flatten(top, dangling_ok=True) - pat.plug(sbend_tree[top], pmap, append=True, mirrored=data.jog_remaining < 0) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) - return tree - - def pathS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planS( - length, - jog, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - return tree - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> ILibrary: - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=(port_names[0], port_names[1])) - - for step in batch: - assert step.tool == self - if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - elif step.opcode == 'S': - self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + 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 @@ -961,7 +511,7 @@ class PathTool(Tool, metaclass=ABCMeta): if straight_length < 0: raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' + 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) @@ -971,7 +521,7 @@ class PathTool(Tool, metaclass=ABCMeta): self, batch: Sequence[RenderStep], *, - port_names: tuple[str, str] = ('A', 'B'), + port_names: Sequence[str] = ('A', 'B'), **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: diff --git a/masque/error.py b/masque/error.py index e475bb0..0e46849 100644 --- a/masque/error.py +++ b/masque/error.py @@ -1,10 +1,3 @@ -import traceback -import pathlib - - -MASQUE_DIR = str(pathlib.Path(__file__).parent) - - class MasqueError(Exception): """ Parent exception for all Masque-related Exceptions @@ -32,64 +25,15 @@ class BuildError(MasqueError): """ pass - class PortError(MasqueError): """ - Exception raised by port-related functions + 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') - - -def format_stacktrace( - stacklevel: int = 1, - *, - skip_file_prefixes: tuple[str, ...] = (MASQUE_DIR,), - low_file_prefixes: tuple[str, ...] = (''), - low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', 'concurrent/futures/process.py'), - ) -> str: - """ - Utility function for making nicer stack traces (e.g. excluding and similar) - - Args: - stacklevel: Number of frames to remove from near this function (default is to - show caller but not ourselves). Similar to `warnings.warn` and `logging.warning`. - skip_file_prefixes: Indicates frames to ignore after counting stack levels; similar - to `warnings.warn` *TODO check if this is actually the same effect re:stacklevel*. - Forces stacklevel to max(2, stacklevel). - Default is to exclude anything within `masque`. - low_file_prefixes: Indicates frames to ignore on the other (entry-point) end of the stack, - based on prefixes on their filenames. - low_file_suffixes: Indicates frames to ignore on the other (entry-point) end of the stack, - based on suffixes on their filenames. - - Returns: - Formatted trimmed stack trace - """ - if skip_file_prefixes: - stacklevel = max(2, stacklevel) - - stack = traceback.extract_stack() - - bad_inds = [ii + 1 for ii, frame in enumerate(stack) - if frame.filename.startswith(low_file_prefixes) or frame.filename.endswith(low_file_suffixes)] - first_ok = max([0] + bad_inds) - - last_ok = -stacklevel - 1 - while last_ok >= -len(stack) and stack[last_ok].filename.startswith(skip_file_prefixes): - last_ok -= 1 - - if selected := stack[first_ok:last_ok + 1]: - pass - elif selected := stack[:-stacklevel]: - pass # noqa: SIM114 # separate elif for clarity - else: - selected = stack - return ''.join(traceback.format_list(selected)) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0f6dd32..1cf5e88 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -214,7 +214,7 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if isinstance(element, LWPolyline): points = numpy.asarray(element.get_points()) elif isinstance(element, Polyline): - points = numpy.asarray([pp.xyz for pp in element.points()]) + points = numpy.asarray(element.points())[:, :2] attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) @@ -351,7 +351,7 @@ def _shapes_to_elements( ) for polygon in shape.to_polygons(): - xy_open = polygon.vertices + xy_open = polygon.vertices + polygon.offset xy_closed = numpy.vstack((xy_open, xy_open[0, :])) block.add_lwpolyline(xy_closed, dxfattribs=attribs) @@ -376,5 +376,5 @@ def _mlayer2dxf(layer: layer_t) -> str: if isinstance(layer, int): return str(layer) if isinstance(layer, tuple): - return f'{layer[0]:d}.{layer[1]:d}' + return f'{layer[0]}.{layer[1]}' raise PatternError(f'Unknown layer type: {layer} ({type(layer)})') diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index 6972cfa..10f5a9a 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -418,8 +418,8 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) - i = int(key) except ValueError as err: raise PatternError(f'Annotation key {key} is not convertable to an integer') from err - if not (0 < i <= 126): - raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,126])') + 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() diff --git a/masque/file/gdsii_arrow.py b/masque/file/gdsii_arrow.py index e56a48e..763c438 100644 --- a/masque/file/gdsii_arrow.py +++ b/masque/file/gdsii_arrow.py @@ -1,4 +1,3 @@ -# ruff: noqa: ARG001, F401 """ GDSII file format readers and writers using the `TODO` library. @@ -41,7 +40,7 @@ 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 ..shapes import Polygon, Path from ..repetition import Grid from ..utils import layer_t, annotations_t from ..library import LazyLibrary, Library, ILibrary, ILibraryView @@ -190,11 +189,11 @@ def read_arrow( 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(), + width = paths.values.field('width').to_numpy(), + path_type = paths.values.field('path_type').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(), + paths.values.field('extension_start').to_numpy(zero_copy_only=False), + paths.values.field('extension_end').to_numpy(zero_copy_only=False), ), axis=-1), )) @@ -205,13 +204,9 @@ def read_arrow( ) mlib = Library() - for cc in range(len(libarr['cells'])): + for cc, cell in enumerate(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) + pat = read_cell(cc, cell, libarr['cell_names'], global_args=global_args, elements=elements) mlib[name] = pat return mlib, library_info @@ -229,6 +224,36 @@ def _read_header(libarr: pyarrow.Array) -> dict[str, Any]: return library_info +def read_cell( + cc: int, + cellarr: pyarrow.Array, + cell_names: pyarrow.Array, + elements: dict[str, Any], + global_args: dict[str, Any], + ) -> Pattern: + """ + TODO + 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() + + _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) + + return pat + + def _grefs_to_mrefs( pat: Pattern, global_args: dict[str, Any], @@ -242,36 +267,30 @@ def _grefs_to_mrefs( prop_val = elem['prop_val'] targets = elem['targets'] + rep_valid = elem['rep_valid'] + 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] + 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] + 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 + annotations: None | dict[int, 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)} + annotations = {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) @@ -293,18 +312,16 @@ def _texts_to_labels( 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]] + layer = layer_tups[layer_inds[ee]] offset = xy[ee] - string = elem_strings[ee] + string = elem['string'][ee] - annotations: None | dict[str, list[int | float | str]] = None + annotations: None | dict[int, 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)} + annotations = {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) @@ -327,28 +344,25 @@ def _gpaths_to_mpaths( 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]] + elem_ind = elem_off[cc] + ee + layer = layer_tups[layer_inds[ee]] vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1]] - width = elem_widths[ee] - cap_int = elem_path_types[ee] + width = elem['width'][elem_ind] + cap_int = elem['path_type'][elem_ind] cap = path_cap_map[cap_int] if cap_int == 4: - cap_extensions = elem_extensions[ee] + cap_extensions = elem['extensions'][elem_ind] else: cap_extensions = None - annotations: None | dict[str, list[int | float | str]] = None + annotations: None | dict[int, 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)} + annotations = {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) @@ -363,60 +377,33 @@ def _boundaries_to_polygons( ) -> 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'] + 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 - 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, nn in zip(unilayer_inds, unilayer_first, unilayer_count, strict=True): - ee_inds = order[ff:ff + nn] - layer = layer_tups[layer_ind] - propless_mask = prop_counts[ee_inds] == 0 + for ee in range(elem_count): + layer = layer_tups[layer_inds[ee]] + vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1] - 1] # -1 to drop closing point - 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])) + annotations: None | dict[int, str] = None + prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1] + if prop_ii < prop_ff: + annotations = {prop_key[off]: prop_val[off] for off in range(prop_ii, prop_ff)} - 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) + 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 _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( diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 672af25..642ad44 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -661,7 +661,7 @@ def repetition_masq2fata( diffs = numpy.diff(rep.displacements, axis=0) diff_ints = rint_cast(diffs) frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) # type: ignore - offset = tuple(rep.displacements[0, :]) + offset = rep.displacements[0, :] else: assert rep is None frep = None diff --git a/masque/file/svg.py b/masque/file/svg.py index 859c074..148f6d4 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -2,7 +2,7 @@ SVG file format readers and writers """ from collections.abc import Mapping -import logging +import warnings import numpy from numpy.typing import ArrayLike @@ -12,9 +12,6 @@ from .utils import mangle_name from .. import Pattern -logger = logging.getLogger(__name__) - - def writefile( library: Mapping[str, Pattern], top: str, @@ -53,7 +50,7 @@ def writefile( bounds = pattern.get_bounds(library=library) if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) - logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) + warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) else: bounds_min, bounds_max = bounds @@ -120,7 +117,7 @@ def writefile_inverted( bounds = pattern.get_bounds(library=library) if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) - logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) + warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) else: bounds_min, bounds_max = bounds diff --git a/masque/library.py b/masque/library.py index 9e7c133..b52da74 100644 --- a/masque/library.py +++ b/masque/library.py @@ -264,7 +264,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): self, tops: str | Sequence[str], flatten_ports: bool = False, - dangling_ok: bool = False, ) -> dict[str, 'Pattern']: """ Returns copies of all `tops` patterns with all refs @@ -277,9 +276,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): tops: The pattern(s) to flattern. flatten_ports: If `True`, keep ports from any referenced patterns; otherwise discard them. - dangling_ok: If `True`, no error will be thrown if any - ref points to a name which is not present in the library. - Default False. Returns: {name: flat_pattern} mapping for all flattened patterns. @@ -296,8 +292,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): for target in pat.refs: if target is None: continue - if dangling_ok and target not in self: - continue if target not in flattened: flatten_single(target) @@ -313,9 +307,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): p.ports.clear() pat.append(p) - for target in set(pat.refs.keys()) & set(self.keys()): - del pat.refs[target] - + pat.refs.clear() flattened[name] = pat for top in tops: diff --git a/masque/pattern.py b/masque/pattern.py index 7e0a79e..f77c64f 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -584,7 +584,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): bounds = numpy.vstack((numpy.min(corners, axis=0), numpy.max(corners, axis=0))) * ref.scale + [ref.offset] if ref.repetition is not None: - bounds += ref.repetition.get_bounds_nonempty() + bounds += ref.repetition.get_bounds() else: # Non-manhattan rotation, have to figure out bounds by rotating the pattern @@ -745,7 +745,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Positionable', entry).offset[1 - across_axis] *= -1 + cast('Positionable', entry).offset[across_axis - 1] *= -1 return self def mirror_elements(self, across_axis: int = 0) -> Self: @@ -1169,13 +1169,12 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ports[new_name] = port for name, port in ports.items(): - pp = port.deepcopy() + p = port.deepcopy() if mirrored: - pp.mirror() - pp.offset[1] *= -1 - pp.rotate_around(pivot, rotation) - pp.translate(offset) - self.ports[name] = pp + p.mirror() + p.rotate_around(pivot, rotation) + p.translate(offset) + self.ports[name] = p if append: if isinstance(other, Abstract): @@ -1203,7 +1202,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # map_out: dict[str, str | None] | None, # *, # mirrored: bool, -# thru: bool | str, +# inherit_name: bool, # set_rotation: bool | None, # append: Literal[False], # ) -> Self: @@ -1217,7 +1216,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # map_out: dict[str, str | None] | None, # *, # mirrored: bool, -# thru: bool | str, +# inherit_name: bool, # set_rotation: bool | None, # append: bool, # ) -> Self: @@ -1230,7 +1229,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_out: dict[str, str | None] | None = None, *, mirrored: bool = False, - thru: bool | str = True, + inherit_name: bool = True, set_rotation: bool | None = None, append: bool = False, ok_connections: Iterable[tuple[str, str]] = (), @@ -1251,7 +1250,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): - `my_pat.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' of `my_pat`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out` argument is - provided, and the `thru` argument is not explicitly set to `False`, + 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. @@ -1264,15 +1263,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): new names for ports in `other`. mirrored: Enables mirroring `other` across the x axis prior to connecting any ports. - thru: If map_in specifies only a single port, `thru` provides a mechainsm - to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, - - If True (default), and `other` has only two ports total, and map_out - doesn't specify a name for the other port, its name is set to the key - in `map_in`, i.e. 'myport'. - - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). - An error is raised if that entry already exists. - - This makes it easy to extend a pattern with simple 2-port devices + 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 @@ -1300,32 +1295,25 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): `PortError` if the specified port mapping is not achieveable (the ports do not line up) """ + # If asked to inherit a name, check that all conditions are met + if (inherit_name + and not map_out + and len(map_in) == 1 + and len(other.ports) == 2): + out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values()))) + map_out = {out_port_name: next(iter(map_in.keys()))} + if map_out is None: map_out = {} map_out = copy.deepcopy(map_out) - # If asked to inherit a name, check that all conditions are met - if isinstance(thru, str): - if not len(map_in) == 1: - raise PatternError(f'Got {thru=} but have multiple map_in entries; don\'t know which one to use') - if thru in map_out: - raise PatternError(f'Got {thru=} but tha port already exists in map_out') - map_out[thru] = next(iter(map_in.keys())) - elif (bool(thru) - and len(map_in) == 1 - and not map_out - and len(other.ports) == 2 - ): - out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values()))) - map_out = {out_port_name: next(iter(map_in.keys()))} - self.check_ports(other.ports.keys(), map_in, map_out) translation, rotation, pivot = self.find_transform( other, map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, + mirrored=mirrored, + set_rotation=set_rotation, + ok_connections=ok_connections, ) # get rid of plugged ports @@ -1338,13 +1326,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self.place( other, - offset = translation, - rotation = rotation, - pivot = pivot, - mirrored = mirrored, - port_map = map_out, - skip_port_check = True, - append = append, + offset=translation, + rotation=rotation, + pivot=pivot, + mirrored=mirrored, + port_map=map_out, + skip_port_check=True, + append=append, ) return self diff --git a/masque/ports.py b/masque/ports.py index 0211723..1cc711a 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -1,5 +1,7 @@ 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 @@ -11,8 +13,8 @@ from numpy import pi from numpy.typing import ArrayLike, NDArray from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable -from .utils import rotate_offsets_around, rotation_matrix_2d -from .error import PortError, format_stacktrace +from .utils import rotate_offsets_around +from .error import PortError logger = logging.getLogger(__name__) @@ -62,7 +64,7 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): return self._rotation @rotation.setter - def rotation(self, val: float | None) -> None: + def rotation(self, val: float) -> None: if val is None: self._rotation = None else: @@ -100,6 +102,7 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): return self def mirror(self, axis: int = 0) -> Self: + self.offset[1 - axis] *= -1 if self.rotation is not None: self.rotation *= -1 self.rotation += axis * pi @@ -142,28 +145,6 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): and self.rotation == other.rotation ) - def measure_travel(self, destination: 'Port') -> tuple[NDArray[numpy.float64], float | None]: - """ - Find the (travel, jog) distances and rotation angle from the current port to the provided - `destination` port. - - Travel is along the source port's axis (into the device interior), and jog is perpendicular, - with left of the travel direction corresponding to a positive jog. - - Args: - (self): Source `Port` - destination: Destination `Port` - - Returns - [travel, jog], rotation - """ - angle_in = self.rotation - angle_out = destination.rotation - assert angle_in is not None - dxy = rotation_matrix_2d(-angle_in) @ (destination.offset - self.offset) - angle = ((angle_out - angle_in) % (2 * pi)) if angle_out is not None else None - return dxy, angle - class PortList(metaclass=ABCMeta): __slots__ = () # Allow subclasses to use __slots__ @@ -324,11 +305,11 @@ class PortList(metaclass=ABCMeta): if type_conflicts.any(): msg = 'Ports have conflicting types:\n' - for nn, (kk, vv) in enumerate(connections.items()): + for nn, (k, v) in enumerate(connections.items()): if type_conflicts[nn]: - msg += f'{kk} | {a_types[nn]}:{b_types[nn]} | {vv}\n' - msg += '\nStack trace:\n' + format_stacktrace() - logger.warning(msg) + 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]) @@ -345,17 +326,17 @@ class PortList(metaclass=ABCMeta): if not numpy.allclose(rotations, 0): rot_deg = numpy.rad2deg(rotations) msg = 'Port orientations do not match:\n' - for nn, (kk, vv) in enumerate(connections.items()): + for nn, (k, v) in enumerate(connections.items()): if not numpy.isclose(rot_deg[nn], 0): - msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n' + 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, (kk, vv) in enumerate(connections.items()): + for nn, (k, v) in enumerate(connections.items()): if not numpy.allclose(translations[nn], 0): - msg += f'{kk} | {translations[nn]} | {vv}\n' + msg += f'{k} | {translations[nn]} | {v}\n' raise PortError(msg) for pp in chain(a_names, b_names): @@ -425,7 +406,7 @@ class PortList(metaclass=ABCMeta): map_out_counts = Counter(map_out.values()) map_out_counts[None] = 0 - conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1} + 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}') @@ -457,7 +438,7 @@ class PortList(metaclass=ABCMeta): `set_rotation` must remain `None`. ok_connections: Set of "allowed" ptype combinations. Identical ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will log a + any other ptypte. Non-allowed ptype connections will emit a warning. Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. @@ -471,12 +452,12 @@ class PortList(metaclass=ABCMeta): s_ports = self[map_in.keys()] o_ports = other[map_in.values()] return self.find_port_transform( - s_ports = s_ports, - o_ports = o_ports, - map_in = map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, + s_ports=s_ports, + o_ports=o_ports, + map_in=map_in, + mirrored=mirrored, + set_rotation=set_rotation, + ok_connections=ok_connections, ) @staticmethod @@ -508,7 +489,7 @@ class PortList(metaclass=ABCMeta): `set_rotation` must remain `None`. ok_connections: Set of "allowed" ptype combinations. Identical ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will log a + any other ptypte. Non-allowed ptype connections will emit a warning. Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. @@ -539,11 +520,11 @@ class PortList(metaclass=ABCMeta): for st, ot in zip(s_types, o_types, strict=True)]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' - for nn, (kk, vv) in enumerate(map_in.items()): + for nn, (k, v) in enumerate(map_in.items()): if type_conflicts[nn]: - msg += f'{kk} | {s_types[nn]}:{o_types[nn]} | {vv}\n' - msg += '\nStack trace:\n' + format_stacktrace() - logger.warning(msg) + 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(): @@ -565,12 +546,8 @@ class PortList(metaclass=ABCMeta): translations = s_offsets - o_offsets if not numpy.allclose(translations[:1], translations): msg = 'Port translations do not match:\n' - common_translation = numpy.min(translations, axis=0) - msg += f'Common: {common_translation} \n' - msg += 'Deltas:\n' for nn, (kk, vv) in enumerate(map_in.items()): - msg += f'{kk} | {translations[nn] - common_translation} | {vv}\n' + msg += f'{kk} | {translations[nn]} | {vv}\n' raise PortError(msg) return translations[0], rotations[0], o_offsets[0] - diff --git a/masque/ref.py b/masque/ref.py index b3a684c..09e00b1 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -11,7 +11,7 @@ import numpy from numpy import pi from numpy.typing import NDArray, ArrayLike -from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key, SupportsBool +from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, @@ -50,11 +50,11 @@ class Ref( # Mirrored property @property - def mirrored(self) -> bool: + def mirrored(self) -> bool: # mypy#3004, setter should be SupportsBool return self._mirrored @mirrored.setter - def mirrored(self, val: SupportsBool) -> None: + def mirrored(self, val: bool) -> None: self._mirrored = bool(val) def __init__( diff --git a/masque/repetition.py b/masque/repetition.py index 5e7a7f0..e6d00fc 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -327,7 +327,7 @@ class Arbitrary(Repetition): """ @property - def displacements(self) -> NDArray[numpy.float64]: + def displacements(self) -> Any: # mypy#3004 NDArray[numpy.float64]: return self._displacements @displacements.setter diff --git a/masque/shapes/__init__.py b/masque/shapes/__init__.py index fd66c59..8ad46ef 100644 --- a/masque/shapes/__init__.py +++ b/masque/shapes/__init__.py @@ -10,7 +10,6 @@ from .shape import ( ) from .polygon import Polygon as Polygon -from .poly_collection import PolyCollection as PolyCollection from .circle import Circle as Circle from .ellipse import Ellipse as Ellipse from .arc import Arc as Arc diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 480835e..f3f4e1e 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -10,11 +10,10 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key -from ..traits import PositionableImpl @functools.total_ordering -class Arc(PositionableImpl, Shape): +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. @@ -43,7 +42,7 @@ class Arc(PositionableImpl, Shape): # radius properties @property - def radii(self) -> NDArray[numpy.float64]: + def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ Return the radii `[rx, ry]` """ @@ -80,7 +79,7 @@ class Arc(PositionableImpl, Shape): # arc start/stop angle properties @property - def angles(self) -> NDArray[numpy.float64]: + 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 @@ -158,7 +157,7 @@ class Arc(PositionableImpl, Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, raw: bool = False, ) -> None: if raw: @@ -171,7 +170,7 @@ class Arc(PositionableImpl, Shape): self._offset = offset self._rotation = rotation self._repetition = repetition - self._annotations = annotations + self._annotations = annotations if annotations is not None else {} else: self.radii = radii self.angles = angles @@ -179,7 +178,7 @@ class Arc(PositionableImpl, Shape): self.offset = offset self.rotation = rotation self.repetition = repetition - self.annotations = annotations + self.annotations = annotations if annotations is not None else {} def __deepcopy__(self, memo: dict | None = None) -> 'Arc': memo = {} if memo is None else memo @@ -413,15 +412,15 @@ class Arc(PositionableImpl, Shape): start_angle -= pi rotation += pi - norm_angles = (start_angle, start_angle + delta_angle) + angles = (start_angle, start_angle + delta_angle) rotation %= 2 * pi width = self.width - return ((type(self), radii, norm_angles, width / norm_value), + return ((type(self), radii, angles, width / norm_value), (self.offset, scale / norm_value, rotation, False), lambda: Arc( radii=radii * norm_value, - angles=norm_angles, + angles=angles, width=width * norm_value, )) diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index b20a681..2d403b4 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -10,11 +10,10 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key -from ..traits import PositionableImpl @functools.total_ordering -class Circle(PositionableImpl, Shape): +class Circle(Shape): """ A circle, which has a position and radius. """ @@ -49,7 +48,7 @@ class Circle(PositionableImpl, Shape): *, offset: ArrayLike = (0.0, 0.0), repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, raw: bool = False, ) -> None: if raw: @@ -57,12 +56,12 @@ class Circle(PositionableImpl, Shape): self._radius = radius self._offset = offset self._repetition = repetition - self._annotations = annotations + self._annotations = annotations if annotations is not None else {} else: self.radius = radius self.offset = offset self.repetition = repetition - self.annotations = annotations + self.annotations = annotations if annotations is not None else {} def __deepcopy__(self, memo: dict | None = None) -> 'Circle': memo = {} if memo is None else memo @@ -124,7 +123,7 @@ class Circle(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused) - self.offset[axis - 1] *= -1 + self.offset *= -1 return self def scale_by(self, c: float) -> 'Circle': diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 6029f2f..0d6a6c5 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -11,11 +11,10 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key -from ..traits import PositionableImpl @functools.total_ordering -class Ellipse(PositionableImpl, Shape): +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. @@ -34,7 +33,7 @@ class Ellipse(PositionableImpl, Shape): # radius properties @property - def radii(self) -> NDArray[numpy.float64]: + def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ Return the radii `[rx, ry]` """ @@ -94,7 +93,7 @@ class Ellipse(PositionableImpl, Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, raw: bool = False, ) -> None: if raw: @@ -104,13 +103,13 @@ class Ellipse(PositionableImpl, Shape): self._offset = offset self._rotation = rotation self._repetition = repetition - self._annotations = annotations + self._annotations = annotations if annotations is not None else {} else: self.radii = radii self.offset = offset self.rotation = rotation self.repetition = repetition - self.annotations = annotations + self.annotations = annotations if annotations is not None else {} def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 7778428..93e85ea 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -1,4 +1,4 @@ -from typing import Any, cast, Self +from typing import Any, cast from collections.abc import Sequence import copy import functools @@ -30,7 +30,8 @@ class PathCap(Enum): @functools.total_ordering class Path(Shape): """ - A path, consisting of a bunch of vertices (Nx2 ndarray), a width, and an end-cap 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. @@ -39,7 +40,7 @@ class Path(Shape): __slots__ = ( '_vertices', '_width', '_cap', '_cap_extensions', # Inherited - '_repetition', '_annotations', + '_offset', '_repetition', '_annotations', ) _vertices: NDArray[numpy.float64] _width: float @@ -86,7 +87,7 @@ class Path(Shape): # cap_extensions property @property - def cap_extensions(self) -> NDArray[numpy.float64] | None: + def cap_extensions(self) -> Any | None: # mypy#3004 NDArray[numpy.float64]]: """ Path end-cap extension @@ -112,7 +113,7 @@ class Path(Shape): # vertices property @property - def vertices(self) -> NDArray[numpy.float64]: + def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]]: """ Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]` @@ -159,28 +160,6 @@ class Path(Shape): raise PatternError('Wrong number of vertices') self.vertices[:, 1] = val - # Offset property for `Positionable` - @property - def offset(self) -> NDArray[numpy.float64]: - """ - [x, y] offset - """ - return numpy.zeros(2) - - @offset.setter - def offset(self, val: ArrayLike) -> None: - if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') - - def set_offset(self, val: ArrayLike) -> Self: - if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') - return self - - def translate(self, offset: ArrayLike) -> Self: - self._vertices += numpy.atleast_2d(offset) - return self - def __init__( self, vertices: ArrayLike, @@ -191,35 +170,36 @@ class Path(Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = 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._annotations = annotations if annotations is not None else {} 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.annotations = annotations if annotations is not None else {} self.width = width self.cap = cap self.cap_extensions = cap_extensions - if rotation: - self.rotate(rotation) - if numpy.any(offset): - self.translate(offset) + 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) @@ -229,6 +209,7 @@ class Path(Shape): def __eq__(self, other: Any) -> bool: return ( type(self) is type(other) + and numpy.array_equal(self.offset, other.offset) and numpy.array_equal(self.vertices, other.vertices) and self.width == other.width and self.cap == other.cap @@ -253,6 +234,8 @@ class Path(Shape): if self.cap_extensions is None: return True return tuple(self.cap_extensions) < tuple(other.cap_extensions) + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) if self.repetition != other.repetition: return rep2key(self.repetition) < rep2key(other.repetition) return annotations_lt(self.annotations, other.annotations) @@ -309,7 +292,7 @@ class Path(Shape): if self.width == 0: verts = numpy.vstack((v, v[::-1])) - return [Polygon(vertices=verts)] + return [Polygon(offset=self.offset, vertices=verts)] perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2 @@ -360,7 +343,7 @@ class Path(Shape): o1.append(v[-1] - perp[-1]) verts = numpy.vstack((o0, o1[::-1])) - polys = [Polygon(vertices=verts)] + polys = [Polygon(offset=self.offset, vertices=verts)] if self.cap == PathCap.Circle: #for vert in v: # not sure if every vertex, or just ends? @@ -372,8 +355,8 @@ class Path(Shape): def get_bounds_single(self) -> NDArray[numpy.float64]: if self.cap == PathCap.Circle: - bounds = numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2, - numpy.max(self.vertices, axis=0) + self.width / 2)) + 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, @@ -407,7 +390,7 @@ class Path(Shape): def normalized_form(self, norm_value: float) -> normalized_shape_tuple: # Note: this function is going to be pretty slow for many-vertexed paths, relative to # other shapes - offset = self.vertices.mean(axis=0) + offset = self.vertices.mean(axis=0) + self.offset zeroed_vertices = self.vertices - offset scale = zeroed_vertices.std() @@ -477,5 +460,5 @@ class Path(Shape): return extensions def __repr__(self) -> str: - centroid = self.vertices.mean(axis=0) + centroid = self.offset + self.vertices.mean(axis=0) return f'' diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py deleted file mode 100644 index 6048f24..0000000 --- a/masque/shapes/poly_collection.py +++ /dev/null @@ -1,223 +0,0 @@ -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 ..error import PatternError -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 - '_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) -> NDArray[numpy.float64]: - """ - Vertices of the polygons, ((N+M+...) x 2). Use with `vertex_offsets`. - """ - return self._vertex_lists - - @property - def vertex_offsets(self) -> 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] - - # Offset property for `Positionable` - @property - def offset(self) -> NDArray[numpy.float64]: - """ - [x, y] offset - """ - return numpy.zeros(2) - - @offset.setter - def offset(self, _val: ArrayLike) -> None: - raise PatternError('PolyCollection offset is forced to (0, 0)') - - def set_offset(self, val: ArrayLike) -> Self: - if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') - return self - - def translate(self, offset: ArrayLike) -> Self: - self._vertex_lists += numpy.atleast_2d(offset) - return self - - def __init__( - self, - vertex_lists: ArrayLike, - 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) - self._vertex_lists = vertex_lists - self._vertex_offsets = vertex_offsets - 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.repetition = repetition - self.annotations = annotations - if rotation: - self.rotate(rotation) - if numpy.any(offset): - self.translate(offset) - - def __deepcopy__(self, memo: dict | None = None) -> Self: - memo = {} if memo is None else memo - new = copy.copy(self) - 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._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 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, - 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((numpy.min(self._vertex_lists, axis=0), - 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 - - 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.vertex_lists.mean(axis=0) - return f'' diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index c8c3ddd..2976271 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING, Self +from typing import Any, cast, TYPE_CHECKING import copy import functools @@ -20,7 +20,7 @@ if TYPE_CHECKING: class Polygon(Shape): """ A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an - implicitly-closed boundary. + implicitly-closed boundary, and an offset. Note that the setter for `Polygon.vertices` creates a copy of the passed vertex coordinates. @@ -30,7 +30,7 @@ class Polygon(Shape): __slots__ = ( '_vertices', # Inherited - '_repetition', '_annotations', + '_offset', '_repetition', '_annotations', ) _vertices: NDArray[numpy.float64] @@ -38,7 +38,7 @@ class Polygon(Shape): # vertices property @property - def vertices(self) -> NDArray[numpy.float64]: + def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`) @@ -85,28 +85,6 @@ class Polygon(Shape): raise PatternError('Wrong number of vertices') self.vertices[:, 1] = val - # Offset property for `Positionable` - @property - def offset(self) -> NDArray[numpy.float64]: - """ - [x, y] offset - """ - return numpy.zeros(2) - - @offset.setter - def offset(self, val: ArrayLike) -> None: - if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') - - def set_offset(self, val: ArrayLike) -> Self: - if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') - return self - - def translate(self, offset: ArrayLike) -> Self: - self._vertices += numpy.atleast_2d(offset) - return self - def __init__( self, vertices: ArrayLike, @@ -114,26 +92,28 @@ class Polygon(Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = 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 + self._annotations = annotations if annotations is not None else {} else: self.vertices = vertices + self.offset = offset self.repetition = repetition - self.annotations = annotations + self.annotations = annotations if annotations is not None else {} if rotation: self.rotate(rotation) - if numpy.any(offset): - self.translate(offset) def __deepcopy__(self, memo: dict | None = None) -> 'Polygon': memo = {} if memo is None else memo new = copy.copy(self) + new._offset = self._offset.copy() new._vertices = self._vertices.copy() new._annotations = copy.deepcopy(self._annotations) return new @@ -141,6 +121,7 @@ class Polygon(Shape): def __eq__(self, other: Any) -> bool: return ( type(self) is type(other) + and numpy.array_equal(self.offset, other.offset) and numpy.array_equal(self.vertices, other.vertices) and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) @@ -160,6 +141,8 @@ class Polygon(Shape): if eq_lt_masked.size > 0: return eq_lt_masked.flat[0] return self.vertices.shape[0] < other.vertices.shape[0] + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) if self.repetition != other.repetition: return rep2key(self.repetition) < rep2key(other.repetition) return annotations_lt(self.annotations, other.annotations) @@ -256,11 +239,6 @@ class Polygon(Shape): Returns: A Polygon object containing the requested rectangle """ - if sum(int(pp is None) for pp in (xmin, xmax, xctr, lx)) != 2: - raise PatternError('Exactly two of xmin, xctr, xmax, lx must be provided!') - if sum(int(pp is None) for pp in (ymin, ymax, yctr, ly)) != 2: - raise PatternError('Exactly two of ymin, yctr, ymax, ly must be provided!') - if lx is None: if xctr is None: assert xmin is not None @@ -270,11 +248,11 @@ class Polygon(Shape): elif xmax is None: assert xmin is not None assert xctr is not None - lx = 2.0 * (xctr - xmin) + lx = 2 * (xctr - xmin) elif xmin is None: assert xctr is not None assert xmax is not None - lx = 2.0 * (xmax - xctr) + lx = 2 * (xmax - xctr) else: raise PatternError('Two of xmin, xctr, xmax, lx must be None!') else: # noqa: PLR5501 @@ -300,11 +278,11 @@ class Polygon(Shape): elif ymax is None: assert ymin is not None assert yctr is not None - ly = 2.0 * (yctr - ymin) + ly = 2 * (yctr - ymin) elif ymin is None: assert yctr is not None assert ymax is not None - ly = 2.0 * (ymax - yctr) + ly = 2 * (ymax - yctr) else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') else: # noqa: PLR5501 @@ -385,8 +363,8 @@ class Polygon(Shape): return [copy.deepcopy(self)] def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition - return numpy.vstack((numpy.min(self.vertices, axis=0), - numpy.max(self.vertices, axis=0))) + 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: @@ -406,7 +384,7 @@ class Polygon(Shape): # other shapes meanv = self.vertices.mean(axis=0) zeroed_vertices = self.vertices - meanv - offset = meanv + offset = meanv + self.offset scale = zeroed_vertices.std() normed_vertices = zeroed_vertices / scale @@ -460,5 +438,5 @@ class Polygon(Shape): return self def __repr__(self) -> str: - centroid = self.vertices.mean(axis=0) + centroid = self.offset + self.vertices.mean(axis=0) return f'' diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 90bca2b..0a7c86d 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -7,7 +7,7 @@ from numpy.typing import NDArray, ArrayLike from ..traits import ( Rotatable, Mirrorable, Copyable, Scalable, - Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl, + PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,7 +26,7 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, +class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): """ Class specifying functions common to all shapes. @@ -134,7 +134,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, mins, maxs = bounds vertex_lists = [] - p_verts = polygon.vertices + 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 @@ -282,7 +282,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, offset = (numpy.where(keep_x)[0][0], numpy.where(keep_y)[0][0]) - rastered = float_raster.raster((polygon.vertices).T, gx, gy) + 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) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 78632f6..69318ac 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -9,8 +9,8 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, Polygon, normalized_shape_tuple from ..error import PatternError from ..repetition import Repetition -from ..traits import PositionableImpl, RotatableImpl -from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key, SupportsBool +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 @@ -18,7 +18,7 @@ from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotatio @functools.total_ordering -class Text(PositionableImpl, RotatableImpl, Shape): +class Text(RotatableImpl, Shape): """ Text (to be printed e.g. as a set of polygons). This is distinct from non-printed Label objects. @@ -55,11 +55,11 @@ class Text(PositionableImpl, RotatableImpl, Shape): self._height = val @property - def mirrored(self) -> bool: + def mirrored(self) -> bool: # mypy#3004, should be bool return self._mirrored @mirrored.setter - def mirrored(self, val: SupportsBool) -> None: + def mirrored(self, val: bool) -> None: self._mirrored = bool(val) def __init__( @@ -71,7 +71,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, raw: bool = False, ) -> None: if raw: @@ -81,14 +81,14 @@ class Text(PositionableImpl, RotatableImpl, Shape): self._height = height self._rotation = rotation self._repetition = repetition - self._annotations = annotations + self._annotations = annotations if annotations is not None else {} else: self.offset = offset self.string = string self.height = height self.rotation = rotation self.repetition = repetition - self.annotations = annotations + self.annotations = annotations if annotations is not None else {} self.font_path = font_path def __deepcopy__(self, memo: dict | None = None) -> Self: @@ -201,7 +201,7 @@ def get_char_as_polygons( font_path: str, char: str, resolution: float = 48 * 64, - ) -> tuple[list[NDArray[numpy.float64]], float]: + ) -> tuple[list[list[list[float]]], float]: from freetype import Face # type: ignore from matplotlib.path import Path # type: ignore @@ -276,12 +276,11 @@ def get_char_as_polygons( advance = slot.advance.x / resolution - polygons: list[NDArray[numpy.float64]] if len(all_verts) == 0: polygons = [] else: path = Path(all_verts, all_codes) path.should_simplify = False - polygons = [numpy.asarray(poly) for poly in path.to_polygons()] + polygons = path.to_polygons() return polygons, advance diff --git a/masque/traits/annotatable.py b/masque/traits/annotatable.py index 1ae41e1..1b2ba23 100644 --- a/masque/traits/annotatable.py +++ b/masque/traits/annotatable.py @@ -45,6 +45,6 @@ class AnnotatableImpl(Annotatable, metaclass=ABCMeta): @annotations.setter def annotations(self, annotations: annotations_t) -> None: - if not isinstance(annotations, dict) and annotations is not None: - raise MasqueError(f'annotations expected dict or None, got {type(annotations)}') + if not isinstance(annotations, dict): + raise MasqueError(f'annotations expected dict, got {type(annotations)}') self._annotations = annotations diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py index 6779869..66e6e7d 100644 --- a/masque/traits/positionable.py +++ b/masque/traits/positionable.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Self, Any from abc import ABCMeta, abstractmethod import numpy @@ -73,7 +73,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta): # # offset property @property - def offset(self) -> NDArray[numpy.float64]: + def offset(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ [x, y] offset """ @@ -95,7 +95,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta): return self def translate(self, offset: ArrayLike) -> Self: - self._offset += numpy.asarray(offset) + self._offset += offset # type: ignore # NDArray += ArrayLike should be fine?? return self diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 2fa86c1..04816f1 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -116,7 +116,7 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta): pivot = numpy.asarray(pivot, dtype=float) cast('Positionable', self).translate(-pivot) cast('Rotatable', self).rotate(rotation) - self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) + self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) # type: ignore # mypy#3004 cast('Positionable', self).translate(+pivot) return self diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 5fddd52..23fb601 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -18,9 +18,9 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> `vertices` with no consecutive duplicates. This may be a view into the original array. """ vertices = numpy.asarray(vertices) - duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1) + duplicates = (vertices == numpy.roll(vertices, 1, axis=0)).all(axis=1) if not closed_path: - duplicates[-1] = False + duplicates[0] = False return vertices[~duplicates] diff --git a/pyproject.toml b/pyproject.toml index 9a29065..9587a04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ dxf = ["ezdxf~=1.0.2"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] -manhatanize_slow = ["float_raster"] [tool.ruff] @@ -90,7 +89,3 @@ lint.ignore = [ "TRY003", # Long exception message ] -[tool.pytest.ini_options] -addopts = "-rsXx" -testpaths = ["masque"] -