diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..0042015 --- /dev/null +++ b/.flake8 @@ -0,0 +1,29 @@ +[flake8] +ignore = + # E501 line too long + E501, + # W391 newlines at EOF + W391, + # E241 multiple spaces after comma + E241, + # E302 expected 2 newlines + E302, + # W503 line break before binary operator (to be deprecated) + W503, + # E265 block comment should start with '# ' + E265, + # E123 closing bracket does not match indentation of opening bracket's line + E123, + # E124 closing bracket does not match visual indentation + E124, + # E221 multiple spaces before operator + E221, + # E201 whitespace after '[' + E201, + # E741 ambiguous variable name 'I' + E741, + + +per-file-ignores = + # F401 import without use + */__init__.py: F401, diff --git a/.gitignore b/.gitignore index 3557665..64466d7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ dist/ *.gds.gz *.svg *.oas +*.oas.gz *.dxf *.dxf.gz diff --git a/README.md b/README.md index fd16875..69e7a7e 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,26 @@ with some vectorized element types (eg. circles, not just polygons), better supp E-beam doses, and the ability to output to multiple formats. - [Source repository](https://mpxd.net/code/jan/masque) -- [PyPi](https://pypi.org/project/masque) +- [PyPI](https://pypi.org/project/masque) ## Installation Requirements: -* python >= 3.5 (written and tested with 3.6) +* python >= 3.7 (written and tested with 3.8) * numpy * matplotlib (optional, used for `visualization` functions and `text`) * python-gdsii (optional, used for `gdsii` i/o) +* klamath (optional, used for `gdsii` i/o and library management) +* ezdxf (optional, used for `dxf` i/o) +* fatamorgana (optional, used for `oasis` i/o) * svgwrite (optional, used for `svg` output) * freetype (optional, used for `text`) Install with pip: ```bash -pip3 install 'masque[visualization,gdsii,svg,text]' +pip3 install 'masque[visualization,gdsii,oasis,dxf,svg,text,klamath]' ``` Alternatively, install from git @@ -31,9 +34,19 @@ Alternatively, install from git pip3 install git+https://mpxd.net/code/jan/masque.git@release ``` +## Translation +- `Pattern`: OASIS or GDS "Cell", DXF "Block" +- `SubPattern`: GDS "AREF/SREF", OASIS "Placement" +- `Shape`: OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline" +- `repetition`: OASIS "repetition". GDS "AREF" is a `SubPattern` combined with a `Grid` repetition. +- `Label`: OASIS, GDS, DXF "Text". +- `annotation`: OASIS or GDS "property" + + ## TODO -* Polygon de-embedding -* Construct from bitmap -* Boolean operations on polygons (using pyclipper) -* Output to OASIS (using fatamorgana) +* Better interface for polygon operations (e.g. with `pyclipper`) + - de-embedding + - boolean ops +* Construct polygons from bitmap using `skimage.find_contours` +* Deal with shape repetitions for dxf, svg diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py index 1a12d0e..0c34cce 100644 --- a/examples/ellip_grating.py +++ b/examples/ellip_grating.py @@ -3,8 +3,7 @@ import numpy import masque -import masque.file.gdsii -import masque.file.dxf +import masque.file.klamath from masque import shapes @@ -21,15 +20,11 @@ def main(): pat.labels.append(masque.Label(string='grating centerline', offset=(1, 0), layer=(1, 2))) pat.scale_by(1000) -# pat.visualize() + pat.visualize() pat2 = pat.copy() pat2.name = 'grating2' - masque.file.gdsii.writefile((pat, pat2), 'out.gds.gz', 1e-9, 1e-3) - - masque.file.dxf.writefile(pat, 'out.dxf.gz') - dxf, info = masque.file.dxf.readfile('out.dxf.gz') - masque.file.dxf.writefile(dxf, 'reout.dxf.gz') + masque.file.klamath.writefile((pat, pat2), 'out.gds.gz', 1e-9, 1e-3) if __name__ == '__main__': diff --git a/examples/test_rep.py b/examples/test_rep.py index 304f2e5..042e1af 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -3,8 +3,11 @@ from numpy import pi import masque import masque.file.gdsii +import masque.file.klamath import masque.file.dxf -from masque import shapes, Pattern, SubPattern, GridRepetition +import masque.file.oasis +from masque import shapes, Pattern, SubPattern +from masque.repetition import Grid from pprint import pprint @@ -15,17 +18,18 @@ def main(): pat.shapes.append(shapes.Arc( radii=(rmin, rmin), width=0.1, - angles=(0*-numpy.pi/4, numpy.pi/4) + angles=(0*-numpy.pi/4, numpy.pi/4), + annotations={'1': ['blah']}, )) pat.scale_by(1000) - pat.visualize() +# pat.visualize() pat2 = pat.copy() pat2.name = 'grating2' pat3 = Pattern('sref_test') pat3.subpatterns = [ - SubPattern(pat, offset=(1e5, 3e5)), + SubPattern(pat, offset=(1e5, 3e5), annotations={'4': ['Hello I am the base subpattern']}), SubPattern(pat, offset=(2e5, 3e5), rotation=pi/3), SubPattern(pat, offset=(3e5, 3e5), rotation=pi/2), SubPattern(pat, offset=(4e5, 3e5), rotation=pi), @@ -51,45 +55,49 @@ def main(): pprint(pat3.subpatterns) pprint(pat.shapes) - args = { - 'pattern': pat, - 'a_vector': [1e4, 0], - 'b_vector': [0, 1.5e4], - 'a_count': 3, - 'b_count': 2, - } + rep = Grid(a_vector=[1e4, 0], + b_vector=[0, 1.5e4], + a_count=3, + b_count=2,) pat4 = Pattern('aref_test') pat4.subpatterns = [ - GridRepetition(**args, offset=(1e5, 3e5)), - GridRepetition(**args, offset=(2e5, 3e5), rotation=pi/3), - GridRepetition(**args, offset=(3e5, 3e5), rotation=pi/2), - GridRepetition(**args, offset=(4e5, 3e5), rotation=pi), - GridRepetition(**args, offset=(5e5, 3e5), rotation=3*pi/2), - GridRepetition(**args, mirrored=(True, False), offset=(1e5, 4e5)), - GridRepetition(**args, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3), - GridRepetition(**args, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2), - GridRepetition(**args, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi), - GridRepetition(**args, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2), - GridRepetition(**args, mirrored=(False, True), offset=(1e5, 5e5)), - GridRepetition(**args, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3), - GridRepetition(**args, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2), - GridRepetition(**args, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi), - GridRepetition(**args, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2), - GridRepetition(**args, mirrored=(True, True), offset=(1e5, 6e5)), - GridRepetition(**args, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3), - GridRepetition(**args, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2), - GridRepetition(**args, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi), - GridRepetition(**args, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2), + 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), ] - masque.file.gdsii.writefile((pat, pat2, pat3, pat4), 'rep.gds.gz', 1e-9, 1e-3) + folder = 'layouts/' + masque.file.klamath.writefile((pat, pat2, pat3, pat4), folder + 'rep.gds.gz', 1e-9, 1e-3) - cells = list(masque.file.gdsii.readfile('rep.gds.gz')[0].values()) - masque.file.gdsii.writefile(cells, 'rerep.gds.gz', 1e-9, 1e-3) + cells = list(masque.file.klamath.readfile(folder + 'rep.gds.gz')[0].values()) + masque.file.klamath.writefile(cells, folder + 'rerep.gds.gz', 1e-9, 1e-3) - masque.file.dxf.writefile(pat4, 'rep.dxf.gz') - dxf, info = masque.file.dxf.readfile('rep.dxf.gz') - masque.file.dxf.writefile(dxf, 'rerep.dxf.gz') + 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__': diff --git a/masque/VERSION b/masque/VERSION index 7e32cd5..8bbe6cf 100644 --- a/masque/VERSION +++ b/masque/VERSION @@ -1 +1 @@ -1.3 +2.2 diff --git a/masque/__init__.py b/masque/__init__.py index c826d18..87ceb5d 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -8,22 +8,13 @@ `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. - - - Dependencies: - - `numpy` - - `matplotlib` [Pattern.visualize(...)] - - `python-gdsii` [masque.file.gdsii] - - `svgwrite` [masque.file.svg] """ import pathlib @@ -31,9 +22,10 @@ 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 +from .utils import layer_t, annotations_t +from .library import Library __author__ = 'Jan Petykiewicz' diff --git a/masque/error.py b/masque/error.py index 4a5c21a..e109c20 100644 --- a/masque/error.py +++ b/masque/error.py @@ -15,3 +15,12 @@ class PatternLockedError(PatternError): """ def __init__(self): PatternError.__init__(self, 'Tried to modify a locked Pattern, subpattern, or shape') + + +class LibraryError(Exception): + """ + Exception raised by Library classes + """ + pass + + diff --git a/masque/file/__init__.py b/masque/file/__init__.py index 8de11a7..8f550c4 100644 --- a/masque/file/__init__.py +++ b/masque/file/__init__.py @@ -1,3 +1,4 @@ """ Functions for reading from and writing to various file formats. -""" \ No newline at end of file +""" + diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 335cd80..906fc2f 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -1,29 +1,27 @@ """ DXF file format readers and writers """ -from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional +from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable import re import io -import copy import base64 import struct import logging import pathlib import gzip -import numpy -from numpy import pi -import ezdxf +import numpy # type: ignore +import ezdxf # type: ignore -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 ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t -from ..utils import remove_colinear_vertices, normalize_mirror +from ..repetition import Grid +from ..utils import rotation_matrix_2d, layer_t logger = logging.getLogger(__name__) + logger.warning('DXF support is experimental and only slightly tested!') @@ -32,9 +30,11 @@ DEFAULT_LAYER = 'DEFAULT' def write(pattern: Pattern, stream: io.TextIOBase, + *, modify_originals: bool = False, dxf_version='AC1024', - disambiguate_func: Callable[[Iterable[Pattern]], None] = None): + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, + ) -> None: """ Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes into polygons, and then writing patterns as DXF `Block`s, polygons as `LWPolyline`s, @@ -54,7 +54,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. @@ -71,6 +71,7 @@ def write(pattern: Pattern, #TODO consider supporting DXF arcs? if disambiguate_func is None: disambiguate_func = disambiguate_pattern_names + assert(disambiguate_func is not None) if not modify_originals: pattern = pattern.deepcopy().deepunlock() @@ -102,7 +103,7 @@ def writefile(pattern: Pattern, filename: Union[str, pathlib.Path], *args, **kwargs, - ): + ) -> None: """ Wrapper for `dxf.write()` that takes a filename or path instead of a stream. @@ -121,14 +122,13 @@ def writefile(pattern: Pattern, open_func = open with open_func(path, mode='wt') as stream: - results = write(pattern, stream, *args, **kwargs) - return results + write(pattern, stream, *args, **kwargs) def readfile(filename: Union[str, pathlib.Path], *args, **kwargs, - ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + ) -> Tuple[Pattern, Dict[str, Any]]: """ Wrapper for `dxf.read()` that takes a filename or path instead of a stream. @@ -152,7 +152,7 @@ def readfile(filename: Union[str, pathlib.Path], def read(stream: io.TextIOBase, clean_vertices: bool = True, - ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + ) -> Tuple[Pattern, Dict[str, Any]]: """ Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are translated into `Pattern` objects; `LWPolyline`s are translated into polygons, and `Insert`s @@ -190,35 +190,36 @@ def read(stream: io.TextIOBase, return pat, library_info -def _read_block(block, clean_vertices): +def _read_block(block, clean_vertices: bool) -> Pattern: pat = Pattern(block.name) for element in block: eltype = element.dxftype() if eltype in ('POLYLINE', 'LWPOLYLINE'): if eltype == 'LWPOLYLINE': - points = numpy.array(element.lwpoints) + points = numpy.array(tuple(element.lwpoints())) else: - points = numpy.array(element.points) + points = numpy.array(tuple(element.points())) attr = element.dxfattribs() - args = {'layer': attr.get('layer', DEFAULT_LAYER), - } + layer = attr.get('layer', DEFAULT_LAYER) if points.shape[1] == 2: - shape = Polygon(**args) + raise PatternError('Invalid or unimplemented polygon?') + #shape = Polygon(layer=layer) elif points.shape[1] > 2: if (points[0, 2] != points[:, 2]).any(): raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') elif points.shape[1] == 4 and (points[:, 3] != 0).any(): raise PatternError('LWPolyLine has bulge (not yet representable in masque!)') - else: - width = points[0, 2] - if width == 0: - width = attr.get('const_width', 0) - if width == 0 and numpy.array_equal(points[0], points[-1]): - shape = Polygon(**args, vertices=points[:-1, :2]) - else: - shape = Path(**args, width=width, vertices=points[:, :2]) + width = points[0, 2] + if width == 0: + width = attr.get('const_width', 0) + + shape: Union[Path, Polygon] + if width == 0 and numpy.array_equal(points[0], points[-1]): + shape = Polygon(layer=layer, vertices=points[:-1, :2]) + else: + shape = Path(layer=layer, width=width, vertices=points[:, :2]) if clean_vertices: try: @@ -233,10 +234,10 @@ def _read_block(block, clean_vertices): 'layer': element.dxfattribs().get('layer', DEFAULT_LAYER), } string = element.dxfattribs().get('text', '') - height = element.dxfattribs().get('height', 0) - if height != 0: - logger.warning('Interpreting DXF TEXT as a label despite nonzero height. ' - 'This could be changed in the future by setting a font path in the masque DXF code.') +# height = element.dxfattribs().get('height', 0) +# if height != 0: +# logger.warning('Interpreting DXF TEXT as a label despite nonzero height. ' +# 'This could be changed in the future by setting a font path in the masque DXF code.') pat.labels.append(Label(string=string, **args)) # else: # pat.shapes.append(Text(string=string, height=height, font_path=????)) @@ -248,7 +249,7 @@ def _read_block(block, clean_vertices): logger.warning('Masque does not support per-axis scaling; using x-scaling only!') scale = abs(xscale) mirrored = (yscale < 0, xscale < 0) - rotation = attr.get('rotation', 0) * pi/180 + rotation = numpy.deg2rad(attr.get('rotation', 0)) offset = attr.get('insert', (0, 0, 0))[:2] @@ -262,20 +263,18 @@ def _read_block(block, clean_vertices): } if 'column_count' in attr: - args['a_vector'] = (attr['column_spacing'], 0) - args['b_vector'] = (0, attr['row_spacing']) - args['a_count'] = attr['column_count'] - args['b_count'] = attr['row_count'] - pat.subpatterns.append(GridRepetition(**args)) - else: - pat.subpatterns.append(SubPattern(**args)) + args['repetition'] = Grid(a_vector=(attr['column_spacing'], 0), + b_vector=(0, attr['row_spacing']), + a_count=attr['column_count'], + b_count=attr['row_count']) + pat.subpatterns.append(SubPattern(**args)) else: logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).') return pat def _subpatterns_to_refs(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], - subpatterns: List[subpattern_t]): + subpatterns: List[SubPattern]) -> None: for subpat in subpatterns: if subpat.pattern is None: continue @@ -288,20 +287,23 @@ 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: - attribs['column_count'] = subpat.a_count - attribs['row_count'] = subpat.b_count + attribs['column_count'] = rep.a_count + attribs['row_count'] = rep.b_count attribs['column_spacing'] = rotated_a[0] attribs['row_spacing'] = rotated_b[1] block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) elif rotated_a[0] == 0 and rotated_b[1] == 0: - attribs['column_count'] = subpat.b_count - attribs['row_count'] = subpat.a_count + attribs['column_count'] = rep.b_count + attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] attribs['row_spacing'] = rotated_a[1] block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) @@ -309,11 +311,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], @@ -330,7 +332,7 @@ def _shapes_to_elements(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Mo def _labels_to_texts(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], - labels: List[Label]): + labels: List[Label]) -> None: for label in labels: attribs = {'layer': _mlayer2dxf(label.layer)} xy = label.offset @@ -347,14 +349,14 @@ def _mlayer2dxf(layer: layer_t) -> str: raise PatternError(f'Unknown layer type: {layer} ({type(layer)})') -def disambiguate_pattern_names(patterns, +def disambiguate_pattern_names(patterns: Sequence[Pattern], max_name_length: int = 32, suffix_length: int = 6, - dup_warn_filter: Callable[[str,], bool] = None, # If returns False, don't warn about this name - ): + dup_warn_filter: Callable[[str], bool] = None, # If returns False, don't warn about this name + ) -> None: used_names = [] for pat in patterns: - sanitized_name = re.compile('[^A-Za-z0-9_\?\$]').sub('_', pat.name) + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', pat.name) i = 0 suffixed_name = sanitized_name @@ -365,17 +367,18 @@ def disambiguate_pattern_names(patterns, i += 1 if sanitized_name == '': - logger.warning('Empty pattern name saved as "{}"'.format(suffixed_name)) + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') elif suffixed_name != sanitized_name: if dup_warn_filter is None or dup_warn_filter(pat.name): - logger.warning('Pattern name "{}" ({}) appears multiple times;\n renaming to "{}"'.format( - pat.name, sanitized_name, suffixed_name)) + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') if len(suffixed_name) == 0: # Should never happen since zero-length names are replaced - raise PatternError('Zero-length name after sanitize,\n originally "{}"'.format(pat.name)) + raise PatternError(f'Zero-length name after sanitize,\n originally "{pat.name}"') if len(suffixed_name) > max_name_length: - raise PatternError('Pattern name "{!r}" length > {} after encode,\n originally "{}"'.format(suffixed_name, max_name_length, pat.name)) + raise PatternError(f'Pattern name "{suffixed_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') pat.name = suffixed_name used_names.append(suffixed_name) diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index 9e8314e..c0ea11d 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -10,53 +10,59 @@ Note that GDSII references follow the same convention as `masque`, Scaling, rotation, and mirroring apply to individual instances, not grid vectors or offsets. + +Notes: + * absolute positioning is not supported + * PLEX is not supported + * ELFLAGS are not supported + * GDS does not support library- or structure-level annotations """ -from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional +from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional +from typing import Sequence import re import io import copy -import numpy import base64 import struct import logging import pathlib import gzip +import numpy # type: ignore # python-gdsii import gdsii.library 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 .utils import clean_pattern_vertices, is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape from ..shapes import Polygon, Path -from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t -from ..utils import remove_colinear_vertices, normalize_mirror - -#TODO absolute positioning +from ..repetition import Grid +from ..utils import get_bit, set_bit, layer_t, normalize_mirror, annotations_t logger = logging.getLogger(__name__) path_cap_map = { - None: Path.Cap.Flush, - 0: Path.Cap.Flush, - 1: Path.Cap.Circle, - 2: Path.Cap.Square, - 4: Path.Cap.SquareCustom, - } + None: Path.Cap.Flush, + 0: Path.Cap.Flush, + 1: Path.Cap.Circle, + 2: Path.Cap.Square, + 4: Path.Cap.SquareCustom, + } -def write(patterns: Union[Pattern, List[Pattern]], - stream: io.BufferedIOBase, +def build(patterns: Union[Pattern, Sequence[Pattern]], meters_per_unit: float, logical_units_per_unit: float = 1, library_name: str = 'masque-gdsii-write', + *, modify_originals: bool = False, - disambiguate_func: Callable[[Iterable[Pattern]], None] = None): + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, + ) -> gdsii.library.Library: """ - Write a `Pattern` or list of patterns to a GDSII file, by first calling + Convert a `Pattern` or list of patterns to a GDSII stream, by first calling `.polygonize()` to change the shapes into polygons, and then writing patterns as GDSII structures, polygons as boundary elements, and subpatterns as structure references (sref). @@ -74,8 +80,7 @@ def write(patterns: Union[Pattern, List[Pattern]], prior to calling this function. Args: - patterns: A Pattern or list of patterns to write to the stream. - stream: Stream object to write to. + patterns: A Pattern or list of patterns to convert. meters_per_unit: Written into the GDSII file, meters per (database) length unit. All distances are assumed to be an integer multiple of this unit, and are stored as such. logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a @@ -90,16 +95,22 @@ def write(patterns: Union[Pattern, List[Pattern]], to make their names valid and unique. Default is `disambiguate_pattern_names`, which attempts to adhere to the GDSII standard as well as possible. WARNING: No additional error checking is performed on the results. + + Returns: + `gdsii.library.Library` """ if isinstance(patterns, Pattern): patterns = [patterns] if disambiguate_func is None: - disambiguate_func = disambiguate_pattern_names + disambiguate_func = disambiguate_pattern_names # type: ignore + assert(disambiguate_func is not None) # placate mypy if not modify_originals: patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] + patterns = [p.wrap_repeated_shapes() for p in patterns] + # Create library lib = gdsii.library.Library(version=600, name=library_name.encode('ASCII'), @@ -116,32 +127,49 @@ def write(patterns: Union[Pattern, List[Pattern]], # Now create a structure for each pattern, and add in any Boundary and SREF elements for pat in patterns_by_id.values(): - structure = gdsii.structure.Structure(name=pat.name) + structure = gdsii.structure.Structure(name=pat.name.encode('ASCII')) lib.append(structure) structure += _shapes_to_elements(pat.shapes) structure += _labels_to_texts(pat.labels) structure += _subpatterns_to_refs(pat.subpatterns) + return lib + + +def write(patterns: Union[Pattern, Sequence[Pattern]], + stream: io.BufferedIOBase, + *args, + **kwargs): + """ + Write a `Pattern` or list of patterns to a GDSII file. + See `masque.file.gdsii.build()` for details. + + Args: + patterns: A Pattern or list of patterns to write to file. + stream: Stream to write to. + *args: passed to `masque.file.gdsii.build()` + **kwargs: passed to `masque.file.gdsii.build()` + """ + lib = build(patterns, *args, **kwargs) lib.save(stream) return - -def writefile(patterns: Union[List[Pattern], Pattern], +def writefile(patterns: Union[Sequence[Pattern], Pattern], filename: Union[str, pathlib.Path], *args, **kwargs, ): """ - Wrapper for `gdsii.write()` that takes a filename or path instead of a stream. + Wrapper for `masque.file.gdsii.write()` that takes a filename or path instead of a stream. Will automatically compress the file if it has a .gz suffix. Args: patterns: `Pattern` or list of patterns to save filename: Filename to save to. - *args: passed to `gdsii.write` - **kwargs: passed to `gdsii.write` + *args: passed to `masque.file.gdsii.write` + **kwargs: passed to `masque.file.gdsii.write` """ path = pathlib.Path(filename) if path.suffix == '.gz': @@ -159,17 +187,17 @@ def readfile(filename: Union[str, pathlib.Path], **kwargs, ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: """ - Wrapper for `gdsii.read()` that takes a filename or path instead of a stream. + Wrapper for `masque.file.gdsii.read()` that takes a filename or path instead of a stream. - Will automatically decompress files with a .gz suffix. + Will automatically decompress gzipped files. Args: filename: Filename to save to. - *args: passed to `gdsii.read` - **kwargs: passed to `gdsii.read` + *args: passed to `masque.file.gdsii.read` + **kwargs: passed to `masque.file.gdsii.read` """ path = pathlib.Path(filename) - if path.suffix == '.gz': + if is_gzipped(path): open_func: Callable = gzip.open else: open_func = open @@ -180,7 +208,6 @@ def readfile(filename: Union[str, pathlib.Path], def read(stream: io.BufferedIOBase, - use_dtype_as_dose: bool = False, clean_vertices: bool = True, ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: """ @@ -196,11 +223,6 @@ def read(stream: io.BufferedIOBase, Args: stream: Stream to read from. - use_dtype_as_dose: If `False`, set each polygon's layer to `(gds_layer, gds_datatype)`. - If `True`, set the layer to `gds_layer` and the dose to `gds_datatype`. - Default `False`. - NOTE: This will be deprecated in the future in favor of - `pattern.apply(masque.file.utils.dtype2dose)`. clean_vertices: If `True`, remove any redundant vertices when loading polygons. The cleaning process removes any polygons with zero area or <3 vertices. Default `True`. @@ -217,71 +239,32 @@ def read(stream: io.BufferedIOBase, 'logical_units_per_unit': lib.logical_unit, } + raw_mode = True # Whether to construct shapes in raw mode (less error checking) + patterns = [] for structure in lib: pat = Pattern(name=structure.name.decode('ASCII')) for element in structure: # Switch based on element type: if isinstance(element, gdsii.elements.Boundary): - args = {'vertices': element.xy[:-1], - 'layer': (element.layer, element.data_type), - } - - poly = Polygon(**args) - - if clean_vertices: - try: - poly.clean_vertices() - except PatternError: - continue - + poly = _boundary_to_polygon(element, raw_mode) pat.shapes.append(poly) if isinstance(element, gdsii.elements.Path): - if element.path_type in path_cap_map: - cap = path_cap_map[element.path_type] - else: - raise PatternError('Unrecognized path type: {}'.format(element.path_type)) - - args = {'vertices': element.xy, - 'layer': (element.layer, element.data_type), - 'width': element.width if element.width is not None else 0.0, - 'cap': cap, - } - - if cap == Path.Cap.SquareCustom: - args['cap_extensions'] = numpy.zeros(2) - if element.bgn_extn is not None: - args['cap_extensions'][0] = element.bgn_extn - if element.end_extn is not None: - args['cap_extensions'][1] = element.end_extn - - path = Path(**args) - - if clean_vertices: - try: - path.clean_vertices() - except PatternError as err: - continue - + path = _gpath_to_mpath(element, raw_mode) pat.shapes.append(path) elif isinstance(element, gdsii.elements.Text): - label = Label(offset=element.xy, + label = Label(offset=element.xy.astype(float), layer=(element.layer, element.text_type), 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)) - - if use_dose_as_dtype: - logger.warning('use_dose_as_dtype will be removed in the future!') - pat = dose2dtype(pat) + elif isinstance(element, (gdsii.elements.SRef, gdsii.elements.ARef)): + pat.subpatterns.append(_ref_to_subpat(element)) + if clean_vertices: + clean_pattern_vertices(pat) patterns.append(pat) # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries @@ -307,123 +290,166 @@ def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]: else: data_type = 0 else: - raise PatternError(f'Invalid layer for gdsii: {layer}. Note that gdsii layers cannot be strings.') + raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.') 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 + 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: - "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. + NOTE: "Absolute" means not affected by parent elements. + That's not currently supported by masque at all (and not planned). """ - 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 - 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. - """ - rotation = 0 - offset = numpy.array(element.xy[0]) - scale = 1 + rotation = 0.0 + offset = numpy.array(element.xy[0], dtype=float) + scale = 1.0 mirror_across_x = False + repetition = None if element.strans is not None: if element.mag is not None: scale = element.mag # Bit 13 means absolute scale if get_bit(element.strans, 15 - 13): - raise PatternError('Absolute scale is not implemented yet!') + raise PatternError('Absolute scale is not implemented in masque!') 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!') + raise PatternError('Absolute rotation is not implemented in masque!') # Bit 0 means mirror x-axis 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) / a_count + b_vector = (element.xy[2] - offset) / b_count + 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), + annotations=_properties_to_annotations(element.properties), + repetition=repetition) + subpat.identifier = (element.struct_name,) + return subpat -def _subpatterns_to_refs(subpatterns: List[subpattern_t] +def _gpath_to_mpath(element: gdsii.elements.Path, raw_mode: bool) -> Path: + if element.path_type in path_cap_map: + cap = path_cap_map[element.path_type] + else: + raise PatternError(f'Unrecognized path type: {element.path_type}') + + args = {'vertices': element.xy.astype(float), + 'layer': (element.layer, element.data_type), + 'width': element.width if element.width is not None else 0.0, + 'cap': cap, + 'offset': numpy.zeros(2), + 'annotations': _properties_to_annotations(element.properties), + 'raw': raw_mode, + } + + if cap == Path.Cap.SquareCustom: + args['cap_extensions'] = numpy.zeros(2) + if element.bgn_extn is not None: + args['cap_extensions'][0] = element.bgn_extn + if element.end_extn is not None: + args['cap_extensions'][1] = element.end_extn + + return Path(**args) + + +def _boundary_to_polygon(element: gdsii.elements.Boundary, raw_mode: bool) -> Polygon: + args = {'vertices': element.xy[:-1].astype(float), + 'layer': (element.layer, element.data_type), + 'offset': numpy.zeros(2), + 'annotations': _properties_to_annotations(element.properties), + 'raw': raw_mode, + } + return Polygon(**args) + + +def _subpatterns_to_refs(subpatterns: List[SubPattern] ) -> List[Union[gdsii.elements.ARef, gdsii.elements.SRef]]: refs = [] for subpat in subpatterns: if subpat.pattern is None: continue - encoded_name = subpat.pattern.name + encoded_name = subpat.pattern.name.encode('ASCII') # Note: GDS mirrors first and rotates second mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) + rep = subpat.repetition + + new_refs: List[Union[gdsii.elements.SRef, gdsii.elements.ARef]] ref: Union[gdsii.elements.SRef, gdsii.elements.ARef] - if isinstance(subpat, GridRepetition): + if isinstance(rep, Grid): xy = numpy.array(subpat.offset) + [ - [0, 0], - subpat.a_vector * subpat.a_count, - subpat.b_vector * subpat.b_count, - ] + [0, 0], + 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: + xy=numpy.round(xy).astype(int), + 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 + ref.properties = _annotations_to_properties(subpat.annotations, 512) - refs.append(ref) + refs += new_refs return refs +def _properties_to_annotations(properties: List[Tuple[int, bytes]]) -> annotations_t: + return {str(k): [v.decode()] for k, v in properties} + + +def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> List[Tuple[int, bytes]]: + cum_len = 0 + props = [] + for key, vals in annotations.items(): + try: + i = int(key) + except ValueError: + raise PatternError(f'Annotation key {key} is not convertable to an integer') + if not (0 < i < 126): + raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])') + + val_strings = ' '.join(str(val) for val in vals) + b = val_strings.encode() + if len(b) > 126: + raise PatternError(f'Annotation value {b!r} is longer than 126 characters!') + cum_len += numpy.ceil(len(b) / 2) * 2 + 2 + if cum_len > max_len: + raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}') + props.append((i, b)) + return props + + def _shapes_to_elements(shapes: List[Shape], polygonize_paths: bool = False ) -> List[Union[gdsii.elements.Boundary, gdsii.elements.Path]]: @@ -431,54 +457,74 @@ def _shapes_to_elements(shapes: List[Shape], # Add a Boundary element for each shape, and Path elements if necessary for shape in shapes: layer, data_type = _mlayer2gds(shape.layer) + properties = _annotations_to_properties(shape.annotations, 128) if isinstance(shape, Path) and not polygonize_paths: xy = numpy.round(shape.vertices + shape.offset).astype(int) width = numpy.round(shape.width).astype(int) - path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) #reverse lookup + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup path = gdsii.elements.Path(layer=layer, data_type=data_type, xy=xy) path.path_type = path_type path.width = width + path.properties = properties elements.append(path) else: for polygon in shape.to_polygons(): xy_open = numpy.round(polygon.vertices + polygon.offset).astype(int) xy_closed = numpy.vstack((xy_open, xy_open[0, :])) - elements.append(gdsii.elements.Boundary(layer=layer, - data_type=data_type, - xy=xy_closed)) + boundary = gdsii.elements.Boundary(layer=layer, + data_type=data_type, + xy=xy_closed) + boundary.properties = properties + elements.append(boundary) return elements def _labels_to_texts(labels: List[Label]) -> List[gdsii.elements.Text]: texts = [] for label in labels: + properties = _annotations_to_properties(label.annotations, 128) layer, text_type = _mlayer2gds(label.layer) xy = numpy.round([label.offset]).astype(int) - texts.append(gdsii.elements.Text(layer=layer, - text_type=text_type, - xy=xy, - string=label.string.encode('ASCII'))) + text = gdsii.elements.Text(layer=layer, + text_type=text_type, + xy=xy, + string=label.string.encode('ASCII')) + text.properties = properties + texts.append(text) return texts -def disambiguate_pattern_names(patterns, +def disambiguate_pattern_names(patterns: Sequence[Pattern], max_name_length: int = 32, suffix_length: int = 6, - dup_warn_filter: Callable[[str,], bool] = None, # If returns False, don't warn about this name + dup_warn_filter: Optional[Callable[[str], bool]] = None, ): + """ + Args: + patterns: List of patterns to disambiguate + max_name_length: Names longer than this will be truncated + suffix_length: Names which get truncated are truncated by this many extra characters. This is to + leave room for a suffix if one is necessary. + dup_warn_filter: (optional) Function for suppressing warnings about cell names changing. Receives + the cell name and returns `False` if the warning should be suppressed and `True` if it should + be displayed. Default displays all warnings. + """ used_names = [] - for pat in patterns: + for pat in set(patterns): + # Shorten names which already exceed max-length if len(pat.name) > max_name_length: shortened_name = pat.name[:max_name_length - suffix_length] - logger.warning('Pattern name "{}" is too long ({}/{} chars),\n'.format(pat.name, len(pat.name), max_name_length) + - ' shortening to "{}" before generating suffix'.format(shortened_name)) + logger.warning(f'Pattern name "{pat.name}" is too long ({len(pat.name)}/{max_name_length} chars),\n' + + f' shortening to "{shortened_name}" before generating suffix') else: shortened_name = pat.name - sanitized_name = re.compile('[^A-Za-z0-9_\?\$]').sub('_', shortened_name) + # Remove invalid characters + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name) + # Add a suffix that makes the name unique i = 0 suffixed_name = sanitized_name while suffixed_name in used_names or suffixed_name == '': @@ -488,18 +534,20 @@ def disambiguate_pattern_names(patterns, i += 1 if sanitized_name == '': - logger.warning('Empty pattern name saved as "{}"'.format(suffixed_name)) + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') elif suffixed_name != sanitized_name: if dup_warn_filter is None or dup_warn_filter(pat.name): - logger.warning('Pattern name "{}" ({}) appears multiple times;\n renaming to "{}"'.format( - pat.name, sanitized_name, suffixed_name)) + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + # Encode into a byte-string and perform some final checks encoded_name = suffixed_name.encode('ASCII') if len(encoded_name) == 0: # Should never happen since zero-length names are replaced - raise PatternError('Zero-length name after sanitize+encode,\n originally "{}"'.format(pat.name)) + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') if len(encoded_name) > max_name_length: - raise PatternError('Pattern name "{!r}" length > {} after encode,\n originally "{}"'.format(encoded_name, max_name_length, pat.name)) + raise PatternError(f'Pattern name "{encoded_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') - pat.name = encoded_name + pat.name = suffixed_name used_names.append(suffixed_name) diff --git a/masque/file/klamath.py b/masque/file/klamath.py new file mode 100644 index 0000000..0f858cf --- /dev/null +++ b/masque/file/klamath.py @@ -0,0 +1,638 @@ +""" +GDSII file format readers and writers using the `klamath` library. + +Note that GDSII references follow the same convention as `masque`, + with this order of operations: + 1. Mirroring + 2. Rotation + 3. Scaling + 4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets) + + Scaling, rotation, and mirroring apply to individual instances, not grid + vectors or offsets. + +Notes: + * absolute positioning is not supported + * PLEX is not supported + * ELFLAGS are not supported + * GDS does not support library- or structure-level annotations + * Creation/modification/access times are set to 1900-01-01 for reproducibility. +""" +from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional +from typing import Sequence, BinaryIO +import re +import io +import mmap +import copy +import base64 +import struct +import logging +import pathlib +import gzip + +import numpy # type: ignore +import klamath +from klamath import records + +from .utils import is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path +from ..repetition import Grid +from ..utils import layer_t, normalize_mirror, annotations_t +from ..library import Library + +logger = logging.getLogger(__name__) + + +path_cap_map = { + 0: Path.Cap.Flush, + 1: Path.Cap.Circle, + 2: Path.Cap.Square, + 4: Path.Cap.SquareCustom, + } + + +def write(patterns: Union[Pattern, Sequence[Pattern]], + stream: BinaryIO, + meters_per_unit: float, + logical_units_per_unit: float = 1, + library_name: str = 'masque-klamath', + *, + modify_originals: bool = False, + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, + ) -> None: + """ + Convert a `Pattern` or list of patterns to a GDSII stream, and then mapping data as follows: + Pattern -> GDSII structure + SubPattern -> GDSII SREF or AREF + Path -> GSDII path + Shape (other than path) -> GDSII boundary/ies + Label -> GDSII text + annnotations -> properties, where possible + + For each shape, + layer is chosen to be equal to `shape.layer` if it is an int, + or `shape.layer[0]` if it is a tuple + datatype is chosen to be `shape.layer[1]` if available, + otherwise `0` + + It is often a good idea to run `pattern.subpatternize()` prior to calling this function, + especially if calling `.polygonize()` will result in very many vertices. + + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. + + Args: + patterns: A Pattern or list of patterns to convert. + meters_per_unit: Written into the GDSII file, meters per (database) length unit. + All distances are assumed to be an integer multiple of this unit, and are stored as such. + logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a + "logical" unit which is different from the "database" unit, for display purposes. + Default `1`. + library_name: Library name written into the GDSII file. + Default 'masque-klamath'. + modify_originals: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`, which + attempts to adhere to the GDSII standard as well as possible. + WARNING: No additional error checking is performed on the results. + """ + if isinstance(patterns, Pattern): + patterns = [patterns] + + if disambiguate_func is None: + disambiguate_func = disambiguate_pattern_names # type: ignore + assert(disambiguate_func is not None) # placate mypy + + if not modify_originals: + patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] + + patterns = [p.wrap_repeated_shapes() for p in patterns] + + # Create library + header = klamath.library.FileHeader(name=library_name.encode('ASCII'), + user_units_per_db_unit=logical_units_per_unit, + meters_per_db_unit=meters_per_unit) + header.write(stream) + + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + disambiguate_func(patterns_by_id.values()) + + # Now create a structure for each pattern, and add in any Boundary and SREF elements + for pat in patterns_by_id.values(): + elements: List[klamath.elements.Element] = [] + elements += _shapes_to_elements(pat.shapes) + elements += _labels_to_texts(pat.labels) + elements += _subpatterns_to_refs(pat.subpatterns) + + klamath.library.write_struct(stream, name=pat.name.encode('ASCII'), elements=elements) + records.ENDLIB.write(stream, None) + + +def writefile(patterns: Union[Sequence[Pattern], Pattern], + filename: Union[str, pathlib.Path], + *args, + **kwargs, + ) -> None: + """ + Wrapper for `write()` that takes a filename or path instead of a stream. + + Will automatically compress the file if it has a .gz suffix. + + Args: + patterns: `Pattern` or list of patterns to save + filename: Filename to save to. + *args: passed to `write()` + **kwargs: passed to `write()` + """ + path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open + + with io.BufferedWriter(open_func(path, mode='wb')) as stream: + write(patterns, stream, *args, **kwargs) + + +def readfile(filename: Union[str, pathlib.Path], + *args, + **kwargs, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + """ + Wrapper for `read()` that takes a filename or path instead of a stream. + + Will automatically decompress gzipped files. + + Args: + filename: Filename to save to. + *args: passed to `read()` + **kwargs: passed to `read()` + """ + path = pathlib.Path(filename) + if is_gzipped(path): + open_func: Callable = gzip.open + else: + open_func = open + + with io.BufferedReader(open_func(path, mode='rb')) as stream: + results = read(stream, *args, **kwargs) + return results + + +def read(stream: BinaryIO, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + """ + Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are + translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs + are translated into SubPattern objects. + + Additional library info is returned in a dict, containing: + 'name': name of the library + 'meters_per_unit': number of meters per database unit (all values are in database units) + 'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns) + per database unit + + Args: + stream: Stream to read from. + + Returns: + - Dict of pattern_name:Patterns generated from GDSII structures + - Dict of GDSII library info + """ + raw_mode = True # Whether to construct shapes in raw mode (less error checking) + library_info = _read_header(stream) + + patterns = [] + found_struct = records.BGNSTR.skip_past(stream) + while found_struct: + name = records.STRNAME.skip_and_read(stream) + pat = read_elements(stream, name=name.decode('ASCII'), raw_mode=raw_mode) + patterns.append(pat) + found_struct = records.BGNSTR.skip_past(stream) + + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + sp.pattern = patterns_dict[sp.identifier[0]] + del sp.identifier + + return patterns_dict, library_info + + +def _read_header(stream: BinaryIO) -> Dict[str, Any]: + """ + Read the file header and create the library_info dict. + """ + header = klamath.library.FileHeader.read(stream) + + library_info = {'name': header.name.decode('ASCII'), + 'meters_per_unit': header.meters_per_db_unit, + 'logical_units_per_unit': header.user_units_per_db_unit, + } + return library_info + + +def read_elements(stream: BinaryIO, + name: str, + raw_mode: bool = True, + ) -> Pattern: + """ + Read elements from a GDS structure and build a Pattern from them. + + Args: + stream: Seekable stream, positioned at a record boundary. + Will be read until an ENDSTR record is consumed. + name: Name of the resulting Pattern + raw_mode: If True, bypass per-shape consistency checking + + Returns: + A pattern containing the elements that were read. + """ + pat = Pattern(name) + + elements = klamath.library.read_elements(stream) + for element in elements: + if isinstance(element, klamath.elements.Boundary): + poly = _boundary_to_polygon(element, raw_mode) + pat.shapes.append(poly) + elif isinstance(element, klamath.elements.Path): + path = _gpath_to_mpath(element, raw_mode) + pat.shapes.append(path) + elif isinstance(element, klamath.elements.Text): + label = Label(offset=element.xy.astype(float), + layer=element.layer, + string=element.string.decode('ASCII'), + annotations=_properties_to_annotations(element.properties)) + pat.labels.append(label) + elif isinstance(element, klamath.elements.Reference): + pat.subpatterns.append(_ref_to_subpat(element)) + return pat + + +def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]: + """ Helper to turn a layer tuple-or-int into a layer and datatype""" + if isinstance(mlayer, int): + layer = mlayer + data_type = 0 + elif isinstance(mlayer, tuple): + layer = mlayer[0] + if len(mlayer) > 1: + data_type = mlayer[1] + else: + data_type = 0 + else: + raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.') + return layer, data_type + + +def _ref_to_subpat(ref: klamath.library.Reference, + ) -> SubPattern: + """ + Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None + and sets the instance .identifier to (struct_name,). + """ + xy = ref.xy.astype(float) + offset = xy[0] + repetition = None + if ref.colrow is not None: + a_count, b_count = ref.colrow + a_vector = (xy[1] - offset) / a_count + b_vector = (xy[2] - offset) / b_count + repetition = Grid(a_vector=a_vector, b_vector=b_vector, + a_count=a_count, b_count=b_count) + + subpat = SubPattern(pattern=None, + offset=offset, + rotation=numpy.deg2rad(ref.angle_deg), + scale=ref.mag, + mirrored=(ref.invert_y, False), + annotations=_properties_to_annotations(ref.properties), + repetition=repetition) + subpat.identifier = (ref.struct_name.decode('ASCII'),) + return subpat + + +def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> Path: + if gpath.path_type in path_cap_map: + cap = path_cap_map[gpath.path_type] + else: + raise PatternError(f'Unrecognized path type: {gpath.path_type}') + + mpath = Path(vertices=gpath.xy.astype(float), + layer=gpath.layer, + width=gpath.width, + cap=cap, + offset=numpy.zeros(2), + annotations=_properties_to_annotations(gpath.properties), + raw=raw_mode, + ) + if cap == Path.Cap.SquareCustom: + mpath.cap_extensions = gpath.extension + return mpath + + +def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> Polygon: + return Polygon(vertices=boundary.xy[:-1].astype(float), + layer=boundary.layer, + offset=numpy.zeros(2), + annotations=_properties_to_annotations(boundary.properties), + raw=raw_mode, + ) + + +def _subpatterns_to_refs(subpatterns: List[SubPattern] + ) -> List[klamath.library.Reference]: + refs = [] + for subpat in subpatterns: + if subpat.pattern is None: + continue + encoded_name = subpat.pattern.name.encode('ASCII') + + # Note: GDS mirrors first and rotates second + mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) + rep = subpat.repetition + angle_deg = numpy.rad2deg(subpat.rotation + extra_angle) % 360 + properties = _annotations_to_properties(subpat.annotations, 512) + + if isinstance(rep, Grid): + xy = numpy.array(subpat.offset) + [ + [0, 0], + rep.a_vector * rep.a_count, + rep.b_vector * rep.b_count, + ] + aref = klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round(xy).astype(int), + colrow=(numpy.round(rep.a_count), numpy.round(rep.b_count)), + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + refs.append(aref) + elif rep is None: + ref = klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round([subpat.offset]).astype(int), + colrow=None, + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + refs.append(ref) + else: + new_srefs = [klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round([subpat.offset + dd]).astype(int), + colrow=None, + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + for dd in rep.displacements] + refs += new_srefs + return refs + + +def _properties_to_annotations(properties: Dict[int, bytes]) -> annotations_t: + return {str(k): [v.decode()] for k, v in properties.items()} + + +def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Dict[int, bytes]: + cum_len = 0 + props = {} + for key, vals in annotations.items(): + try: + i = int(key) + except ValueError: + raise PatternError(f'Annotation key {key} is not convertable to an integer') + if not (0 < i < 126): + raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])') + + val_strings = ' '.join(str(val) for val in vals) + b = val_strings.encode() + if len(b) > 126: + raise PatternError(f'Annotation value {b!r} is longer than 126 characters!') + cum_len += numpy.ceil(len(b) / 2) * 2 + 2 + if cum_len > max_len: + raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}') + props[i] = b + return props + + +def _shapes_to_elements(shapes: List[Shape], + polygonize_paths: bool = False + ) -> List[klamath.elements.Element]: + elements: List[klamath.elements.Element] = [] + # Add a Boundary element for each shape, and Path elements if necessary + for shape in shapes: + layer, data_type = _mlayer2gds(shape.layer) + properties = _annotations_to_properties(shape.annotations, 128) + if isinstance(shape, Path) and not polygonize_paths: + xy = numpy.round(shape.vertices + shape.offset).astype(int) + width = numpy.round(shape.width).astype(int) + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + + extension: Tuple[int, int] + if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: + extension = tuple(shape.cap_extensions) # type: ignore + else: + extension = (0, 0) + + path = klamath.elements.Path(layer=(layer, data_type), + xy=xy, + path_type=path_type, + width=width, + extension=extension, + properties=properties) + elements.append(path) + elif isinstance(shape, Polygon): + polygon = shape + xy_open = numpy.round(polygon.vertices + polygon.offset).astype(int) + xy_closed = numpy.vstack((xy_open, xy_open[0, :])) + boundary = klamath.elements.Boundary(layer=(layer, data_type), + xy=xy_closed, + properties=properties) + elements.append(boundary) + else: + for polygon in shape.to_polygons(): + xy_open = numpy.round(polygon.vertices + polygon.offset).astype(int) + xy_closed = numpy.vstack((xy_open, xy_open[0, :])) + boundary = klamath.elements.Boundary(layer=(layer, data_type), + xy=xy_closed, + properties=properties) + elements.append(boundary) + return elements + + +def _labels_to_texts(labels: List[Label]) -> List[klamath.elements.Text]: + texts = [] + for label in labels: + properties = _annotations_to_properties(label.annotations, 128) + layer, text_type = _mlayer2gds(label.layer) + xy = numpy.round([label.offset]).astype(int) + text = klamath.elements.Text(layer=(layer, text_type), + xy=xy, + string=label.string.encode('ASCII'), + properties=properties, + presentation=0, # TODO maybe set some of these? + angle_deg=0, + invert_y=False, + width=0, + path_type=0, + mag=1) + texts.append(text) + return texts + + +def disambiguate_pattern_names(patterns: Sequence[Pattern], + max_name_length: int = 32, + suffix_length: int = 6, + dup_warn_filter: Optional[Callable[[str], bool]] = None, + ): + """ + Args: + patterns: List of patterns to disambiguate + max_name_length: Names longer than this will be truncated + suffix_length: Names which get truncated are truncated by this many extra characters. This is to + leave room for a suffix if one is necessary. + dup_warn_filter: (optional) Function for suppressing warnings about cell names changing. Receives + the cell name and returns `False` if the warning should be suppressed and `True` if it should + be displayed. Default displays all warnings. + """ + used_names = [] + for pat in set(patterns): + # Shorten names which already exceed max-length + if len(pat.name) > max_name_length: + shortened_name = pat.name[:max_name_length - suffix_length] + logger.warning(f'Pattern name "{pat.name}" is too long ({len(pat.name)}/{max_name_length} chars),\n' + + f' shortening to "{shortened_name}" before generating suffix') + else: + shortened_name = pat.name + + # Remove invalid characters + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name) + + # Add a suffix that makes the name unique + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + # Encode into a byte-string and perform some final checks + encoded_name = suffixed_name.encode('ASCII') + if len(encoded_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') + if len(encoded_name) > max_name_length: + raise PatternError(f'Pattern name "{encoded_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) + + +def load_library(stream: BinaryIO, + tag: str, + is_secondary: Optional[Callable[[str], bool]] = None, + ) -> Tuple[Library, Dict[str, Any]]: + """ + Scan a GDSII stream to determine what structures are present, and create + a library from them. This enables deferred reading of structures + on an as-needed basis. + All structures are loaded as secondary + + Args: + stream: Seekable stream. Position 0 should be the start of the file. + The caller should leave the stream open while the library + is still in use, since the library will need to access it + in order to read the structure contents. + tag: Unique identifier that will be used to identify this data source + is_secondary: Function which takes a structure name and returns + True if the structure should only be used as a subcell + and not appear in the main Library interface. + Default always returns False. + + Returns: + Library object, allowing for deferred load of structures. + Additional library info (dict, same format as from `read`). + """ + if is_secondary is None: + def is_secondary(k: str): + return False + + stream.seek(0) + library_info = _read_header(stream) + structs = klamath.library.scan_structs(stream) + + lib = Library() + for name_bytes, pos in structs.items(): + name = name_bytes.decode('ASCII') + + def mkstruct(pos: int = pos, name: str = name) -> Pattern: + stream.seek(pos) + return read_elements(stream, name, raw_mode=True) + + lib.set_value(name, tag, mkstruct, secondary=is_secondary(name)) + + return lib, library_info + + +def load_libraryfile(filename: Union[str, pathlib.Path], + tag: str, + is_secondary: Optional[Callable[[str], bool]] = None, + use_mmap: bool = True, + ) -> Tuple[Library, Dict[str, Any]]: + """ + Wrapper for `load_library()` that takes a filename or path instead of a stream. + + Will automatically decompress the file if it is gzipped. + + NOTE that any streams/mmaps opened will remain open until ALL of the + `PatternGenerator` objects in the library are garbage collected. + + Args: + path: filename or path to read from + tag: Unique identifier for library, see `load_library` + is_secondary: Function specifying subcess, see `load_library` + use_mmap: If `True`, will attempt to memory-map the file instead + of buffering. In the case of gzipped files, the file + is decompressed into a python `bytes` object in memory + and reopened as an `io.BytesIO` stream. + + Returns: + Library object, allowing for deferred load of structures. + Additional library info (dict, same format as from `read`). + """ + path = pathlib.Path(filename) + if is_gzipped(path): + if mmap: + logger.info('Asked to mmap a gzipped file, reading into memory instead...') + base_stream = gzip.open(path, mode='rb') + stream = io.BytesIO(base_stream.read()) + else: + base_stream = gzip.open(path, mode='rb') + stream = io.BufferedReader(base_stream) + else: + base_stream = open(path, mode='rb') + if mmap: + stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) + else: + stream = io.BufferedReader(base_stream) + return load_library(stream, tag, is_secondary) diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 390b82d..dea703d 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -15,45 +15,52 @@ from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, import re import io import copy -import numpy import base64 import struct import logging import pathlib import gzip +import numpy # type: ignore import fatamorgana import fatamorgana.records as fatrec -from fatamorgana.basic import PathExtensionScheme +from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringReference -from .utils import mangle_name, make_dose_table -from .. import Pattern, SubPattern, GridRepetition, PatternError, Label, Shape, subpattern_t -from ..shapes import Polygon, Path -from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t -from ..utils import remove_colinear_vertices, normalize_mirror +from .utils import clean_pattern_vertices, is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path, Circle +from ..repetition import Grid, Arbitrary, Repetition +from ..utils import layer_t, normalize_mirror, annotations_t logger = logging.getLogger(__name__) +logger.warning('OASIS support is experimental and mostly untested!') + + path_cap_map = { - PathExtensionScheme.Flush: Path.Cap.Flush, - PathExtensionScheme.HalfWidth: Path.Cap.Square, - PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom, - } + PathExtensionScheme.Flush: Path.Cap.Flush, + PathExtensionScheme.HalfWidth: Path.Cap.Square, + PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom, + } +#TODO implement more shape types? -def write(patterns: Union[Pattern, List[Pattern]], - stream: io.BufferedIOBase, +def build(patterns: Union[Pattern, Sequence[Pattern]], units_per_micron: int, - layer_map: Dict[str, Union[int, Tuple[int, int]]] = None, + layer_map: Optional[Dict[str, Union[int, Tuple[int, int]]]] = None, + *, modify_originals: bool = False, - disambiguate_func: Callable[[Iterable[Pattern]], None] = None): + disambiguate_func: Optional[Callable[[Iterable[Pattern]], None]] = None, + annotations: Optional[annotations_t] = None + ) -> fatamorgana.OasisLayout: """ - Write a `Pattern` or list of patterns to a OASIS file, writing patterns - as OASIS cells, polygons as Polygon records, and subpatterns as Placement - records. Other shape types may be converted to polygons if no equivalent - record type exists (or is not implemented here yet). #TODO + Convert a `Pattern` or list of patterns to an OASIS stream, writing patterns + as OASIS cells, subpatterns as Placement records, and other shapes and labels + mapped to equivalent record types (Polygon, Path, Circle, Text). + Other shape types may be converted to polygons if no equivalent + record type exists (or is not implemented here yet). For each shape, layer is chosen to be equal to `shape.layer` if it is an int, @@ -67,16 +74,26 @@ def write(patterns: Union[Pattern, List[Pattern]], prior to calling this function. Args: - patterns: A Pattern or list of patterns to write to file. - stream: Stream object to write to. + patterns: A Pattern or list of patterns to convert. units_per_micron: Written into the OASIS file, number of grid steps per micrometer. All distances are assumed to be an integer multiple of the grid step, and are stored as such. - layer_map: Dictionary which translates + layer_map: Dictionary which translates layer names into layer numbers. If this argument is + provided, input shapes and labels are allowed to have layer names instead of numbers. + It is assumed that geometry and text share the same layer names, and each name is + assigned only to a single layer (not a range). + If more fine-grained control is needed, manually pre-processing shapes' layer names + into numbers, omit this argument, and manually generate the required + `fatamorgana.records.LayerName` entries. + Default is an empty dict (no names provided). modify_originals: If `True`, the original pattern is modified as part of the writing process. Otherwise, a copy is made and `deepunlock()`-ed. Default `False`. disambiguate_func: Function which takes a list of patterns and alters them to make their names valid and unique. Default is `disambiguate_pattern_names`. + annotations: dictionary of key-value pairs which are saved as library-level properties + + Returns: + `fatamorgana.OasisLayout` """ if isinstance(patterns, Pattern): patterns = [patterns] @@ -87,23 +104,32 @@ def write(patterns: Union[Pattern, List[Pattern]], if disambiguate_func is None: disambiguate_func = disambiguate_pattern_names + if annotations is None: + annotations = {} + if not modify_originals: patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] # Create library - lib = fatamorgana.OasisLayout(unit, validation=None) + lib = fatamorgana.OasisLayout(unit=units_per_micron, validation=None) + lib.properties = annotations_to_properties(annotations) - for name, layer_num in layer_map.items(): - layer, data_type = _mlayer2oas(layer_num) - lib.layer_names.append( #TODO figure out how to deal with text layers - LayerName(nstring=name, - layer_interval=(layer, layer), - type_interval=(data_type, data_type), - is_textlayer=False)) + if layer_map: + for name, layer_num in layer_map.items(): + layer, data_type = _mlayer2oas(layer_num) + lib.layers += [ + fatrec.LayerName(nstring=name, + layer_interval=(layer, layer), + type_interval=(data_type, data_type), + is_textlayer=tt) + for tt in (True, False)] - def layer2oas(layer: layer_t) -> Tuple[int, int]: - layer_num = layer_map[layer] if isinstance(layer, str) else layer - return _mlayer2oas(layer_num) + def layer2oas(mlayer: layer_t) -> Tuple[int, int]: + assert(layer_map is not None) + layer_num = layer_map[mlayer] if isinstance(mlayer, str) else mlayer + return _mlayer2oas(layer_num) + else: + layer2oas = _mlayer2oas # Get a dict of id(pattern) -> pattern patterns_by_id = {id(pattern): pattern for pattern in patterns} @@ -115,18 +141,37 @@ def write(patterns: Union[Pattern, List[Pattern]], # Now create a structure for each pattern for pat in patterns_by_id.values(): - structure = fatamorgana.Cell(name=NString(pat.name)) + structure = fatamorgana.Cell(name=pat.name) lib.cells.append(structure) + structure.properties += annotations_to_properties(pat.annotations) + structure.geometry += _shapes_to_elements(pat.shapes, layer2oas) structure.geometry += _labels_to_texts(pat.labels, layer2oas) - structure.placements += _subpatterns_to_refs(pat.subpatterns) + structure.placements += _subpatterns_to_placements(pat.subpatterns) + return lib + + +def write(patterns: Union[Sequence[Pattern], Pattern], + stream: io.BufferedIOBase, + *args, + **kwargs): + """ + Write a `Pattern` or list of patterns to a OASIS file. See `oasis.build()` + for details. + + Args: + patterns: A Pattern or list of patterns to write to file. + stream: Stream to write to. + *args: passed to `oasis.build()` + **kwargs: passed to `oasis.build()` + """ + lib = build(patterns, *args, **kwargs) lib.write(stream) - return -def writefile(patterns: Union[List[Pattern], Pattern], +def writefile(patterns: Union[Sequence[Pattern], Pattern], filename: Union[str, pathlib.Path], *args, **kwargs, @@ -160,7 +205,7 @@ def readfile(filename: Union[str, pathlib.Path], """ Wrapper for `oasis.read()` that takes a filename or path instead of a stream. - Will automatically decompress files with a .gz suffix. + Will automatically decompress gzipped files. Args: filename: Filename to save to. @@ -168,7 +213,7 @@ def readfile(filename: Union[str, pathlib.Path], **kwargs: passed to `oasis.read` """ path = pathlib.Path(filename) - if path.suffix == '.gz': + if is_gzipped(path): open_func: Callable = gzip.open else: open_func = open @@ -184,10 +229,12 @@ 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) + 'layer_map': Mapping from layer names to fatamorgana.LayerName objects + 'annotations': Mapping of {key: value} pairs from library's properties Args: stream: Stream to read from. @@ -196,75 +243,205 @@ def read(stream: io.BufferedIOBase, Default `True`. Returns: - - Dict of pattern_name:Patterns generated from GDSII structures - - Dict of GDSII library info + - Dict of `pattern_name`:`Pattern`s generated from OASIS cells + - Dict of OASIS library info """ - lib = fatamorgana.OASISLayout.read(stream) + lib = fatamorgana.OasisLayout.read(stream) - library_info = {'units_per_micrometer': lib.unit, - } + library_info: Dict[str, Any] = { + 'units_per_micrometer': lib.unit, + 'annotations': properties_to_annotations(lib.properties, lib.propnames, lib.propstrings), + } + + layer_map = {} + for layer_name in lib.layers: + layer_map[str(layer_name.nstring)] = layer_name + library_info['layer_map'] = layer_map patterns = [] for cell in lib.cells: - pat = Pattern(name=cell.name.string) + if isinstance(cell.name, int): + cell_name = lib.cellnames[cell.name].nstring.string + else: + cell_name = cell.name.string + + pat = Pattern(name=cell_name) for element in cell.geometry: - if element.repetition is not None: - raise PatternError('masque OASIS reader does not implement repetitions for shapes yet') + if isinstance(element, fatrec.XElement): + logger.warning('Skipping XElement record') + # note XELEMENT has no repetition + continue + + assert(not isinstance(element.repetition, fatamorgana.ReuseRepetition)) + repetition = repetition_fata2masq(element.repetition) # Switch based on element type: if isinstance(element, fatrec.Polygon): - args = {'vertices': element.point_list, - 'layer': (element.layer, element.data_type) - 'offset': (element.x, element.y), - } - poly = Polygon(**args) - - if clean_vertices: - try: - poly.clean_vertices() - except PatternError: - continue + vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) + annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) + poly = Polygon(vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + annotations=annotations, + repetition=repetition) pat.shapes.append(poly) - if isinstance(element, fatrec.Path): - cap_start = path_cap_map[element.extension_start[0]] - cap_end = path_cap_map[element.extension_end[0]] + elif isinstance(element, fatrec.Path): + vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) + + cap_start = path_cap_map[element.get_extension_start()[0]] + cap_end = path_cap_map[element.get_extension_end()[0]] if cap_start != cap_end: - raise Exception('masque does not support multiple cap types on a single path.') #TODO handle multiple cap types + raise Exception('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types cap = cap_start - args = {'vertices': element.point_list, - 'layer': (element.layer, element.data_type) - 'offset': (element.x, element.y), - 'width': element.half_width * 2, - 'cap': cap, - } - + path_args: Dict[str, Any] = {} if cap == Path.Cap.SquareCustom: - args['cap_extensions'] = numpy.array((element.extension_start[1], - element.extension_end[1])) - path = Path(**args) + path_args['cap_extensions'] = numpy.array((element.get_extension_start()[1], + element.get_extension_end()[1])) - if clean_vertices: - try: - path.clean_vertices() - except PatternError as err: - continue + annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) + path = Path(vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + width=element.get_half_width() * 2, + cap=cap, + **path_args) pat.shapes.append(path) + elif isinstance(element, fatrec.Rectangle): + width = element.get_width() + height = element.get_height() + annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) + rect = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), + annotations=annotations, + ) + pat.shapes.append(rect) + + elif isinstance(element, fatrec.Trapezoid): + vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (element.get_width(), element.get_height()) + a = element.get_delta_a() + b = element.get_delta_b() + if element.get_is_vertical(): + if a > 0: + vertices[0, 1] += a + else: + vertices[3, 1] += a + + if b > 0: + vertices[2, 1] -= b + else: + vertices[1, 1] -= b + else: + if a > 0: + vertices[1, 0] += a + else: + vertices[0, 0] += a + + if b > 0: + vertices[3, 0] -= b + else: + vertices[2, 0] -= b + + annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) + trapz = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=vertices, + annotations=annotations, + ) + pat.shapes.append(trapz) + + elif isinstance(element, fatrec.CTrapezoid): + cttype = element.get_ctrapezoid_type() + height = element.get_height() + width = element.get_width() + + vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height) + + if cttype in (0, 4, 7): + vertices[2, 0] -= height + if cttype in (1, 5, 6): + vertices[3, 0] -= height + if cttype in (2, 4, 6): + vertices[1, 0] += height + if cttype in (3, 5, 7): + vertices[0, 0] += height + + if cttype in (8, 12, 15): + vertices[2, 0] -= width + if cttype in (9, 13, 14): + vertices[1, 0] -= width + if cttype in (10, 12, 14): + vertices[3, 0] += width + if cttype in (11, 13, 15): + vertices[0, 0] += width + + if cttype == 16: + vertices = vertices[[0, 1, 3], :] + elif cttype == 17: + vertices = vertices[[0, 1, 2], :] + elif cttype == 18: + vertices = vertices[[0, 2, 3], :] + elif cttype == 19: + vertices = vertices[[1, 2, 3], :] + elif cttype == 20: + vertices = vertices[[0, 1, 3], :] + vertices[1, 0] += height + elif cttype == 21: + vertices = vertices[[0, 1, 2], :] + vertices[0, 0] += height + elif cttype == 22: + vertices = vertices[[0, 1, 3], :] + vertices[3, 1] += width + elif cttype == 23: + vertices = vertices[[0, 2, 3], :] + vertices[0, 1] += width + + annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) + ctrapz = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=vertices, + annotations=annotations, + ) + pat.shapes.append(ctrapz) + + elif isinstance(element, fatrec.Circle): + annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) + circle = Circle(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + radius=float(element.get_radius())) + pat.shapes.append(circle) + elif isinstance(element, fatrec.Text): - args = {'layer': (element.layer, element.data_type) - 'offset': (element.x, element.y), - 'string': str(element.string), - } - pat.labels.append(Label(**args)) + annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) + label = Label(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + string=str(element.get_string())) + pat.labels.append(label) + + else: + logger.warning(f'Skipping record {element} (unimplemented)') + continue for placement in cell.placements: - pat.subpattterns.append += _placement_to_subpats(placement) + pat.subpatterns.append(_placement_to_subpat(placement, lib)) + if clean_vertices: + clean_pattern_vertices(pat) patterns.append(pat) # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries @@ -272,7 +449,9 @@ def read(stream: io.BufferedIOBase, patterns_dict = dict(((p.name, p) for p in patterns)) for p in patterns_dict.values(): for sp in p.subpatterns: - sp.pattern = patterns_dict[sp.identifier[0]] + ident = sp.identifier[0] + name = ident if isinstance(ident, str) else lib.cellnames[ident].nstring.string + sp.pattern = patterns_dict[name] del sp.identifier return patterns_dict, library_info @@ -290,45 +469,35 @@ def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]: else: data_type = 0 else: - raise PatternError(f'Invalid layer for OASIS: {layer}. Note that OASIS layers cannot be strings.') #TODO allow string layers using layer map def + raise PatternError(f'Invalid layer for OASIS: {layer}. Note that OASIS layers cannot be ' + f'strings unless a layer map is provided.') return layer, data_type -def _placement_to_subpats(placement: fatrec.Placement) -> List[subpattern_t]: +def _placement_to_subpat(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> SubPattern: """ Helper function to create a SubPattern from a placment. Sets subpat.pattern to None and sets the instance .identifier to (struct_name,). """ + assert(not isinstance(placement.repetition, fatamorgana.ReuseRepetition)) xy = numpy.array((placement.x, placement.y)) - kwargs = { - 'pattern': None, - 'mirrored': (placement.flip, False), - 'rotation': float(placement.angle * pi/180) - 'scale': placement.magnification, - 'identifier': (placement.name,), - } - - 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, - **kwargs) - subpats = [subpat] - 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, **kwargs)) - elif rep is None - subpats = [SubPattern(offset=xy + rep_offset, **kwargs)] - return subpats + mag = placement.magnification if placement.magnification is not None else 1 + pname = placement.get_name() + name = pname if isinstance(pname, int) else pname.string + annotations = properties_to_annotations(placement.properties, lib.propnames, lib.propstrings) + subpat = SubPattern(offset=xy, + pattern=None, + mirrored=(placement.flip, False), + rotation=numpy.deg2rad(placement.angle), + scale=float(mag), + identifier=(name,), + repetition=repetition_fata2masq(placement.repetition), + annotations=annotations) + return subpat -def _subpatterns_to_refs(subpatterns: List[subpattern_t] - ) -> List[fatrec.Placement]]: +def _subpatterns_to_placements(subpatterns: List[SubPattern] + ) -> List[fatrec.Placement]: refs = [] for subpat in subpatterns: if subpat.pattern is None: @@ -336,25 +505,19 @@ def _subpatterns_to_refs(subpatterns: List[subpattern_t] # Note: OASIS mirrors first and rotates second mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) - xy = numpy.round(subpat.offset).astype(int) - args = { - 'x': xy[0], - 'y': xy[1], - } - - if isinstance(subpat, GridRepetition): - kwargs['rep'] = 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)) + frep, rep_offset = repetition_masq2fata(subpat.repetition) + offset = numpy.round(subpat.offset + rep_offset).astype(int) + angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360 ref = fatrec.Placement( - name=subpat.pattern.name, - flip=mirror_across_x, - angle=((subpat.rotation + extra_angle) * 180 / numpy.pi) % 360, - magnification=subpat.scale, - **kwargs) + name=subpat.pattern.name, + flip=mirror_across_x, + angle=angle, + magnification=subpat.scale, + properties=annotations_to_properties(subpat.annotations), + x=offset[0], + y=offset[1], + repetition=frep) refs.append(ref) return refs @@ -362,36 +525,54 @@ def _subpatterns_to_refs(subpatterns: List[subpattern_t] def _shapes_to_elements(shapes: List[Shape], layer2oas: Callable[[layer_t], Tuple[int, int]], - polygonize_paths: bool = False, - ) -> List[Union[fatrec.Polygon, fatrec.Path]]: + ) -> List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]]: # Add a Polygon record for each shape, and Path elements if necessary - elements: List[Union[fatrec.Polygon, fatrec.Path]] = [] + elements: List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]] = [] for shape in shapes: - layer, data_type = layer2oas(shape.layer) - if isinstance(shape, Path) and not polygonize_paths: - offset = numpy.round(shape.offset).astype(int) - points = numpy.round(shape.vertices).astype(int) + layer, datatype = layer2oas(shape.layer) + repetition, rep_offset = repetition_masq2fata(shape.repetition) + properties = annotations_to_properties(shape.annotations) + if isinstance(shape, Circle): + offset = numpy.round(shape.offset + rep_offset).astype(int) + radius = numpy.round(shape.radius).astype(int) + circle = fatrec.Circle(layer=layer, + datatype=datatype, + radius=radius, + x=offset[0], + y=offset[1], + properties=properties, + repetition=repetition) + elements.append(circle) + elif isinstance(shape, Path): + xy = numpy.round(shape.offset + shape.vertices[0] + rep_offset).astype(int) + deltas = numpy.round(numpy.diff(shape.vertices, axis=0)).astype(int) half_width = numpy.round(shape.width / 2).astype(int) - path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) #reverse lookup + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None) + extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) path = fatrec.Path(layer=layer, - data_type=data_type, - point_list=points, + datatype=datatype, + point_list=deltas, half_width=half_width, - x=offset[0], - y=offset[1], - extension_start=path_type, #TODO implement multiple cap types? - extension_end=path_type, + x=xy[0], + y=xy[1], + extension_start=extension_start, # TODO implement multiple cap types? + extension_end=extension_end, + properties=properties, + repetition=repetition, ) elements.append(path) else: for polygon in shape.to_polygons(): - points = numpy.round(polygon.vertices).astype(int) - offset = numpy.round(polygon.offset).astype(int) + xy = numpy.round(polygon.offset + polygon.vertices[0] + rep_offset).astype(int) + points = numpy.round(numpy.diff(polygon.vertices, axis=0)).astype(int) elements.append(fatrec.Polygon(layer=layer, - data_type=data_type, - x=offset[0], - y=offset[1], - point_list=point_list)) + datatype=datatype, + x=xy[0], + y=xy[1], + point_list=points, + properties=properties, + repetition=repetition)) return elements @@ -400,22 +581,26 @@ def _labels_to_texts(labels: List[Label], ) -> List[fatrec.Text]: texts = [] for label in labels: - layer, text_type = layer2oas(label.layer) - xy = numpy.round(label.offset).astype(int) + layer, datatype = layer2oas(label.layer) + repetition, rep_offset = repetition_masq2fata(label.repetition) + xy = numpy.round(label.offset + rep_offset).astype(int) + properties = annotations_to_properties(label.annotations) texts.append(fatrec.Text(layer=layer, - text_type=text_type, + datatype=datatype, x=xy[0], y=xy[1], - string=string)) + string=label.string, + properties=properties, + repetition=repetition)) return texts def disambiguate_pattern_names(patterns, - dup_warn_filter: Callable[[str,], bool] = None, # If returns False, don't warn about this name + dup_warn_filter: Callable[[str], bool] = None, # If returns False, don't warn about this name ): used_names = [] for pat in patterns: - sanitized_name = re.compile('[^A-Za-z0-9_\?\$]').sub('_', pat.name) + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', pat.name) i = 0 suffixed_name = sanitized_name @@ -426,16 +611,101 @@ def disambiguate_pattern_names(patterns, i += 1 if sanitized_name == '': - logger.warning('Empty pattern name saved as "{}"'.format(suffixed_name)) + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') elif suffixed_name != sanitized_name: if dup_warn_filter is None or dup_warn_filter(pat.name): - logger.warning('Pattern name "{}" ({}) appears multiple times;\n renaming to "{}"'.format( - pat.name, sanitized_name, suffixed_name)) + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') - encoded_name = suffixed_name.encode('ASCII') - if len(encoded_name) == 0: + if len(suffixed_name) == 0: # Should never happen since zero-length names are replaced - raise PatternError('Zero-length name after sanitize+encode,\n originally "{}"'.format(pat.name)) + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') - pat.name = encoded_name + pat.name = suffixed_name used_names.append(suffixed_name) + + +def repetition_fata2masq(rep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None] + ) -> Optional[Repetition]: + mrep: Optional[Repetition] + if isinstance(rep, fatamorgana.GridRepetition): + mrep = Grid(a_vector=rep.a_vector, + b_vector=rep.b_vector, + a_count=rep.a_count, + b_count=rep.b_count) + elif isinstance(rep, fatamorgana.ArbitraryRepetition): + displacements = numpy.cumsum(numpy.column_stack((rep.x_displacements, + rep.y_displacements))) + displacements = numpy.vstack(([0, 0], displacements)) + mrep = Arbitrary(displacements) + elif rep is None: + mrep = None + return mrep + + +def repetition_masq2fata(rep: Optional[Repetition] + ) -> Tuple[Union[fatamorgana.GridRepetition, + fatamorgana.ArbitraryRepetition, + None], + Tuple[int, int]]: + frep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None] + if isinstance(rep, Grid): + frep = fatamorgana.GridRepetition( + 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)) + offset = (0, 0) + elif isinstance(rep, Arbitrary): + diffs = numpy.diff(rep.displacements, axis=0) + diff_ints = numpy.round(diffs).astype(int) + frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) + offset = rep.displacements[0, :] + else: + assert(rep is None) + frep = None + offset = (0, 0) + return frep, offset + + +def annotations_to_properties(annotations: annotations_t) -> List[fatrec.Property]: + #TODO determine is_standard based on key? + properties = [] + for key, values in annotations.items(): + vals = [AString(v) if isinstance(v, str) else v + for v in values] + properties.append(fatrec.Property(key, vals, is_standard=False)) + return properties + + +def properties_to_annotations(properties: List[fatrec.Property], + propnames: Dict[int, NString], + propstrings: Dict[int, AString], + ) -> annotations_t: + annotations = {} + for proprec in properties: + assert(proprec.name is not None) + if isinstance(proprec.name, int): + key = propnames[proprec.name].string + else: + key = proprec.name.string + values: List[Union[str, float, int]] = [] + + assert(proprec.values is not None) + for value in proprec.values: + if isinstance(value, (float, int)): + values.append(value) + elif isinstance(value, (NString, AString)): + values.append(value.string) + elif isinstance(value, PropStringReference): + values.append(propstrings[value.ref].string) # dereference + else: + string = repr(value) + logger.warning(f'Converting property value for key ({key}) to string ({string})') + values.append(string) + annotations[key] = values + return annotations + + properties = [fatrec.Property(key, vals, is_standard=False) + for key, vals in annotations.items()] + return properties diff --git a/masque/file/svg.py b/masque/file/svg.py index deef59a..a9f7c47 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -2,17 +2,19 @@ SVG file format readers and writers """ from typing import Dict, Optional -import svgwrite -import numpy import warnings +import numpy # type: ignore +import svgwrite # type: ignore + from .utils import mangle_name from .. import Pattern def writefile(pattern: Pattern, - filename: str, - custom_attributes: bool=False): + filename: str, + custom_attributes: bool = False, + ) -> None: """ Write a Pattern to an SVG file, by first calling .polygonize() on it to change the shapes into polygons, and then writing patterns as SVG @@ -79,8 +81,7 @@ def writefile(pattern: Pattern, for subpat in pat.subpatterns: if subpat.pattern is None: continue - transform = 'scale({:g}) rotate({:g}) translate({:g},{:g})'.format( - subpat.scale, subpat.rotation, subpat.offset[0], subpat.offset[1]) + transform = f'scale({subpat.scale:g}) rotate({subpat.rotation:g}) translate({subpat.offset[0]:g},{subpat.offset[1]:g})' use = svg.use(href='#' + mangle_name(subpat.pattern), transform=transform) if custom_attributes: use['pattern_dose'] = subpat.dose diff --git a/masque/file/utils.py b/masque/file/utils.py index f70f1da..d183b39 100644 --- a/masque/file/utils.py +++ b/masque/file/utils.py @@ -1,13 +1,16 @@ """ Helper functions for file reading and writing """ -import re from typing import Set, Tuple, List +import re +import copy +import pathlib -from masque.pattern import Pattern +from .. import Pattern, PatternError +from ..shapes import Polygon, Path -def mangle_name(pattern: Pattern, dose_multiplier: float=1.0) -> str: +def mangle_name(pattern: Pattern, dose_multiplier: float = 1.0) -> str: """ Create a name using `pattern.name`, `id(pattern)`, and the dose multiplier. @@ -18,13 +21,37 @@ def mangle_name(pattern: Pattern, dose_multiplier: float=1.0) -> str: Returns: Mangled name. """ - expression = re.compile('[^A-Za-z0-9_\?\$]') + expression = re.compile(r'[^A-Za-z0-9_\?\$]') full_name = '{}_{}_{}'.format(pattern.name, dose_multiplier, id(pattern)) sanitized_name = expression.sub('_', full_name) return sanitized_name -def make_dose_table(patterns: List[Pattern], dose_multiplier: float=1.0) -> Set[Tuple[int, float]]: +def clean_pattern_vertices(pat: Pattern) -> Pattern: + """ + Given a pattern, remove any redundant vertices in its polygons and paths. + The cleaning process completely removes any polygons with zero area or <3 vertices. + + Args: + pat: Pattern to clean + + Returns: + pat + """ + remove_inds = [] + for ii, shape in enumerate(pat.shapes): + if not isinstance(shape, (Polygon, Path)): + continue + try: + shape.clean_vertices() + except PatternError: + remove_inds.append(ii) + for ii in sorted(remove_inds, reverse=True): + del pat.shapes[ii] + return pat + + +def make_dose_table(patterns: List[Pattern], dose_multiplier: float = 1.0) -> Set[Tuple[int, float]]: """ Create a set containing `(id(pat), written_dose)` for each pattern (including subpatterns) @@ -116,14 +143,14 @@ def dose2dtype(patterns: List[Pattern], # Create a new pattern for each non-1-dose entry in the dose table # and update the shapes to reflect their new dose - new_pats = {} # (id, dose) -> new_pattern mapping + new_pats = {} # (id, dose) -> new_pattern mapping for pat_id, pat_dose in sd_table: if pat_dose == 1: new_pats[(pat_id, pat_dose)] = patterns_by_id[pat_id] continue old_pat = patterns_by_id[pat_id] - pat = old_pat.copy() # keep old subpatterns + pat = old_pat.copy() # keep old subpatterns pat.shapes = copy.deepcopy(old_pat.shapes) pat.labels = copy.deepcopy(old_pat.labels) @@ -150,3 +177,9 @@ def dose2dtype(patterns: List[Pattern], subpat.pattern = new_pats[(id(subpat.pattern), dose_mult)] return patterns, dose_vals_list + + +def is_gzipped(path: pathlib.Path) -> bool: + with open(path, 'rb') as stream: + magic_bytes = stream.read(2) + return magic_bytes == b'\x1f\x8b' diff --git a/masque/label.py b/masque/label.py index df7f3cf..b436653 100644 --- a/masque/label.py +++ b/masque/label.py @@ -1,23 +1,22 @@ -from typing import List, Tuple, Dict +from typing import Tuple, Dict, Optional, TypeVar import copy -import numpy -from numpy import pi +import numpy # type: ignore -from .error import PatternError, PatternLockedError -from .utils import is_scalar, vector2, rotation_matrix_2d, layer_t +from .repetition import Repetition +from .utils import vector2, rotation_matrix_2d, layer_t, AutoSlots, annotations_t +from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, LockableImpl, RepeatableImpl +from .traits import AnnotatableImpl -class Label: +L = TypeVar('L', bound='Label') + + +class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, AnnotatableImpl, + 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 +24,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: @@ -77,49 +41,37 @@ class Label: def __init__(self, string: str, + *, offset: vector2 = (0.0, 0.0), layer: layer_t = 0, - locked: bool = False): - object.__setattr__(self, 'locked', False) + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, + locked: bool = False, + ) -> None: + LockableImpl.unlock(self) self.identifier = () self.string = string self.offset = numpy.array(offset, dtype=float, copy=True) self.layer = layer - self.locked = locked + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} + self.set_locked(locked) - def __copy__(self) -> 'Label': + def __copy__(self: L) -> L: return Label(string=self.string, offset=self.offset.copy(), layer=self.layer, + repetition=self.repetition, locked=self.locked) - def __deepcopy__(self, memo: Dict = None) -> 'Label': + def __deepcopy__(self: L, memo: Dict = None) -> L: memo = {} if memo is None else memo new = copy.copy(self).unlock() new._offset = self._offset.copy() - new.locked = self.locked + new.set_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': + def rotate_around(self: L, pivot: vector2, rotation: float) -> L: """ Rotate the label around a point. @@ -149,26 +101,14 @@ 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) + def lock(self: L) -> L: + 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 + def unlock(self: L) -> L: + LockableImpl.unlock(self) + PositionableImpl._unlock(self) return self def __repr__(self) -> str: diff --git a/masque/library/NOTES.md b/masque/library/NOTES.md new file mode 100644 index 0000000..0e02be9 --- /dev/null +++ b/masque/library/NOTES.md @@ -0,0 +1,8 @@ + +- library: + - takes in a string + - builds device if not ready yet + - returns device + - can overwrite device and update pointers? + - lockable? + - add functions? diff --git a/masque/library/__init__.py b/masque/library/__init__.py new file mode 100644 index 0000000..a72f3b9 --- /dev/null +++ b/masque/library/__init__.py @@ -0,0 +1 @@ +from .library import Library, PatternGenerator diff --git a/masque/library/library.py b/masque/library/library.py new file mode 100644 index 0000000..91fd6a8 --- /dev/null +++ b/masque/library/library.py @@ -0,0 +1,282 @@ +""" +Library class for managing unique name->pattern mappings and + deferred loading or creation. +""" +from typing import Dict, Callable, TypeVar, TYPE_CHECKING +from typing import Any, Tuple, Union, Iterator +import logging +from pprint import pformat +from dataclasses import dataclass + +from ..error import LibraryError + +if TYPE_CHECKING: + from ..pattern import Pattern + + +logger = logging.getLogger(__name__) + + +@dataclass +class PatternGenerator: + __slots__ = ('tag', 'gen') + tag: str + """ Unique identifier for the source """ + + gen: Callable[[], 'Pattern'] + """ Function which generates a pattern when called """ + + +L = TypeVar('L', bound='Library') + + +class Library: + """ + This class is usually used to create a device library by mapping names to + functions which generate or load the relevant `Pattern` object as-needed. + + Generated/loaded patterns can have "symbolic" references, where a SubPattern + object `sp` has a `None`-valued `sp.pattern` attribute, in which case the + Library expects `sp.identifier[0]` to contain a string which specifies the + referenced pattern's name. + + Patterns can either be "primary" (default) or "secondary". Both get the + same deferred-load behavior, but "secondary" patterns may have conflicting + names and are not accessible through basic []-indexing. They are only used + to fill symbolic references in cases where there is no "primary" pattern + available, and only if both the referencing and referenced pattern-generators' + `tag` values match (i.e., only if they came from the same source). + + Primary patterns can be turned into secondary patterns with the `demote` + method, `promote` performs the reverse (secondary -> primary) operation. + + The `set_const` and `set_value` methods provide an easy way to transparently + construct PatternGenerator objects and directly set create "secondary" + patterns. + + The cache can be disabled by setting the `enable_cache` attribute to `False`. + """ + primary: Dict[str, PatternGenerator] + secondary: Dict[Tuple[str, str], PatternGenerator] + cache: Dict[Union[str, Tuple[str, str]], 'Pattern'] + enable_cache: bool = True + + def __init__(self) -> None: + self.primary = {} + self.secondary = {} + self.cache = {} + + def __setitem__(self, key: str, value: PatternGenerator) -> None: + self.primary[key] = value + if key in self.cache: + del self.cache[key] + + def __delitem__(self, key: str) -> None: + if isinstance(key, str): + del self.primary[key] + elif isinstance(key, tuple): + del self.secondary[key] + + if key in self.cache: + del self.cache[key] + + def __getitem__(self, key: str) -> 'Pattern': + return self.get_primary(key) + + def __iter__(self) -> Iterator[str]: + return iter(self.keys()) + + def __contains__(self, key: str) -> bool: + return key in self.primary + + def get_primary(self, key: str) -> 'Pattern': + if self.enable_cache and key in self.cache: + logger.debug(f'found {key} in cache') + return self.cache[key] + + logger.debug(f'loading {key}') + pg = self.primary[key] + pat = pg.gen() + self.resolve_subpatterns(pat, pg.tag) + self.cache[key] = pat + return pat + + def get_secondary(self, key: str, tag: str) -> 'Pattern': + logger.debug(f'get_secondary({key}, {tag})') + key2 = (key, tag) + if self.enable_cache and key2 in self.cache: + return self.cache[key2] + + pg = self.secondary[key2] + pat = pg.gen() + self.resolve_subpatterns(pat, pg.tag) + self.cache[key2] = pat + return pat + + def resolve_subpatterns(self, pat: 'Pattern', tag: str) -> 'Pattern': + logger.debug(f'Resolving subpatterns in {pat.name}') + for sp in pat.subpatterns: + if sp.pattern is not None: + continue + + key = sp.identifier[0] + if key in self.primary: + sp.pattern = self.get_primary(key) + continue + + if (key, tag) in self.secondary: + sp.pattern = self.get_secondary(key, tag) + continue + + raise LibraryError(f'Broken reference to {key} (tag {tag})') + return pat + + def keys(self) -> Iterator[str]: + return iter(self.primary.keys()) + + def values(self) -> Iterator['Pattern']: + return iter(self[key] for key in self.keys()) + + def items(self) -> Iterator[Tuple[str, 'Pattern']]: + return iter((key, self[key]) for key in self.keys()) + + def __repr__(self) -> str: + return '' + + def set_const(self, key: str, tag: Any, const: 'Pattern', secondary: bool = False) -> None: + """ + Convenience function to avoid having to manually wrap + constant values into callables. + + Args: + key: Lookup key, usually the cell/pattern name + tag: Unique tag for the source, used to disambiguate secondary patterns + const: Pattern object to return + secondary: If True, this pattern is not accessible for normal lookup, and is + only used as a sub-component of other patterns if no non-secondary + equivalent is available. + """ + pg = PatternGenerator(tag=tag, gen=lambda: const) + if secondary: + self.secondary[(key, tag)] = pg + else: + self.primary[key] = pg + + def set_value(self, key: str, tag: str, value: Callable[[], 'Pattern'], secondary: bool = False) -> None: + """ + Convenience function to automatically build a PatternGenerator. + + Args: + key: Lookup key, usually the cell/pattern name + tag: Unique tag for the source, used to disambiguate secondary patterns + value: Callable which takes no arguments and generates the `Pattern` object + secondary: If True, this pattern is not accessible for normal lookup, and is + only used as a sub-component of other patterns if no non-secondary + equivalent is available. + """ + pg = PatternGenerator(tag=tag, gen=value) + if secondary: + self.secondary[(key, tag)] = pg + else: + self.primary[key] = pg + + def precache(self: L) -> L: + """ + Force all patterns into the cache + + Returns: + self + """ + for key in self.primary: + _ = self.get_primary(key) + for key2 in self.secondary: + _ = self.get_secondary(*key2) + return self + + def add(self: L, other: L) -> L: + """ + Add keys from another library into this one. + + There must be no conflicting keys. + + Args: + other: The library to insert keys from + + Returns: + self + """ + conflicts = [key for key in other.primary + if key in self.primary] + if conflicts: + raise LibraryError('Duplicate keys encountered in library merge: ' + pformat(conflicts)) + + conflicts2 = [key2 for key2 in other.secondary + if key2 in self.secondary] + if conflicts2: + raise LibraryError('Duplicate secondary keys encountered in library merge: ' + pformat(conflicts2)) + + self.primary.update(other.primary) + self.secondary.update(other.secondary) + self.cache.update(other.cache) + return self + + def demote(self, key: str) -> None: + """ + Turn a primary pattern into a secondary one. + It will no longer be accessible through [] indexing and will only be used to + when referenced by other patterns from the same source, and only if no primary + pattern with the same name exists. + + Args: + key: Lookup key, usually the cell/pattern name + """ + pg = self.primary[key] + key2 = (key, pg.tag) + self.secondary[key2] = pg + if key in self.cache: + self.cache[key2] = self.cache[key] + del self[key] + + def promote(self, key: str, tag: str) -> None: + """ + Turn a secondary pattern into a primary one. + It will become accessible through [] indexing and will be used to satisfy any + reference to a pattern with its key, regardless of tag. + + Args: + key: Lookup key, usually the cell/pattern name + tag: Unique tag for identifying the pattern's source, used to disambiguate + secondary patterns + """ + if key in self.primary: + raise LibraryError(f'Promoting ({key}, {tag}), but {key} already exists in primary!') + + key2 = (key, tag) + pg = self.secondary[key2] + self.primary[key] = pg + if key2 in self.cache: + self.cache[key] = self.cache[key2] + del self.secondary[key2] + del self.cache[key2] + + +r""" + # Add a filter for names which aren't added + + - Registration: + - scanned files (tag=filename, gen_fn[stream, {name: pos}]) + - generator functions (tag='fn?', gen_fn[params]) + - merge decision function (based on tag and cell name, can be "neither") ??? neither=keep both, load using same tag! + - Load process: + - file: + - read single cell + - check subpat identifiers, and load stuff recursively based on those. If not present, load from same file?? + - function: + - generate cell + - traverse and check if we should load any subcells from elsewhere. replace if so. + * should fn generate subcells at all, or register those separately and have us control flow? maybe ask us and generate itself if not present? + + - Scan all GDS files, save name -> (file, position). Keep the streams handy. + - Merge all names. This requires subcell merge because we don't know hierarchy. + - possibly include a "neither" option during merge, to deal with subcells. Means: just use parent's file. +""" diff --git a/masque/library/utils.py b/masque/library/utils.py new file mode 100644 index 0000000..020ac3f --- /dev/null +++ b/masque/library/utils.py @@ -0,0 +1,48 @@ +from typing import Callable, TypeVar, Generic +from functools import lru_cache + + +Key = TypeVar('Key') +Value = TypeVar('Value') + + +class DeferredDict(dict, Generic[Key, Value]): + """ + This is a modified `dict` which is used to defer loading/generating + values until they are accessed. + + ``` + bignum = my_slow_function() # slow function call, would like to defer this + numbers = Library() + numbers['big'] = my_slow_function # no slow function call here + assert(bignum == numbers['big']) # first access is slow (function called) + assert(bignum == numbers['big']) # second access is fast (result is cached) + ``` + + The `set_const` method is provided for convenience; + `numbers['a'] = lambda: 10` is equivalent to `numbers.set_const('a', 10)`. + """ + def __init__(self, *args, **kwargs) -> None: + dict.__init__(self) + self.update(*args, **kwargs) + + def __setitem__(self, key: Key, value: Callable[[], Value]) -> None: + cached_fn = lru_cache(maxsize=1)(value) + dict.__setitem__(self, key, cached_fn) + + def __getitem__(self, key: Key) -> Value: + return dict.__getitem__(self, key)() + + def update(self, *args, **kwargs) -> None: + for k, v in dict(*args, **kwargs).items(): + self[k] = v + + def __repr__(self) -> str: + return '' + + def set_const(self, key: Key, value: Value) -> None: + """ + Convenience function to avoid having to manually wrap + constant values into callables. + """ + self[key] = lambda: value diff --git a/masque/pattern.py b/masque/pattern.py index 4348338..55c9ab7 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -3,34 +3,36 @@ """ from typing import List, Callable, Tuple, Dict, Union, Set, Sequence, Optional, Type, overload -from typing import MutableMapping, Iterable +from typing import MutableMapping, Iterable, TypeVar, Any import copy -import itertools import pickle +from itertools import chain from collections import defaultdict -import numpy +import numpy # type: ignore 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 +from .utils import rotation_matrix_2d, vector2, normalize_mirror, AutoSlots, annotations_t from .error import PatternError, PatternLockedError +from .traits import LockableImpl, AnnotatableImpl, Scalable visitor_function_t = Callable[['Pattern', Tuple['Pattern'], Dict, numpy.ndarray], 'Pattern'] -class Pattern: +P = TypeVar('P', bound='Pattern') + + +class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): """ 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') + __slots__ = ('shapes', 'labels', 'subpatterns', 'name') shapes: List[Shape] """ List of all shapes in this Pattern. @@ -40,26 +42,24 @@ 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 """ A name for this pattern """ - locked: bool - """ When the pattern is locked, no changes may be made. """ - def __init__(self, name: str = '', + *, shapes: Sequence[Shape] = (), labels: Sequence[Label] = (), - subpatterns: Sequence[subpattern_t] = (), + subpatterns: Sequence[SubPattern] = (), + annotations: Optional[annotations_t] = None, locked: bool = False, - ): + ) -> None: """ Basic init; arguments get assigned to member variables. Non-list inputs for shapes and subpatterns get converted to lists. @@ -71,7 +71,7 @@ class Pattern: name: An identifier for the Pattern locked: Whether to lock the pattern after construction """ - object.__setattr__(self, 'locked', False) + LockableImpl.unlock(self) if isinstance(shapes, list): self.shapes = shapes else: @@ -87,31 +87,34 @@ class Pattern: else: self.subpatterns = list(subpatterns) + self.annotations = annotations if annotations is not None else {} self.name = name - self.locked = locked + self.set_locked(locked) - def __setattr__(self, name, value): - if self.locked and name != 'locked': - raise PatternLockedError() - object.__setattr__(self, name, value) - - def __copy__(self, memo: Dict = None) -> 'Pattern': + def __copy__(self: P, memo: Dict = None) -> P: return Pattern(name=self.name, shapes=copy.deepcopy(self.shapes), labels=copy.deepcopy(self.labels), subpatterns=[copy.copy(sp) for sp in self.subpatterns], + annotations=copy.deepcopy(self.annotations), locked=self.locked) - def __deepcopy__(self, memo: Dict = None) -> 'Pattern': + def __deepcopy__(self: P, memo: Dict = None) -> P: memo = {} if memo is None else memo - new = Pattern(name=self.name, - shapes=copy.deepcopy(self.shapes, memo), - labels=copy.deepcopy(self.labels, memo), - subpatterns=copy.deepcopy(self.subpatterns, memo), - locked=self.locked) + new = Pattern( + name=self.name, + shapes=copy.deepcopy(self.shapes, memo), + labels=copy.deepcopy(self.labels, memo), + subpatterns=copy.deepcopy(self.subpatterns, memo), + annotations=copy.deepcopy(self.annotations, memo), + locked=self.locked) return new - def append(self, other_pattern: 'Pattern') -> 'Pattern': + def rename(self: P, name: str) -> P: + self.name = name + return self + + def append(self: P, other_pattern: P) -> P: """ Appends all shapes, labels and subpatterns from other_pattern to self's shapes, labels, and supbatterns. @@ -127,12 +130,12 @@ class Pattern: self.labels += other_pattern.labels return self - def subset(self, + def subset(self: P, 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': + ) -> P: """ Returns a Pattern containing only the entities (e.g. shapes) for which the given entity_func returns True. @@ -152,7 +155,7 @@ class Pattern: A Pattern containing all the shapes and subpatterns for which the parameter functions return True """ - def do_subset(src: Optional['Pattern']) -> Optional['Pattern']: + def do_subset(src: Optional[P]) -> Optional[P]: if src is None: return None pat = Pattern(name=src.name) @@ -172,10 +175,10 @@ class Pattern: assert(pat is not None) return pat - def apply(self, - func: Callable[[Optional['Pattern']], Optional['Pattern']], - memo: Optional[Dict[int, Optional['Pattern']]] = None, - ) -> Optional['Pattern']: + def apply(self: P, + func: Callable[[Optional[P]], Optional[P]], + memo: Optional[Dict[int, Optional[P]]] = None, + ) -> Optional[P]: """ Recursively apply func() to this pattern and any pattern it references. func() is expected to take and return a Pattern. @@ -215,13 +218,13 @@ class Pattern: pat = memo[pat_id] return pat - def dfs(self, + def dfs(self: P, visit_before: visitor_function_t = None, visit_after: visitor_function_t = None, transform: Union[numpy.ndarray, bool, None] = False, memo: Optional[Dict] = None, - hierarchy: Tuple['Pattern', ...] = (), - ) -> 'Pattern': + hierarchy: Tuple[P, ...] = (), + ) -> P: """ Experimental convenience function. Performs a depth-first traversal of this pattern and its subpatterns. @@ -277,7 +280,7 @@ class Pattern: if transform is not False: sign = numpy.ones(2) if transform[3]: - sign[1] = -1 + sign[1] = -1 xy = numpy.dot(rotation_matrix_2d(transform[2]), subpattern.offset * sign) mirror_x, angle = normalize_mirror(subpattern.mirrored) angle += subpattern.rotation @@ -287,20 +290,23 @@ class Pattern: sp_transform = False if subpattern.pattern is not None: - subpattern.pattern = subpattern.pattern.dfs(visit_before=visit_before, - visit_after=visit_after, - transform=sp_transform, - memo=memo, - hierarchy=hierarchy + (self,)) + result = subpattern.pattern.dfs(visit_before=visit_before, + visit_after=visit_after, + transform=sp_transform, + memo=memo, + hierarchy=hierarchy + (self,)) + if result is not subpattern.pattern: + # skip assignment to avoid PatternLockedError unless modified + subpattern.pattern = result if visit_after is not None: pat = visit_after(pat, hierarchy=hierarchy, memo=memo, transform=transform) # type: ignore return pat - def polygonize(self, + def polygonize(self: P, poly_num_points: Optional[int] = None, poly_max_arclen: Optional[float] = None, - ) -> 'Pattern': + ) -> P: """ Calls `.to_polygons(...)` on all the shapes in this Pattern and any referenced patterns, replacing them with the returned polygons. @@ -317,18 +323,18 @@ class Pattern: self """ old_shapes = self.shapes - self.shapes = list(itertools.chain.from_iterable( - (shape.to_polygons(poly_num_points, poly_max_arclen) - for shape in old_shapes))) + self.shapes = list(chain.from_iterable( + (shape.to_polygons(poly_num_points, poly_max_arclen) + for shape in old_shapes))) for subpat in self.subpatterns: if subpat.pattern is not None: subpat.pattern.polygonize(poly_num_points, poly_max_arclen) return self - def manhattanize(self, + def manhattanize(self: P, grid_x: numpy.ndarray, grid_y: numpy.ndarray, - ) -> 'Pattern': + ) -> P: """ Calls `.polygonize()` and `.flatten()` on the pattern, then calls `.manhattanize()` on all the resulting shapes, replacing them with the returned Manhattan polygons. @@ -343,15 +349,15 @@ class Pattern: self.polygonize().flatten() old_shapes = self.shapes - self.shapes = list(itertools.chain.from_iterable( - (shape.manhattanize(grid_x, grid_y) for shape in old_shapes))) + self.shapes = list(chain.from_iterable( + (shape.manhattanize(grid_x, grid_y) for shape in old_shapes))) return self - def subpatternize(self, + def subpatternize(self: P, recursive: bool = True, norm_value: int = int(1e6), exclude_types: Tuple[Type] = (Polygon,) - ) -> 'Pattern': + ) -> P: """ Iterates through this `Pattern` and all referenced `Pattern`s. Within each `Pattern`, it iterates over all shapes, calling `.normalized_form(norm_value)` on them to retrieve a scale-, @@ -407,9 +413,8 @@ class Pattern: for i, values in shape_table[label][1]: (offset, scale, rotation, mirror_x, dose) = values - subpat = SubPattern(pattern=pat, offset=offset, scale=scale, - rotation=rotation, dose=dose, mirrored=(mirror_x, False)) - self.subpatterns.append(subpat) + self.addsp(pattern=pat, offset=offset, scale=scale, + rotation=rotation, dose=dose, mirrored=(mirror_x, False)) shapes_to_remove.append(i) # Remove any shapes for which we have created subpatterns. @@ -441,28 +446,31 @@ class Pattern: pass def referenced_patterns_by_id(self, - include_none: bool = False + include_none: bool = False, + recursive: bool = True, ) -> Union[Dict[int, Optional['Pattern']], Dict[int, 'Pattern']]: """ Create a dictionary with `{id(pat): pat}` for all Pattern objects referenced by this - Pattern (operates recursively on all referenced Patterns as well) + Pattern (by default, operates recursively on all referenced Patterns as well). Args: include_none: If `True`, references to `None` will be included. Default `False`. + recursive: If `True`, operates recursively on all referenced patterns. Default `True`. Returns: Dictionary with `{id(pat): pat}` for all referenced Pattern objects """ ids: Dict[int, Optional['Pattern']] = {} for subpat in self.subpatterns: - if id(subpat.pattern) not in ids: - if subpat.pattern is not None: - ids[id(subpat.pattern)] = subpat.pattern - ids.update(subpat.pattern.referenced_patterns_by_id()) - elif include_none: - ids[id(subpat.pattern)] = subpat.pattern + pat = subpat.pattern + if id(pat) in ids: + continue + if include_none or pat is not None: + ids[id(pat)] = pat + if recursive and pat is not None: + ids.update(pat.referenced_patterns_by_id()) return ids def referenced_patterns_by_name(self, **kwargs) -> List[Tuple[Optional[str], Optional['Pattern']]]: @@ -483,6 +491,31 @@ class Pattern: pat_list = [(p.name if p is not None else None, p) for p in pats_by_id.values()] return pat_list + def subpatterns_by_id(self, + include_none: bool = False, + recursive: bool = True, + ) -> Dict[int, List[SubPattern]]: + """ + Create a dictionary which maps `{id(referenced_pattern): [subpattern0, ...]}` + for all SubPattern objects referenced by this Pattern (by default, operates + recursively on all referenced Patterns as well). + + Args: + include_none: If `True`, references to `None` will be included. Default `False`. + recursive: If `True`, operates recursively on all referenced patterns. Default `True`. + + Returns: + Dictionary mapping each pattern id to a list of subpattern objects referencing the pattern. + """ + ids: Dict[int, List[SubPattern]] = defaultdict(list) + for subpat in self.subpatterns: + pat = subpat.pattern + if include_none or pat is not None: + ids[id(pat)].append(subpat) + if recursive and pat is not None: + ids.update(pat.subpatterns_by_id(include_none=include_none)) + return dict(ids) + def get_bounds(self) -> Union[numpy.ndarray, None]: """ Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the @@ -492,13 +525,12 @@ class Pattern: Returns: `[[x_min, y_min], [x_max, y_max]]` or `None` """ - entries = self.shapes + self.subpatterns + self.labels - if not entries: + if self.is_empty(): return None min_bounds = numpy.array((+inf, +inf)) max_bounds = numpy.array((-inf, -inf)) - for entry in entries: + for entry in chain(self.shapes, self.subpatterns, self.labels): bounds = entry.get_bounds() if bounds is None: continue @@ -509,7 +541,7 @@ class Pattern: else: return numpy.vstack((min_bounds, max_bounds)) - def flatten(self) -> 'Pattern': + def flatten(self: P) -> P: """ Removes all subpatterns and adds equivalent shapes. @@ -545,7 +577,50 @@ class Pattern: self.append(p) return self - def translate_elements(self, offset: vector2) -> 'Pattern': + def wrap_repeated_shapes(self: P, + name_func: Callable[['Pattern', Union[Shape, Label]], str] = lambda p, s: '_repetition', + recursive: bool = True, + ) -> P: + """ + Wraps all shapes and labels with a non-`None` `repetition` attribute + into a `SubPattern`/`Pattern` combination, and applies the `repetition` + to each `SubPattern` instead of its contained shape. + + Args: + name_func: Function f(this_pattern, shape) which generates a name for the + wrapping pattern. Default always returns '_repetition'. + recursive: If `True`, this function is also applied to all referenced patterns + recursively. Default `True`. + + Returns: + self + """ + def do_wrap(pat: Optional[Pattern]) -> Optional[Pattern]: + if pat is None: + return pat + + for shape in pat.shapes: + if shape.repetition is None: + continue + pat.addsp(Pattern(name_func(pat, shape), shapes=[shape])) + shape.repetition = None + + for label in self.labels: + if label.repetition is None: + continue + pat.addsp(Pattern(name_func(pat, shape), labels=[label])) + label.repetition = None + + return pat + + if recursive: + self.apply(do_wrap) + else: + do_wrap(self) + + return self + + def translate_elements(self: P, offset: vector2) -> P: """ Translates all shapes, label, and subpatterns by the given offset. @@ -555,11 +630,11 @@ class Pattern: Returns: self """ - for entry in self.shapes + self.subpatterns + self.labels: + for entry in chain(self.shapes, self.subpatterns, self.labels): entry.translate(offset) return self - def scale_elements(self, c: float) -> 'Pattern': + def scale_elements(self: P, c: float) -> P: """" Scales all shapes and subpatterns by the given value. @@ -569,11 +644,11 @@ class Pattern: Returns: self """ - for entry in self.shapes + self.subpatterns: + for entry in chain(self.shapes, self.subpatterns): entry.scale_by(c) return self - def scale_by(self, c: float) -> 'Pattern': + def scale_by(self: P, c: float) -> P: """ Scale this Pattern by the given value (all shapes and subpatterns and their offsets are scaled) @@ -584,14 +659,15 @@ class Pattern: Returns: self """ - for entry in self.shapes + self.subpatterns: + entry: Scalable + for entry in chain(self.shapes, self.subpatterns): entry.offset *= c entry.scale_by(c) for label in self.labels: label.offset *= c return self - def rotate_around(self, pivot: vector2, rotation: float) -> 'Pattern': + def rotate_around(self: P, pivot: vector2, rotation: float) -> P: """ Rotate the Pattern around the a location. @@ -609,7 +685,7 @@ class Pattern: self.translate_elements(+pivot) return self - def rotate_element_centers(self, rotation: float) -> 'Pattern': + def rotate_element_centers(self: P, rotation: float) -> P: """ Rotate the offsets of all shapes, labels, and subpatterns around (0, 0) @@ -619,11 +695,11 @@ class Pattern: Returns: self """ - for entry in self.shapes + self.subpatterns + self.labels: + for entry in chain(self.shapes, self.subpatterns, self.labels): entry.offset = numpy.dot(rotation_matrix_2d(rotation), entry.offset) return self - def rotate_elements(self, rotation: float) -> 'Pattern': + def rotate_elements(self: P, rotation: float) -> P: """ Rotate each shape and subpattern around its center (offset) @@ -633,11 +709,11 @@ class Pattern: Returns: self """ - for entry in self.shapes + self.subpatterns: + for entry in chain(self.shapes, self.subpatterns): entry.rotate(rotation) return self - def mirror_element_centers(self, axis: int) -> 'Pattern': + def mirror_element_centers(self: P, axis: int) -> P: """ Mirror the offsets of all shapes, labels, and subpatterns across an axis @@ -648,11 +724,11 @@ class Pattern: Returns: self """ - for entry in self.shapes + self.subpatterns + self.labels: + for entry in chain(self.shapes, self.subpatterns, self.labels): entry.offset[axis - 1] *= -1 return self - def mirror_elements(self, axis: int) -> 'Pattern': + def mirror_elements(self: P, axis: int) -> P: """ Mirror each shape and subpattern across an axis, relative to its offset @@ -664,11 +740,11 @@ class Pattern: Returns: self """ - for entry in self.shapes + self.subpatterns: + for entry in chain(self.shapes, self.subpatterns): entry.mirror(axis) return self - def mirror(self, axis: int) -> 'Pattern': + def mirror(self: P, axis: int) -> P: """ Mirror the Pattern across an axis @@ -683,7 +759,7 @@ class Pattern: self.mirror_element_centers(axis) return self - def scale_element_doses(self, c: float) -> 'Pattern': + def scale_element_doses(self: P, c: float) -> P: """ Multiply all shape and subpattern doses by a factor @@ -693,11 +769,11 @@ class Pattern: Return: self """ - for entry in self.shapes + self.subpatterns: + for entry in chain(self.shapes, self.subpatterns): entry.dose *= c return self - def copy(self) -> 'Pattern': + def copy(self: P) -> P: """ Return a copy of the Pattern, deep-copying shapes and copying subpattern entries, but not deep-copying any referenced patterns. @@ -709,7 +785,7 @@ class Pattern: """ return copy.copy(self) - def deepcopy(self) -> 'Pattern': + def deepcopy(self: P) -> P: """ Convenience method for `copy.deepcopy(pattern)` @@ -723,11 +799,26 @@ class Pattern: Returns: True if the pattern is contains no shapes, labels, or subpatterns. """ - return (len(self.subpatterns) == 0 and - len(self.shapes) == 0 and - len(self.labels) == 0) + return (len(self.subpatterns) == 0 + and len(self.shapes) == 0 + and len(self.labels) == 0) - def lock(self) -> 'Pattern': + def addsp(self, *args: Dict[str, Any], **kwargs: Dict[str, Any]): + """ + Convenience function which constructs a subpattern object and adds it + to this pattern. + + Args: + *args: Passed to SubPattern() + **kwargs: Passed to SubPattern() + + Returns: + self + """ + self.subpatterns.append(SubPattern(*args, **kwargs)) + return self + + def lock(self: P) -> P: """ Lock the pattern, raising an exception if it is modified. Also see `deeplock()`. @@ -735,26 +826,28 @@ class Pattern: Returns: self """ - self.shapes = tuple(self.shapes) - self.labels = tuple(self.labels) - self.subpatterns = tuple(self.subpatterns) - object.__setattr__(self, 'locked', True) + if not self.locked: + self.shapes = tuple(self.shapes) + self.labels = tuple(self.labels) + self.subpatterns = tuple(self.subpatterns) + LockableImpl.lock(self) return self - def unlock(self) -> 'Pattern': + def unlock(self: P) -> P: """ Unlock the pattern Returns: self """ - object.__setattr__(self, 'locked', False) - self.shapes = list(self.shapes) - self.labels = list(self.labels) - self.subpatterns = list(self.subpatterns) + if self.locked: + LockableImpl.unlock(self) + self.shapes = list(self.shapes) + self.labels = list(self.labels) + self.subpatterns = list(self.subpatterns) return self - def deeplock(self) -> 'Pattern': + def deeplock(self: P) -> P: """ Recursively lock the pattern, all referenced shapes, subpatterns, and labels. @@ -762,13 +855,13 @@ class Pattern: self """ self.lock() - for ss in self.shapes + self.labels: + for ss in chain(self.shapes, self.labels): ss.lock() for sp in self.subpatterns: sp.deeplock() return self - def deepunlock(self) -> 'Pattern': + def deepunlock(self: P) -> P: """ Recursively unlock the pattern, all referenced shapes, subpatterns, and labels. @@ -779,7 +872,7 @@ class Pattern: self """ self.unlock() - for ss in self.shapes + self.labels: + for ss in chain(self.shapes, self.labels): ss.unlock() for sp in self.subpatterns: sp.deepunlock() @@ -819,7 +912,8 @@ class Pattern: offset: vector2 = (0., 0.), line_color: str = 'k', fill_color: str = 'none', - overdraw: bool = False): + overdraw: bool = False, + ) -> None: """ Draw a picture of the Pattern and wait for the user to inspect it @@ -835,8 +929,8 @@ class Pattern: overdraw: Whether to create a new figure or draw on a pre-existing one """ # TODO: add text labels to visualize() - from matplotlib import pyplot - import matplotlib.collections + from matplotlib import pyplot # type: ignore + import matplotlib.collections # type: ignore offset = numpy.array(offset, dtype=float) @@ -878,12 +972,9 @@ class Pattern: A filtered list in which no pattern is referenced by any other pattern. """ def get_children(pat: Pattern, memo: Set) -> Set: - if pat in memo: - return memo - children = set(sp.pattern for sp in pat.subpatterns if sp.pattern is not None) new_children = children - memo - memo |= children + memo |= new_children for child_pat in new_children: memo |= get_children(child_pat, memo) diff --git a/masque/positionable.py b/masque/positionable.py deleted file mode 100644 index 893ec25..0000000 --- a/masque/positionable.py +++ /dev/null @@ -1,81 +0,0 @@ -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, layer_t - - -T = TypeVar('T', bound='Positionable') - - -class Positionable(metaclass=ABCMeta): - """ - Abstract class for all positionable entities - """ - __slots__ = ('_offset',) - - _offset: numpy.ndarray - """ `[x_offset, y_offset]` """ - - # --- 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 - - # ---- 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() - - - # ---- Non-abstract methods - def translate(self: T, offset: vector2) -> T: - """ - Translate the entity by the given offset - - Args: - offset: [x_offset, y,offset] - - Returns: - self - """ - self.offset += offset - return self - - def lock(self: T) -> T: - """ - Lock the entity, disallowing further changes - - Returns: - self - """ - self.offset.flags.writeable = False - return self - - def unlock(self: T) -> T: - """ - Unlock the entity - - Returns: - self - """ - self.offset.flags.writeable = True - return self diff --git a/masque/py.typed b/masque/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/masque/repetition.py b/masque/repetition.py index 247f1eb..396d351 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 +from typing import Union, Dict, Optional, Sequence, Any import copy +from abc import ABCMeta, abstractmethod -import numpy -from numpy import pi +import numpy # type: ignore -from .error import PatternError, PatternLockedError -from .utils import is_scalar, rotation_matrix_2d, vector2 - -if TYPE_CHECKING: - from . import Pattern +from .error import PatternError +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,71 +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.flatten()[:, None] * self.a_vector[None, :] + + bb.flatten()[:, None] * self.b_vector[None, :]) # noqa + + 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 = [] - - for a in range(self.a_count): - for b in range(self.b_count): - offset = a * self.a_vector + b * self.b_vector - newPat = self.pattern.deepcopy().deepunlock() - newPat.translate_elements(offset) - patterns.append(newPat) - - combined = patterns[0] - for p in patterns[1:]: - combined.append(p) - - combined.scale_by(self.scale) - [combined.mirror(ax) for ax, do in enumerate(self.mirrored) if do] - combined.rotate_around((0.0, 0.0), self.rotation) - combined.translate_elements(self.offset) - combined.scale_element_doses(self.dose) - - 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) @@ -392,15 +183,179 @@ 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 + Mirror the Grid across an axis. + + Args: + axis: Axis to mirror across. + (0: mirror across x-axis, 1: mirror across y-axis) + + Returns: + self + """ + self.a_vector[1 - axis] *= -1 + if self.b_vector is not None: + self.b_vector[1 - axis] *= -1 + return self + + def get_bounds(self) -> Optional[numpy.ndarray]: + """ + Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the + extent of the `Grid` in each dimension. + + Returns: + `[[x_min, y_min], [x_max, y_max]]` or `None` + """ + a_extent = self.a_vector * self.a_count + b_extent = self.b_vector * self.b_count if self.b_count != 0 else 0 + + corners = ((0, 0), a_extent, b_extent, a_extent + b_extent) + xy_min = numpy.min(corners, axis=0) + xy_max = numpy.max(corners, axis=0) + return numpy.array((xy_min, xy_max)) + + def scale_by(self, c: float) -> 'Grid': + """ + Scale the Grid by a factor + + Args: + c: scaling factor + + Returns: + self + """ + self.a_vector *= c + if self.b_vector is not None: + self.b_vector *= c + return self + + def lock(self) -> 'Grid': + """ + Lock the `Grid`, disallowing changes. + + Returns: + self + """ + self.a_vector.flags.writeable = False + if self.b_vector is not None: + self.b_vector.flags.writeable = False + LockableImpl.lock(self) + return self + + def unlock(self) -> 'Grid': + """ + Unlock the `Grid` + + Returns: + self + """ + self.a_vector.flags.writeable = True + if self.b_vector is not None: + self.b_vector.flags.writeable = True + LockableImpl.unlock(self) + return self + + def __repr__(self) -> str: + locked = ' L' if self.locked else '' + bv = f', {self.b_vector}' if self.b_vector is not None else '' + 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. + """ + + @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 __init__(self, + displacements: numpy.ndarray, + locked: bool = False,): + """ + Args: + displacements: List of vectors (Nx2 ndarray) specifying displacements. + locked: Whether the object is locked after initialization. + """ + object.__setattr__(self, 'locked', False) + self.displacements = displacements + self.locked = locked + + 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) + + def rotate(self, rotation: float) -> 'Arbitrary': + """ + Rotate dispacements (around (0, 0)) Args: rotation: Angle to rotate by (counterclockwise, radians) @@ -408,12 +363,12 @@ class GridRepetition: Returns: self """ - self.rotation += rotation + self.displacements = numpy.dot(rotation_matrix_2d(rotation), self.displacements.T).T return self - def mirror(self, axis: int) -> 'GridRepetition': + def mirror(self, axis: int) -> 'Arbitrary': """ - Mirror the GridRepetition across an axis. + Mirror the displacements across an axis. Args: axis: Axis to mirror across. @@ -422,43 +377,24 @@ 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 + self.displacements[1 - axis] *= -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 `displacements` 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() + xy_min = numpy.min(self.displacements, axis=0) + xy_max = numpy.max(self.displacements, axis=0) + return numpy.array((xy_min, xy_max)) - def scale_by(self, c: float) -> 'GridRepetition': + def scale_by(self, c: float) -> 'Arbitrary': """ - Scale the GridRepetition by a factor + Scale the displacements by a factor Args: c: scaling factor @@ -466,107 +402,6 @@ class GridRepetition: Returns: self """ - self.scale_elements_by(c) - self.a_vector *= c - if self.b_vector is not None: - self.b_vector *= c + self.displacements *= c return self - def scale_elements_by(self, c: float) -> 'GridRepetition': - """ - Scale each element by a factor - - Args: - c: scaling factor - - 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) - return self - - def unlock(self) -> 'GridRepetition': - """ - Unlock the `GridRepetition` - - 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() - 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'') diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 7b1cedc..0416d01 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -1,15 +1,18 @@ -from typing import List, Tuple, Dict, Optional, Sequence +from typing import List, Dict, Optional, Sequence import copy import math -import numpy + +import numpy # type: ignore 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 ..repetition import Repetition +from ..utils import is_scalar, vector2, layer_t, AutoSlots, annotations_t +from ..traits import LockableImpl -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 +23,7 @@ class Arc(Shape): """ __slots__ = ('_radii', '_angles', '_width', '_rotation', 'poly_num_points', 'poly_max_arclen') + _radii: numpy.ndarray """ Two radii for defining an ellipse """ @@ -77,7 +81,7 @@ class Arc(Shape): # arc start/stop angle properties @property - def angles(self) -> numpy.ndarray: #ndarray[float] + def angles(self) -> numpy.ndarray: """ Return the start and stop angles `[a_start, a_stop]`. Angles are measured from x-axis after rotation @@ -150,6 +154,7 @@ class Arc(Shape): radii: vector2, angles: vector2, width: float, + *, poly_num_points: Optional[int] = DEFAULT_POLY_NUM_POINTS, poly_max_arclen: Optional[float] = None, offset: vector2 = (0.0, 0.0), @@ -157,28 +162,46 @@ class Arc(Shape): mirrored: Sequence[bool] = (False, False), layer: layer_t = 0, dose: float = 1.0, - locked: bool = False): - object.__setattr__(self, 'locked', False) + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, + locked: bool = False, + raw: bool = False, + ): + LockableImpl.unlock(self) self.identifier = () - self.radii = radii - self.angles = angles - self.width = width - self.offset = offset - self.rotation = rotation - [self.mirror(a) for a, do in enumerate(mirrored) if do] - self.layer = layer - self.dose = dose + if raw: + self._radii = radii + self._angles = angles + self._width = width + self._offset = offset + self._rotation = rotation + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + self._layer = layer + self._dose = dose + else: + self.radii = radii + self.angles = angles + self.width = width + self.offset = offset + self.rotation = rotation + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} + self.layer = layer + self.dose = dose self.poly_num_points = poly_num_points self.poly_max_arclen = poly_max_arclen - self.locked = locked + [self.mirror(a) for a, do in enumerate(mirrored) if do] + self.set_locked(locked) - def __deepcopy__(self, memo: Dict = None) -> 'Arc': + def __deepcopy__(self, memo: Dict = None) -> 'Arc': memo = {} if memo is None else memo new = copy.copy(self).unlock() new._offset = self._offset.copy() new._radii = self._radii.copy() new._angles = self._angles.copy() - new.locked = self.locked + new._annotations = copy.deepcopy(self._annotations) + new.set_locked(self.locked) return new def to_polygons(self, @@ -191,8 +214,8 @@ class Arc(Shape): poly_max_arclen = self.poly_max_arclen if (poly_num_points is None) and (poly_max_arclen is None): - raise PatternError('Max number of points and arclength left unspecified' + - ' (default was also overridden)') + raise PatternError('Max number of points and arclength left unspecified' + + ' (default was also overridden)') r0, r1 = self.radii @@ -212,8 +235,9 @@ class Arc(Shape): n += [poly_num_points] if poly_max_arclen is not None: n += [perimeter / poly_max_arclen] - thetas_inner = numpy.linspace(a_ranges[0][1], a_ranges[0][0], max(n), endpoint=True) - thetas_outer = numpy.linspace(a_ranges[1][0], a_ranges[1][1], max(n), endpoint=True) + num_points = int(round(max(n))) + thetas_inner = numpy.linspace(a_ranges[0][1], a_ranges[0][0], num_points, endpoint=True) + thetas_outer = numpy.linspace(a_ranges[1][0], a_ranges[1][1], num_points, endpoint=True) sin_th_i, cos_th_i = (numpy.sin(thetas_inner), numpy.cos(thetas_inner)) sin_th_o, cos_th_o = (numpy.sin(thetas_outer), numpy.cos(thetas_outer)) @@ -249,7 +273,7 @@ class Arc(Shape): mins = [] maxs = [] for a, sgn in zip(a_ranges, (-1, +1)): - wh = sgn * self.width/2 + wh = sgn * self.width / 2 rx = self.radius_x + wh ry = self.radius_y + wh @@ -263,7 +287,7 @@ class Arc(Shape): # Cutoff angles xpt = (-self.rotation) % (2 * pi) + a0_offset - ypt = (pi/2 - self.rotation) % (2 * pi) + a0_offset + ypt = (pi / 2 - self.rotation) % (2 * pi) + a0_offset xnt = (xpt - pi) % (2 * pi) + a0_offset ynt = (ypt - pi) % (2 * pi) + a0_offset @@ -332,9 +356,9 @@ class Arc(Shape): rotation %= 2 * pi width = self.width - return (type(self), radii, angles, width/norm_value, self.layer), \ - (self.offset, scale/norm_value, rotation, False, self.dose), \ - lambda: Arc(radii=radii*norm_value, angles=angles, width=width*norm_value, layer=self.layer) + return ((type(self), radii, angles, width / norm_value, self.layer), + (self.offset, scale / norm_value, rotation, False, self.dose), + lambda: Arc(radii=radii * norm_value, angles=angles, width=width * norm_value, layer=self.layer)) def get_cap_edges(self) -> numpy.ndarray: ''' @@ -349,7 +373,7 @@ class Arc(Shape): mins = [] maxs = [] for a, sgn in zip(a_ranges, (-1, +1)): - wh = sgn * self.width/2 + wh = sgn * self.width / 2 rx = self.radius_x + wh ry = self.radius_y + wh @@ -364,7 +388,7 @@ class Arc(Shape): mins.append([xn, yn]) maxs.append([xp, yp]) - return numpy.array([mins, maxs]) + self.offset + return numpy.array([mins, maxs]) + self.offset def _angles_to_parameters(self) -> numpy.ndarray: ''' @@ -374,12 +398,12 @@ class Arc(Shape): ''' a = [] for sgn in (-1, +1): - wh = sgn * self.width/2 + wh = sgn * self.width / 2 rx = self.radius_x + wh ry = self.radius_y + wh # create paremeter 'a' for parametrized ellipse - a0, a1 = (numpy.arctan2(rx*numpy.sin(a), ry*numpy.cos(a)) for a in self.angles) + a0, a1 = (numpy.arctan2(rx * numpy.sin(a), ry * numpy.cos(a)) for a in self.angles) sign = numpy.sign(self.angles[1] - self.angles[0]) if sign != numpy.sign(a1 - a0): a1 += sign * 2 * pi @@ -400,8 +424,8 @@ class Arc(Shape): return self def __repr__(self) -> str: - angles = f' a°{self.angles*180/pi}' - rotation = f' r°{self.rotation*180/pi:g}' if self.rotation != 0 else '' + angles = f' a°{numpy.rad2deg(self.angles)}' + rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' dose = f' d{self.dose:g}' if self.dose != 1 else '' locked = ' L' if self.locked else '' return f'' diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index f7221d5..d7aaba4 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -1,18 +1,22 @@ from typing import List, Dict, Optional import copy -import numpy + +import numpy # type: ignore 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 ..repetition import Repetition +from ..utils import is_scalar, vector2, layer_t, AutoSlots, annotations_t +from ..traits import LockableImpl -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 """ @@ -40,27 +44,43 @@ class Circle(Shape): def __init__(self, radius: float, + *, poly_num_points: Optional[int] = DEFAULT_POLY_NUM_POINTS, poly_max_arclen: Optional[float] = None, offset: vector2 = (0.0, 0.0), layer: layer_t = 0, dose: float = 1.0, - locked: bool = False): - object.__setattr__(self, 'locked', False) + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, + locked: bool = False, + raw: bool = False, + ): + LockableImpl.unlock(self) self.identifier = () - self.offset = numpy.array(offset, dtype=float) - self.layer = layer - self.dose = dose - self.radius = radius + if raw: + self._radius = radius + self._offset = offset + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + self._layer = layer + self._dose = dose + else: + self.radius = radius + self.offset = offset + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} + self.layer = layer + self.dose = dose self.poly_num_points = poly_num_points self.poly_max_arclen = poly_max_arclen - self.locked = locked + self.set_locked(locked) - def __deepcopy__(self, memo: Dict = None) -> 'Circle': + def __deepcopy__(self, memo: Dict = None) -> 'Circle': memo = {} if memo is None else memo new = copy.copy(self).unlock() new._offset = self._offset.copy() - new.locked = self.locked + new._annotations = copy.deepcopy(self._annotations) + new.set_locked(self.locked) return new def to_polygons(self, @@ -81,7 +101,8 @@ class Circle(Shape): n += [poly_num_points] if poly_max_arclen is not None: n += [2 * pi * self.radius / poly_max_arclen] - thetas = numpy.linspace(2 * pi, 0, max(n), endpoint=False) + num_points = int(round(max(n))) + thetas = numpy.linspace(2 * pi, 0, num_points, endpoint=False) xs = numpy.cos(thetas) * self.radius ys = numpy.sin(thetas) * self.radius xys = numpy.vstack((xs, ys)).T @@ -106,9 +127,9 @@ class Circle(Shape): def normalized_form(self, norm_value) -> normalized_shape_tuple: rotation = 0.0 magnitude = self.radius / norm_value - return (type(self), self.layer), \ - (self.offset, magnitude, rotation, False, self.dose), \ - lambda: Circle(radius=norm_value, layer=self.layer) + return ((type(self), self.layer), + (self.offset, magnitude, rotation, False, self.dose), + lambda: Circle(radius=norm_value, layer=self.layer)) def __repr__(self) -> str: dose = f' d{self.dose:g}' if self.dose != 1 else '' diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 9779e69..140f590 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -1,21 +1,25 @@ -from typing import List, Tuple, Dict, Sequence, Optional +from typing import List, Dict, Sequence, Optional import copy import math -import numpy + +import numpy # type: ignore 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 ..repetition import Repetition +from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots, annotations_t +from ..traits import LockableImpl -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 """ @@ -85,6 +89,7 @@ class Ellipse(Shape): def __init__(self, radii: vector2, + *, poly_num_points: Optional[int] = DEFAULT_POLY_NUM_POINTS, poly_max_arclen: Optional[float] = None, offset: vector2 = (0.0, 0.0), @@ -92,25 +97,41 @@ class Ellipse(Shape): mirrored: Sequence[bool] = (False, False), layer: layer_t = 0, dose: float = 1.0, - locked: bool = False): - object.__setattr__(self, 'locked', False) + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, + locked: bool = False, + raw: bool = False, + ): + LockableImpl.unlock(self) self.identifier = () - self.radii = radii - self.offset = offset - self.rotation = rotation + if raw: + self._radii = radii + self._offset = offset + self._rotation = rotation + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + self._layer = layer + self._dose = dose + else: + self.radii = radii + self.offset = offset + self.rotation = rotation + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} + self.layer = layer + self.dose = dose [self.mirror(a) for a, do in enumerate(mirrored) if do] - self.layer = layer - self.dose = dose self.poly_num_points = poly_num_points self.poly_max_arclen = poly_max_arclen - self.locked = locked + self.set_locked(locked) - def __deepcopy__(self, memo: Dict = None) -> 'Ellipse': + def __deepcopy__(self, memo: Dict = None) -> 'Ellipse': memo = {} if memo is None else memo new = copy.copy(self).unlock() new._offset = self._offset.copy() new._radii = self._radii.copy() - new.locked = self.locked + new._annotations = copy.deepcopy(self._annotations) + new.set_locked(self.locked) return new def to_polygons(self, @@ -139,7 +160,8 @@ class Ellipse(Shape): n += [poly_num_points] if poly_max_arclen is not None: n += [perimeter / poly_max_arclen] - thetas = numpy.linspace(2 * pi, 0, max(n), endpoint=False) + num_points = int(round(max(n))) + thetas = numpy.linspace(2 * pi, 0, num_points, endpoint=False) sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas)) xs = r0 * cos_th @@ -176,9 +198,9 @@ class Ellipse(Shape): radii = self.radii[::-1] / self.radius_y scale = self.radius_y angle = (self.rotation + pi / 2) % pi - return (type(self), radii, self.layer), \ - (self.offset, scale/norm_value, angle, False, self.dose), \ - lambda: Ellipse(radii=radii*norm_value, layer=self.layer) + return ((type(self), radii, self.layer), + (self.offset, scale / norm_value, angle, False, self.dose), + lambda: Ellipse(radii=radii * norm_value, layer=self.layer)) def lock(self) -> 'Ellipse': self.radii.flags.writeable = False diff --git a/masque/shapes/path.py b/masque/shapes/path.py index db7831a..1cad032 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -1,13 +1,16 @@ from typing import List, Tuple, Dict, Optional, Sequence import copy from enum import Enum -import numpy + +import numpy # type: ignore 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 remove_colinear_vertices, remove_duplicate_vertices +from ..repetition import Repetition +from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots +from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t +from ..traits import LockableImpl class PathCap(Enum): @@ -15,10 +18,10 @@ class PathCap(Enum): Circle = 1 # Path extends past final vertices with a semicircle of radius width/2 Square = 2 # Path extends past final vertices with a width-by-width/2 rectangle SquareCustom = 4 # Path extends past final vertices with a rectangle of length - # defined by path.cap_extensions +# # 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. @@ -100,7 +103,7 @@ class Path(Shape): @vertices.setter def vertices(self, val: numpy.ndarray): - val = numpy.array(val, dtype=float) #TODO document that these might not be copied + val = numpy.array(val, dtype=float) # TODO document that these might not be copied if len(val.shape) < 2 or val.shape[1] != 2: raise PatternError('Vertices must be an Nx2 array') if val.shape[0] < 2: @@ -140,6 +143,7 @@ class Path(Shape): def __init__(self, vertices: numpy.ndarray, width: float = 0.0, + *, cap: PathCap = PathCap.Flush, cap_extensions: numpy.ndarray = None, offset: vector2 = (0.0, 0.0), @@ -147,39 +151,55 @@ class Path(Shape): mirrored: Sequence[bool] = (False, False), layer: layer_t = 0, dose: float = 1.0, + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, locked: bool = False, + raw: bool = False, ): - object.__setattr__(self, 'locked', False) + LockableImpl.unlock(self) self._cap_extensions = None # Since .cap setter might access it self.identifier = () - self.offset = offset - self.layer = layer - self.dose = dose - self.vertices = vertices - self.width = width - self.cap = cap - if cap_extensions is not None: + if raw: + self._vertices = vertices + self._offset = offset + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + self._layer = layer + self._dose = dose + self._width = width + self._cap = cap + self._cap_extensions = cap_extensions + else: + self.vertices = vertices + self.offset = offset + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} + self.layer = layer + self.dose = dose + self.width = width + self.cap = cap self.cap_extensions = cap_extensions self.rotate(rotation) [self.mirror(a) for a, do in enumerate(mirrored) if do] - self.locked = locked + self.set_locked(locked) - def __deepcopy__(self, memo: Dict = None) -> 'Path': + def __deepcopy__(self, memo: Dict = None) -> 'Path': memo = {} if memo is None else memo new = copy.copy(self).unlock() new._offset = self._offset.copy() new._vertices = self._vertices.copy() new._cap = copy.deepcopy(self._cap, memo) new._cap_extensions = copy.deepcopy(self._cap_extensions, memo) - new.locked = self.locked + new._annotations = copy.deepcopy(self._annotations) + new.set_locked(self.locked) return new @staticmethod def travel(travel_pairs: Tuple[Tuple[float, float]], width: float = 0.0, cap: PathCap = PathCap.Flush, - cap_extensions = None, + cap_extensions: Optional[Tuple[float, float]] = None, offset: vector2 = (0.0, 0.0), rotation: float = 0, mirrored: Sequence[bool] = (False, False), @@ -255,9 +275,9 @@ class Path(Shape): intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1] - towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp? -# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight - acute = (dv[1:] * dv[:-1]).sum(axis=1) < 0 # angle is acute? + towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp? +# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight + acute = (dv[1:] * dv[:-1]).sum(axis=1) < 0 # angle is acute? # Build vertices o0 = [v[0] + perp[0]] @@ -309,12 +329,13 @@ class Path(Shape): bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :]) bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :]) else: - raise PatternError('get_bounds() not implemented for endcaps: {}'.format(self.cap)) + raise PatternError(f'get_bounds() not implemented for endcaps: {self.cap}') return bounds def rotate(self, theta: float) -> 'Path': - self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T + if theta != 0: + self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T return self def mirror(self, axis: int) -> 'Path': @@ -349,10 +370,10 @@ class Path(Shape): width0 = self.width / norm_value - return (type(self), reordered_vertices.data.tobytes(), width0, self.cap, self.layer), \ - (offset, scale/norm_value, rotation, False, self.dose), \ - lambda: Path(reordered_vertices*norm_value, width=self.width*norm_value, - cap=self.cap, layer=self.layer) + return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, self.layer), + (offset, scale / norm_value, rotation, False, self.dose), + lambda: Path(reordered_vertices * norm_value, width=self.width * norm_value, + cap=self.cap, layer=self.layer)) def clean_vertices(self) -> 'Path': """ @@ -388,7 +409,7 @@ class Path(Shape): if self.cap == PathCap.Square: extensions = numpy.full(2, self.width / 2) elif self.cap == PathCap.SquareCustom: - extensions = self.cap_extensions + extensions = self.cap_extensions else: # Flush or Circle extensions = numpy.zeros(2) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 71c9491..59ab82d 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,15 +1,18 @@ -from typing import List, Tuple, Dict, Optional, Sequence +from typing import List, Dict, Optional, Sequence import copy -import numpy + +import numpy # type: ignore 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 remove_colinear_vertices, remove_duplicate_vertices +from ..repetition import Repetition +from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots +from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t +from ..traits import LockableImpl -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 +20,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], ...]` """ @@ -30,7 +34,7 @@ class Polygon(Shape): @vertices.setter def vertices(self, val: numpy.ndarray): - val = numpy.array(val, dtype=float) #TODO document that these might not be copied + val = numpy.array(val, dtype=float) # TODO document that these might not be copied if len(val.shape) < 2 or val.shape[1] != 2: raise PatternError('Vertices must be an Nx2 array') if val.shape[0] < 3: @@ -69,29 +73,44 @@ class Polygon(Shape): def __init__(self, vertices: numpy.ndarray, + *, offset: vector2 = (0.0, 0.0), rotation: float = 0.0, mirrored: Sequence[bool] = (False, False), layer: layer_t = 0, dose: float = 1.0, + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, locked: bool = False, + raw: bool = False, ): - object.__setattr__(self, 'locked', False) + LockableImpl.unlock(self) self.identifier = () - self.layer = layer - self.dose = dose - self.vertices = vertices - self.offset = offset + if raw: + self._vertices = vertices + self._offset = offset + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + self._layer = layer + self._dose = dose + else: + self.vertices = vertices + self.offset = offset + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} + self.layer = layer + self.dose = dose self.rotate(rotation) [self.mirror(a) for a, do in enumerate(mirrored) if do] - self.locked = locked + self.set_locked(locked) - def __deepcopy__(self, memo: Optional[Dict] = None) -> 'Polygon': + def __deepcopy__(self, memo: Optional[Dict] = None) -> 'Polygon': memo = {} if memo is None else memo new = copy.copy(self).unlock() new._offset = self._offset.copy() new._vertices = self._vertices.copy() - new.locked = self.locked + new._annotations = copy.deepcopy(self._annotations) + new.set_locked(self.locked) return new @staticmethod @@ -250,7 +269,6 @@ class Polygon(Shape): layer=layer, dose=dose) return poly - def to_polygons(self, poly_num_points: int = None, # unused poly_max_arclen: float = None, # unused @@ -262,7 +280,8 @@ class Polygon(Shape): self.offset + numpy.max(self.vertices, axis=0))) def rotate(self, theta: float) -> 'Polygon': - self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T + if theta != 0: + self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T return self def mirror(self, axis: int) -> 'Polygon': @@ -296,9 +315,9 @@ class Polygon(Shape): # TODO: normalize mirroring? - return (type(self), reordered_vertices.data.tobytes(), self.layer), \ - (offset, scale/norm_value, rotation, False, self.dose), \ - lambda: Polygon(reordered_vertices*norm_value, layer=self.layer) + return ((type(self), reordered_vertices.data.tobytes(), self.layer), + (offset, scale / norm_value, rotation, False, self.dose), + lambda: Polygon(reordered_vertices * norm_value, layer=self.layer)) def clean_vertices(self) -> 'Polygon': """ diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index cfc4a54..0603ec7 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -1,10 +1,12 @@ from typing import List, Tuple, Callable, TypeVar, Optional, TYPE_CHECKING from abc import ABCMeta, abstractmethod -import copy -import numpy -from ..error import PatternError, PatternLockedError -from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t +import numpy # type: ignore + +from ..traits import (PositionableImpl, LayerableImpl, DoseableImpl, + Rotatable, Mirrorable, Copyable, Scalable, + PivotableImpl, LockableImpl, RepeatableImpl, + AnnotatableImpl) if TYPE_CHECKING: from . import Polygon @@ -23,36 +25,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, RepeatableImpl, LockableImpl, AnnotatableImpl, 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 """ + __slots__ = () # Children should use AutoSlots 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__: + for name in self.__slots__: # type: str object.__setattr__(new, name, getattr(self, name)) return new @@ -79,53 +65,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 +89,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, @@ -288,7 +139,6 @@ class Shape(metaclass=ABCMeta): if err_xmax >= 0.5: gxi_max += 1 - if abs(dv[0]) < 1e-20: # Vertical line, don't calculate slope xi = [gxi_min, gxi_max - 1] @@ -301,8 +151,9 @@ class Shape(metaclass=ABCMeta): vertex_lists.append(segment) continue - m = dv[1]/dv[0] - def get_grid_inds(xes): + m = dv[1] / dv[0] + + def get_grid_inds(xes: numpy.ndarray) -> numpy.ndarray: ys = m * (xes - v[0]) + v[1] # (inds - 1) is the index of the y-grid line below the edge's intersection with the x-grid @@ -324,7 +175,7 @@ class Shape(metaclass=ABCMeta): xs2 = (xs[:-1] + xs[1:]) / 2 inds2 = get_grid_inds(xs2) - xinds = numpy.round(numpy.arange(gxi_min, gxi_max - 0.99, 1/3)).astype(int) + xinds = numpy.round(numpy.arange(gxi_min, gxi_max - 0.99, 1 / 3)).astype(int) # interleave the results yinds = xinds.copy() @@ -348,7 +199,6 @@ class Shape(metaclass=ABCMeta): return manhattan_polygons - def manhattanize(self, grid_x: numpy.ndarray, grid_y: numpy.ndarray @@ -392,7 +242,7 @@ class Shape(metaclass=ABCMeta): List of `Polygon` objects with grid-aligned edges. """ from . import Polygon - import skimage.measure + import skimage.measure # type: ignore import float_raster grid_x = numpy.unique(grid_x) @@ -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..07cc1a7 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -1,27 +1,32 @@ -from typing import List, Tuple, Dict, Sequence, Optional, MutableSequence +from typing import List, Tuple, Dict, Sequence, Optional import copy -import numpy + +import numpy # type: ignore 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 ..repetition import Repetition +from ..traits import RotatableImpl +from ..utils import is_scalar, vector2, get_bit, normalize_mirror, layer_t, AutoSlots +from ..utils import annotations_t +from ..traits import LockableImpl # 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] + _mirrored: numpy.ndarray # ndarray[bool] font_path: str # vertices property @@ -33,17 +38,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: @@ -57,7 +51,7 @@ class Text(Shape): # Mirrored property @property - def mirrored(self) -> numpy.ndarray: #ndarray[bool] + def mirrored(self) -> numpy.ndarray: # ndarray[bool] return self._mirrored @mirrored.setter @@ -70,31 +64,49 @@ class Text(Shape): string: str, height: float, font_path: str, + *, offset: vector2 = (0.0, 0.0), rotation: float = 0.0, mirrored: Tuple[bool, bool] = (False, False), layer: layer_t = 0, dose: float = 1.0, + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, locked: bool = False, + raw: bool = False, ): - object.__setattr__(self, 'locked', False) + LockableImpl.unlock(self) self.identifier = () - self.offset = offset - self.layer = layer - self.dose = dose - self.string = string - self.height = height - self.rotation = rotation + if raw: + self._offset = offset + self._layer = layer + self._dose = dose + self._string = string + self._height = height + self._rotation = rotation + self._mirrored = mirrored + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + else: + self.offset = offset + self.layer = layer + self.dose = dose + self.string = string + self.height = height + self.rotation = rotation + self.mirrored = mirrored + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} self.font_path = font_path - self.mirrored = mirrored - self.locked = locked + self.set_locked(locked) - def __deepcopy__(self, memo: Dict = None) -> 'Text': + def __deepcopy__(self, memo: Dict = None) -> 'Text': memo = {} if memo is None else memo new = copy.copy(self).unlock() new._offset = self._offset.copy() new._mirrored = copy.deepcopy(self._mirrored, memo) - new.locked = self.locked + new._annotations = copy.deepcopy(self._annotations) + new.set_locked(self.locked) return new def to_polygons(self, @@ -120,10 +132,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 @@ -136,14 +144,14 @@ class Text(Shape): mirror_x, rotation = normalize_mirror(self.mirrored) rotation += self.rotation rotation %= 2 * pi - return (type(self), self.string, self.font_path, self.layer), \ - (self.offset, self.height / norm_value, rotation, mirror_x, self.dose), \ - lambda: Text(string=self.string, - height=self.height * norm_value, - font_path=self.font_path, - rotation=rotation, - mirrored=(mirror_x, False), - layer=self.layer) + return ((type(self), self.string, self.font_path, self.layer), + (self.offset, self.height / norm_value, rotation, mirror_x, self.dose), + lambda: Text(string=self.string, + height=self.height * norm_value, + font_path=self.font_path, + rotation=rotation, + mirrored=(mirror_x, False), + layer=self.layer)) def get_bounds(self) -> numpy.ndarray: # rotation makes this a huge pain when using slot.advance and glyph.bbox(), so @@ -160,10 +168,10 @@ class Text(Shape): def get_char_as_polygons(font_path: str, char: str, - resolution: float = 48*64, + resolution: float = 48 * 64, ) -> Tuple[List[List[List[float]]], float]: - from freetype import Face - from matplotlib.path import Path + from freetype import Face # type: ignore + from matplotlib.path import Path # type: ignore """ Get a list of polygons representing a single character. diff --git a/masque/snaps/snapper.py b/masque/snaps/snapper.py new file mode 100644 index 0000000..190ba55 --- /dev/null +++ b/masque/snaps/snapper.py @@ -0,0 +1,121 @@ + + +callback_t = Callable[[T, numpy.ndarray, Dict[str, int]], ...] + + +class Device: + pattern: Pattern + ports: numpy.ndarray + port_names: Dict[str, int] + callback: Optional[callback_t] = None + + def __init__(self, + pattern: Optional[Pattern] = None, + ports: Optional[numpy.ndarray] = None, + names: Optional[Union[Dict[str, int], List[Optional[str]]] = None, + callback: Optional[callback_t]= None + ) -> None: + if pattern is None: + self.pattern = Pattern() + + if ports is None: + self.ports = {0: [0, 0, 0, 0], + 1: [0, 0, pi, 0]} + else: + self.ports = numpy.array(ports) + + if callback: + self.callback = callback + self.callback(self, self.ports.keys()) + + def __getitem__(self, key) -> numpy.ndarray: + if isinstance(key, str): + inds = [self.port_names[key]] + elif hasattr(key, '__iter__'): + inds = [self.port_names.get(k, k) for k in key] + else: + inds = [self.port_names.get(key, key)] + return self.ports[inds] + + def build(self: T, + name: str, + other: Device, + map_in: Dict[port_t, port_t], + map_out: Dict[port_t, port_t], + mirror: bool = False, + ) -> T: + translation, rotation = self.find_transform(other, map_in, map_out, mirror) + + pat = Pattern(name) + pat.addsp(self.pattern) + new = Device(pat, ports=self.ports, port_names=self.port_names, callback=self.callback) + return new + + + def plug(self, other, map_in, map_out, mirror): + translation, rotation, pivot = self.find_transform(other, map_in, map_out, mirror) + + sp = SubPattern(other.pattern, mirrored=mirror) + sp.rotate_around(pivot, rotation) + sp.translate(translation) + self.pat.subpatterns.append(sp) + + # get rid of plugged ports + + # insert remaining device ports into router port list + + with numpy.errstate(invalid='ignore'): + self.ports[:, 2] %= 2 * pi + + if self.port_callback: + self.port_callback(...) + + def find_transform(self, other, map_in, map_out, mirror): + s_ports = self[map_in.keys()] + o_ports= other[map_in.values()] + + if mirror[0]: + o_ports[:, 1] *= -1 + o_ports[:, 2] += pi + if mirror[1]: + o_ports[:, 0] *= -1 + o_ports[:, 2] += pi + + s_offsets = s_ports[:, :2] + s_angles = s_ports[:, 2] + s_types = s_ports[:, 3] + o_offsets = o_ports[:, :2] + o_angles = o_ports[:, 2] + o_types = o_ports[:, 3] + + if (r_types != d_types) & (r_types != 0) & (d_types != 0): + #TODO warn about types + + rotations = numpy.mod(s_angles - o_angles - pi, 2 * pi) + + if not numpy.allclose(rotations[:1], rotations): + # TODO warn about rotation + + rot_ports = rotate_ports_around(o_ports, o_offsets[0], rotations[0]) #TODO also rotate unplugged device ports + + translations = r_offsets - d_offsets + translation = translations[0] + + if not numpy.allclose(translations[:1], translations): + + return translations[0], rotations[0] + + + def as_pattern(self, name) -> Pattern: + return self.pat.copy().rename(name) + + def as_device(self, name): + return Device(self.as_pattern, REMAINING_NON-NAN_PORTS) #TODO + + +def rotate_ports_around(ports: numpy.ndarray, pivot: numpy.ndarray, angle: float) -> numpy.ndarray: + ports[:, :2] -= pivot + ports[:, :2] = (rotation_matrix_2d(angle) @ ports[:, :2].T).T + ports[:, :2] += pivot + ports[:, 2] += angle + return ports diff --git a/masque/subpattern.py b/masque/subpattern.py index f0af4c0..9d04b89 100644 --- a/masque/subpattern.py +++ b/masque/subpattern.py @@ -4,68 +4,61 @@ """ #TODO more top-level documentation -from typing import Union, List, Dict, Tuple, Optional, Sequence, TYPE_CHECKING, Any +from typing import Dict, Tuple, Optional, Sequence, TYPE_CHECKING, Any, TypeVar import copy -import numpy +import numpy # type: ignore from numpy import pi -from .error import PatternError, PatternLockedError -from .utils import is_scalar, rotation_matrix_2d, vector2 -from .repetition import GridRepetition +from .error import PatternError +from .utils import is_scalar, vector2, AutoSlots, annotations_t +from .repetition import Repetition +from .traits import (PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, + Mirrorable, PivotableImpl, Copyable, LockableImpl, RepeatableImpl, + AnnotatableImpl) if TYPE_CHECKING: from . import Pattern -class SubPattern: +S = TypeVar('S', bound='SubPattern') + + +class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mirrorable, + PivotableImpl, Copyable, RepeatableImpl, LockableImpl, AnnotatableImpl, + 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 """ - - _rotation: float - """ rotation for the instance, radians counterclockwise """ - - _dose: float - """ dose 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 """ - def __init__(self, pattern: Optional['Pattern'], + *, offset: vector2 = (0.0, 0.0), rotation: float = 0.0, mirrored: Optional[Sequence[bool]] = None, dose: float = 1.0, scale: float = 1.0, + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, locked: bool = False, - identifier: Tuple[Any, ...] = ()): + identifier: Tuple[Any, ...] = (), + ) -> None: """ Args: pattern: Pattern to reference. @@ -74,10 +67,11 @@ 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) self.identifier = identifier self.pattern = pattern self.offset = offset @@ -87,28 +81,29 @@ class SubPattern: if mirrored is None: mirrored = [False, False] self.mirrored = mirrored - self.locked = locked + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} + self.set_locked(locked) - def __setattr__(self, name, value): - if self.locked and name != 'locked': - raise PatternLockedError() - object.__setattr__(self, name, value) - - def __copy__(self) -> 'SubPattern': + def __copy__(self: S) -> S: new = SubPattern(pattern=self.pattern, offset=self.offset.copy(), rotation=self.rotation, dose=self.dose, scale=self.scale, mirrored=self.mirrored.copy(), + repetition=copy.deepcopy(self.repetition), + annotations=copy.deepcopy(self.annotations), locked=self.locked) return new - def __deepcopy__(self, memo: Dict = None) -> 'SubPattern': + def __deepcopy__(self: S, memo: Dict = None) -> S: memo = {} if memo is None else memo new = copy.copy(self).unlock() new.pattern = copy.deepcopy(self.pattern, memo) - new.locked = self.locked + new.repetition = copy.deepcopy(self.repetition, memo) + new.annotations = copy.deepcopy(self.annotations, memo) + new.set_locked(self.locked) return new # pattern property @@ -120,60 +115,9 @@ class SubPattern: 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)) + raise PatternError(f'Provided pattern {val} is not a Pattern object or None!') 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,64 +142,28 @@ class SubPattern: pattern.rotate_around((0.0, 0.0), self.rotation) pattern.translate_elements(self.offset) pattern.scale_element_doses(self.dose) + + if self.repetition is not None: + combined = type(pattern)(name='__repetition__') + for dd in self.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 - """ + def rotate(self: S, rotation: float) -> S: self.rotation += rotation + if self.repetition is not None: + self.repetition.rotate(rotation) return self - def mirror(self, axis: int) -> 'SubPattern': - """ - Mirror the subpattern across an axis. - - Args: - axis: Axis to mirror across. - - Returns: - self - """ + def mirror(self: S, axis: int) -> S: self.mirrored[axis] = not self.mirrored[axis] self.rotation *= -1 + if self.repetition is not None: + self.repetition.mirror(axis) return self def get_bounds(self) -> Optional[numpy.ndarray]: @@ -271,62 +179,31 @@ 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': + def lock(self: S) -> S: """ Lock the SubPattern, disallowing changes 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': + def unlock(self: S) -> S: """ Unlock the 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': + def deeplock(self: S) -> S: """ Recursively lock the SubPattern and its contained pattern @@ -338,7 +215,7 @@ class SubPattern: self.pattern.deeplock() return self - def deepunlock(self) -> 'SubPattern': + def deepunlock(self: S) -> S: """ Recursively unlock the SubPattern and its contained pattern @@ -361,6 +238,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..3f88554 --- /dev/null +++ b/masque/traits/__init__.py @@ -0,0 +1,13 @@ +""" +Traits (mixins) and default implementations +""" +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 +from .annotatable import Annotatable, AnnotatableImpl diff --git a/masque/traits/annotatable.py b/masque/traits/annotatable.py new file mode 100644 index 0000000..9d49018 --- /dev/null +++ b/masque/traits/annotatable.py @@ -0,0 +1,55 @@ +from typing import TypeVar +#from types import MappingProxyType +from abc import ABCMeta, abstractmethod + +from ..utils import annotations_t +from ..error import PatternError + + +T = TypeVar('T', bound='Annotatable') +I = TypeVar('I', bound='AnnotatableImpl') + + +class Annotatable(metaclass=ABCMeta): + """ + Abstract class for all annotatable entities + Annotations correspond to GDS/OASIS "properties" + """ + __slots__ = () + + ''' + ---- Properties + ''' + @property + @abstractmethod + def annotations(self) -> annotations_t: + """ + Dictionary mapping annotation names to values + """ + pass + + +class AnnotatableImpl(Annotatable, metaclass=ABCMeta): + """ + Simple implementation of `Annotatable`. + """ + __slots__ = () + + _annotations: annotations_t + """ Dictionary storing annotation name/value pairs """ + + ''' + ---- Non-abstract properties + ''' + @property + def annotations(self) -> annotations_t: + return self._annotations +# # TODO: Find a way to make sure the subclass implements Lockable without dealing with diamond inheritance or this extra hasattr +# if hasattr(self, 'is_locked') and self.is_locked(): +# return MappingProxyType(self._annotations) + + @annotations.setter + def annotations(self, annotations: annotations_t): + if not isinstance(annotations, dict): + raise PatternError(f'annotations expected dict, got {type(annotations)}') + self._annotations = annotations diff --git a/masque/traits/copyable.py b/masque/traits/copyable.py new file mode 100644 index 0000000..aa84356 --- /dev/null +++ b/masque/traits/copyable.py @@ -0,0 +1,34 @@ +from typing import TypeVar +from abc import ABCMeta +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..217872c --- /dev/null +++ b/masque/traits/doseable.py @@ -0,0 +1,76 @@ +from typing import TypeVar +from abc import ABCMeta, abstractmethod + +from ..error import PatternError + + +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 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..812511b --- /dev/null +++ b/masque/traits/layerable.py @@ -0,0 +1,73 @@ +from typing import TypeVar +from abc import ABCMeta, abstractmethod + +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) + """ + pass + +# @layer.setter +# @abstractmethod +# def layer(self, val: layer_t): +# pass + + ''' + ---- 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..5a0f06f --- /dev/null +++ b/masque/traits/lockable.py @@ -0,0 +1,93 @@ +from typing import TypeVar +from abc import ABCMeta, abstractmethod + +from ..error import PatternLockedError + + +T = TypeVar('T', bound='Lockable') +I = TypeVar('I', bound='LockableImpl') + + +class Lockable(metaclass=ABCMeta): + """ + Abstract class for all lockable entities + """ + __slots__ = () + + ''' + ---- Methods + ''' + @abstractmethod + def lock(self: T) -> T: + """ + Lock the object, disallowing further changes + + Returns: + self + """ + pass + + @abstractmethod + def unlock(self: T) -> T: + """ + Unlock the object, reallowing changes + + Returns: + self + """ + pass + + @abstractmethod + def is_locked(self) -> bool: + """ + Returns: + True if the object is locked + """ + pass + + def set_locked(self: T, locked: bool) -> T: + """ + Locks or unlocks based on the argument. + No action if already in the requested state. + + Args: + locked: State to set. + + Returns: + self + """ + if locked != self.is_locked(): + if locked: + self.lock() + else: + self.unlock() + return self + + +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 + + def is_locked(self) -> bool: + return self.locked diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py new file mode 100644 index 0000000..1ec54f6 --- /dev/null +++ b/masque/traits/mirrorable.py @@ -0,0 +1,58 @@ +from typing import TypeVar +from abc import ABCMeta, abstractmethod + + +T = TypeVar('T', bound='Mirrorable') +#I = TypeVar('I', 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..71f90ec --- /dev/null +++ b/masque/traits/positionable.py @@ -0,0 +1,132 @@ +# TODO top-level comment about how traits should set __slots__ = (), and how to use AutoSlots + +from typing import TypeVar +from abc import ABCMeta, abstractmethod +import numpy # type: ignore + +from ..error import PatternError +from ..utils import 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) or val.dtype != numpy.float64: + 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..4a7f391 --- /dev/null +++ b/masque/traits/repeatable.py @@ -0,0 +1,82 @@ +from typing import TypeVar, Optional, TYPE_CHECKING +from abc import ABCMeta, abstractmethod + +from ..error import PatternError + + +if TYPE_CHECKING: + from ..repetition import Repetition + + +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 + ''' + @abstractmethod + 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: Optional['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..c0641f0 --- /dev/null +++ b/masque/traits/rotatable.py @@ -0,0 +1,119 @@ +from typing import TypeVar +from abc import ABCMeta, abstractmethod + +import numpy # type: ignore +from numpy import pi + +#from .positionable import Positionable +from ..error import PatternError +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__ = () + + @abstractmethod + 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..b31c2f9 --- /dev/null +++ b/masque/traits/scalable.py @@ -0,0 +1,77 @@ +from typing import TypeVar +from abc import ABCMeta, abstractmethod + +from ..error import PatternError +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..a09a09c 100644 --- a/masque/utils.py +++ b/masque/utils.py @@ -1,14 +1,16 @@ """ Various helper functions """ +from typing import Any, Union, Tuple, Sequence, Dict, List +from abc import ABCMeta -from typing import Any, Union, Tuple, Sequence +import numpy # type: ignore -import numpy # Type definitions vector2 = Union[numpy.ndarray, Tuple[float, float], Sequence[float]] layer_t = Union[int, Tuple[int, int], str] +annotations_t = Dict[str, List[Union[int, float, str]]] def is_scalar(var: Any) -> bool: @@ -82,7 +84,7 @@ def normalize_mirror(mirrored: Sequence[bool]) -> Tuple[bool, float]: """ mirrored_x, mirrored_y = mirrored - mirror_x = (mirrored_x != mirrored_y) #XOR + mirror_x = (mirrored_x != mirrored_y) # XOR angle = numpy.pi if mirrored_y else 0 return mirror_x, angle @@ -122,8 +124,8 @@ def remove_colinear_vertices(vertices: numpy.ndarray, closed_path: bool = True) # Check for dx0/dy0 == dx1/dy1 - dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] - dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] #[[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dy0]] + dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dy0]] dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 @@ -133,3 +135,30 @@ 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): + parents = set() + for base in bases: + parents |= set(base.mro()) + + slots = tuple(dctn.get('__slots__', tuple())) + for parent in parents: + if not hasattr(parent, '__annotations__'): + continue + slots += tuple(getattr(parent, '__annotations__').keys()) + + dctn['__slots__'] = slots + return super().__new__(cls, name, bases, dctn) + diff --git a/setup.py b/setup.py index 739b5f3..ff8aad9 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ from setuptools import setup, find_packages + with open('README.md', 'r') as f: long_description = f.read() @@ -18,15 +19,20 @@ setup(name='masque', url='https://mpxd.net/code/jan/masque', packages=find_packages(), package_data={ - 'masque': ['VERSION'] + 'masque': ['VERSION', + 'py.typed', + ] }, install_requires=[ 'numpy', ], extras_require={ - 'visualization': ['matplotlib'], 'gdsii': ['python-gdsii'], + 'klamath': ['klamath'], + 'oasis': ['fatamorgana>=0.7'], + 'dxf': ['ezdxf'], 'svg': ['svgwrite'], + 'visualization': ['matplotlib'], 'text': ['freetype-py', 'matplotlib'], }, classifiers=[ @@ -37,10 +43,42 @@ setup(name='masque', 'Intended Audience :: Manufacturing', 'Intended Audience :: Science/Research', 'License :: OSI Approved :: GNU Affero General Public License v3', - 'Operating System :: POSIX :: Linux', - 'Operating System :: Microsoft :: Windows', 'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)', 'Topic :: Scientific/Engineering :: Visualization', ], + keywords=[ + 'layout', + 'design', + 'CAD', + 'EDA', + 'electronics', + 'photonics', + 'IC', + 'mask', + 'pattern', + 'drawing', + 'lithography', + 'litho', + 'geometry', + 'geometric', + 'polygon', + 'curve', + 'ellipse', + 'oas', + 'gds', + 'dxf', + 'svg', + 'OASIS', + 'gdsii', + 'gds2', + 'convert', + 'stream', + 'custom', + 'visualize', + 'vector', + 'freeform', + 'manhattan', + 'angle', + ], )