diff --git a/examples/test_rep.py b/examples/test_rep.py new file mode 100644 index 0000000..80496ad --- /dev/null +++ b/examples/test_rep.py @@ -0,0 +1,102 @@ +import numpy +from numpy import pi + +import masque +import masque.file.gdsii +import masque.file.dxf +import masque.file.oasis +from masque import shapes, Pattern, SubPattern +from masque.repetition import Grid + +from pprint import pprint + + +def main(): + pat = masque.Pattern(name='ellip_grating') + for rmin in numpy.arange(10, 15, 0.5): + pat.shapes.append(shapes.Arc( + radii=(rmin, rmin), + width=0.1, + angles=(0*-numpy.pi/4, numpy.pi/4) + )) + + pat.scale_by(1000) +# pat.visualize() + pat2 = pat.copy() + pat2.name = 'grating2' + + pat3 = Pattern('sref_test') + pat3.subpatterns = [ + SubPattern(pat, offset=(1e5, 3e5)), + SubPattern(pat, offset=(2e5, 3e5), rotation=pi/3), + SubPattern(pat, offset=(3e5, 3e5), rotation=pi/2), + SubPattern(pat, offset=(4e5, 3e5), rotation=pi), + SubPattern(pat, offset=(5e5, 3e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(True, False), offset=(1e5, 4e5)), + SubPattern(pat, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3), + SubPattern(pat, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2), + SubPattern(pat, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi), + SubPattern(pat, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(False, True), offset=(1e5, 5e5)), + SubPattern(pat, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3), + SubPattern(pat, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2), + SubPattern(pat, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi), + SubPattern(pat, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(True, True), offset=(1e5, 6e5)), + SubPattern(pat, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3), + SubPattern(pat, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2), + SubPattern(pat, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi), + SubPattern(pat, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2), + ] + + pprint(pat3) + pprint(pat3.subpatterns) + pprint(pat.shapes) + + rep = Grid(a_vector=[1e4, 0], + b_vector=[0, 1.5e4], + a_count=3, + b_count=2,) + pat4 = Pattern('aref_test') + pat4.subpatterns = [ + SubPattern(pat, repetition=rep, offset=(1e5, 3e5)), + SubPattern(pat, repetition=rep, offset=(2e5, 3e5), rotation=pi/3), + SubPattern(pat, repetition=rep, offset=(3e5, 3e5), rotation=pi/2), + SubPattern(pat, repetition=rep, offset=(4e5, 3e5), rotation=pi), + SubPattern(pat, repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(1e5, 4e5)), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(1e5, 5e5)), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(1e5, 6e5)), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2), + ] + + folder = 'layouts/' + masque.file.gdsii.writefile((pat, pat2, pat3, pat4), folder + 'rep.gds.gz', 1e-9, 1e-3) + + cells = list(masque.file.gdsii.readfile(folder + 'rep.gds.gz')[0].values()) + masque.file.gdsii.writefile(cells, folder + 'rerep.gds.gz', 1e-9, 1e-3) + + masque.file.dxf.writefile(pat4, folder + 'rep.dxf.gz') + dxf, info = masque.file.dxf.readfile(folder + 'rep.dxf.gz') + masque.file.dxf.writefile(dxf, folder + 'rerep.dxf.gz') + + layer_map = {'base': (0,0), 'mylabel': (1,2)} + masque.file.oasis.writefile((pat, pat2, pat3, pat4), folder + 'rep.oas.gz', 1000, layer_map=layer_map) + oas, info = masque.file.oasis.readfile(folder + 'rep.oas.gz') + masque.file.oasis.writefile(list(oas.values()), folder + 'rerep.oas.gz', 1000, layer_map=layer_map) + print(info) + + +if __name__ == '__main__': + main() diff --git a/masque/__init__.py b/masque/__init__.py index c826d18..7087b00 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -8,12 +8,10 @@ `Pattern` is a basic object containing a 2D lithography mask, composed of a list of `Shape` objects, a list of `Label` objects, and a list of references to other `Patterns` (using - `SubPattern` and `GridRepetition`). + `SubPattern`). `SubPattern` provides basic support for nesting `Pattern` objects within each other, by adding - offset, rotation, scaling, and other such properties to a Pattern reference. - - `GridRepetition` provides support for nesting regular arrays of `Pattern` objects. + offset, rotation, scaling, repetition, and other such properties to a Pattern reference. Note that the methods for these classes try to avoid copying wherever possible, so unless otherwise noted, assume that arguments are stored by-reference. @@ -31,8 +29,7 @@ import pathlib from .error import PatternError, PatternLockedError from .shapes import Shape from .label import Label -from .subpattern import SubPattern, subpattern_t -from .repetition import GridRepetition +from .subpattern import SubPattern from .pattern import Pattern diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 737a915..4faac8c 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -16,8 +16,9 @@ from numpy import pi import ezdxf from .utils import mangle_name, make_dose_table -from .. import Pattern, SubPattern, GridRepetition, PatternError, Label, Shape, subpattern_t +from .. import Pattern, SubPattern, PatternError, Label, Shape from ..shapes import Polygon, Path +from ..repetition import Grid from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t from ..utils import remove_colinear_vertices, normalize_mirror @@ -55,7 +56,7 @@ def write(pattern: Pattern, If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` prior to calling this function. - Only `GridRepetition` objects with manhattan basis vectors are preserved as arrays. Since DXF + Only `Grid` repetition objects with manhattan basis vectors are preserved as arrays. Since DXF rotations apply to basis vectors while `masque`'s rotations do not, the basis vectors of an array with rotated instances must be manhattan _after_ having a compensating rotation applied. @@ -276,7 +277,7 @@ def _read_block(block, clean_vertices): def _subpatterns_to_refs(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], - subpatterns: List[subpattern_t]): + subpatterns: List[SubPattern]): for subpat in subpatterns: if subpat.pattern is None: continue @@ -289,9 +290,12 @@ def _subpatterns_to_refs(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.M 'rotation': rotation, } - if isinstance(subpat, GridRepetition): - a = subpat.a_vector - b = subpat.b_vector if subpat.b_vector is not None else numpy.zeros(2) + rep = subpat.repetition + if rep is None: + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) + elif isinstance(rep, Grid): + a = rep.a_vector + b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) rotated_a = rotation_matrix_2d(-subpat.rotation) @ a rotated_b = rotation_matrix_2d(-subpat.rotation) @ b if rotated_a[1] == 0 and rotated_b[0] == 0: @@ -310,11 +314,11 @@ def _subpatterns_to_refs(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.M #NOTE: We could still do non-manhattan (but still orthogonal) grids by getting # creative with counter-rotated nested patterns, but probably not worth it. # Instead, just break appart the grid into individual elements: - for aa in numpy.arange(subpat.a_count): - for bb in numpy.arange(subpat.b_count): - block.add_blockref(encoded_name, subpat.offset + aa * a + bb * b, dxfattribs=attribs) + for dd in rep.displacements: + block.add_blockref(encoded_name, subpat.offset + dd, dxfattribs=attribs) else: - block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) + for dd in rep.displacements: + block.add_blockref(encoded_name, subpat.offset + dd, dxfattribs=attribs) def _shapes_to_elements(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index 3496d9c..4c2198c 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -28,8 +28,9 @@ import gdsii.structure import gdsii.elements from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose -from .. import Pattern, SubPattern, GridRepetition, PatternError, Label, Shape, subpattern_t +from .. import Pattern, SubPattern, PatternError, Label, Shape from ..shapes import Polygon, Path +from ..repetition import Grid from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t from ..utils import remove_colinear_vertices, normalize_mirror @@ -291,11 +292,9 @@ def read(stream: io.BufferedIOBase, string=element.string.decode('ASCII')) pat.labels.append(label) - elif isinstance(element, gdsii.elements.SRef): - pat.subpatterns.append(_sref_to_subpat(element)) - - elif isinstance(element, gdsii.elements.ARef): - pat.subpatterns.append(_aref_to_gridrep(element)) + elif (isinstance(element, gdsii.elements.SRef) or + isinstance(element, gdsii.elements.ARef)): + pat.subpatterns.append(_ref_to_subpat(element)) if use_dtype_as_dose: logger.warning('use_dtype_as_dose will be removed in the future!') @@ -330,40 +329,11 @@ def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]: return layer, data_type -def _sref_to_subpat(element: gdsii.elements.SRef) -> SubPattern: +def _ref_to_subpat(element: Union[gdsii.elements.SRef, + gdsii.elements.ARef] + ) -> SubPattern: """ - Helper function to create a SubPattern from an SREF. Sets subpat.pattern to None - and sets the instance .identifier to (struct_name,). - - BUG: - "Absolute" means not affected by parent elements. - That's not currently supported by masque at all, so need to either tag it and - undo the parent transformations, or implement it in masque. - """ - subpat = SubPattern(pattern=None, offset=element.xy) - subpat.identifier = (element.struct_name,) - if element.strans is not None: - if element.mag is not None: - subpat.scale = element.mag - # Bit 13 means absolute scale - if get_bit(element.strans, 15 - 13): - #subpat.offset *= subpat.scale - raise PatternError('Absolute scale is not implemented yet!') - if element.angle is not None: - subpat.rotation = element.angle * numpy.pi / 180 - # Bit 14 means absolute rotation - if get_bit(element.strans, 15 - 14): - #subpat.offset = numpy.dot(rotation_matrix_2d(subpat.rotation), subpat.offset) - raise PatternError('Absolute rotation is not implemented yet!') - # Bit 0 means mirror x-axis - if get_bit(element.strans, 15 - 0): - subpat.mirrored[0] = 1 - return subpat - - -def _aref_to_gridrep(element: gdsii.elements.ARef) -> GridRepetition: - """ - Helper function to create a GridRepetition from an AREF. Sets gridrep.pattern to None + Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None and sets the instance .identifier to (struct_name,). BUG: @@ -375,6 +345,7 @@ def _aref_to_gridrep(element: gdsii.elements.ARef) -> GridRepetition: offset = numpy.array(element.xy[0]) scale = 1 mirror_across_x = False + repetition = None if element.strans is not None: if element.mag is not None: @@ -383,7 +354,7 @@ def _aref_to_gridrep(element: gdsii.elements.ARef) -> GridRepetition: if get_bit(element.strans, 15 - 13): raise PatternError('Absolute scale is not implemented yet!') if element.angle is not None: - rotation = element.angle * numpy.pi / 180 + rotation = numpy.deg2rad(element.angle) # Bit 14 means absolute rotation if get_bit(element.strans, 15 - 14): raise PatternError('Absolute rotation is not implemented yet!') @@ -391,25 +362,24 @@ def _aref_to_gridrep(element: gdsii.elements.ARef) -> GridRepetition: if get_bit(element.strans, 15 - 0): mirror_across_x = True - counts = [element.cols, element.rows] - a_vector = (element.xy[1] - offset) / counts[0] - b_vector = (element.xy[2] - offset) / counts[1] + if isinstance(element, gdsii.elements.ARef): + a_count = element.cols + b_count = element.rows + a_vector = (element.xy[1] - offset) / counts[0] + b_vector = (element.xy[2] - offset) / counts[1] + repetition = Grid(a_vector=a_vector, b_vector=b_vector, + a_count=a_count, b_count=b_count) - gridrep = GridRepetition(pattern=None, - a_vector=a_vector, - b_vector=b_vector, - a_count=counts[0], - b_count=counts[1], - offset=offset, - rotation=rotation, - scale=scale, - mirrored=(mirror_across_x, False)) - gridrep.identifier = (element.struct_name,) - - return gridrep + subpat = SubPattern(pattern=None, + offset=offset, + rotation=rotation, + scale=scale, + mirrored=(mirror_across_x, False)) + subpat.identifier = (element.struct_name,) + return subpat -def _subpatterns_to_refs(subpatterns: List[subpattern_t] +def _subpatterns_to_refs(subpatterns: List[SubPattern] ) -> List[Union[gdsii.elements.ARef, gdsii.elements.SRef]]: refs = [] for subpat in subpatterns: @@ -420,26 +390,35 @@ def _subpatterns_to_refs(subpatterns: List[subpattern_t] # Note: GDS mirrors first and rotates second mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) ref: Union[gdsii.elements.SRef, gdsii.elements.ARef] - if isinstance(subpat, GridRepetition): + + rep = subpat.repetition + if isinstance(rep, Grid): xy = numpy.array(subpat.offset) + [ [0, 0], - subpat.a_vector * subpat.a_count, - subpat.b_vector * subpat.b_count, + rep.a_vector * rep.a_count, + rep.b_vector * rep.b_count, ] ref = gdsii.elements.ARef(struct_name=encoded_name, xy=numpy.round(xy).astype(int), - cols=numpy.round(subpat.a_count).astype(int), - rows=numpy.round(subpat.b_count).astype(int)) - else: + cols=numpy.round(rep.a_count).astype(int), + rows=numpy.round(rep.b_count).astype(int)) + new_refs = [ref] + elif rep is None: ref = gdsii.elements.SRef(struct_name=encoded_name, xy=numpy.round([subpat.offset]).astype(int)) + new_refs = [ref] + else: + new_refs = [gdsii.elements.SRef(struct_name=encoded_name, + xy=numpy.round([subpat.offset + dd]).astype(int)) + for dd in rep.displacements] - ref.angle = ((subpat.rotation + extra_angle) * 180 / numpy.pi) % 360 - # strans must be non-None for angle and mag to take effect - ref.strans = set_bit(0, 15 - 0, mirror_across_x) - ref.mag = subpat.scale + for ref in new_refs: + ref.angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360 + # strans must be non-None for angle and mag to take effect + ref.strans = set_bit(0, 15 - 0, mirror_across_x) + ref.mag = subpat.scale - refs.append(ref) + refs += new_refs return refs diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 1de1e09..775cff7 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -28,8 +28,9 @@ import fatamorgana.records as fatrec from fatamorgana.basic import PathExtensionScheme from .utils import mangle_name, make_dose_table -from .. import Pattern, SubPattern, GridRepetition, PatternError, Label, Shape, subpattern_t +from .. import Pattern, SubPattern, PatternError, Label, Shape from ..shapes import Polygon, Path, Circle +from ..repetition import Grid, Arbitrary, Repetition from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t from ..utils import remove_colinear_vertices, normalize_mirror @@ -221,7 +222,7 @@ def read(stream: io.BufferedIOBase, """ Read a OASIS file and translate it into a dict of Pattern objects. OASIS cells are translated into Pattern objects; Polygons are translated into polygons, and Placements - are translated into SubPattern or GridRepetition objects. + are translated into SubPattern objects. Additional library info is returned in a dict, containing: 'units_per_micrometer': number of database units per micrometer (all values are in database units) @@ -417,7 +418,7 @@ def read(stream: io.BufferedIOBase, continue for placement in cell.placements: - pat.subpatterns += _placement_to_subpats(placement) + pat.subpatterns.append(_placement_to_subpat(placement)) patterns.append(pat) @@ -451,7 +452,7 @@ def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]: return layer, data_type -def _placement_to_subpats(placement: fatrec.Placement) -> List[subpattern_t]: +def _placement_to_subpat(placement: fatrec.Placement) -> SubPattern: """ Helper function to create a SubPattern from a placment. Sets subpat.pattern to None and sets the instance .identifier to (struct_name,). @@ -468,27 +469,24 @@ def _placement_to_subpats(placement: fatrec.Placement) -> List[subpattern_t]: 'identifier': (name,), } - subpats: List[subpattern_t] + mrep: Repetition rep = placement.repetition if isinstance(rep, fatamorgana.GridRepetition): - subpat = GridRepetition(a_vector=rep.a_vector, - b_vector=rep.b_vector, - a_count=rep.a_count, - b_count=rep.b_count, - offset=xy, - **args) - subpats = [subpat] + mrep = Grid(a_vector=rep.a_vector, + b_vector=rep.b_vector, + a_count=rep.a_count, + b_count=rep.b_count) elif isinstance(rep, fatamorgana.ArbitraryRepetition): - subpats = [] - for rep_offset in numpy.cumsum(numpy.column_stack((rep.x_displacements, - rep.y_displacements))): - subpats.append(SubPattern(offset=xy + rep_offset, **args)) + mrep = Arbitrary(numpy.cumsum(numpy.column_stack((rep.x_displacements, + rep.y_displacements)))) elif rep is None: - subpats = [SubPattern(offset=xy, **args)] - return subpats + mrep = None + + subpat = SubPattern(offset=xy, repetition=mrep, **args) + return subpat -def _subpatterns_to_refs(subpatterns: List[subpattern_t] +def _subpatterns_to_refs(subpatterns: List[SubPattern] ) -> List[fatrec.Placement]: refs = [] for subpat in subpatterns: @@ -503,14 +501,21 @@ def _subpatterns_to_refs(subpatterns: List[subpattern_t] 'y': xy[1], } - if isinstance(subpat, GridRepetition): + rep = subpat.repetition + if isinstance(rep, Grid): args['repetition'] = fatamorgana.GridRepetition( - a_vector=numpy.round(subpat.a_vector).astype(int), - b_vector=numpy.round(subpat.b_vector).astype(int), - a_count=numpy.round(subpat.a_count).astype(int), - b_count=numpy.round(subpat.b_count).astype(int)) + a_vector=numpy.round(rep.a_vector).astype(int), + b_vector=numpy.round(rep.b_vector).astype(int), + a_count=numpy.round(rep.a_count).astype(int), + b_count=numpy.round(rep.b_count).astype(int)) + elif isinstance(rep, Arbitrary): + diffs = numpy.diff(rep.displacements, axis=0) + args['repetition'] = fatamorgana.ArbitraryRepetition( + numpy.round(diffs).astype(int)) + else: + assert(rep is None) - angle = ((subpat.rotation + extra_angle) * 180 / numpy.pi) % 360 + angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360 ref = fatrec.Placement( name=subpat.pattern.name, flip=mirror_across_x, diff --git a/masque/label.py b/masque/label.py index df7f3cf..9a6d326 100644 --- a/masque/label.py +++ b/masque/label.py @@ -4,20 +4,15 @@ import numpy from numpy import pi from .error import PatternError, PatternLockedError -from .utils import is_scalar, vector2, rotation_matrix_2d, layer_t +from .utils import is_scalar, vector2, rotation_matrix_2d, layer_t, AutoSlots +from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, LockableImpl -class Label: +class Label(PositionableImpl, LayerableImpl, LockableImpl, Pivotable, Copyable, metaclass=AutoSlots): """ A text annotation with a position and layer (but no size; it is not drawn) """ - __slots__ = ('_offset', '_layer', '_string', 'identifier', 'locked') - - _offset: numpy.ndarray - """ [x_offset, y_offset] """ - - _layer: layer_t - """ Layer (integer >= 0, or 2-Tuple of integers) """ + __slots__ = ( '_string', 'identifier') _string: str """ Label string """ @@ -25,44 +20,9 @@ class Label: identifier: Tuple """ Arbitrary identifier tuple, useful for keeping track of history when flattening """ - locked: bool - """ If `True`, any changes to the label will raise a `PatternLockedError` """ - - def __setattr__(self, name, value): - if self.locked and name != 'locked': - raise PatternLockedError() - object.__setattr__(self, name, value) - - # ---- Properties - # offset property - @property - def offset(self) -> numpy.ndarray: - """ - [x, y] offset - """ - return self._offset - - @offset.setter - def offset(self, val: vector2): - if not isinstance(val, numpy.ndarray): - val = numpy.array(val, dtype=float) - - if val.size != 2: - raise PatternError('Offset must be convertible to size-2 ndarray') - self._offset = val.flatten().astype(float) - - # layer property - @property - def layer(self) -> layer_t: - """ - Layer number or name (int, tuple of ints, or string) - """ - return self._layer - - @layer.setter - def layer(self, val: layer_t): - self._layer = val - + ''' + ---- Properties + ''' # string property @property def string(self) -> str: @@ -100,25 +60,6 @@ class Label: new.locked = self.locked return new - def copy(self) -> 'Label': - """ - Returns a deep copy of the label. - """ - return copy.deepcopy(self) - - def translate(self, offset: vector2) -> 'Label': - """ - Translate the label by the given offset - - Args: - offset: [x_offset, y,offset] - - Returns: - self - """ - self.offset += offset - return self - def rotate_around(self, pivot: vector2, rotation: float) -> 'Label': """ Rotate the label around a point. @@ -150,25 +91,13 @@ class Label: return numpy.array([self.offset, self.offset]) def lock(self) -> 'Label': - """ - Lock the Label, causing any modifications to raise an exception. - - Return: - self - """ - self.offset.flags.writeable = False - object.__setattr__(self, 'locked', True) + PositionableImpl._lock(self) + LockableImpl.lock(self) return self def unlock(self) -> 'Label': - """ - Unlock the Label, re-allowing changes. - - Return: - self - """ - object.__setattr__(self, 'locked', False) - self.offset.flags.writeable = True + LockableImpl.unlock(self) + PositionableImpl._unlock(self) return self def __repr__(self) -> str: diff --git a/masque/pattern.py b/masque/pattern.py index 663b060..2e57464 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -13,8 +13,7 @@ import numpy from numpy import inf # .visualize imports matplotlib and matplotlib.collections -from .subpattern import SubPattern, subpattern_t -from .repetition import GridRepetition +from .subpattern import SubPattern from .shapes import Shape, Polygon from .label import Label from .utils import rotation_matrix_2d, vector2, normalize_mirror @@ -27,8 +26,7 @@ visitor_function_t = Callable[['Pattern', Tuple['Pattern'], Dict, numpy.ndarray] class Pattern: """ 2D layout consisting of some set of shapes, labels, and references to other Pattern objects - (via SubPattern and GridRepetition). Shapes are assumed to inherit from - masque.shapes.Shape or provide equivalent functions. + (via SubPattern). Shapes are assumed to inherit from masque.shapes.Shape or provide equivalent functions. """ __slots__ = ('shapes', 'labels', 'subpatterns', 'name', 'locked') @@ -40,11 +38,10 @@ class Pattern: labels: List[Label] """ List of all labels in this Pattern. """ - subpatterns: List[subpattern_t] - """ List of all objects referencing other patterns in this Pattern. - Examples are SubPattern (gdsii "instances") or GridRepetition (gdsii "arrays") + subpatterns: List[SubPattern] + """ List of all references to other patterns (`SubPattern`s) in this `Pattern`. Multiple objects in this list may reference the same Pattern object - (multiple instances of the same object). + (i.e. multiple instances of the same object). """ name: str @@ -57,7 +54,7 @@ class Pattern: name: str = '', shapes: Sequence[Shape] = (), labels: Sequence[Label] = (), - subpatterns: Sequence[subpattern_t] = (), + subpatterns: Sequence[SubPattern] = (), locked: bool = False, ): """ @@ -134,7 +131,7 @@ class Pattern: def subset(self, shapes_func: Callable[[Shape], bool] = None, labels_func: Callable[[Label], bool] = None, - subpatterns_func: Callable[[subpattern_t], bool] = None, + subpatterns_func: Callable[[SubPattern], bool] = None, recursive: bool = False, ) -> 'Pattern': """ @@ -493,7 +490,7 @@ class Pattern: def subpatterns_by_id(self, include_none: bool = False, recursive: bool = True, - ) -> Dict[int, List[subpattern_t]]: + ) -> Dict[int, List[SubPattern]]: """ Create a dictionary which maps `{id(referenced_pattern): [subpattern0, ...]}` for all SubPattern objects referenced by this Pattern (by default, operates @@ -506,7 +503,7 @@ class Pattern: Returns: Dictionary mapping each pattern id to a list of subpattern objects referencing the pattern. """ - ids: Dict[int, List[subpattern_t]] = defaultdict(list) + ids: Dict[int, List[SubPattern]] = defaultdict(list) for subpat in self.subpatterns: pat = subpat.pattern if include_none or pat is not None: diff --git a/masque/repetition.py b/masque/repetition.py index 81e8b86..737fb33 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -1,78 +1,47 @@ """ - Repetitions provides support for efficiently nesting multiple identical - instances of a Pattern in the same parent Pattern. + Repetitions provide support for efficiently representing multiple identical + instances of an object . """ from typing import Union, List, Dict, Tuple, Optional, Sequence, TYPE_CHECKING, Any import copy +from abc import ABCMeta, abstractmethod import numpy -from numpy import pi from .error import PatternError, PatternLockedError -from .utils import is_scalar, rotation_matrix_2d, vector2 - -if TYPE_CHECKING: - from . import Pattern +from .utils import rotation_matrix_2d, vector2, AutoSlots +from .traits import LockableImpl, Copyable, Scalable, Rotatable, Mirrorable -# TODO need top-level comment about what order rotation/scale/offset/mirror/array are applied - -class GridRepetition: +class Repetition(Copyable, Rotatable, Mirrorable, Scalable, metaclass=ABCMeta): """ - GridRepetition provides support for efficiently embedding multiple copies of a `Pattern` - into another `Pattern` at regularly-spaced offsets. - - Note that rotation, scaling, and mirroring are applied to individual instances of the - pattern, not to the grid vectors. - - The order of operations is - 1. A single refernce instance to the target pattern is mirrored - 2. The single instance is rotated. - 3. The instance is scaled by the scaling factor. - 4. The instance is shifted by the provided offset - (no mirroring/scaling/rotation is applied to the offset). - 5. Additional copies of the instance will appear at coordinates specified by - `(offset + aa * a_vector + bb * b_vector)`, with `aa in range(0, a_count)` - and `bb in range(0, b_count)`. All instance locations remain unaffected by - mirroring/scaling/rotation, though each instance's data will be transformed - relative to the instance's location (i.e. relative to the contained pattern's - (0, 0) point). + Interface common to all objects which specify repetitions """ - __slots__ = ('_pattern', - '_offset', - '_rotation', - '_dose', - '_scale', - '_mirrored', - '_a_vector', + __slots__ = () + + @property + @abstractmethod + def displacements(self) -> numpy.ndarray: + """ + An Nx2 ndarray specifying all offsets generated by this repetition + """ + pass + + +class Grid(LockableImpl, Repetition, metaclass=AutoSlots): + """ + `Grid` describes a 2D grid formed by two basis vectors and two 'counts' (sizes). + + The second basis vector and count (`b_vector` and `b_count`) may be omitted, + which makes the grid describe a 1D array. + + Note that the offsets in either the 2D or 1D grids do not have to be axis-aligned. + """ + __slots__ = ('_a_vector', '_b_vector', '_a_count', - '_b_count', - 'identifier', - 'locked') - - _pattern: Optional['Pattern'] - """ The `Pattern` being instanced """ - - _offset: numpy.ndarray - """ (x, y) offset for the base instance """ - - _dose: float - """ Scaling factor applied to the dose """ - - _rotation: float - """ Rotation of the individual instances in the grid (not the grid vectors). - Radians, counterclockwise. - """ - - _scale: float - """ Scaling factor applied to individual instances in the grid (not the grid vectors) """ - - _mirrored: numpy.ndarray # ndarray[bool] - """ Whether to mirror individual instances across the x and y axes - (Applies to individual instances in the grid, not the grid vectors) - """ + '_b_count') _a_vector: numpy.ndarray """ Vector `[x, y]` specifying the first lattice vector of the grid. @@ -91,28 +60,14 @@ class GridRepetition: _b_count: int """ Number of instances along the direction specified by the `b_vector` """ - identifier: Tuple[Any, ...] - """ Arbitrary identifier, used internally by some `masque` functions. """ - - locked: bool - """ If `True`, disallows changes to the GridRepetition """ - def __init__(self, - pattern: Optional['Pattern'], a_vector: numpy.ndarray, a_count: int, b_vector: Optional[numpy.ndarray] = None, b_count: Optional[int] = 1, - offset: vector2 = (0.0, 0.0), - rotation: float = 0.0, - mirrored: Optional[Sequence[bool]] = None, - dose: float = 1.0, - scale: float = 1.0, - locked: bool = False, - identifier: Tuple[Any, ...] = ()): + locked: bool = False,): """ Args: - pattern: Pattern to reference. a_vector: First lattice vector, of the form `[x, y]`. Specifies center-to-center spacing between adjacent instances. a_count: Number of elements in the a_vector direction. @@ -121,14 +76,7 @@ class GridRepetition: Can be omitted when specifying a 1D array. b_count: Number of elements in the `b_vector` direction. Should be omitted if `b_vector` was omitted. - offset: (x, y) offset applied to all instances. - rotation: Rotation (radians, counterclockwise) applied to each instance. - Relative to each instance's (0, 0). - mirrored: Whether to mirror individual instances across the x and y axes. - dose: Scaling factor applied to the dose. - scale: Scaling factor applied to the instances' geometry. - locked: Whether the `GridRepetition` is locked after initialization. - identifier: Arbitrary tuple, used internally by some `masque` functions. + locked: Whether the `Grid` is locked after initialization. Raises: PatternError if `b_*` inputs conflict with each other @@ -144,132 +92,31 @@ class GridRepetition: b_vector = numpy.array([0.0, 0.0]) if a_count < 1: - raise PatternError('Repetition has too-small a_count: ' - '{}'.format(a_count)) + raise PatternError(f'Repetition has too-small a_count: {a_count}') if b_count < 1: - raise PatternError('Repetition has too-small b_count: ' - '{}'.format(b_count)) + raise PatternError(f'Repetition has too-small b_count: {b_count}') object.__setattr__(self, 'locked', False) self.a_vector = a_vector self.b_vector = b_vector self.a_count = a_count self.b_count = b_count - - self.identifier = identifier - self.pattern = pattern - self.offset = offset - self.rotation = rotation - self.dose = dose - self.scale = scale - if mirrored is None: - mirrored = [False, False] - self.mirrored = mirrored self.locked = locked - def __setattr__(self, name, value): - if self.locked and name != 'locked': - raise PatternLockedError() - object.__setattr__(self, name, value) - - def __copy__(self) -> 'GridRepetition': - new = GridRepetition(pattern=self.pattern, - a_vector=self.a_vector.copy(), - b_vector=copy.copy(self.b_vector), - a_count=self.a_count, - b_count=self.b_count, - offset=self.offset.copy(), - rotation=self.rotation, - dose=self.dose, - scale=self.scale, - mirrored=self.mirrored.copy(), - locked=self.locked) + def __copy__(self) -> 'Grid': + new = Grid(a_vector=self.a_vector.copy(), + b_vector=copy.copy(self.b_vector), + a_count=self.a_count, + b_count=self.b_count, + locked=self.locked) return new - def __deepcopy__(self, memo: Dict = None) -> 'GridRepetition': + def __deepcopy__(self, memo: Dict = None) -> 'Grid': memo = {} if memo is None else memo new = copy.copy(self).unlock() - new.pattern = copy.deepcopy(self.pattern, memo) new.locked = self.locked return new - # pattern property - @property - def pattern(self) -> Optional['Pattern']: - return self._pattern - - @pattern.setter - def pattern(self, val: Optional['Pattern']): - from .pattern import Pattern - if val is not None and not isinstance(val, Pattern): - raise PatternError('Provided pattern {} is not a Pattern object or None!'.format(val)) - self._pattern = val - - # offset property - @property - def offset(self) -> numpy.ndarray: - return self._offset - - @offset.setter - def offset(self, val: vector2): - if self.locked: - raise PatternLockedError() - - if not isinstance(val, numpy.ndarray): - val = numpy.array(val, dtype=float) - - if val.size != 2: - raise PatternError('Offset must be convertible to size-2 ndarray') - self._offset = val.flatten().astype(float) - - # dose property - @property - def dose(self) -> float: - return self._dose - - @dose.setter - def dose(self, val: float): - if not is_scalar(val): - raise PatternError('Dose must be a scalar') - if not val >= 0: - raise PatternError('Dose must be non-negative') - self._dose = val - - # scale property - @property - def scale(self) -> float: - return self._scale - - @scale.setter - def scale(self, val: float): - if not is_scalar(val): - raise PatternError('Scale must be a scalar') - if not val > 0: - raise PatternError('Scale must be positive') - self._scale = val - - # Rotation property [ccw] - @property - def rotation(self) -> float: - return self._rotation - - @rotation.setter - def rotation(self, val: float): - if not is_scalar(val): - raise PatternError('Rotation must be a scalar') - self._rotation = val % (2 * pi) - - # Mirrored property - @property - def mirrored(self) -> numpy.ndarray: # ndarray[bool] - return self._mirrored - - @mirrored.setter - def mirrored(self, val: Sequence[bool]): - if is_scalar(val): - raise PatternError('Mirrored must be a 2-element list of booleans') - self._mirrored = numpy.array(val, dtype=bool, copy=True) - # a_vector property @property def a_vector(self) -> numpy.ndarray: @@ -320,69 +167,15 @@ class GridRepetition: raise PatternError('b_count must be convertable to an int!') self._b_count = int(val) - def as_pattern(self) -> 'Pattern': + @property + def displacements(self) -> numpy.ndarray: + aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij') + return (aa.flat[:, None] * self.a_vector[None, :] + + bb.flat[:, None] * self.b_vector[None, :]) + + def rotate(self, rotation: float) -> 'Grid': """ - Returns a copy of self.pattern which has been scaled, rotated, repeated, etc. - etc. according to this `GridRepetition`'s properties. - - Returns: - A copy of self.pattern which has been scaled, rotated, repeated, etc. - etc. according to this `GridRepetition`'s properties. - """ - assert(self.pattern is not None) - patterns = [] - - pat = self.pattern.deepcopy().deepunlock() - pat.scale_by(self.scale) - [pat.mirror(ax) for ax, do in enumerate(self.mirrored) if do] - pat.rotate_around((0.0, 0.0), self.rotation) - pat.translate_elements(self.offset) - pat.scale_element_doses(self.dose) - - combined = type(pat)(name='__GridRepetition__') - for a in range(self.a_count): - for b in range(self.b_count): - offset = a * self.a_vector + b * self.b_vector - newPat = pat.deepcopy() - newPat.translate_elements(offset) - combined.append(newPat) - - return combined - - def translate(self, offset: vector2) -> 'GridRepetition': - """ - Translate by the given offset - - Args: - offset: `[x, y]` to translate by - - Returns: - self - """ - self.offset += offset - return self - - def rotate_around(self, pivot: vector2, rotation: float) -> 'GridRepetition': - """ - Rotate the array around a point - - Args: - pivot: Point `[x, y]` to rotate around - rotation: Angle to rotate by (counterclockwise, radians) - - Returns: - self - """ - pivot = numpy.array(pivot, dtype=float) - self.translate(-pivot) - self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - self.rotate(rotation) - self.translate(+pivot) - return self - - def rotate(self, rotation: float) -> 'GridRepetition': - """ - Rotate around (0, 0) + Rotate lattice vectors (around (0, 0)) Args: rotation: Angle to rotate by (counterclockwise, radians) @@ -390,28 +183,14 @@ class GridRepetition: Returns: self """ - self.rotate_elements(rotation) self.a_vector = numpy.dot(rotation_matrix_2d(rotation), self.a_vector) if self.b_vector is not None: self.b_vector = numpy.dot(rotation_matrix_2d(rotation), self.b_vector) return self - def rotate_elements(self, rotation: float) -> 'GridRepetition': + def mirror(self, axis: int) -> 'Grid': """ - Rotate each element around its origin - - Args: - rotation: Angle to rotate by (counterclockwise, radians) - - Returns: - self - """ - self.rotation += rotation - return self - - def mirror(self, axis: int) -> 'GridRepetition': - """ - Mirror the GridRepetition across an axis. + Mirror the Grid across an axis. Args: axis: Axis to mirror across. @@ -420,43 +199,30 @@ class GridRepetition: Returns: self """ - self.mirror_elements(axis) self.a_vector[1-axis] *= -1 if self.b_vector is not None: self.b_vector[1-axis] *= -1 return self - def mirror_elements(self, axis: int) -> 'GridRepetition': - """ - Mirror each element across an axis relative to its origin. - - Args: - axis: Axis to mirror across. - (0: mirror across x-axis, 1: mirror across y-axis) - - Returns: - self - """ - self.mirrored[axis] = not self.mirrored[axis] - self.rotation *= -1 - return self - def get_bounds(self) -> Optional[numpy.ndarray]: """ Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the - extent of the `GridRepetition` in each dimension. - Returns `None` if the contained `Pattern` is empty. + extent of the `Grid` in each dimension. Returns: `[[x_min, y_min], [x_max, y_max]]` or `None` """ - if self.pattern is None: - return None - return self.as_pattern().get_bounds() + a_extent = self.a_vector * self.a_count + b_extent = self.b_vector * self.b_count if self.b_count != 0 else 0 - def scale_by(self, c: float) -> 'GridRepetition': + corners = ((0, 0), a_extent, b_extent, a_extent + b_extent) + xy_min = numpy.min(corners, axis=0) + xy_max = numpy.min(corners, axis=0) + return numpy.array((xy_min, xy_max)) + + def scale_by(self, c: float) -> 'Grid': """ - Scale the GridRepetition by a factor + Scale the Grid by a factor Args: c: scaling factor @@ -464,107 +230,116 @@ class GridRepetition: Returns: self """ - self.scale_elements_by(c) self.a_vector *= c if self.b_vector is not None: self.b_vector *= c return self - def scale_elements_by(self, c: float) -> 'GridRepetition': + def lock(self) -> 'Grid': """ - Scale each element by a factor - - Args: - c: scaling factor + Lock the `Grid`, disallowing changes. Returns: self """ - self.scale *= c - return self - - def copy(self) -> 'GridRepetition': - """ - Return a shallow copy of the repetition. - - Returns: - `copy.copy(self)` - """ - return copy.copy(self) - - def deepcopy(self) -> 'GridRepetition': - """ - Return a deep copy of the repetition. - - Returns: - `copy.deepcopy(self)` - """ - return copy.deepcopy(self) - - def lock(self) -> 'GridRepetition': - """ - Lock the `GridRepetition`, disallowing changes. - - Returns: - self - """ - self.offset.flags.writeable = False self.a_vector.flags.writeable = False - self.mirrored.flags.writeable = False if self.b_vector is not None: self.b_vector.flags.writeable = False - object.__setattr__(self, 'locked', True) + LockableImpl.lock(self) return self - def unlock(self) -> 'GridRepetition': + def unlock(self) -> 'Grid': """ - Unlock the `GridRepetition` + Unlock the `Grid` Returns: self """ - self.offset.flags.writeable = True self.a_vector.flags.writeable = True - self.mirrored.flags.writeable = True if self.b_vector is not None: self.b_vector.flags.writeable = True - object.__setattr__(self, 'locked', False) - return self - - def deeplock(self) -> 'GridRepetition': - """ - Recursively lock the `GridRepetition` and its contained pattern - - Returns: - self - """ - assert(self.pattern is not None) - self.lock() - self.pattern.deeplock() - return self - - def deepunlock(self) -> 'GridRepetition': - """ - Recursively unlock the `GridRepetition` and its contained pattern - - This is dangerous unless you have just performed a deepcopy, since - the component parts may be reused elsewhere. - - Returns: - self - """ - assert(self.pattern is not None) - self.unlock() - self.pattern.deepunlock() + LockableImpl.unlock(self) return self def __repr__(self) -> str: - name = self.pattern.name if self.pattern is not None else None - rotation = f' r{self.rotation*180/pi:g}' if self.rotation != 0 else '' - scale = f' d{self.scale:g}' if self.scale != 1 else '' - mirrored = ' m{:d}{:d}'.format(*self.mirrored) if self.mirrored.any() else '' - dose = f' d{self.dose:g}' if self.dose != 1 else '' locked = ' L' if self.locked else '' bv = f', {self.b_vector}' if self.b_vector is not None else '' - return (f'') + return (f'') + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, type(self)): + return False + if self.a_count != other.a_count or self.b_count != other.b_count: + return False + if any(self.a_vector[ii] != other.a_vector[ii] for ii in range(2)): + return False + if self.b_vector is None and other.b_vector is None: + return True + if self.b_vector is None or other.b_vector is None: + return False + if any(self.b_vector[ii] != other.b_vector[ii] for ii in range(2)): + return False + if self.locked != other.locked: + return False + return True + + +class Arbitrary(LockableImpl, Repetition, metaclass=AutoSlots): + """ + `Arbitrary` is a simple list of (absolute) displacements for instances. + + Attributes: + displacements (numpy.ndarray): absolute displacements of all elements + `[[x0, y0], [x1, y1], ...]` + """ + + _displacements: numpy.ndarray + """ List of vectors `[[x0, y0], [x1, y1], ...]` specifying the offsets + of the instances. + """ + + locked: bool + """ If `True`, disallows changes to the object. """ + + @property + def displacements(self) -> numpy.ndarray: + return self._displacements + + @displacements.setter + def displacements(self, val: Union[Sequence[Sequence[float]], numpy.ndarray]): + val = numpy.array(val, float) + val = numpy.sort(val.view([('', val.dtype)] * val.shape[1]), 0).view(val.dtype) # sort rows + self._displacements = val + + def lock(self) -> 'Arbitrary': + """ + Lock the object, disallowing changes. + + Returns: + self + """ + self._displacements.flags.writeable = False + LockableImpl.lock(self) + return self + + def unlock(self) -> 'Arbitrary': + """ + Unlock the object + + Returns: + self + """ + self._displacements.flags.writeable = True + LockableImpl.unlock(self) + return self + + def __repr__(self) -> str: + locked = ' L' if self.locked else '' + return (f'') + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, type(self)): + return False + if self.locked != other.locked: + return False + return numpy.array_equal(self.displacements, other.displacements) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 25a9b2e..7de8f76 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -6,10 +6,10 @@ from numpy import pi from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS from .. import PatternError -from ..utils import is_scalar, vector2, layer_t +from ..utils import is_scalar, vector2, layer_t, AutoSlots -class Arc(Shape): +class Arc(Shape, metaclass=AutoSlots): """ 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. @@ -20,6 +20,7 @@ class Arc(Shape): """ __slots__ = ('_radii', '_angles', '_width', '_rotation', 'poly_num_points', 'poly_max_arclen') + _radii: numpy.ndarray """ Two radii for defining an ellipse """ diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 404aeae..1090588 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -5,14 +5,15 @@ from numpy import pi from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS from .. import PatternError -from ..utils import is_scalar, vector2, layer_t +from ..utils import is_scalar, vector2, layer_t, AutoSlots -class Circle(Shape): +class Circle(Shape, metaclass=AutoSlots): """ A circle, which has a position and radius. """ __slots__ = ('_radius', 'poly_num_points', 'poly_max_arclen') + _radius: float """ Circle radius """ diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 3288614..45c1ea1 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -6,16 +6,17 @@ from numpy import pi from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS from .. import PatternError -from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t +from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots -class Ellipse(Shape): +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') + _radii: numpy.ndarray """ Ellipse radii """ diff --git a/masque/shapes/path.py b/masque/shapes/path.py index db7831a..a092356 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -6,7 +6,7 @@ from numpy import pi, inf from . import Shape, normalized_shape_tuple, Polygon, Circle from .. import PatternError -from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t +from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots from ..utils import remove_colinear_vertices, remove_duplicate_vertices @@ -18,7 +18,7 @@ class PathCap(Enum): # defined by path.cap_extensions -class Path(Shape): +class Path(Shape, metaclass=AutoSlots): """ A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape, and an offset. diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 71c9491..5bbd7b0 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -5,11 +5,11 @@ from numpy import pi from . import Shape, normalized_shape_tuple from .. import PatternError -from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t +from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots from ..utils import remove_colinear_vertices, remove_duplicate_vertices -class Polygon(Shape): +class Polygon(Shape, metaclass=AutoSlots): """ A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an implicitly-closed boundary, and an offset. @@ -17,6 +17,7 @@ class Polygon(Shape): A `normalized_form(...)` is available, but can be quite slow with lots of vertices. """ __slots__ = ('_vertices',) + _vertices: numpy.ndarray """ Nx2 ndarray of vertices `[[x0, y0], [x1, y1], ...]` """ diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index cfc4a54..759942f 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -5,6 +5,9 @@ import numpy from ..error import PatternError, PatternLockedError from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t +from ..traits import (PositionableImpl, LayerableImpl, DoseableImpl, + Rotatable, Mirrorable, Copyable, Scalable, + PivotableImpl, LockableImpl) if TYPE_CHECKING: from . import Polygon @@ -23,38 +26,20 @@ DEFAULT_POLY_NUM_POINTS = 24 T = TypeVar('T', bound='Shape') -class Shape(metaclass=ABCMeta): +class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable, Copyable, Scalable, PivotableImpl, LockableImpl, metaclass=ABCMeta): """ Abstract class specifying functions common to all shapes. """ - __slots__ = ('_offset', '_layer', '_dose', 'identifier', 'locked') - - _offset: numpy.ndarray - """ `[x_offset, y_offset]` """ - - _layer: layer_t - """ Layer (integer >= 0 or tuple) """ - - _dose: float - """ Dose """ identifier: Tuple """ An arbitrary identifier for the shape, usually empty but used by `Pattern.flatten()` """ - locked: bool - """ If `True`, any changes to the shape will raise a `PatternLockedError` """ - - def __setattr__(self, name, value): - if self.locked and name != 'locked': - raise PatternLockedError() - object.__setattr__(self, name, value) - - def __copy__(self) -> 'Shape': - cls = self.__class__ - new = cls.__new__(cls) - for name in Shape.__slots__ + self.__slots__: - object.__setattr__(new, name, getattr(self, name)) - return new +# def __copy__(self) -> 'Shape': +# cls = self.__class__ +# new = cls.__new__(cls) +# for name in Shape.__slots__ + self.__slots__: +# object.__setattr__(new, name, getattr(self, name)) +# return new ''' --- Abstract methods @@ -79,53 +64,6 @@ class Shape(metaclass=ABCMeta): """ pass - @abstractmethod - def get_bounds(self) -> numpy.ndarray: - """ - Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the shape. - """ - pass - - @abstractmethod - def rotate(self: T, theta: float) -> T: - """ - Rotate the shape around its origin (0, 0), ignoring its offset. - - Args: - theta: Angle to rotate by (counterclockwise, radians) - - Returns: - self - """ - pass - - @abstractmethod - def mirror(self: T, axis: int) -> T: - """ - Mirror the shape across an axis. - - Args: - axis: Axis to mirror across. - (0: mirror across x axis, 1: mirror across y axis) - - Returns: - self - """ - pass - - @abstractmethod - def scale_by(self: T, c: float) -> T: - """ - Scale the shape's size (eg. radius, for a circle) by a constant factor. - - Args: - c: Factor to scale by - - Returns: - self - """ - pass - @abstractmethod def normalized_form(self: T, norm_value: int) -> normalized_shape_tuple: """ @@ -150,97 +88,9 @@ class Shape(metaclass=ABCMeta): """ pass - ''' - ---- Non-abstract properties - ''' - # offset property - @property - def offset(self) -> numpy.ndarray: - """ - [x, y] offset - """ - return self._offset - - @offset.setter - def offset(self, val: vector2): - if not isinstance(val, numpy.ndarray): - val = numpy.array(val, dtype=float) - - if val.size != 2: - raise PatternError('Offset must be convertible to size-2 ndarray') - self._offset = val.flatten() - - # layer property - @property - def layer(self) -> layer_t: - """ - Layer number or name (int, tuple of ints, or string) - """ - return self._layer - - @layer.setter - def layer(self, val: layer_t): - self._layer = val - - # dose property - @property - def dose(self) -> float: - """ - Dose (float >= 0) - """ - return self._dose - - @dose.setter - def dose(self, val: float): - if not is_scalar(val): - raise PatternError('Dose must be a scalar') - if not val >= 0: - raise PatternError('Dose must be non-negative') - self._dose = val - ''' ---- Non-abstract methods ''' - def copy(self: T) -> T: - """ - Returns a deep copy of the shape. - - Returns: - copy.deepcopy(self) - """ - return copy.deepcopy(self) - - def translate(self: T, offset: vector2) -> T: - """ - Translate the shape by the given offset - - Args: - offset: [x_offset, y,offset] - - Returns: - self - """ - self.offset += offset - return self - - def rotate_around(self: T, pivot: vector2, rotation: float) -> T: - """ - Rotate the shape around a point. - - Args: - pivot: Point (x, y) to rotate around - rotation: Angle to rotate by (counterclockwise, radians) - - Returns: - self - """ - pivot = numpy.array(pivot, dtype=float) - self.translate(-pivot) - self.rotate(rotation) - self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - self.translate(+pivot) - return self - def manhattanize_fast(self, grid_x: numpy.ndarray, grid_y: numpy.ndarray, @@ -442,37 +292,12 @@ class Shape(metaclass=ABCMeta): return manhattan_polygons - def set_layer(self: T, layer: layer_t) -> T: - """ - Chainable method for changing the layer. - - Args: - layer: new value for self.layer - - Returns: - self - """ - self.layer = layer - return self - def lock(self: T) -> T: - """ - Lock the Shape, disallowing further changes - - Returns: - self - """ - self.offset.flags.writeable = False - object.__setattr__(self, 'locked', True) + PositionableImpl._lock(self) + LockableImpl.lock(self) return self def unlock(self: T) -> T: - """ - Unlock the Shape - - Returns: - self - """ - object.__setattr__(self, 'locked', False) - self.offset.flags.writeable = True + LockableImpl.unlock(self) + PositionableImpl._unlock(self) return self diff --git a/masque/shapes/text.py b/masque/shapes/text.py index bb8ed0d..9b00161 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -5,22 +5,23 @@ from numpy import pi, inf from . import Shape, Polygon, normalized_shape_tuple from .. import PatternError -from ..utils import is_scalar, vector2, get_bit, normalize_mirror, layer_t +from ..traits import RotatableImpl +from ..utils import is_scalar, vector2, get_bit, normalize_mirror, layer_t, AutoSlots # Loaded on use: # from freetype import Face # from matplotlib.path import Path -class Text(Shape): +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', '_rotation', '_mirrored', 'font_path') + __slots__ = ('_string', '_height', '_mirrored', 'font_path') + _string: str _height: float - _rotation: float _mirrored: numpy.ndarray #ndarray[bool] font_path: str @@ -33,17 +34,6 @@ class Text(Shape): def string(self, val: str): self._string = val - # Rotation property - @property - def rotation(self) -> float: - return self._rotation - - @rotation.setter - def rotation(self, val: float): - if not is_scalar(val): - raise PatternError('Rotation must be a scalar') - self._rotation = val % (2 * pi) - # Height property @property def height(self) -> float: @@ -120,10 +110,6 @@ class Text(Shape): return all_polygons - def rotate(self, theta: float) -> 'Text': - self.rotation += theta - return self - def mirror(self, axis: int) -> 'Text': self.mirrored[axis] = not self.mirrored[axis] return self diff --git a/masque/subpattern.py b/masque/subpattern.py index f0af4c0..7243e5e 100644 --- a/masque/subpattern.py +++ b/masque/subpattern.py @@ -11,51 +11,50 @@ import numpy from numpy import pi from .error import PatternError, PatternLockedError -from .utils import is_scalar, rotation_matrix_2d, vector2 -from .repetition import GridRepetition +from .utils import is_scalar, rotation_matrix_2d, vector2, AutoSlots +from .repetition import Repetition +from .traits import (PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, + Mirrorable, Pivotable, Copyable, LockableImpl, RepeatableImpl) if TYPE_CHECKING: from . import Pattern -class SubPattern: +class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mirrorable, + Pivotable, Copyable, RepeatableImpl, LockableImpl, metaclass=AutoSlots): """ SubPattern provides basic support for nesting Pattern objects within each other, by adding offset, rotation, scaling, and associated methods. """ __slots__ = ('_pattern', - '_offset', - '_rotation', - '_dose', - '_scale', '_mirrored', 'identifier', - 'locked') + ) _pattern: Optional['Pattern'] """ The `Pattern` being instanced """ - _offset: numpy.ndarray - """ (x, y) offset for the instance """ +# _offset: numpy.ndarray +# """ (x, y) offset for the instance """ - _rotation: float - """ rotation for the instance, radians counterclockwise """ +# _rotation: float +# """ rotation for the instance, radians counterclockwise """ - _dose: float - """ dose factor for the instance """ +# _dose: float +# """ dose factor for the instance """ - _scale: float - """ scale factor for the instance """ +# _scale: float +# """ scale factor for the instance """ _mirrored: numpy.ndarray # ndarray[bool] - """ Whether to mirror the instanc across the x and/or y axes. """ + """ Whether to mirror the instance across the x and/or y axes. """ identifier: Tuple[Any, ...] """ Arbitrary identifier, used internally by some `masque` functions. """ - locked: bool - """ If `True`, disallows changes to the GridRepetition """ +# locked: bool +# """ If `True`, disallows changes to the SubPattern""" def __init__(self, pattern: Optional['Pattern'], @@ -64,6 +63,7 @@ class SubPattern: mirrored: Optional[Sequence[bool]] = None, dose: float = 1.0, scale: float = 1.0, + repetition: Optional[Repetition] = None, locked: bool = False, identifier: Tuple[Any, ...] = ()): """ @@ -74,10 +74,12 @@ class SubPattern: mirrored: Whether to mirror the referenced pattern across its x and y axes. dose: Scaling factor applied to the dose. scale: Scaling factor applied to the pattern's geometry. + repetition: TODO locked: Whether the `SubPattern` is locked after initialization. identifier: Arbitrary tuple, used internally by some `masque` functions. """ - object.__setattr__(self, 'locked', False) + LockableImpl.unlock(self) +# object.__setattr__(self, 'locked', False) self.identifier = identifier self.pattern = pattern self.offset = offset @@ -87,13 +89,9 @@ class SubPattern: if mirrored is None: mirrored = [False, False] self.mirrored = mirrored + self.repetition = repetition self.locked = locked - def __setattr__(self, name, value): - if self.locked and name != 'locked': - raise PatternLockedError() - object.__setattr__(self, name, value) - def __copy__(self) -> 'SubPattern': new = SubPattern(pattern=self.pattern, offset=self.offset.copy(), @@ -123,57 +121,6 @@ class SubPattern: raise PatternError('Provided pattern {} is not a Pattern object or None!'.format(val)) self._pattern = val - # offset property - @property - def offset(self) -> numpy.ndarray: - return self._offset - - @offset.setter - def offset(self, val: vector2): - if not isinstance(val, numpy.ndarray): - val = numpy.array(val, dtype=float) - - if val.size != 2: - raise PatternError('Offset must be convertible to size-2 ndarray') - self._offset = val.flatten().astype(float) - - # dose property - @property - def dose(self) -> float: - return self._dose - - @dose.setter - def dose(self, val: float): - if not is_scalar(val): - raise PatternError('Dose must be a scalar') - if not val >= 0: - raise PatternError('Dose must be non-negative') - self._dose = val - - # scale property - @property - def scale(self) -> float: - return self._scale - - @scale.setter - def scale(self, val: float): - if not is_scalar(val): - raise PatternError('Scale must be a scalar') - if not val > 0: - raise PatternError('Scale must be positive') - self._scale = val - - # Rotation property [ccw] - @property - def rotation(self) -> float: - return self._rotation - - @rotation.setter - def rotation(self, val: float): - if not is_scalar(val): - raise PatternError('Rotation must be a scalar') - self._rotation = val % (2 * pi) - # Mirrored property @property def mirrored(self) -> numpy.ndarray: # ndarray[bool] @@ -198,52 +145,17 @@ class SubPattern: pattern.rotate_around((0.0, 0.0), self.rotation) pattern.translate_elements(self.offset) pattern.scale_element_doses(self.dose) + + if pattern.repetition is not None: + combined = type(pat)(name='__repetition__') + for dd in pattern.repetition.displacements: + temp_pat = pattern.deepcopy() + temp_pat.translate_elements(dd) + combined.append(temp_pat) + pattern = combined + return pattern - def translate(self, offset: vector2) -> 'SubPattern': - """ - Translate by the given offset - - Args: - offset: Offset `[x, y]` to translate by - - Returns: - self - """ - self.offset += offset - return self - - def rotate_around(self, pivot: vector2, rotation: float) -> 'SubPattern': - """ - Rotate around a point - - Args: - pivot: Point `[x, y]` to rotate around - rotation: Angle to rotate by (counterclockwise, radians) - - Returns: - self - """ - pivot = numpy.array(pivot, dtype=float) - self.translate(-pivot) - self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - self.rotate(rotation) - self.translate(+pivot) - return self - - def rotate(self, rotation: float) -> 'SubPattern': - """ - Rotate the instance around it's origin - - Args: - rotation: Angle to rotate by (counterclockwise, radians) - - Returns: - self - """ - self.rotation += rotation - return self - def mirror(self, axis: int) -> 'SubPattern': """ Mirror the subpattern across an axis. @@ -271,37 +183,6 @@ class SubPattern: return None return self.as_pattern().get_bounds() - def scale_by(self, c: float) -> 'SubPattern': - """ - Scale the subpattern by a factor - - Args: - c: scaling factor - - Returns: - self - """ - self.scale *= c - return self - - def copy(self) -> 'SubPattern': - """ - Return a shallow copy of the subpattern. - - Returns: - `copy.copy(self)` - """ - return copy.copy(self) - - def deepcopy(self) -> 'SubPattern': - """ - Return a deep copy of the subpattern. - - Returns: - `copy.deepcopy(self)` - """ - return copy.deepcopy(self) - def lock(self) -> 'SubPattern': """ Lock the SubPattern, disallowing changes @@ -309,9 +190,9 @@ class SubPattern: Returns: self """ - self.offset.flags.writeable = False self.mirrored.flags.writeable = False - object.__setattr__(self, 'locked', True) + PositionableImpl._lock(self) + LockableImpl.lock(self) return self def unlock(self) -> 'SubPattern': @@ -321,9 +202,9 @@ class SubPattern: Returns: self """ - self.offset.flags.writeable = True + LockableImpl.unlock(self) + PositionableImpl._unlock(self) self.mirrored.flags.writeable = True - object.__setattr__(self, 'locked', False) return self def deeplock(self) -> 'SubPattern': @@ -361,6 +242,3 @@ class SubPattern: dose = f' d{self.dose:g}' if self.dose != 1 else '' locked = ' L' if self.locked else '' return f'' - - -subpattern_t = Union[SubPattern, GridRepetition] diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py new file mode 100644 index 0000000..8af6434 --- /dev/null +++ b/masque/traits/__init__.py @@ -0,0 +1,9 @@ +from .positionable import Positionable, PositionableImpl +from .layerable import Layerable, LayerableImpl +from .doseable import Doseable, DoseableImpl +from .rotatable import Rotatable, RotatableImpl, Pivotable, PivotableImpl +from .repeatable import Repeatable, RepeatableImpl +from .scalable import Scalable, ScalableImpl +from .mirrorable import Mirrorable +from .copyable import Copyable +from .lockable import Lockable, LockableImpl diff --git a/masque/traits/copyable.py b/masque/traits/copyable.py new file mode 100644 index 0000000..5a318d7 --- /dev/null +++ b/masque/traits/copyable.py @@ -0,0 +1,34 @@ +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy + + +T = TypeVar('T', bound='Copyable') + + +class Copyable(metaclass=ABCMeta): + """ + Abstract class which adds .copy() and .deepcopy() + """ + __slots__ = () + + ''' + ---- Non-abstract methods + ''' + def copy(self: T) -> T: + """ + Return a shallow copy of the object. + + Returns: + `copy.copy(self)` + """ + return copy.copy(self) + + def deepcopy(self: T) -> T: + """ + Return a deep copy of the object. + + Returns: + `copy.deepcopy(self)` + """ + return copy.deepcopy(self) diff --git a/masque/traits/doseable.py b/masque/traits/doseable.py new file mode 100644 index 0000000..ded93fa --- /dev/null +++ b/masque/traits/doseable.py @@ -0,0 +1,82 @@ +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy +import numpy + +from ..error import PatternError, PatternLockedError +from ..utils import is_scalar + + +T = TypeVar('T', bound='Doseable') +I = TypeVar('I', bound='DoseableImpl') + + +class Doseable(metaclass=ABCMeta): + """ + Abstract class for all doseable entities + """ + __slots__ = () + + ''' + ---- Properties + ''' + @property + @abstractmethod + def dose(self) -> float: + """ + Dose (float >= 0) + """ + pass + + @dose.setter + @abstractmethod + def dose(self, val: float): + pass + + ''' + ---- Methods + ''' + def set_dose(self: T, dose: float) -> T: + """ + Set the dose + + Args: + dose: new value for dose + + Returns: + self + """ + pass + + +class DoseableImpl(Doseable, metaclass=ABCMeta): + """ + Simple implementation of Doseable + """ + __slots__ = () + + _dose: float + """ Dose """ + + ''' + ---- Non-abstract properties + ''' + @property + def dose(self) -> float: + return self._dose + + @dose.setter + def dose(self, val: float): + if not is_scalar(val): + raise PatternError('Dose must be a scalar') + if not val >= 0: + raise PatternError('Dose must be non-negative') + self._dose = val + + + ''' + ---- Non-abstract methods + ''' + def set_dose(self: I, dose: float) -> I: + self.dose = dose + return self diff --git a/masque/traits/layerable.py b/masque/traits/layerable.py new file mode 100644 index 0000000..4cf0b1f --- /dev/null +++ b/masque/traits/layerable.py @@ -0,0 +1,76 @@ +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy +import numpy + +from ..error import PatternError, PatternLockedError +from ..utils import layer_t + + +T = TypeVar('T', bound='Layerable') +I = TypeVar('I', bound='LayerableImpl') + + +class Layerable(metaclass=ABCMeta): + """ + Abstract class for all layerable entities + """ + __slots__ = () + ''' + ---- Properties + ''' + @property + @abstractmethod + def layer(self) -> layer_t: + """ + Layer number or name (int, tuple of ints, or string) + """ + return self._layer + + @layer.setter + @abstractmethod + def layer(self, val: layer_t): + self._layer = val + + ''' + ---- Methods + ''' + def set_layer(self: T, layer: layer_t) -> T: + """ + Set the layer + + Args: + layer: new value for layer + + Returns: + self + """ + pass + + +class LayerableImpl(Layerable, metaclass=ABCMeta): + """ + Simple implementation of Layerable + """ + __slots__ = () + + _layer: layer_t + """ Layer number, pair, or name """ + + ''' + ---- Non-abstract properties + ''' + @property + def layer(self) -> layer_t: + return self._layer + + @layer.setter + def layer(self, val: layer_t): + self._layer = val + + ''' + ---- Non-abstract methods + ''' + def set_layer(self: I, layer: layer_t) -> I: + self.layer = layer + return self diff --git a/masque/traits/lockable.py b/masque/traits/lockable.py new file mode 100644 index 0000000..e0ea24e --- /dev/null +++ b/masque/traits/lockable.py @@ -0,0 +1,76 @@ +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy +import numpy + +from ..error import PatternError, PatternLockedError + + +T = TypeVar('T', bound='Lockable') +I = TypeVar('I', bound='LockableImpl') + + +class Lockable(metaclass=ABCMeta): + """ + Abstract class for all lockable entities + """ + __slots__ = () + + ''' + ---- Methods + ''' + def set_dose(self: T, dose: float) -> T: + """ + Set the dose + + Args: + dose: new value for dose + + Returns: + self + """ + pass + + def lock(self: T) -> T: + """ + Lock the object, disallowing further changes + + Returns: + self + """ + pass + + def unlock(self: T) -> T: + """ + Unlock the object, reallowing changes + + Returns: + self + """ + pass + + +class LockableImpl(Lockable, metaclass=ABCMeta): + """ + Simple implementation of Lockable + """ + __slots__ = () + + locked: bool + """ If `True`, disallows changes to the object """ + + ''' + ---- Non-abstract methods + ''' + def __setattr__(self, name, value): + if self.locked and name != 'locked': + raise PatternLockedError() + object.__setattr__(self, name, value) + + def lock(self: I) -> I: + object.__setattr__(self, 'locked', True) + return self + + def unlock(self: I) -> I: + object.__setattr__(self, 'locked', False) + return self diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py new file mode 100644 index 0000000..76226c4 --- /dev/null +++ b/masque/traits/mirrorable.py @@ -0,0 +1,61 @@ +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy +import numpy + +from ..error import PatternError, PatternLockedError + +T = TypeVar('T', bound='Mirrorable') +T = TypeVar('T', bound='MirrorableImpl') + + +class Mirrorable(metaclass=ABCMeta): + """ + Abstract class for all mirrorable entities + """ + __slots__ = () + + ''' + ---- Abstract methods + ''' + @abstractmethod + def mirror(self: T, axis: int) -> T: + """ + Mirror the entity across an axis. + + Args: + axis: Axis to mirror across. + + Returns: + self + """ + pass + + +#class MirrorableImpl(Mirrorable, metaclass=ABCMeta): +# """ +# Simple implementation of `Mirrorable` +# """ +# __slots__ = () +# +# _mirrored: numpy.ndarray # ndarray[bool] +# """ Whether to mirror the instance across the x and/or y axes. """ +# +# ''' +# ---- Properties +# ''' +# # Mirrored property +# @property +# def mirrored(self) -> numpy.ndarray: # ndarray[bool] +# """ Whether to mirror across the [x, y] axes, respectively """ +# return self._mirrored +# +# @mirrored.setter +# def mirrored(self, val: Sequence[bool]): +# if is_scalar(val): +# raise PatternError('Mirrored must be a 2-element list of booleans') +# self._mirrored = numpy.array(val, dtype=bool, copy=True) +# +# ''' +# ---- Methods +# ''' diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py new file mode 100644 index 0000000..3669a7e --- /dev/null +++ b/masque/traits/positionable.py @@ -0,0 +1,135 @@ +# TODO top-level comment about how traits should set __slots__ = (), and how to use AutoSlots + +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy +import numpy + +from ..error import PatternError, PatternLockedError +from ..utils import is_scalar, rotation_matrix_2d, vector2 + + +T = TypeVar('T', bound='Positionable') +I = TypeVar('I', bound='PositionableImpl') + + +class Positionable(metaclass=ABCMeta): + """ + Abstract class for all positionable entities + """ + __slots__ = () + + ''' + ---- Abstract properties + ''' + @property + @abstractmethod + def offset(self) -> numpy.ndarray: + """ + [x, y] offset + """ + pass + + @offset.setter + @abstractmethod + def offset(self, val: vector2): + pass + + ''' + --- Abstract methods + ''' + @abstractmethod + def get_bounds(self) -> numpy.ndarray: + """ + Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity. + """ + pass + + @abstractmethod + def set_offset(self: T, offset: vector2) -> T: + """ + Set the offset + + Args: + offset: [x_offset, y,offset] + + Returns: + self + """ + pass + + @abstractmethod + def translate(self: T, offset: vector2) -> T: + """ + Translate the entity by the given offset + + Args: + offset: [x_offset, y,offset] + + Returns: + self + """ + pass + + +class PositionableImpl(Positionable, metaclass=ABCMeta): + """ + Simple implementation of Positionable + """ + __slots__ = () + + _offset: numpy.ndarray + """ `[x_offset, y_offset]` """ + + ''' + ---- Properties + ''' + # offset property + @property + def offset(self) -> numpy.ndarray: + """ + [x, y] offset + """ + return self._offset + + @offset.setter + def offset(self, val: vector2): + if not isinstance(val, numpy.ndarray): + val = numpy.array(val, dtype=float) + + if val.size != 2: + raise PatternError('Offset must be convertible to size-2 ndarray') + self._offset = val.flatten() + + + ''' + ---- Methods + ''' + def set_offset(self: I, offset: vector2) -> I: + self.offset = offset + return self + + + def translate(self: I, offset: vector2) -> I: + self._offset += offset + return self + + def _lock(self: I) -> I: + """ + Lock the entity, disallowing further changes + + Returns: + self + """ + self._offset.flags.writeable = False + return self + + def _unlock(self: I) -> I: + """ + Unlock the entity + + Returns: + self + """ + self._offset.flags.writeable = True + return self diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py new file mode 100644 index 0000000..1f2a99b --- /dev/null +++ b/masque/traits/repeatable.py @@ -0,0 +1,79 @@ +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy +import numpy + +from ..error import PatternError, PatternLockedError + + +T = TypeVar('T', bound='Repeatable') +I = TypeVar('I', bound='RepeatableImpl') + + +class Repeatable(metaclass=ABCMeta): + """ + Abstract class for all repeatable entities + """ + __slots__ = () + + ''' + ---- Properties + ''' + @property + @abstractmethod + def repetition(self) -> Optional['Repetition']: + """ + Repetition object, or None (single instance only) + """ + pass + + @repetition.setter + @abstractmethod + def repetition(self, repetition: Optional['Repetition']): + pass + + ''' + ---- Methods + ''' + def set_repetition(self: T, repetition: Optional['Repetition']) -> T: + """ + Set the repetition + + Args: + repetition: new value for repetition, or None (single instance) + + Returns: + self + """ + pass + + +class RepeatableImpl(Repeatable, metaclass=ABCMeta): + """ + Simple implementation of `Repeatable` + """ + __slots__ = () + + _repetition: Optional['Repetition'] + """ Repetition object, or None (single instance only) """ + + ''' + ---- Non-abstract properties + ''' + @property + def repetition(self) -> Optional['Repetition']: + return self._repetition + + @repetition.setter + def repetition(self, repetition: Optional['Repetition']): + from ..repetition import Repetition + if repetition is not None and not isinstance(repetition, Repetition): + raise PatternError(f'{repetition} is not a valid Repetition object!') + self._repetition = repetition + + ''' + ---- Non-abstract methods + ''' + def set_repetition(self: I, repetition: 'Repetition') -> I: + self.repetition = repetition + return self diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py new file mode 100644 index 0000000..e01c81d --- /dev/null +++ b/masque/traits/rotatable.py @@ -0,0 +1,119 @@ +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy + +import numpy +from numpy import pi + +from .positionable import Positionable +from ..error import PatternError, PatternLockedError +from ..utils import is_scalar, rotation_matrix_2d, vector2 + +T = TypeVar('T', bound='Rotatable') +I = TypeVar('I', bound='RotatableImpl') +P = TypeVar('P', bound='Pivotable') +J = TypeVar('J', bound='PivotableImpl') + + +class Rotatable(metaclass=ABCMeta): + """ + Abstract class for all rotatable entities + """ + __slots__ = () + + ''' + ---- Abstract methods + ''' + @abstractmethod + def rotate(self: T, theta: float) -> T: + """ + Rotate the shape around its origin (0, 0), ignoring its offset. + + Args: + theta: Angle to rotate by (counterclockwise, radians) + + Returns: + self + """ + pass + + +class RotatableImpl(Rotatable, metaclass=ABCMeta): + """ + Simple implementation of `Rotatable` + """ + __slots__ = () + + _rotation: float + """ rotation for the object, radians counterclockwise """ + + ''' + ---- Properties + ''' + @property + def rotation(self) -> float: + """ Rotation, radians counterclockwise """ + return self._rotation + + @rotation.setter + def rotation(self, val: float): + if not is_scalar(val): + raise PatternError('Rotation must be a scalar') + self._rotation = val % (2 * pi) + + ''' + ---- Methods + ''' + def rotate(self: I, rotation: float) -> I: + self.rotation += rotation + return self + + def set_rotation(self: I, rotation: float) -> I: + """ + Set the rotation to a value + + Args: + rotation: radians ccw + + Returns: + self + """ + self.rotation = rotation + return self + + +class Pivotable(metaclass=ABCMeta): + """ + Abstract class for entites which can be rotated around a point. + This requires that they are `Positionable` but not necessarily `Rotatable` themselves. + """ + __slots__ = () + + def rotate_around(self: P, pivot: vector2, rotation: float) -> P: + """ + Rotate the object around a point. + + Args: + pivot: Point (x, y) to rotate around + rotation: Angle to rotate by (counterclockwise, radians) + + Returns: + self + """ + pass + + +class PivotableImpl(Pivotable, metaclass=ABCMeta): + """ + Implementation of `Pivotable` for objects which are `Rotatable` + """ + __slots__ = () + + def rotate_around(self: J, pivot: vector2, rotation: float) -> J: + pivot = numpy.array(pivot, dtype=float) + self.translate(-pivot) + self.rotate(rotation) + self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) + self.translate(+pivot) + return self + diff --git a/masque/traits/scalable.py b/masque/traits/scalable.py new file mode 100644 index 0000000..ac349a2 --- /dev/null +++ b/masque/traits/scalable.py @@ -0,0 +1,79 @@ +from typing import List, Tuple, Callable, TypeVar, Optional +from abc import ABCMeta, abstractmethod +import copy +import numpy + +from ..error import PatternError, PatternLockedError +from ..utils import is_scalar + + +T = TypeVar('T', bound='Scalable') +I = TypeVar('I', bound='ScalableImpl') + + +class Scalable(metaclass=ABCMeta): + """ + Abstract class for all scalable entities + """ + __slots__ = () + + ''' + ---- Abstract methods + ''' + @abstractmethod + def scale_by(self: T, c: float) -> T: + """ + Scale the entity by a factor + + Args: + c: scaling factor + + Returns: + self + """ + pass + + +class ScalableImpl(Scalable, metaclass=ABCMeta): + """ + Simple implementation of Scalable + """ + __slots__ = () + + _scale: float + """ scale factor for the entity """ + + ''' + ---- Properties + ''' + @property + def scale(self) -> float: + return self._scale + + @scale.setter + def scale(self, val: float): + if not is_scalar(val): + raise PatternError('Scale must be a scalar') + if not val > 0: + raise PatternError('Scale must be positive') + self._scale = val + + ''' + ---- Methods + ''' + def scale_by(self: I, c: float) -> I: + self.scale *= c + return self + + def set_scale(self: I, scale: float) -> I: + """ + Set the sclae to a value + + Args: + scale: absolute scale factor + + Returns: + self + """ + self.scale = scale + return self diff --git a/masque/utils.py b/masque/utils.py index e71a0cd..979ffde 100644 --- a/masque/utils.py +++ b/masque/utils.py @@ -3,6 +3,7 @@ Various helper functions """ from typing import Any, Union, Tuple, Sequence +from abc import ABCMeta import numpy @@ -133,3 +134,25 @@ def remove_colinear_vertices(vertices: numpy.ndarray, closed_path: bool = True) slopes_equal[[0, -1]] = False return vertices[~slopes_equal] + + +class AutoSlots(ABCMeta): + """ + Metaclass for automatically generating __slots__ based on superclass type annotations. + + Superclasses must set `__slots__ = ()` to make this work properly. + + This is a workaround for the fact that non-empty `__slots__` can't be used + with multiple inheritance. Since we only use multiple inheritance with abstract + classes, they can have empty `__slots__` and their attribute type annotations + can be used to generate a full `__slots__` for the concrete class. + """ + def __new__(cls, name, bases, dctn): + slots = tuple(dctn.get('__slots__', tuple())) + for base in bases: + if not hasattr(base, '__annotations__'): + continue + slots += tuple(getattr(base, '__annotations__').keys()) + dctn['__slots__'] = slots + return super().__new__(cls,name,bases,dctn) +