diff --git a/masque/builder/devices.py b/masque/builder/devices.py index 54d3940..2bccfb1 100644 --- a/masque/builder/devices.py +++ b/masque/builder/devices.py @@ -14,8 +14,9 @@ from numpy.typing import ArrayLike, NDArray from ..pattern import Pattern from ..subpattern import SubPattern from ..traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable -from ..utils import AutoSlots, rotation_matrix_2d +from ..utils import AutoSlots from ..error import DeviceError +from ..ports import PortList, Port from .tools import Tool from .utils import ell @@ -23,382 +24,10 @@ from .utils import ell logger = logging.getLogger(__name__) -P = TypeVar('P', bound='Port') -PL = TypeVar('PL', bound='PortList') -PL2 = TypeVar('PL2', bound='PortList') D = TypeVar('D', bound='Device') DR = TypeVar('DR', bound='DeviceRef') -class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, metaclass=AutoSlots): - """ - A point at which a `Device` can be snapped to another `Device`. - - Each port has an `offset` ((x, y) position) and may also have a - `rotation` (orientation) and a `ptype` (port type). - - The `rotation` is an angle, in radians, measured counterclockwise - from the +x axis, pointing inwards into the device which owns the port. - The rotation may be set to `None`, indicating that any orientation is - allowed (e.g. for a DC electrical port). It is stored modulo 2pi. - - The `ptype` is an arbitrary string, default of `unk` (unknown). - """ - __slots__ = ('ptype', '_rotation') - - _rotation: Optional[float] - """ radians counterclockwise from +x, pointing into device body. - Can be `None` to signify undirected port """ - - ptype: str - """ Port types must match to be plugged together if both are non-zero """ - - def __init__( - self, - offset: ArrayLike, - rotation: Optional[float], - ptype: str = 'unk', - ) -> None: - self.offset = offset - self.rotation = rotation - self.ptype = ptype - - @property - def rotation(self) -> Optional[float]: - """ Rotation, radians counterclockwise, pointing into device body. Can be None. """ - return self._rotation - - @rotation.setter - def rotation(self, val: float) -> None: - if val is None: - self._rotation = None - else: - if not numpy.size(val) == 1: - raise DeviceError('Rotation must be a scalar') - self._rotation = val % (2 * pi) - - def get_bounds(self): - return numpy.vstack((self.offset, self.offset)) - - def set_ptype(self: P, ptype: str) -> P: - """ Chainable setter for `ptype` """ - self.ptype = ptype - return self - - def mirror(self: P, axis: int) -> P: - self.offset[1 - axis] *= -1 - if self.rotation is not None: - self.rotation *= -1 - self.rotation += axis * pi - return self - - def rotate(self: P, rotation: float) -> P: - if self.rotation is not None: - self.rotation += rotation - return self - - def set_rotation(self: P, rotation: Optional[float]) -> P: - self.rotation = rotation - return self - - def __repr__(self) -> str: - if self.rotation is None: - rot = 'any' - else: - rot = str(numpy.rad2deg(self.rotation)) - return f'<{self.offset}, {rot}, [{self.ptype}]>' - - -class PortList(Copyable, Mirrorable, metaclass=ABCMeta): - __slots__ = ('ports',) - - ports: Dict[str, Port] - """ Uniquely-named ports which can be used to snap to other Device instances""" - - @overload - def __getitem__(self, key: str) -> Port: - pass - - @overload - def __getitem__(self, key: Union[List[str], Tuple[str, ...], KeysView[str], ValuesView[str]]) -> Dict[str, Port]: - pass - - def __getitem__(self, key: Union[str, Iterable[str]]) -> Union[Port, Dict[str, Port]]: - """ - For convenience, ports can be read out using square brackets: - - `device['A'] == Port((0, 0), 0)` - - `device[['A', 'B']] == {'A': Port((0, 0), 0), - 'B': Port((0, 0), pi)}` - """ - if isinstance(key, str): - return self.ports[key] - else: - return {k: self.ports[k] for k in key} - - def rename_ports( - self: PL, - mapping: Dict[str, Optional[str]], - overwrite: bool = False, - ) -> PL: - """ - Renames ports as specified by `mapping`. - Ports can be explicitly deleted by mapping them to `None`. - - Args: - mapping: Dict of `{'old_name': 'new_name'}` pairs. Names can be mapped - to `None` to perform an explicit deletion. `'new_name'` can also - overwrite an existing non-renamed port to implicitly delete it if - `overwrite` is set to `True`. - overwrite: Allows implicit deletion of ports if set to `True`; see `mapping`. - - Returns: - self - """ - if not overwrite: - duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values()) - if duplicates: - raise DeviceError(f'Unrenamed ports would be overwritten: {duplicates}') - - renamed = {mapping[k]: self.ports.pop(k) for k in mapping.keys()} - if None in renamed: - del renamed[None] - - self.ports.update(renamed) # type: ignore - return self - - def check_ports( - self: PL, - other_names: Iterable[str], - map_in: Optional[Dict[str, str]] = None, - map_out: Optional[Dict[str, Optional[str]]] = None, - ) -> PL: - """ - Given the provided port mappings, check that: - - All of the ports specified in the mappings exist - - There are no duplicate port names after all the mappings are performed - - Args: - other_names: List of port names being considered for inclusion into - `self.ports` (before mapping) - map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying - port connections between the two devices. - map_out: Dict of `{'old_name': 'new_name'}` mappings, specifying - new names for unconnected `other_names` ports. - - Returns: - self - - Raises: - `DeviceError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other_names`. - `DeviceError` if there are any duplicate names after `map_in` and `map_out` - are applied. - """ - if map_in is None: - map_in = {} - - if map_out is None: - map_out = {} - - other = set(other_names) - - missing_inkeys = set(map_in.keys()) - set(self.ports.keys()) - if missing_inkeys: - raise DeviceError(f'`map_in` keys not present in device: {missing_inkeys}') - - missing_invals = set(map_in.values()) - other - if missing_invals: - raise DeviceError(f'`map_in` values not present in other device: {missing_invals}') - - missing_outkeys = set(map_out.keys()) - other - if missing_outkeys: - raise DeviceError(f'`map_out` keys not present in other device: {missing_outkeys}') - - orig_remaining = set(self.ports.keys()) - set(map_in.keys()) - other_remaining = other - set(map_out.keys()) - set(map_in.values()) - mapped_vals = set(map_out.values()) - mapped_vals.discard(None) - - conflicts_final = orig_remaining & (other_remaining | mapped_vals) - if conflicts_final: - raise DeviceError(f'Device ports conflict with existing ports: {conflicts_final}') - - conflicts_partial = other_remaining & mapped_vals - if conflicts_partial: - raise DeviceError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}') - - map_out_counts = Counter(map_out.values()) - map_out_counts[None] = 0 - conflicts_out = {k for k, v in map_out_counts.items() if v > 1} - if conflicts_out: - raise DeviceError(f'Duplicate targets in `map_out`: {conflicts_out}') - - return self - - def as_interface( - self, - in_prefix: str = 'in_', - out_prefix: str = '', - port_map: Optional[Union[Dict[str, str], Sequence[str]]] = None - ) -> 'Device': - """ - Begin building a new device based on all or some of the ports in the - current device. Do not include the current device; instead use it - to define ports (the "interface") for the new device. - - The ports specified by `port_map` (default: all ports) are copied to - new device, and additional (input) ports are created facing in the - opposite directions. The specified `in_prefix` and `out_prefix` are - prepended to the port names to differentiate them. - - By default, the flipped ports are given an 'in_' prefix and unflipped - ports keep their original names, enabling intuitive construction of - a device that will "plug into" the current device; the 'in_*' ports - are used for plugging the devices together while the original port - names are used for building the new device. - - Another use-case could be to build the new device using the 'in_' - ports, creating a new device which could be used in place of the - current device. - - Args: - in_prefix: Prepended to port names for newly-created ports with - reversed directions compared to the current device. - out_prefix: Prepended to port names for ports which are directly - copied from the current device. - port_map: Specification for ports to copy into the new device: - - If `None`, all ports are copied. - - If a sequence, only the listed ports are copied - - If a mapping, the listed ports (keys) are copied and - renamed (to the values). - - Returns: - The new device, with an empty pattern and 2x as many ports as - listed in port_map. - - Raises: - `DeviceError` if `port_map` contains port names not present in the - current device. - `DeviceError` if applying the prefixes results in duplicate port - names. - """ - if port_map: - if isinstance(port_map, dict): - missing_inkeys = set(port_map.keys()) - set(self.ports.keys()) - orig_ports = {port_map[k]: v for k, v in self.ports.items() if k in port_map} - else: - port_set = set(port_map) - missing_inkeys = port_set - set(self.ports.keys()) - orig_ports = {k: v for k, v in self.ports.items() if k in port_set} - - if missing_inkeys: - raise DeviceError(f'`port_map` keys not present in device: {missing_inkeys}') - else: - orig_ports = self.ports - - ports_in = {f'{in_prefix}{name}': port.deepcopy().rotate(pi) - for name, port in orig_ports.items()} - ports_out = {f'{out_prefix}{name}': port.deepcopy() - for name, port in orig_ports.items()} - - duplicates = set(ports_out.keys()) & set(ports_in.keys()) - if duplicates: - raise DeviceError(f'Duplicate keys after prefixing, try a different prefix: {duplicates}') - - new = Device(ports={**ports_in, **ports_out}) - return new - - def find_transform( - self: PL, - other: PL2, - map_in: Dict[str, str], - *, - mirrored: Tuple[bool, bool] = (False, False), - set_rotation: Optional[bool] = None, - ) -> Tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: - """ - Given a device `other` and a mapping `map_in` specifying port connections, - find the transform which will correctly align the specified ports. - - Args: - other: a device - map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying - port connections between the two devices. - mirrored: Mirrors `other` across the x or y axes prior to - connecting any ports. - set_rotation: If the necessary rotation cannot be determined from - the ports being connected (i.e. all pairs have at least one - port with `rotation=None`), `set_rotation` must be provided - to indicate how much `other` should be rotated. Otherwise, - `set_rotation` must remain `None`. - - Returns: - - The (x, y) translation (performed last) - - The rotation (radians, counterclockwise) - - The (x, y) pivot point for the rotation - - The rotation should be performed before the translation. - """ - s_ports = self[map_in.keys()] - o_ports = other[map_in.values()] - - s_offsets = numpy.array([p.offset for p in s_ports.values()]) - o_offsets = numpy.array([p.offset for p in o_ports.values()]) - s_types = [p.ptype for p in s_ports.values()] - o_types = [p.ptype for p in o_ports.values()] - - s_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in s_ports.values()]) - o_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in o_ports.values()]) - s_has_rot = numpy.array([p.rotation is not None for p in s_ports.values()], dtype=bool) - o_has_rot = numpy.array([p.rotation is not None for p in o_ports.values()], dtype=bool) - has_rot = s_has_rot & o_has_rot - - if mirrored[0]: - o_offsets[:, 1] *= -1 - o_rotations *= -1 - if mirrored[1]: - o_offsets[:, 0] *= -1 - o_rotations *= -1 - o_rotations += pi - - type_conflicts = numpy.array([st != ot and st != 'unk' and ot != 'unk' - for st, ot in zip(s_types, o_types)]) - if type_conflicts.any(): - ports = numpy.where(type_conflicts) - msg = 'Ports have conflicting types:\n' - for nn, (k, v) in enumerate(map_in.items()): - if type_conflicts[nn]: - msg += f'{k} | {s_types[nn]}:{o_types[nn]} | {v}\n' - msg = ''.join(traceback.format_stack()) + '\n' + msg - warnings.warn(msg, stacklevel=2) - - rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) - if not has_rot.any(): - if set_rotation is None: - DeviceError('Must provide set_rotation if rotation is indeterminate') - rotations[:] = set_rotation - else: - rotations[~has_rot] = rotations[has_rot][0] - - if not numpy.allclose(rotations[:1], rotations): - rot_deg = numpy.rad2deg(rotations) - msg = f'Port orientations do not match:\n' - for nn, (k, v) in enumerate(map_in.items()): - msg += f'{k} | {rot_deg[nn]:g} | {v}\n' - raise DeviceError(msg) - - pivot = o_offsets[0].copy() - rotate_offsets_around(o_offsets, pivot, rotations[0]) - translations = s_offsets - o_offsets - if not numpy.allclose(translations[:1], translations): - msg = f'Port translations do not match:\n' - for nn, (k, v) in enumerate(map_in.items()): - msg += f'{k} | {translations[nn]} | {v}\n' - raise DeviceError(msg) - - return translations[0], rotations[0], o_offsets[0] - - class DeviceRef(PortList): __slots__ = ('name',) @@ -908,12 +537,3 @@ class Device(PortList): # TODO def path_join() and def bus_join()? -def rotate_offsets_around( - offsets: NDArray[numpy.float64], - pivot: NDArray[numpy.float64], - angle: float, - ) -> NDArray[numpy.float64]: - offsets -= pivot - offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T - offsets += pivot - return offsets diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 5da215b..f92c845 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -11,7 +11,7 @@ Note that OASIS references follow the same convention as `masque`, Scaling, rotation, and mirroring apply to individual instances, not grid vectors or offsets. """ -from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Mapping, Optional +from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Mapping, Optional, cast import re import io import copy @@ -482,7 +482,7 @@ def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]: def _placement_to_subpat(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> SubPattern: """ - Helper function to create a SubPattern from a placment. Sets subpat.target to the placemen name. + Helper function to create a SubPattern from a placment. Sets subpat.target to the placement name. """ assert(not isinstance(placement.repetition, fatamorgana.ReuseRepetition)) xy = numpy.array((placement.x, placement.y)) @@ -551,7 +551,7 @@ def _shapes_to_elements( circle = fatrec.Circle( layer=layer, datatype=datatype, - radius=radius, + radius=cast(int, radius), x=offset[0], y=offset[1], properties=properties, @@ -568,8 +568,8 @@ def _shapes_to_elements( path = fatrec.Path( layer=layer, datatype=datatype, - point_list=deltas, - half_width=half_width, + point_list=cast(Sequence[Sequence[int]], deltas), + half_width=cast(int, half_width), x=xy[0], y=xy[1], extension_start=extension_start, # TODO implement multiple cap types? @@ -587,7 +587,7 @@ def _shapes_to_elements( datatype=datatype, x=xy[0], y=xy[1], - point_list=points, + point_list=cast(List[List[int]], points), properties=properties, repetition=repetition, )) @@ -674,16 +674,16 @@ def repetition_masq2fata( a_count = rint_cast(rep.a_count) b_count = rint_cast(rep.b_count) if rep.b_count is not None else None frep = fatamorgana.GridRepetition( - a_vector=a_vector, - b_vector=b_vector, - a_count=a_count, - b_count=b_count, + a_vector=cast(List[int], a_vector), + b_vector=cast(Optional[List[int]], b_vector), + a_count=cast(int, a_count), + b_count=cast(Optional[int], b_count), ) offset = (0, 0) elif isinstance(rep, Arbitrary): diffs = numpy.diff(rep.displacements, axis=0) diff_ints = rint_cast(diffs) - frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) + frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) # type: ignore offset = rep.displacements[0, :] else: assert(rep is None) diff --git a/masque/pattern.py b/masque/pattern.py index 156c436..81c9748 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -18,19 +18,19 @@ from .shapes import Shape, Polygon from .label import Label from .utils import rotation_matrix_2d, normalize_mirror, AutoSlots, annotations_t from .error import PatternError -from .traits import AnnotatableImpl, Scalable, Mirrorable -from .traits import Rotatable, Positionable +from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable +from .ports import Port, PortList P = TypeVar('P', bound='Pattern') -class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): +class Pattern(PortList, AnnotatableImpl, Mirrorable, metaclass=AutoSlots): """ 2D layout consisting of some set of shapes, labels, and references to other Pattern objects (via SubPattern). Shapes are assumed to inherit from masque.shapes.Shape or provide equivalent functions. """ - __slots__ = ('shapes', 'labels', 'subpatterns') + __slots__ = ('shapes', 'labels', 'subpatterns', 'ports') shapes: List[Shape] """ List of all shapes in this Pattern. @@ -46,6 +46,9 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): (i.e. multiple instances of the same object). """ + ports: Dict[str, Port] + """ Uniquely-named ports which can be used to snap to other Pattern instances""" + def __init__( self, *, @@ -53,6 +56,7 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): labels: Sequence[Label] = (), subpatterns: Sequence[SubPattern] = (), annotations: Optional[annotations_t] = None, + ports: Optional[Mapping[str, Port]] = None ) -> None: """ Basic init; arguments get assigned to member variables. @@ -62,6 +66,8 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): shapes: Initial shapes in the Pattern labels: Initial labels in the Pattern subpatterns: Initial subpatterns in the Pattern + annotations: Initial annotations for the pattern + ports: Any ports in the pattern """ if isinstance(shapes, list): self.shapes = shapes @@ -78,14 +84,25 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): else: self.subpatterns = list(subpatterns) + if ports is not None: + ports = dict(copy.deepcopy(ports)) + self.annotations = annotations if annotations is not None else {} + def __repr__(self) -> str: + s = f' 'Pattern': return Pattern( shapes=copy.deepcopy(self.shapes), labels=copy.deepcopy(self.labels), subpatterns=[copy.copy(sp) for sp in self.subpatterns], annotations=copy.deepcopy(self.annotations), + ports=copy.deepcopy(self.ports), ) def __deepcopy__(self, memo: Optional[Dict] = None) -> 'Pattern': @@ -95,10 +112,11 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): labels=copy.deepcopy(self.labels, memo), subpatterns=copy.deepcopy(self.subpatterns, memo), annotations=copy.deepcopy(self.annotations, memo), + ports=copy.deepcopy(self.ports), ) return new - def append(self: P, other_pattern: P) -> P: + def append(self: P, other_pattern: Pattern) -> P: """ Appends all shapes, labels and subpatterns from other_pattern to self's shapes, labels, and supbatterns. @@ -112,6 +130,8 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): self.subpatterns += other_pattern.subpatterns self.shapes += other_pattern.shapes self.labels += other_pattern.labels + self.annotations += other_pattern.annotations + self.ports += other_pattern.ports return self def subset( @@ -119,31 +139,55 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): shapes: Optional[Callable[[Shape], bool]] = None, labels: Optional[Callable[[Label], bool]] = None, subpatterns: Optional[Callable[[SubPattern], bool]] = None, + annotations: Optional[Callable[[annotation_t], bool]] = None, + ports: Optional[Callable[[str], bool]] = None, + default_keep: bool = False ) -> 'Pattern': """ Returns a Pattern containing only the entities (e.g. shapes) for which the given entity_func returns True. - Self is _not_ altered, but shapes, labels, and subpatterns are _not_ copied. + Self is _not_ altered, but shapes, labels, and subpatterns are _not_ copied, just referenced. Args: - shapes: Given a shape, returns a boolean denoting whether the shape is a member - of the subset. Default always returns False. - labels: Given a label, returns a boolean denoting whether the label is a member - of the subset. Default always returns False. - subpatterns: Given a subpattern, returns a boolean denoting if it is a member - of the subset. Default always returns False. + shapes: Given a shape, returns a boolean denoting whether the shape is a member of the subset. + labels: Given a label, returns a boolean denoting whether the label is a member of the subset. + subpatterns: Given a subpattern, returns a boolean denoting if it is a member of the subset. + annotations: Given an annotation, returns a boolean denoting if it is a member of the subset. + ports: Given a port, returns a boolean denoting if it is a member of the subset. + default_keep: If `True`, keeps all elements of a given type if no function is supplied. + Default `False` (discards all elements). Returns: A Pattern containing all the shapes and subpatterns for which the parameter functions return True """ pat = Pattern() + if shapes is not None: pat.shapes = [s for s in self.shapes if shapes(s)] + elif default_keep: + pat.shapes = copy.copy(self.shapes) + if labels is not None: pat.labels = [s for s in self.labels if labels(s)] + elif default_keep: + pat.labels = copy.copy(self.labels) + if subpatterns is not None: pat.subpatterns = [s for s in self.subpatterns if subpatterns(s)] + elif default_keep: + pat.subpatterns = copy.copy(self.subpatterns) + + if annotations is not None: + pat.annotations = [s for s in self.annotations if annotations(s)] + elif default_keep: + pat.annotations = copy.copy(self.annotations) + + if ports is not None: + pat.ports = {k: v for k, v in self.ports.items() if ports(k)} + elif default_keep: + pat.ports = copy.copy(self.ports) + return pat def polygonize( @@ -281,8 +325,8 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): Returns: self """ - for entry in chain(self.shapes, self.subpatterns, self.labels): - entry.translate(offset) + for entry in chain(self.shapes, self.subpatterns, self.labels, self.ports): + cast(Positionable, entry).translate(offset) return self def scale_elements(self: P, c: float) -> P: @@ -295,9 +339,8 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): Returns: self """ - entry: Scalable for entry in chain(self.shapes, self.subpatterns): - entry.scale_by(c) + cast(Scalable, entry).scale_by(c) return self def scale_by(self: P, c: float) -> P: @@ -311,16 +354,23 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): Returns: self """ - entry: Scalable for entry in chain(self.shapes, self.subpatterns): - entry.offset *= c - entry.scale_by(c) - if entry.repetition: - entry.repetition.scale_by(c) + cast(Positionable, entry).offset *= c + cast(Scalable, entry).scale_by(c) + + rep = cast(Repeatable, entry).repetition + if rep: + rep.scale_by(c) + for label in self.labels: - label.offset *= c - if label.repetition: - label.repetition.scale_by(c) + cast(Positionable, label).offset *= c + + rep = cast(Repeatable, label).repetition + if rep: + rep.scale_by(c) + + for port in self.ports.values(): + port.offset *= c return self def rotate_around(self: P, pivot: ArrayLike, rotation: float) -> P: @@ -351,8 +401,9 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): Returns: self """ - for entry in chain(self.shapes, self.subpatterns, self.labels): - entry.offset = numpy.dot(rotation_matrix_2d(rotation), entry.offset) + for entry in chain(self.shapes, self.subpatterns, self.labels, self.ports): + old_offset = cast(Positionable, entry).offset + cast(Positionable, entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) return self def rotate_elements(self: P, rotation: float) -> P: @@ -380,8 +431,8 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): Returns: self """ - for entry in chain(self.shapes, self.subpatterns, self.labels): - entry.offset[axis - 1] *= -1 + for entry in chain(self.shapes, self.subpatterns, self.labels, self.ports): + cast(Positionable, entry).offset[axis - 1] *= -1 return self def mirror_elements(self: P, axis: int) -> P: @@ -566,6 +617,3 @@ class Pattern(AnnotatableImpl, Mirrorable, metaclass=AutoSlots): pyplot.xlabel('x') pyplot.ylabel('y') pyplot.show() - - def __repr__(self) -> str: - return (f'') diff --git a/masque/repetition.py b/masque/repetition.py index f7842c0..02d3c96 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -39,10 +39,10 @@ class Grid(Repetition, metaclass=AutoSlots): Note that the offsets in either the 2D or 1D grids do not have to be axis-aligned. """ - __slots__ = ('_a_vector', - '_b_vector', - '_a_count', - '_b_count') + __slots__ = ( + '_a_vector','_b_vector', + '_a_count', '_b_count', + ) _a_vector: NDArray[numpy.float64] """ Vector `[x, y]` specifying the first lattice vector of the grid. diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index b33427e..c5712e5 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -21,8 +21,12 @@ class Arc(Shape, metaclass=AutoSlots): The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius. The start and stop angle are measured counterclockwise from the first (x) radius. """ - __slots__ = ('_radii', '_angles', '_width', '_rotation', - 'poly_num_points', 'poly_max_arclen') + __slots__ = ( + '_radii', '_angles', '_width', '_rotation', + 'poly_num_points', 'poly_max_arclen', + # Inherited + '_offset', '_layer', '_repetition', '_annotations', + ) _radii: NDArray[numpy.float64] """ Two radii for defining an ellipse """ diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 7854044..e1b0bf2 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -15,7 +15,11 @@ class Circle(Shape, metaclass=AutoSlots): """ A circle, which has a position and radius. """ - __slots__ = ('_radius', 'poly_num_points', 'poly_max_arclen') + __slots__ = ( + '_radius', 'poly_num_points', 'poly_max_arclen', + # Inherited + '_offset', '_layer', '_repetition', '_annotations', + ) _radius: float """ Circle radius """ diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 5cd532e..2d21b86 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -17,8 +17,12 @@ class Ellipse(Shape, metaclass=AutoSlots): An ellipse, which has a position, two radii, and a rotation. The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius. """ - __slots__ = ('_radii', '_rotation', - 'poly_num_points', 'poly_max_arclen') + __slots__ = ( + '_radii', '_rotation', + 'poly_num_points', 'poly_max_arclen', + # Inherited + '_offset', '_layer', '_repetition', '_annotations', + ) _radii: NDArray[numpy.float64] """ Ellipse radii """ diff --git a/masque/shapes/path.py b/masque/shapes/path.py index dafe523..f177d07 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -28,7 +28,11 @@ class Path(Shape, metaclass=AutoSlots): A normalized_form(...) is available, but can be quite slow with lots of vertices. """ - __slots__ = ('_vertices', '_width', '_cap', '_cap_extensions') + __slots__ = ( + '_vertices', '_width', '_cap', '_cap_extensions', + # Inherited + '_offset', '_layer', '_repetition', '_annotations', + ) _vertices: NDArray[numpy.float64] _width: float _cap: PathCap diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 20d5b47..0a05e78 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -19,7 +19,11 @@ class Polygon(Shape, metaclass=AutoSlots): A `normalized_form(...)` is available, but can be quite slow with lots of vertices. """ - __slots__ = ('_vertices',) + __slots__ = ( + '_vertices', + # Inherited + '_offset', '_layer', '_repetition', '_annotations', + ) _vertices: NDArray[numpy.float64] """ Nx2 ndarray of vertices `[[x0, y0], [x1, y1], ...]` """ diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 66f83db..3e4c1bb 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: # Type definitions normalized_shape_tuple = Tuple[ Tuple, - Tuple[NDArray[numpy.float64], float, float, bool, float], + Tuple[NDArray[numpy.float64], float, float, bool], Callable[[], 'Shape'], ] diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 9fad29a..92ecb68 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -22,7 +22,11 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots): Text (to be printed e.g. as a set of polygons). This is distinct from non-printed Label objects. """ - __slots__ = ('_string', '_height', '_mirrored', 'font_path') + __slots__ = ( + '_string', '_height', '_mirrored', 'font_path', + # Inherited + '_offset', '_layer', '_repetition', '_annotations', '_rotation', + ) _string: str _height: float diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py index f4d1a5f..b7548cb 100644 --- a/masque/traits/positionable.py +++ b/masque/traits/positionable.py @@ -30,10 +30,10 @@ class Positionable(metaclass=ABCMeta): """ pass -# @offset.setter -# @abstractmethod -# def offset(self, val: ArrayLike): -# pass + @offset.setter + @abstractmethod + def offset(self, val: ArrayLike): + pass @abstractmethod def set_offset(self: T, offset: ArrayLike) -> T: diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py index eb0741a..d55d9b2 100644 --- a/masque/utils/__init__.py +++ b/masque/utils/__init__.py @@ -11,6 +11,6 @@ from .bitwise import get_bit, set_bit from .vertices import ( remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points ) -from .transform import rotation_matrix_2d, normalize_mirror +from .transform import rotation_matrix_2d, normalize_mirror, rotate_offsets_around #from . import pack2d diff --git a/masque/utils/transform.py b/masque/utils/transform.py index 92f52c5..6265d26 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -38,3 +38,17 @@ def normalize_mirror(mirrored: Sequence[bool]) -> Tuple[bool, float]: mirror_x = (mirrored_x != mirrored_y) # XOR angle = numpy.pi if mirrored_y else 0 return mirror_x, angle + + +def rotate_offsets_around( + offsets: NDArray[numpy.float64], + pivot: NDArray[numpy.float64], + angle: float, + ) -> NDArray[numpy.float64]: + """ + Rotates offsets around a pivot point. + """ + offsets -= pivot + offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T + offsets += pivot + return offsets diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index ac4a40e..826b3ec 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -77,7 +77,7 @@ def poly_contains_points( vertices = numpy.array(vertices, copy=False) if points.size == 0: - return numpy.zeros(0) + return numpy.zeros(0, dtype=numpy.int8) min_bounds = numpy.min(vertices, axis=0)[None, :] max_bounds = numpy.max(vertices, axis=0)[None, :]