From 1b0b056bf961bfd2cef1d5e76ceba83d15e48be3 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 19 May 2020 00:03:29 -0700
Subject: [PATCH 01/71] break out build() which returns the
 gdsii.library.Library object

---
 masque/file/gdsii.py | 46 ++++++++++++++++++++++++++++++--------------
 1 file changed, 32 insertions(+), 14 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 98a9a51..94c6928 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -48,15 +48,15 @@ path_cap_map = {
                }
 
 
-def write(patterns: Union[Pattern, List[Pattern]],
-          stream: io.BufferedIOBase,
+def build(patterns: Union[Pattern, List[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 +74,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,6 +89,9 @@ 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]
@@ -123,25 +125,42 @@ def write(patterns: Union[Pattern, List[Pattern]],
         structure += _labels_to_texts(pat.labels)
         structure += _subpatterns_to_refs(pat.subpatterns)
 
+    return lib
+
+
+def write(patterns: Union[Pattern, List[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 `oasis.build()`
+        **kwargs: passed to `oasis.build()`
+    """
+    lib = build(patterns, *args, **kwargs)
     lib.save(stream)
     return
 
-
 def writefile(patterns: Union[List[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':
@@ -243,14 +262,14 @@ 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.
 
     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':
@@ -591,4 +610,3 @@ def disambiguate_pattern_names(patterns,
 
         pat.name = encoded_name
         used_names.append(suffixed_name)
-

From 8082743e1736bec7f77c7e555d7751e593f8cd41 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 19 May 2020 00:13:50 -0700
Subject: [PATCH 02/71] move dose2dtype() into masque.file.utils, add
 dtype2dose(), and add a note that use_dtype_as_dose

---
 masque/file/gdsii.py | 106 ++++-------------------------------------
 masque/file/utils.py | 110 ++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 117 insertions(+), 99 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 94c6928..60755d6 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -27,7 +27,7 @@ import gdsii.library
 import gdsii.structure
 import gdsii.elements
 
-from .utils import mangle_name, make_dose_table
+from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose
 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
@@ -173,90 +173,6 @@ def writefile(patterns: Union[List[Pattern], Pattern],
     return results
 
 
-def dose2dtype(patterns: List[Pattern],
-               ) -> Tuple[List[Pattern], List[float]]:
-    """
-     For each shape in each pattern, set shape.layer to the tuple
-     (base_layer, datatype), where:
-        layer is chosen to be equal to the original shape.layer if it is an int,
-            or shape.layer[0] if it is a tuple. `str` layers raise a PatterError.
-        datatype is chosen arbitrarily, based on calcualted dose for each shape.
-            Shapes with equal calcualted dose will have the same datatype.
-            A list of doses is retured, providing a mapping between datatype
-            (list index) and dose (list entry).
-
-    Note that this function modifies the input Pattern(s).
-
-    Args:
-        patterns: A `Pattern` or list of patterns to write to file. Modified by this function.
-
-    Returns:
-        (patterns, dose_list)
-            patterns: modified input patterns
-            dose_list: A list of doses, providing a mapping between datatype (int, list index)
-                       and dose (float, list entry).
-    """
-    # 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
-
-    # Get a table of (id(pat), written_dose) for each pattern and subpattern
-    sd_table = make_dose_table(patterns)
-
-    # Figure out all the unique doses necessary to write this pattern
-    #  This means going through each row in sd_table and adding the dose values needed to write
-    #  that subpattern at that dose level
-    dose_vals = set()
-    for pat_id, pat_dose in sd_table:
-        pat = patterns_by_id[pat_id]
-        for shape in pat.shapes:
-            dose_vals.add(shape.dose * pat_dose)
-
-    if len(dose_vals) > 256:
-        raise PatternError('Too many dose values: {}, maximum 256 when using dtypes.'.format(len(dose_vals)))
-
-    dose_vals_list = list(dose_vals)
-
-    # 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
-    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.shapes = copy.deepcopy(old_pat.shapes)
-        pat.labels = copy.deepcopy(old_pat.labels)
-
-        encoded_name = mangle_name(pat, pat_dose)
-        if len(encoded_name) == 0:
-            raise PatternError('Zero-length name after mangle+encode, originally "{}"'.format(pat.name))
-        pat.name = encoded_name
-
-        for shape in pat.shapes:
-            data_type = dose_vals_list.index(shape.dose * pat_dose)
-            if isinstance(shape.layer, int):
-                shape.layer = (shape.layer, data_type)
-            elif isinstance(shape.layer, tuple):
-                shape.layer = (shape.layer[0], data_type)
-            else:
-                raise PatternError(f'Invalid layer for gdsii: {shape.layer}')
-
-        new_pats[(pat_id, pat_dose)] = pat
-
-    # Go back through all the dose-specific patterns and fix up their subpattern entries
-    for (pat_id, pat_dose), pat in new_pats.items():
-        for subpat in pat.subpatterns:
-            dose_mult = subpat.dose * pat_dose
-            subpat.pattern = new_pats[(id(subpat.pattern), dose_mult)]
-
-    return patterns, dose_vals_list
-
-
 def readfile(filename: Union[str, pathlib.Path],
              *args,
              **kwargs,
@@ -302,6 +218,8 @@ def read(stream: io.BufferedIOBase,
         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`.
@@ -325,14 +243,9 @@ def read(stream: io.BufferedIOBase,
             # Switch based on element type:
             if isinstance(element, gdsii.elements.Boundary):
                 args = {'vertices': element.xy[:-1],
+                        'layer': (element.layer, element.data_type),
                        }
 
-                if use_dtype_as_dose:
-                    args['dose'] = element.data_type
-                    args['layer'] = element.layer
-                else:
-                    args['layer'] = (element.layer, element.data_type)
-
                 poly = Polygon(**args)
 
                 if clean_vertices:
@@ -350,6 +263,7 @@ def read(stream: io.BufferedIOBase,
                     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,
                        }
@@ -361,12 +275,6 @@ def read(stream: io.BufferedIOBase,
                     if element.end_extn is not None:
                         args['cap_extensions'][1] = element.end_extn
 
-                if use_dtype_as_dose:
-                    args['dose'] = element.data_type
-                    args['layer'] = element.layer
-                else:
-                    args['layer'] = (element.layer, element.data_type)
-
                 path = Path(**args)
 
                 if clean_vertices:
@@ -389,6 +297,10 @@ def read(stream: io.BufferedIOBase,
             elif isinstance(element, gdsii.elements.ARef):
                 pat.subpatterns.append(_aref_to_gridrep(element))
 
+        if use_dtype_as_dose:
+            logger.warning('use_dtype_as_dose will be removed in the future!')
+            pat = dtype2dose(pat)
+
         patterns.append(pat)
 
     # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries
diff --git a/masque/file/utils.py b/masque/file/utils.py
index 9092ab9..e36765e 100644
--- a/masque/file/utils.py
+++ b/masque/file/utils.py
@@ -1,10 +1,11 @@
 """
 Helper functions for file reading and writing
 """
-import re
 from typing import Set, Tuple, List
+import re
+import copy
 
-from masque.pattern import Pattern
+from .. import Pattern, PatternError
 
 
 def mangle_name(pattern: Pattern, dose_multiplier: float=1.0) -> str:
@@ -45,3 +46,108 @@ def make_dose_table(patterns: List[Pattern], dose_multiplier: float=1.0) -> Set[
                 subpat_dose_table = make_dose_table([subpat.pattern], subpat.dose * dose_multiplier)
                 dose_table = dose_table.union(subpat_dose_table)
     return dose_table
+
+
+def dtype2dose(pattern: Pattern) -> Pattern:
+    """
+    For each shape in the pattern, if the layer is a tuple, set the
+      layer to the tuple's first element and set the dose to the
+      tuple's second element.
+
+    Generally intended for use with `Pattern.apply()`.
+
+    Args:
+        pattern: Pattern to modify
+
+    Returns:
+        pattern
+    """
+    for shape in pattern.shapes:
+        if isinstance(shape.layer, tuple):
+            shape.dose = shape.layer[1]
+            shape.layer = shape.layer[0]
+    return pattern
+
+
+def dose2dtype(patterns: List[Pattern],
+               ) -> Tuple[List[Pattern], List[float]]:
+    """
+     For each shape in each pattern, set shape.layer to the tuple
+     (base_layer, datatype), where:
+        layer is chosen to be equal to the original shape.layer if it is an int,
+            or shape.layer[0] if it is a tuple. `str` layers raise a PatterError.
+        datatype is chosen arbitrarily, based on calcualted dose for each shape.
+            Shapes with equal calcualted dose will have the same datatype.
+            A list of doses is retured, providing a mapping between datatype
+            (list index) and dose (list entry).
+
+    Note that this function modifies the input Pattern(s).
+
+    Args:
+        patterns: A `Pattern` or list of patterns to write to file. Modified by this function.
+
+    Returns:
+        (patterns, dose_list)
+            patterns: modified input patterns
+            dose_list: A list of doses, providing a mapping between datatype (int, list index)
+                       and dose (float, list entry).
+    """
+    # 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
+
+    # Get a table of (id(pat), written_dose) for each pattern and subpattern
+    sd_table = make_dose_table(patterns)
+
+    # Figure out all the unique doses necessary to write this pattern
+    #  This means going through each row in sd_table and adding the dose values needed to write
+    #  that subpattern at that dose level
+    dose_vals = set()
+    for pat_id, pat_dose in sd_table:
+        pat = patterns_by_id[pat_id]
+        for shape in pat.shapes:
+            dose_vals.add(shape.dose * pat_dose)
+
+    if len(dose_vals) > 256:
+        raise PatternError('Too many dose values: {}, maximum 256 when using dtypes.'.format(len(dose_vals)))
+
+    dose_vals_list = list(dose_vals)
+
+    # 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
+    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.shapes = copy.deepcopy(old_pat.shapes)
+        pat.labels = copy.deepcopy(old_pat.labels)
+
+        encoded_name = mangle_name(pat, pat_dose)
+        if len(encoded_name) == 0:
+            raise PatternError('Zero-length name after mangle+encode, originally "{}"'.format(pat.name))
+        pat.name = encoded_name
+
+        for shape in pat.shapes:
+            data_type = dose_vals_list.index(shape.dose * pat_dose)
+            if isinstance(shape.layer, int):
+                shape.layer = (shape.layer, data_type)
+            elif isinstance(shape.layer, tuple):
+                shape.layer = (shape.layer[0], data_type)
+            else:
+                raise PatternError(f'Invalid layer for gdsii: {shape.layer}')
+
+        new_pats[(pat_id, pat_dose)] = pat
+
+    # Go back through all the dose-specific patterns and fix up their subpattern entries
+    for (pat_id, pat_dose), pat in new_pats.items():
+        for subpat in pat.subpatterns:
+            dose_mult = subpat.dose * pat_dose
+            subpat.pattern = new_pats[(id(subpat.pattern), dose_mult)]
+
+    return patterns, dose_vals_list

From 1bb4bd6bb7d60e0caee4b16277807e11964854b4 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 19 May 2020 00:15:51 -0700
Subject: [PATCH 03/71] add py.typed to enable type checking for downstream

---
 masque/py.typed | 0
 setup.py        | 4 +++-
 2 files changed, 3 insertions(+), 1 deletion(-)
 create mode 100644 masque/py.typed

diff --git a/masque/py.typed b/masque/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/setup.py b/setup.py
index 739b5f3..f45961e 100644
--- a/setup.py
+++ b/setup.py
@@ -18,7 +18,9 @@ setup(name='masque',
       url='https://mpxd.net/code/jan/masque',
       packages=find_packages(),
       package_data={
-          'masque': ['VERSION']
+          'masque': ['VERSION',
+                     'py.typed',
+                     ]
       },
       install_requires=[
             'numpy',

From 6e957d761ac58891d0f36e8b3d290f9cfc5e1790 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 19 May 2020 00:29:30 -0700
Subject: [PATCH 04/71] newline

---
 masque/file/dxf.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/masque/file/dxf.py b/masque/file/dxf.py
index 335cd80..737a915 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -24,6 +24,7 @@ from ..utils import remove_colinear_vertices, normalize_mirror
 
 logger = logging.getLogger(__name__)
 
+
 logger.warning('DXF support is experimental and only slightly tested!')
 
 

From f204d917c9845e945664eb6a9f732d048953c646 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 19 May 2020 01:00:00 -0700
Subject: [PATCH 05/71] Add basic support for OASIS and update setup/docs for
 OASIS and DXF support

---
 README.md            |   9 +-
 masque/file/oasis.py | 612 +++++++++++++++++++++++++++++++++++++++++++
 setup.py             |   4 +-
 3 files changed, 621 insertions(+), 4 deletions(-)
 create mode 100644 masque/file/oasis.py

diff --git a/README.md b/README.md
index fd16875..38ad690 100644
--- a/README.md
+++ b/README.md
@@ -13,17 +13,19 @@ E-beam doses, and the ability to output to multiple formats.
 ## 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)
+* 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]'
 ```
 
 Alternatively, install from git
@@ -36,4 +38,5 @@ pip3 install git+https://mpxd.net/code/jan/masque.git@release
 * Polygon de-embedding
 * Construct from bitmap
 * Boolean operations on polygons (using pyclipper)
-* Output to OASIS (using fatamorgana)
+* Implement shape/cell properties
+* Implement OASIS-style repetitions for shapes
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
new file mode 100644
index 0000000..1de1e09
--- /dev/null
+++ b/masque/file/oasis.py
@@ -0,0 +1,612 @@
+"""
+OASIS file format readers and writers
+
+Note that OASIS references follow the same convention as `masque`,
+  with this order of operations:
+   1. Mirroring
+   2. Rotation
+   3. Scaling
+   4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)
+
+  Scaling, rotation, and mirroring apply to individual instances, not grid
+   vectors or offsets.
+"""
+from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional
+import re
+import io
+import copy
+import base64
+import struct
+import logging
+import pathlib
+import gzip
+import numpy
+from numpy import pi
+
+import fatamorgana
+import fatamorgana.records as fatrec
+from fatamorgana.basic import PathExtensionScheme
+
+from .utils import mangle_name, make_dose_table
+from .. import Pattern, SubPattern, GridRepetition, PatternError, Label, Shape, subpattern_t
+from ..shapes import Polygon, Path, Circle
+from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
+from ..utils import remove_colinear_vertices, normalize_mirror
+
+
+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,
+               }
+
+#TODO implement properties
+#TODO implement more shape types?
+
+def build(patterns: Union[Pattern, List[Pattern]],
+          units_per_micron: int,
+          layer_map: Dict[str, Union[int, Tuple[int, int]]] = None,
+          modify_originals: bool = False,
+          disambiguate_func: Callable[[Iterable[Pattern]], None] = None,
+          ) -> fatamorgana.OasisLayout:
+    """
+    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,
+            or `shape.layer[0]` if it is a tuple
+        datatype is chosen to be `shape.layer[1]` if available,
+            otherwise `0`
+        If a layer map is provided, layer strings will be converted
+            automatically, and layer names will be written to the file.
+
+    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.
+        units_per_micron: Written into the OASIS file, number of grid steps per micrometer.
+            All distances are assumed to be an integer multiple of the grid step, and are stored as such.
+        layer_map: Dictionary which translates layer names into layer numbers. If this argument is
+            provided, input shapes and labels are allowed to have layer names instead of numbers.
+            It is assumed that geometry and text share the same layer names, and each name is
+            assigned only to a single layer (not a range).
+            If more fine-grained control is needed, manually pre-processing shapes' layer names
+            into numbers, omit this argument, and manually generate the required
+            `fatamorgana.records.LayerName` entries.
+            Default is an empty dict (no names provided).
+        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`.
+
+    Returns:
+        `fatamorgana.OasisLayout`
+    """
+    if isinstance(patterns, Pattern):
+        patterns = [patterns]
+
+    if layer_map is None:
+        layer_map = {}
+
+    if disambiguate_func is None:
+        disambiguate_func = disambiguate_pattern_names
+
+    if not modify_originals:
+        patterns = [p.deepunlock() for p in copy.deepcopy(patterns)]
+
+    # Create library
+    lib = fatamorgana.OasisLayout(unit=units_per_micron, validation=None)
+
+    if layer_map:
+        for name, layer_num in layer_map.items():
+            layer, data_type = _mlayer2oas(layer_num)
+            lib.layers += [
+                    fatrec.LayerName(nstring=name,
+                                     layer_interval=(layer, layer),
+                                     type_interval=(data_type, data_type),
+                                     is_textlayer=tt)
+                    for tt in (True, False)]
+
+        def layer2oas(mlayer: layer_t) -> Tuple[int, int]:
+            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}
+    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
+    for pat in patterns_by_id.values():
+        structure = fatamorgana.Cell(name=pat.name)
+        lib.cells.append(structure)
+
+        structure.geometry += _shapes_to_elements(pat.shapes, layer2oas)
+        structure.geometry += _labels_to_texts(pat.labels, layer2oas)
+        structure.placements += _subpatterns_to_refs(pat.subpatterns)
+
+    return lib
+
+
+def write(patterns: Union[List[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)
+
+
+def writefile(patterns: Union[List[Pattern], Pattern],
+              filename: Union[str, pathlib.Path],
+              *args,
+              **kwargs,
+              ):
+    """
+    Wrapper for `oasis.write()` that takes a filename or path instead of a stream.
+
+    Will automatically compress the file if it has a .gz suffix.
+
+    Args:
+        patterns: `Pattern` or list of patterns to save
+        filename: Filename to save to.
+        *args: passed to `oasis.write`
+        **kwargs: passed to `oasis.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:
+        results = write(patterns, stream, *args, **kwargs)
+    return results
+
+
+def readfile(filename: Union[str, pathlib.Path],
+             *args,
+             **kwargs,
+             ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
+    """
+    Wrapper for `oasis.read()` that takes a filename or path instead of a stream.
+
+    Will automatically decompress files with a .gz suffix.
+
+    Args:
+        filename: Filename to save to.
+        *args: passed to `oasis.read`
+        **kwargs: passed to `oasis.read`
+    """
+    path = pathlib.Path(filename)
+    if path.suffix == '.gz':
+        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: io.BufferedIOBase,
+         clean_vertices: bool = True,
+         ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
+    """
+    Read a OASIS file and translate it into a dict of Pattern objects. OASIS cells are
+     translated into Pattern objects; Polygons are translated into polygons, and Placements
+     are translated into SubPattern or GridRepetition 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)
+
+    Args:
+        stream: Stream to read from.
+        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`.
+
+    Returns:
+        - Dict of `pattern_name`:`Pattern`s generated from OASIS cells
+        - Dict of OASIS library info
+    """
+
+    lib = fatamorgana.OasisLayout.read(stream)
+
+    library_info: Dict[str, Any] = {
+            'units_per_micrometer': lib.unit,
+            }
+
+    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:
+        if isinstance(cell.name, int):
+            cell_name = lib.cellnames[cell.name].string
+        else:
+            cell_name = cell.name.string
+
+        pat = Pattern(name=cell_name)
+        for element in cell.geometry:
+            if isinstance(element, fatrec.XElement):
+                logger.warning('Skipping XElement record')
+                continue
+
+            if element.repetition is not None:
+                # note XELEMENT has no repetition
+                raise PatternError('masque OASIS reader does not implement repetitions for shapes yet')
+
+            # Switch based on element type:
+            if isinstance(element, fatrec.Polygon):
+                vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
+                poly = Polygon(vertices=vertices,
+                               layer=element.get_layer_tuple(),
+                               offset=element.get_xy())
+
+                if clean_vertices:
+                    try:
+                        poly.clean_vertices()
+                    except PatternError:
+                        continue
+
+                pat.shapes.append(poly)
+
+            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
+                cap = cap_start
+
+                path_args: Dict[str, Any] = {}
+                if cap == Path.Cap.SquareCustom:
+                    path_args['cap_extensions'] = numpy.array((element.get_extension_start()[1],
+                                                               element.get_extension_end()[1]))
+                path = Path(vertices=vertices,
+                            layer=element.get_layer_tuple(),
+                            offset=element.get_xy(),
+                            width=element.get_half_width() * 2,
+                            cap=cap,
+                            **path_args)
+
+                if clean_vertices:
+                    try:
+                        path.clean_vertices()
+                    except PatternError as err:
+                        continue
+
+                pat.shapes.append(path)
+
+            elif isinstance(element, fatrec.Rectangle):
+                width = element.get_width()
+                height = element.get_height()
+                rect = Polygon(layer=element.get_layer_tuple(),
+                               offset=element.get_xy(),
+                               vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
+                               )
+                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
+
+                trapz = Polygon(layer=element.get_layer_tuple(),
+                                offset=element.get_xy(),
+                                vertices=vertices,
+                                )
+                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
+
+                ctrapz = Polygon(layer=element.get_layer_tuple(),
+                                offset=element.get_xy(),
+                                vertices=vertices,
+                                )
+                pat.shapes.append(ctrapz)
+
+            elif isinstance(element, fatrec.Circle):
+                circle = Circle(layer=element.get_layer_tuple(),
+                                offset=element.get_xy(),
+                                radius=float(element.get_radius()))
+                pat.shapes.append(circle)
+
+            elif isinstance(element, fatrec.Text):
+                label = Label(layer=element.get_layer_tuple(),
+                              offset=element.get_xy(),
+                              string=str(element.get_string()))
+                pat.labels.append(label)
+
+            else:
+                logger.warning(f'Skipping record {element} (unimplemented)')
+                continue
+
+        for placement in cell.placements:
+            pat.subpatterns += _placement_to_subpats(placement)
+
+        patterns.append(pat)
+
+    # 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:
+            ident = sp.identifier[0]
+            name = ident if isinstance(ident, str) else lib.cellnames[ident].string
+            sp.pattern = patterns_dict[name]
+            del sp.identifier
+
+    return patterns_dict, library_info
+
+
+def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]:
+    """ Helper to turn a layer tuple-or-int into a layer and datatype"""
+    if isinstance(mlayer, int):
+        layer = mlayer
+        data_type = 0
+    elif isinstance(mlayer, tuple):
+        layer = mlayer[0]
+        if len(mlayer) > 1:
+            data_type = mlayer[1]
+        else:
+            data_type = 0
+    else:
+        raise PatternError(f'Invalid layer for OASIS: {layer}. Note that OASIS layers cannot be '
+                            'strings unless a layer map is provided.')
+    return layer, data_type
+
+
+def _placement_to_subpats(placement: fatrec.Placement) -> List[subpattern_t]:
+    """
+    Helper function to create a SubPattern from a placment. Sets subpat.pattern to None
+     and sets the instance .identifier to (struct_name,).
+    """
+    xy = numpy.array((placement.x, placement.y))
+    mag = placement.magnification if placement.magnification is not None else 1
+    pname = placement.get_name()
+    name = pname if isinstance(pname, int) else pname.string
+    args: Dict[str, Any] = {
+       'pattern': None,
+       'mirrored': (placement.flip, False),
+       'rotation': float(placement.angle * pi/180),
+       'scale': mag,
+       'identifier': (name,),
+       }
+
+    subpats: List[subpattern_t]
+    rep = placement.repetition
+    if isinstance(rep, fatamorgana.GridRepetition):
+        subpat = GridRepetition(a_vector=rep.a_vector,
+                                b_vector=rep.b_vector,
+                                a_count=rep.a_count,
+                                b_count=rep.b_count,
+                                offset=xy,
+                                **args)
+        subpats = [subpat]
+    elif isinstance(rep, fatamorgana.ArbitraryRepetition):
+        subpats = []
+        for rep_offset in numpy.cumsum(numpy.column_stack((rep.x_displacements,
+                                                           rep.y_displacements))):
+            subpats.append(SubPattern(offset=xy + rep_offset, **args))
+    elif rep is None:
+        subpats = [SubPattern(offset=xy, **args)]
+    return subpats
+
+
+def _subpatterns_to_refs(subpatterns: List[subpattern_t]
+                        ) -> List[fatrec.Placement]:
+    refs = []
+    for subpat in subpatterns:
+        if subpat.pattern is None:
+            continue
+
+        # Note: OASIS mirrors first and rotates second
+        mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored)
+        xy = numpy.round(subpat.offset).astype(int)
+        args: Dict[str, Any] = {
+            'x': xy[0],
+            'y': xy[1],
+            }
+
+        if isinstance(subpat, GridRepetition):
+            args['repetition'] = fatamorgana.GridRepetition(
+                              a_vector=numpy.round(subpat.a_vector).astype(int),
+                              b_vector=numpy.round(subpat.b_vector).astype(int),
+                              a_count=numpy.round(subpat.a_count).astype(int),
+                              b_count=numpy.round(subpat.b_count).astype(int))
+
+        angle = ((subpat.rotation + extra_angle) * 180 / numpy.pi) % 360
+        ref = fatrec.Placement(
+                name=subpat.pattern.name,
+                flip=mirror_across_x,
+                angle=angle,
+                magnification=subpat.scale,
+                **args)
+
+        refs.append(ref)
+    return refs
+
+
+def _shapes_to_elements(shapes: List[Shape],
+                        layer2oas: Callable[[layer_t], Tuple[int, int]],
+                       ) -> 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, fatrec.Circle]] = []
+    for shape in shapes:
+        layer, datatype = layer2oas(shape.layer)
+        if isinstance(shape, Circle):
+            offset = numpy.round(shape.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])
+            elements.append(circle)
+        elif isinstance(shape, Path):
+            xy = numpy.round(shape.offset + shape.vertices[0]).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
+            extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
+            extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
+            path = fatrec.Path(layer=layer,
+                               datatype=datatype,
+                               point_list=deltas,
+                               half_width=half_width,
+                               x=xy[0],
+                               y=xy[1],
+                               extension_start=extension_start,       #TODO implement multiple cap types?
+                               extension_end=extension_end,
+                               )
+            elements.append(path)
+        else:
+            for polygon in shape.to_polygons():
+                xy = numpy.round(polygon.offset + polygon.vertices[0]).astype(int)
+                points = numpy.round(numpy.diff(polygon.vertices, axis=0)).astype(int)
+                elements.append(fatrec.Polygon(layer=layer,
+                                               datatype=datatype,
+                                               x=xy[0],
+                                               y=xy[1],
+                                               point_list=points))
+    return elements
+
+
+def _labels_to_texts(labels: List[Label],
+                     layer2oas: Callable[[layer_t], Tuple[int, int]],
+                     ) -> List[fatrec.Text]:
+    texts = []
+    for label in labels:
+        layer, datatype = layer2oas(label.layer)
+        xy = numpy.round(label.offset).astype(int)
+        texts.append(fatrec.Text(layer=layer,
+                                 datatype=datatype,
+                                 x=xy[0],
+                                 y=xy[1],
+                                 string=label.string))
+    return texts
+
+
+def disambiguate_pattern_names(patterns,
+                               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)
+
+        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('Empty pattern name saved as "{}"'.format(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))
+
+        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))
+
+        pat.name = suffixed_name
+        used_names.append(suffixed_name)
diff --git a/setup.py b/setup.py
index f45961e..595c8e9 100644
--- a/setup.py
+++ b/setup.py
@@ -26,9 +26,11 @@ setup(name='masque',
             'numpy',
       ],
       extras_require={
-          'visualization': ['matplotlib'],
           'gdsii': ['python-gdsii'],
+          'oasis': ['fatamorgana>=0.7'],
+          'dxf': ['ezdxf'],
           'svg': ['svgwrite'],
+          'visualization': ['matplotlib'],
           'text': ['freetype-py', 'matplotlib'],
       },
       classifiers=[

From f2c58c290ff399b4a9ea671e33f667869461dde4 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 19 May 2020 01:01:03 -0700
Subject: [PATCH 06/71] add .oas.gz to .gitignore

---
 .gitignore | 1 +
 1 file changed, 1 insertion(+)

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

From 778e54c89560ffb0be4f38ee40b1b349960572cd Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 19 May 2020 01:01:31 -0700
Subject: [PATCH 07/71] bump version to v1.4

---
 masque/VERSION | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/VERSION b/masque/VERSION
index 7e32cd5..c068b24 100644
--- a/masque/VERSION
+++ b/masque/VERSION
@@ -1 +1 @@
-1.3
+1.4

From e401f37993e7b0d2f07e80a2463ea91b5707c262 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 23 May 2020 19:37:55 -0700
Subject: [PATCH 08/71] Improve documentation on disambiguate_pattern_names

---
 masque/file/gdsii.py | 20 +++++++++++++++++---
 1 file changed, 17 insertions(+), 3 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 60755d6..4837765 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -11,7 +11,7 @@ Note that GDSII references follow the same convention as `masque`,
   Scaling, rotation, and mirroring apply to individual instances, not grid
    vectors or offsets.
 """
-from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional
+from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional, Sequence
 import re
 import io
 import copy
@@ -482,13 +482,24 @@ def _labels_to_texts(labels: List[Label]) -> List[gdsii.elements.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:
+        # 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) +
@@ -496,8 +507,10 @@ def disambiguate_pattern_names(patterns,
         else:
             shortened_name = pat.name
 
+        # Remove invalid characters
         sanitized_name = re.compile('[^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 == '':
@@ -513,6 +526,7 @@ def disambiguate_pattern_names(patterns,
                 logger.warning('Pattern name "{}" ({}) appears multiple times;\n renaming to "{}"'.format(
                                 pat.name, sanitized_name, 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

From 09615eaea6506c2208657daa5203035556787bf7 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 23 May 2020 19:38:17 -0700
Subject: [PATCH 09/71] use set() to remove any duplicates in patterns

---
 masque/file/gdsii.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 4837765..3496d9c 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -498,7 +498,7 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
             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]

From 1976c6e684eb096075019c9924b86f5a8d8d0d02 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 23 May 2020 19:38:48 -0700
Subject: [PATCH 10/71] Add `recursive` arg to referenced_patterns_by_id

---
 masque/pattern.py | 19 +++++++++++--------
 1 file changed, 11 insertions(+), 8 deletions(-)

diff --git a/masque/pattern.py b/masque/pattern.py
index 4348338..9cd49c2 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -441,28 +441,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']]]:

From 07ee25e735a372528afbce091864b2e37f854a1d Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 23 May 2020 19:39:03 -0700
Subject: [PATCH 11/71] add subpatterns_by_id()

---
 masque/pattern.py | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/masque/pattern.py b/masque/pattern.py
index 9cd49c2..e3f8c8c 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -486,6 +486,32 @@ 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_t]]:
+        """
+        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_t]] = 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

From 53d2a9ca1a850f1d19a64a466e63eb9dd4c44283 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 23 May 2020 19:39:48 -0700
Subject: [PATCH 12/71] Only swap between tuple/list if actually necessary

---
 masque/pattern.py | 18 ++++++++++--------
 1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/masque/pattern.py b/masque/pattern.py
index e3f8c8c..7599e06 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -764,10 +764,11 @@ 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)
+            object.__setattr__(self, 'locked', True)
         return self
 
     def unlock(self) -> 'Pattern':
@@ -777,10 +778,11 @@ class 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:
+            object.__setattr__(self, 'locked', False)
+            self.shapes = list(self.shapes)
+            self.labels = list(self.labels)
+            self.subpatterns = list(self.subpatterns)
         return self
 
     def deeplock(self) -> 'Pattern':

From f3a1db30c58b5a0d1014b51b0e845c3908f4853f Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 8 Jul 2020 18:21:42 -0700
Subject: [PATCH 13/71] Fix order of rotation/mirror/offset when calling
 as_pattern() on repetitions

---
 masque/repetition.py | 22 ++++++++++------------
 1 file changed, 10 insertions(+), 12 deletions(-)

diff --git a/masque/repetition.py b/masque/repetition.py
index 247f1eb..81e8b86 100644
--- a/masque/repetition.py
+++ b/masque/repetition.py
@@ -332,22 +332,20 @@ class GridRepetition:
         assert(self.pattern is not None)
         patterns = []
 
+        pat = self.pattern.deepcopy().deepunlock()
+        pat.scale_by(self.scale)
+        [pat.mirror(ax) for ax, do in enumerate(self.mirrored) if do]
+        pat.rotate_around((0.0, 0.0), self.rotation)
+        pat.translate_elements(self.offset)
+        pat.scale_element_doses(self.dose)
+
+        combined = type(pat)(name='__GridRepetition__')
         for a in range(self.a_count):
             for b in range(self.b_count):
                 offset = a * self.a_vector + b * self.b_vector
-                newPat = self.pattern.deepcopy().deepunlock()
+                newPat = pat.deepcopy()
                 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)
+                combined.append(newPat)
 
         return combined
 

From 1ae9225130ccea50fb7a3ae1553450f1385b3e48 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 8 Jul 2020 18:32:19 -0700
Subject: [PATCH 14/71] add rename() method for Pattern

---
 masque/pattern.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/masque/pattern.py b/masque/pattern.py
index 7599e06..84c274d 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -111,6 +111,10 @@ class Pattern:
                 locked=self.locked)
         return new
 
+    def rename(self, name: str) -> 'Pattern':
+        self.name = name
+        return self
+
     def append(self, other_pattern: 'Pattern') -> 'Pattern':
         """
         Appends all shapes, labels and subpatterns from other_pattern to self's shapes,

From 0589fbb1b8f903dbcf73ee25f736b438bf550a06 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 8 Jul 2020 18:42:39 -0700
Subject: [PATCH 15/71] bump version to v1.5

---
 masque/VERSION | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/VERSION b/masque/VERSION
index c068b24..c239c60 100644
--- a/masque/VERSION
+++ b/masque/VERSION
@@ -1 +1 @@
-1.4
+1.5

From a4b57762086cbb8c93a2f9b0d3c9481a4f3e2cf7 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sun, 12 Jul 2020 03:50:19 -0700
Subject: [PATCH 16/71] Don't return early, since we add patterns to memo
 before they've been checked

---
 masque/pattern.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/masque/pattern.py b/masque/pattern.py
index 84c274d..f254352 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -913,9 +913,6 @@ 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

From 89bd1e6abed71779a440b37df561b5307249ac2e Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sun, 12 Jul 2020 03:50:32 -0700
Subject: [PATCH 17/71] only add new_children (marginally faster)

---
 masque/pattern.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/pattern.py b/masque/pattern.py
index f254352..663b060 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -915,7 +915,7 @@ class Pattern:
         def get_children(pat: Pattern, memo: Set) -> Set:
             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)

From 0fa073b48820e5b59f8535e7ae4e2cf96b4867ca Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 21 Jul 2020 20:38:38 -0700
Subject: [PATCH 18/71] Make sure linspace gets an integer number of points

---
 masque/shapes/arc.py     | 5 +++--
 masque/shapes/circle.py  | 3 ++-
 masque/shapes/ellipse.py | 3 ++-
 3 files changed, 7 insertions(+), 4 deletions(-)

diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py
index 7b1cedc..25a9b2e 100644
--- a/masque/shapes/arc.py
+++ b/masque/shapes/arc.py
@@ -212,8 +212,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))
diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py
index f7221d5..404aeae 100644
--- a/masque/shapes/circle.py
+++ b/masque/shapes/circle.py
@@ -81,7 +81,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
diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py
index 9779e69..3288614 100644
--- a/masque/shapes/ellipse.py
+++ b/masque/shapes/ellipse.py
@@ -139,7 +139,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

From d4fbdd8d27167cfcb94016dc563c1b0b487f428a Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 11 Aug 2020 01:17:23 -0700
Subject: [PATCH 19/71] add fast-path for 0-degree rotations

---
 masque/shapes/path.py    | 3 ++-
 masque/shapes/polygon.py | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/masque/shapes/path.py b/masque/shapes/path.py
index db7831a..b618001 100644
--- a/masque/shapes/path.py
+++ b/masque/shapes/path.py
@@ -314,7 +314,8 @@ class Path(Shape):
         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':
diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py
index 71c9491..97cc66c 100644
--- a/masque/shapes/polygon.py
+++ b/masque/shapes/polygon.py
@@ -262,7 +262,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':

From bab40474a0c511926b544a141387b5df501a6d15 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 22 Jul 2020 02:45:16 -0700
Subject: [PATCH 20/71] Add repetitions and split up code into traits

---
 examples/test_rep.py          | 102 +++++++
 masque/__init__.py            |   9 +-
 masque/file/dxf.py            |  24 +-
 masque/file/gdsii.py          | 113 +++-----
 masque/file/oasis.py          |  55 ++--
 masque/label.py               |  93 +-----
 masque/pattern.py             |  21 +-
 masque/repetition.py          | 515 ++++++++++------------------------
 masque/shapes/arc.py          |   5 +-
 masque/shapes/circle.py       |   5 +-
 masque/shapes/ellipse.py      |   5 +-
 masque/shapes/path.py         |   4 +-
 masque/shapes/polygon.py      |   5 +-
 masque/shapes/shape.py        | 203 +-------------
 masque/shapes/text.py         |  24 +-
 masque/subpattern.py          | 194 +++----------
 masque/traits/__init__.py     |   9 +
 masque/traits/copyable.py     |  34 +++
 masque/traits/doseable.py     |  82 ++++++
 masque/traits/layerable.py    |  76 +++++
 masque/traits/lockable.py     |  76 +++++
 masque/traits/mirrorable.py   |  61 ++++
 masque/traits/positionable.py | 135 +++++++++
 masque/traits/repeatable.py   |  79 ++++++
 masque/traits/rotatable.py    | 119 ++++++++
 masque/traits/scalable.py     |  79 ++++++
 masque/utils.py               |  23 ++
 27 files changed, 1202 insertions(+), 948 deletions(-)
 create mode 100644 examples/test_rep.py
 create mode 100644 masque/traits/__init__.py
 create mode 100644 masque/traits/copyable.py
 create mode 100644 masque/traits/doseable.py
 create mode 100644 masque/traits/layerable.py
 create mode 100644 masque/traits/lockable.py
 create mode 100644 masque/traits/mirrorable.py
 create mode 100644 masque/traits/positionable.py
 create mode 100644 masque/traits/repeatable.py
 create mode 100644 masque/traits/rotatable.py
 create mode 100644 masque/traits/scalable.py

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

From 794ebb6b37a36e05728311b780acc0d7473a37ad Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 22 Jul 2020 21:48:34 -0700
Subject: [PATCH 21/71] repetition related fixup

---
 masque/file/dxf.py            |  8 ++--
 masque/file/gdsii.py          |  9 +++--
 masque/file/oasis.py          | 14 ++++---
 masque/repetition.py          | 70 +++++++++++++++++++++++++++++++++--
 masque/subpattern.py          | 22 ++---------
 masque/traits/doseable.py     |  8 ++--
 masque/traits/layerable.py    | 10 ++---
 masque/traits/lockable.py     | 12 ------
 masque/traits/mirrorable.py   |  2 +-
 masque/traits/positionable.py |  8 ++--
 masque/traits/repeatable.py   | 17 ++++++---
 11 files changed, 112 insertions(+), 68 deletions(-)

diff --git a/masque/file/dxf.py b/masque/file/dxf.py
index 4faac8c..56b0bc5 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -299,14 +299,14 @@ def _subpatterns_to_refs(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.M
             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)
diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 4c2198c..0be8e3e 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -365,8 +365,8 @@ def _ref_to_subpat(element: Union[gdsii.elements.SRef,
     if isinstance(element, gdsii.elements.ARef):
         a_count = element.cols
         b_count = element.rows
-        a_vector = (element.xy[1] - offset) / counts[0]
-        b_vector = (element.xy[2] - offset) / counts[1]
+        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)
 
@@ -389,9 +389,10 @@ def _subpatterns_to_refs(subpatterns: List[SubPattern]
 
         # Note: GDS mirrors first and rotates second
         mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored)
-        ref: Union[gdsii.elements.SRef, gdsii.elements.ARef]
-
         rep = subpat.repetition
+
+        new_refs: List[Union[gdsii.elements.SRef, gdsii.elements.ARef]]
+        ref: Union[gdsii.elements.SRef, gdsii.elements.ARef]
         if isinstance(rep, Grid):
             xy = numpy.array(subpat.offset) + [
                   [0, 0],
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 775cff7..981d1e0 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -469,7 +469,7 @@ def _placement_to_subpat(placement: fatrec.Placement) -> SubPattern:
        'identifier': (name,),
        }
 
-    mrep: Repetition
+    mrep: Optional[Repetition]
     rep = placement.repetition
     if isinstance(rep, fatamorgana.GridRepetition):
         mrep = Grid(a_vector=rep.a_vector,
@@ -477,8 +477,10 @@ def _placement_to_subpat(placement: fatrec.Placement) -> SubPattern:
                     a_count=rep.a_count,
                     b_count=rep.b_count)
     elif isinstance(rep, fatamorgana.ArbitraryRepetition):
-        mrep = Arbitrary(numpy.cumsum(numpy.column_stack((rep.x_displacements,
-                                                          rep.y_displacements))))
+        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
 
@@ -510,8 +512,10 @@ def _subpatterns_to_refs(subpatterns: List[SubPattern]
                               b_count=numpy.round(rep.b_count).astype(int))
         elif isinstance(rep, Arbitrary):
             diffs = numpy.diff(rep.displacements, axis=0)
-            args['repetition'] = fatamorgana.ArbitraryRepetition(
-                                    numpy.round(diffs).astype(int))
+            diff_ints = numpy.round(diffs).astype(int)
+            args['repetition'] = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1])
+            args['x'] += rep.displacements[0, 0]
+            args['y'] += rep.displacements[0, 1]
         else:
             assert(rep is None)
 
diff --git a/masque/repetition.py b/masque/repetition.py
index 737fb33..5707a50 100644
--- a/masque/repetition.py
+++ b/masque/repetition.py
@@ -217,7 +217,7 @@ class Grid(LockableImpl, Repetition, metaclass=AutoSlots):
 
         corners = ((0, 0), a_extent, b_extent, a_extent + b_extent)
         xy_min = numpy.min(corners, axis=0)
-        xy_max = numpy.min(corners, axis=0)
+        xy_max = numpy.max(corners, axis=0)
         return numpy.array((xy_min, xy_max))
 
     def scale_by(self, c: float) -> 'Grid':
@@ -298,9 +298,6 @@ class Arbitrary(LockableImpl, Repetition, metaclass=AutoSlots):
           of the instances.
     """
 
-    locked: bool
-    """ If `True`, disallows changes to the object. """
-
     @property
     def displacements(self) -> numpy.ndarray:
         return self._displacements
@@ -311,6 +308,18 @@ class Arbitrary(LockableImpl, Repetition, metaclass=AutoSlots):
         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.
@@ -343,3 +352,56 @@ class Arbitrary(LockableImpl, Repetition, metaclass=AutoSlots):
         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)
+
+        Returns:
+            self
+        """
+        self.displacements = numpy.dot(rotation_matrix_2d(rotation), self.displacements.T).T
+        return self
+
+    def mirror(self, axis: int) -> 'Arbitrary':
+        """
+        Mirror the displacements across an axis.
+
+        Args:
+            axis: Axis to mirror across.
+                (0: mirror across x-axis, 1: mirror across y-axis)
+
+        Returns:
+            self
+        """
+        self.displacements[1-axis] *= -1
+        return self
+
+    def get_bounds(self) -> Optional[numpy.ndarray]:
+        """
+        Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
+         extent of the `displacements` in each dimension.
+
+        Returns:
+            `[[x_min, y_min], [x_max, y_max]]` or `None`
+        """
+        xy_min = numpy.min(self.displacements, axis=0)
+        xy_max = numpy.max(self.displacements, axis=0)
+        return numpy.array((xy_min, xy_max))
+
+    def scale_by(self, c: float) -> 'Arbitrary':
+        """
+        Scale the displacements by a factor
+
+        Args:
+            c: scaling factor
+
+        Returns:
+            self
+        """
+        self.displacements *= c
+        return self
+
diff --git a/masque/subpattern.py b/masque/subpattern.py
index 7243e5e..e898349 100644
--- a/masque/subpattern.py
+++ b/masque/subpattern.py
@@ -35,27 +35,12 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
     _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 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 SubPattern"""
-
     def __init__(self,
                  pattern: Optional['Pattern'],
                  offset: vector2 = (0.0, 0.0),
@@ -79,7 +64,6 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
             identifier: Arbitrary tuple, used internally by some `masque` functions.
         """
         LockableImpl.unlock(self)
-#        object.__setattr__(self, 'locked', False)
         self.identifier = identifier
         self.pattern = pattern
         self.offset = offset
@@ -146,9 +130,9 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
         pattern.translate_elements(self.offset)
         pattern.scale_element_doses(self.dose)
 
-        if pattern.repetition is not None:
-            combined = type(pat)(name='__repetition__')
-            for dd in pattern.repetition.displacements:
+        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)
diff --git a/masque/traits/doseable.py b/masque/traits/doseable.py
index ded93fa..4b9daf3 100644
--- a/masque/traits/doseable.py
+++ b/masque/traits/doseable.py
@@ -28,10 +28,10 @@ class Doseable(metaclass=ABCMeta):
         """
         pass
 
-    @dose.setter
-    @abstractmethod
-    def dose(self, val: float):
-        pass
+#    @dose.setter
+#    @abstractmethod
+#    def dose(self, val: float):
+#        pass
 
     '''
     ---- Methods
diff --git a/masque/traits/layerable.py b/masque/traits/layerable.py
index 4cf0b1f..5382450 100644
--- a/masque/traits/layerable.py
+++ b/masque/traits/layerable.py
@@ -25,12 +25,12 @@ class Layerable(metaclass=ABCMeta):
         """
         Layer number or name (int, tuple of ints, or string)
         """
-        return self._layer
+        pass
 
-    @layer.setter
-    @abstractmethod
-    def layer(self, val: layer_t):
-        self._layer = val
+#    @layer.setter
+#    @abstractmethod
+#    def layer(self, val: layer_t):
+#        pass
 
     '''
     ---- Methods
diff --git a/masque/traits/lockable.py b/masque/traits/lockable.py
index e0ea24e..cc12760 100644
--- a/masque/traits/lockable.py
+++ b/masque/traits/lockable.py
@@ -19,18 +19,6 @@ class Lockable(metaclass=ABCMeta):
     '''
     ---- Methods
     '''
-    def set_dose(self: T, dose: float) -> T:
-        """
-        Set the dose
-
-        Args:
-            dose: new value for dose
-
-        Returns:
-            self
-        """
-        pass
-
     def lock(self: T) -> T:
         """
         Lock the object, disallowing further changes
diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py
index 76226c4..1e10800 100644
--- a/masque/traits/mirrorable.py
+++ b/masque/traits/mirrorable.py
@@ -6,7 +6,7 @@ import numpy
 from ..error import PatternError, PatternLockedError
 
 T = TypeVar('T', bound='Mirrorable')
-T = TypeVar('T', bound='MirrorableImpl')
+#I = TypeVar('I', bound='MirrorableImpl')
 
 
 class Mirrorable(metaclass=ABCMeta):
diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py
index 3669a7e..4db6880 100644
--- a/masque/traits/positionable.py
+++ b/masque/traits/positionable.py
@@ -30,10 +30,10 @@ class Positionable(metaclass=ABCMeta):
         """
         pass
 
-    @offset.setter
-    @abstractmethod
-    def offset(self, val: vector2):
-        pass
+#    @offset.setter
+#    @abstractmethod
+#    def offset(self, val: vector2):
+#        pass
 
     '''
     --- Abstract methods
diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py
index 1f2a99b..3971a94 100644
--- a/masque/traits/repeatable.py
+++ b/masque/traits/repeatable.py
@@ -1,4 +1,4 @@
-from typing import List, Tuple, Callable, TypeVar, Optional
+from typing import List, Tuple, Callable, TypeVar, Optional, TYPE_CHECKING
 from abc import ABCMeta, abstractmethod
 import copy
 import numpy
@@ -6,6 +6,10 @@ import numpy
 from ..error import PatternError, PatternLockedError
 
 
+if TYPE_CHECKING:
+    from ..repetition import Repetition
+
+
 T = TypeVar('T', bound='Repeatable')
 I = TypeVar('I', bound='RepeatableImpl')
 
@@ -27,14 +31,15 @@ class Repeatable(metaclass=ABCMeta):
         """
         pass
 
-    @repetition.setter
-    @abstractmethod
-    def repetition(self, repetition: Optional['Repetition']):
-        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
@@ -74,6 +79,6 @@ class RepeatableImpl(Repeatable, metaclass=ABCMeta):
     '''
     ---- Non-abstract methods
     '''
-    def set_repetition(self: I, repetition: 'Repetition') -> I:
+    def set_repetition(self: I, repetition: Optional['Repetition']) -> I:
         self.repetition = repetition
         return self

From ad6fa88e53bd70d37b18b8beb49e57c8a344b99b Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 22 Jul 2020 21:49:27 -0700
Subject: [PATCH 22/71] Expect name to still be a string after disambiguation

Check that encode('ascii') doesn't make it zero-length, but don't
actually return the encoded form.
---
 masque/file/gdsii.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 0be8e3e..c91c061 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -119,7 +119,7 @@ def build(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)
@@ -385,7 +385,7 @@ def _subpatterns_to_refs(subpatterns: List[SubPattern]
     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)
@@ -514,5 +514,5 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
         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))
 
-        pat.name = encoded_name
+        pat.name = suffixed_name
         used_names.append(suffixed_name)

From 629a6a9ba2d8113a0d73de07364f562970b16c34 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 22 Jul 2020 21:50:39 -0700
Subject: [PATCH 23/71] enable per-shape repetitions

---
 masque/label.py          | 11 +++++++---
 masque/pattern.py        | 46 ++++++++++++++++++++++++++++++++++++++++
 masque/shapes/arc.py     |  3 +++
 masque/shapes/circle.py  |  3 +++
 masque/shapes/ellipse.py |  3 +++
 masque/shapes/path.py    |  3 +++
 masque/shapes/polygon.py |  3 +++
 masque/shapes/shape.py   |  5 +++--
 masque/shapes/text.py    |  3 +++
 9 files changed, 75 insertions(+), 5 deletions(-)

diff --git a/masque/label.py b/masque/label.py
index 9a6d326..72b7266 100644
--- a/masque/label.py
+++ b/masque/label.py
@@ -1,14 +1,16 @@
-from typing import List, Tuple, Dict
+from typing import List, Tuple, Dict, Optional
 import copy
 import numpy
 from numpy import pi
 
+from .repetition import Repetition
 from .error import PatternError, PatternLockedError
 from .utils import is_scalar, vector2, rotation_matrix_2d, layer_t, AutoSlots
-from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, LockableImpl
+from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, LockableImpl, RepeatableImpl
 
 
-class Label(PositionableImpl, LayerableImpl, LockableImpl, Pivotable, Copyable, metaclass=AutoSlots):
+class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl,
+            Pivotable, Copyable, metaclass=AutoSlots):
     """
     A text annotation with a position and layer (but no size; it is not drawn)
     """
@@ -39,18 +41,21 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, Pivotable, Copyable,
                  string: str,
                  offset: vector2 = (0.0, 0.0),
                  layer: layer_t = 0,
+                 repetition: Optional[Repetition] = None,
                  locked: bool = False):
         object.__setattr__(self, 'locked', False)
         self.identifier = ()
         self.string = string
         self.offset = numpy.array(offset, dtype=float, copy=True)
         self.layer = layer
+        self.repetition = repetition
         self.locked = locked
 
     def __copy__(self) -> 'Label':
         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':
diff --git a/masque/pattern.py b/masque/pattern.py
index 2e57464..9c45749 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -575,6 +575,52 @@ class Pattern:
             self.append(p)
         return self
 
+    def wrap_repeated_shapes(self,
+                             name_func: Callable[['Pattern', Union[Shape, Label]], str] = lambda p, s: '_repetition',
+                             recursive: bool = True,
+                             ) -> 'Pattern':
+        """
+        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
+
+            new_subpatterns = []
+            for shape in pat.shapes:
+                if shape.repetition is None:
+                    continue
+                new_subpatterns.append(SubPattern(Pattern(name_func(pat, shape), shapes=[shape])))
+                shape.repetition = None
+
+            for label in self.labels:
+                if label.repetition is None:
+                    continue
+                new_subpatterns.append(SubPattern(Pattern(name_func(pat, shape), labels=[label])))
+                label.repetition = None
+
+            pat.subpatterns += new_subpatterns
+            return pat
+
+        if recursive:
+            self.apply(do_wrap)
+        else:
+            do_wrap(self)
+
+        return self
+
+
     def translate_elements(self, offset: vector2) -> 'Pattern':
         """
         Translates all shapes, label, and subpatterns by the given offset.
diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py
index 7de8f76..a33d0d7 100644
--- a/masque/shapes/arc.py
+++ b/masque/shapes/arc.py
@@ -6,6 +6,7 @@ from numpy import pi
 
 from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
 from .. import PatternError
+from ..repetition import Repetition
 from ..utils import is_scalar, vector2, layer_t, AutoSlots
 
 
@@ -158,6 +159,7 @@ class Arc(Shape, metaclass=AutoSlots):
                  mirrored: Sequence[bool] = (False, False),
                  layer: layer_t = 0,
                  dose: float = 1.0,
+                 repetition: Optional[Repetition] = None,
                  locked: bool = False):
         object.__setattr__(self, 'locked', False)
         self.identifier = ()
@@ -171,6 +173,7 @@ class Arc(Shape, metaclass=AutoSlots):
         self.dose = dose
         self.poly_num_points = poly_num_points
         self.poly_max_arclen = poly_max_arclen
+        self.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Arc':
diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py
index 1090588..2834b2a 100644
--- a/masque/shapes/circle.py
+++ b/masque/shapes/circle.py
@@ -5,6 +5,7 @@ from numpy import pi
 
 from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
 from .. import PatternError
+from ..repetition import Repetition
 from ..utils import is_scalar, vector2, layer_t, AutoSlots
 
 
@@ -46,6 +47,7 @@ class Circle(Shape, metaclass=AutoSlots):
                  offset: vector2 = (0.0, 0.0),
                  layer: layer_t = 0,
                  dose: float = 1.0,
+                 repetition: Optional[Repetition] = None,
                  locked: bool = False):
         object.__setattr__(self, 'locked', False)
         self.identifier = ()
@@ -55,6 +57,7 @@ class Circle(Shape, metaclass=AutoSlots):
         self.radius = radius
         self.poly_num_points = poly_num_points
         self.poly_max_arclen = poly_max_arclen
+        self.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Circle':
diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py
index 45c1ea1..f4cc683 100644
--- a/masque/shapes/ellipse.py
+++ b/masque/shapes/ellipse.py
@@ -6,6 +6,7 @@ from numpy import pi
 
 from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
 from .. import PatternError
+from ..repetition import Repetition
 from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots
 
 
@@ -93,6 +94,7 @@ class Ellipse(Shape, metaclass=AutoSlots):
                  mirrored: Sequence[bool] = (False, False),
                  layer: layer_t = 0,
                  dose: float = 1.0,
+                 repetition: Optional[Repetition] = None,
                  locked: bool = False):
         object.__setattr__(self, 'locked', False)
         self.identifier = ()
@@ -104,6 +106,7 @@ class Ellipse(Shape, metaclass=AutoSlots):
         self.dose = dose
         self.poly_num_points = poly_num_points
         self.poly_max_arclen = poly_max_arclen
+        self.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Ellipse':
diff --git a/masque/shapes/path.py b/masque/shapes/path.py
index d4ffac9..15b1ead 100644
--- a/masque/shapes/path.py
+++ b/masque/shapes/path.py
@@ -6,6 +6,7 @@ from numpy import pi, inf
 
 from . import Shape, normalized_shape_tuple, Polygon, Circle
 from .. import PatternError
+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
 
@@ -147,6 +148,7 @@ class Path(Shape, metaclass=AutoSlots):
                  mirrored: Sequence[bool] = (False, False),
                  layer: layer_t = 0,
                  dose: float = 1.0,
+                 repetition: Optional[Repetition] = None,
                  locked: bool = False,
                  ):
         object.__setattr__(self, 'locked', False)
@@ -163,6 +165,7 @@ class Path(Shape, metaclass=AutoSlots):
             self.cap_extensions = cap_extensions
         self.rotate(rotation)
         [self.mirror(a) for a, do in enumerate(mirrored) if do]
+        self.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Path':
diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py
index ca0c301..cee5780 100644
--- a/masque/shapes/polygon.py
+++ b/masque/shapes/polygon.py
@@ -5,6 +5,7 @@ from numpy import pi
 
 from . import Shape, normalized_shape_tuple
 from .. import PatternError
+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
 
@@ -75,6 +76,7 @@ class Polygon(Shape, metaclass=AutoSlots):
                  mirrored: Sequence[bool] = (False, False),
                  layer: layer_t = 0,
                  dose: float = 1.0,
+                 repetition: Optional[Repetition] = None,
                  locked: bool = False,
                  ):
         object.__setattr__(self, 'locked', False)
@@ -85,6 +87,7 @@ class Polygon(Shape, metaclass=AutoSlots):
         self.offset = offset
         self.rotate(rotation)
         [self.mirror(a) for a, do in enumerate(mirrored) if do]
+        self.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Optional[Dict] = None) -> 'Polygon':
diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py
index 759942f..c6a0c63 100644
--- a/masque/shapes/shape.py
+++ b/masque/shapes/shape.py
@@ -7,7 +7,7 @@ from ..error import PatternError, PatternLockedError
 from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t
 from ..traits import (PositionableImpl, LayerableImpl, DoseableImpl,
                       Rotatable, Mirrorable, Copyable, Scalable,
-                      PivotableImpl, LockableImpl)
+                      PivotableImpl, LockableImpl, RepeatableImpl)
 
 if TYPE_CHECKING:
     from . import Polygon
@@ -26,7 +26,8 @@ DEFAULT_POLY_NUM_POINTS = 24
 T = TypeVar('T', bound='Shape')
 
 
-class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable, Copyable, Scalable, PivotableImpl, LockableImpl, metaclass=ABCMeta):
+class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable, Copyable, Scalable,
+            PivotableImpl, RepeatableImpl, LockableImpl, metaclass=ABCMeta):
     """
     Abstract class specifying functions common to all shapes.
     """
diff --git a/masque/shapes/text.py b/masque/shapes/text.py
index 9b00161..d796511 100644
--- a/masque/shapes/text.py
+++ b/masque/shapes/text.py
@@ -5,6 +5,7 @@ from numpy import pi, inf
 
 from . import Shape, Polygon, normalized_shape_tuple
 from .. import PatternError
+from ..repetition import Repetition
 from ..traits import RotatableImpl
 from ..utils import is_scalar, vector2, get_bit, normalize_mirror, layer_t, AutoSlots
 
@@ -65,6 +66,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
                  mirrored: Tuple[bool, bool] = (False, False),
                  layer: layer_t = 0,
                  dose: float = 1.0,
+                 repetition: Optional[Repetition] = None,
                  locked: bool = False,
                  ):
         object.__setattr__(self, 'locked', False)
@@ -77,6 +79,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
         self.rotation = rotation
         self.font_path = font_path
         self.mirrored = mirrored
+        self.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Text':

From 7ce601dc1d5875dcc1a665fa40feb6e21e06fc9f Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Mon, 27 Jul 2020 01:32:34 -0700
Subject: [PATCH 24/71] Enable repeated shapes in gdsii and oasis

---
 masque/file/gdsii.py |   2 +
 masque/file/oasis.py | 123 +++++++++++++++++++++++++------------------
 2 files changed, 75 insertions(+), 50 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index c91c061..a8a583d 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -103,6 +103,8 @@ def build(patterns: Union[Pattern, List[Pattern]],
     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'),
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 981d1e0..834949d 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -260,18 +260,19 @@ def read(stream: io.BufferedIOBase,
         for element in cell.geometry:
             if isinstance(element, fatrec.XElement):
                 logger.warning('Skipping XElement record')
+                # note XELEMENT has no repetition
                 continue
 
-            if element.repetition is not None:
-                # note XELEMENT has no repetition
-                raise PatternError('masque OASIS reader does not implement repetitions for shapes yet')
+
+            repetition = repetition_fata2masq(element.repetition)
 
             # Switch based on element type:
             if isinstance(element, fatrec.Polygon):
                 vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
                 poly = Polygon(vertices=vertices,
                                layer=element.get_layer_tuple(),
-                               offset=element.get_xy())
+                               offset=element.get_xy(),
+                               repetition=repetition)
 
                 if clean_vertices:
                     try:
@@ -297,6 +298,7 @@ def read(stream: io.BufferedIOBase,
                 path = Path(vertices=vertices,
                             layer=element.get_layer_tuple(),
                             offset=element.get_xy(),
+                            repeition=repetition,
                             width=element.get_half_width() * 2,
                             cap=cap,
                             **path_args)
@@ -314,6 +316,7 @@ def read(stream: io.BufferedIOBase,
                 height = element.get_height()
                 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),
                                )
                 pat.shapes.append(rect)
@@ -345,6 +348,7 @@ def read(stream: io.BufferedIOBase,
 
                 trapz = Polygon(layer=element.get_layer_tuple(),
                                 offset=element.get_xy(),
+                                repetition=repetition,
                                 vertices=vertices,
                                 )
                 pat.shapes.append(trapz)
@@ -396,20 +400,23 @@ def read(stream: io.BufferedIOBase,
                     vertices[0, 1] += width
 
                 ctrapz = Polygon(layer=element.get_layer_tuple(),
-                                offset=element.get_xy(),
-                                vertices=vertices,
-                                )
+                                 offset=element.get_xy(),
+                                 repetition=repetition,
+                                 vertices=vertices,
+                                 )
                 pat.shapes.append(ctrapz)
 
             elif isinstance(element, fatrec.Circle):
                 circle = Circle(layer=element.get_layer_tuple(),
                                 offset=element.get_xy(),
+                                repetition=repetition,
                                 radius=float(element.get_radius()))
                 pat.shapes.append(circle)
 
             elif isinstance(element, fatrec.Text):
                 label = Label(layer=element.get_layer_tuple(),
                               offset=element.get_xy(),
+                              repetition=repetition,
                               string=str(element.get_string()))
                 pat.labels.append(label)
 
@@ -467,24 +474,10 @@ def _placement_to_subpat(placement: fatrec.Placement) -> SubPattern:
        'rotation': float(placement.angle * pi/180),
        'scale': mag,
        'identifier': (name,),
+       'repetition': repetition_fata2masq(placement.repetition),
        }
 
-    mrep: Optional[Repetition]
-    rep = placement.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
-
-    subpat = SubPattern(offset=xy, repetition=mrep, **args)
+    subpat = SubPattern(offset=xy, **args)
     return subpat
 
 
@@ -497,27 +490,14 @@ def _subpatterns_to_refs(subpatterns: List[SubPattern]
 
         # Note: OASIS mirrors first and rotates second
         mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored)
-        xy = numpy.round(subpat.offset).astype(int)
-        args: Dict[str, Any] = {
-            'x': xy[0],
-            'y': xy[1],
-            }
+        frep, rep_offset = repetition_masq2fata(subpat.repetition)
 
-        rep = subpat.repetition
-        if isinstance(rep, Grid):
-            args['repetition'] = 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))
-        elif isinstance(rep, Arbitrary):
-            diffs = numpy.diff(rep.displacements, axis=0)
-            diff_ints = numpy.round(diffs).astype(int)
-            args['repetition'] = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1])
-            args['x'] += rep.displacements[0, 0]
-            args['y'] += rep.displacements[0, 1]
-        else:
-            assert(rep is None)
+        offset = numpy.round(subpat.offset + rep_offset).astype(int)
+        args: Dict[str, Any] = {
+            'x': offset[0],
+            'y': offset[1],
+            'repetition': frep,
+            }
 
         angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360
         ref = fatrec.Placement(
@@ -538,17 +518,19 @@ def _shapes_to_elements(shapes: List[Shape],
     elements: List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]] = []
     for shape in shapes:
         layer, datatype = layer2oas(shape.layer)
+        repetition, rep_offset = repetition_masq2fata(shape.repetition)
         if isinstance(shape, Circle):
-            offset = numpy.round(shape.offset).astype(int)
+            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])
+                                   y=offset[1],
+                                   repetition=repetition)
             elements.append(circle)
         elif isinstance(shape, Path):
-            xy = numpy.round(shape.offset + shape.vertices[0]).astype(int)
+            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
@@ -562,17 +544,19 @@ def _shapes_to_elements(shapes: List[Shape],
                                y=xy[1],
                                extension_start=extension_start,       #TODO implement multiple cap types?
                                extension_end=extension_end,
+                               repetition=repetition,
                                )
             elements.append(path)
         else:
             for polygon in shape.to_polygons():
-                xy = numpy.round(polygon.offset + polygon.vertices[0]).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,
                                                datatype=datatype,
                                                x=xy[0],
                                                y=xy[1],
-                                               point_list=points))
+                                               point_list=points,
+                                               repetition=repetition))
     return elements
 
 
@@ -582,12 +566,14 @@ def _labels_to_texts(labels: List[Label],
     texts = []
     for label in labels:
         layer, datatype = layer2oas(label.layer)
-        xy = numpy.round(label.offset).astype(int)
+        repetition, rep_offset = repetition_masq2fata(shape.repetition)
+        xy = numpy.round(label.offset + rep_offset).astype(int)
         texts.append(fatrec.Text(layer=layer,
                                  datatype=datatype,
                                  x=xy[0],
                                  y=xy[1],
-                                 string=label.string))
+                                 string=label.string,
+                                 repetition=repetition))
     return texts
 
 
@@ -619,3 +605,40 @@ def disambiguate_pattern_names(patterns,
 
         pat.name = suffixed_name
         used_names.append(suffixed_name)
+
+
+def repetition_fata2masq(rep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None]
+                         ) -> 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]):
+    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

From f57ccc073d9981b0d7fd237921480121dd216ace Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 11 Aug 2020 01:18:29 -0700
Subject: [PATCH 25/71] add `raw` arg, which skips setter funcs

---
 masque/shapes/arc.py     | 32 ++++++++++++++++++++++----------
 masque/shapes/ellipse.py | 24 +++++++++++++++++-------
 masque/shapes/path.py    | 26 ++++++++++++++++++--------
 masque/shapes/polygon.py | 18 +++++++++++++-----
 masque/shapes/text.py    | 27 +++++++++++++++++++--------
 5 files changed, 89 insertions(+), 38 deletions(-)

diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py
index a33d0d7..4918ea7 100644
--- a/masque/shapes/arc.py
+++ b/masque/shapes/arc.py
@@ -160,20 +160,32 @@ class Arc(Shape, metaclass=AutoSlots):
                  layer: layer_t = 0,
                  dose: float = 1.0,
                  repetition: Optional[Repetition] = None,
-                 locked: bool = False):
+                 locked: bool = False,
+                 raw: bool = False,
+                 ):
         object.__setattr__(self, 'locked', False)
         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._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.layer = layer
+            self.dose = dose
         self.poly_num_points = poly_num_points
         self.poly_max_arclen = poly_max_arclen
-        self.repetition = repetition
+        [self.mirror(a) for a, do in enumerate(mirrored) if do]
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Arc':
diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py
index f4cc683..e3836a1 100644
--- a/masque/shapes/ellipse.py
+++ b/masque/shapes/ellipse.py
@@ -95,18 +95,28 @@ class Ellipse(Shape, metaclass=AutoSlots):
                  layer: layer_t = 0,
                  dose: float = 1.0,
                  repetition: Optional[Repetition] = None,
-                 locked: bool = False):
+                 locked: bool = False,
+                 raw: bool = False,
+                 ):
         object.__setattr__(self, 'locked', False)
         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._layer = layer
+            self._dose = dose
+        else:
+            self.radii = radii
+            self.offset = offset
+            self.rotation = rotation
+            self.repetition = repetition
+            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.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Ellipse':
diff --git a/masque/shapes/path.py b/masque/shapes/path.py
index 15b1ead..3f2f72f 100644
--- a/masque/shapes/path.py
+++ b/masque/shapes/path.py
@@ -150,22 +150,32 @@ class Path(Shape, metaclass=AutoSlots):
                  dose: float = 1.0,
                  repetition: Optional[Repetition] = None,
                  locked: bool = False,
+                 raw: bool = False,
                  ):
         object.__setattr__(self, 'locked', False)
         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._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.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.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Path':
diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py
index cee5780..08da89e 100644
--- a/masque/shapes/polygon.py
+++ b/masque/shapes/polygon.py
@@ -78,16 +78,24 @@ class Polygon(Shape, metaclass=AutoSlots):
                  dose: float = 1.0,
                  repetition: Optional[Repetition] = None,
                  locked: bool = False,
+                 raw: bool = False,
                  ):
         object.__setattr__(self, 'locked', False)
         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._layer = layer
+            self._dose = dose
+        else:
+            self.vertices = vertices
+            self.offset = offset
+            self.repetition = repetition
+            self.layer = layer
+            self.dose = dose
         self.rotate(rotation)
         [self.mirror(a) for a, do in enumerate(mirrored) if do]
-        self.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Optional[Dict] = None) -> 'Polygon':
diff --git a/masque/shapes/text.py b/masque/shapes/text.py
index d796511..599d377 100644
--- a/masque/shapes/text.py
+++ b/masque/shapes/text.py
@@ -68,18 +68,29 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
                  dose: float = 1.0,
                  repetition: Optional[Repetition] = None,
                  locked: bool = False,
+                 raw: bool = False,
                  ):
         object.__setattr__(self, 'locked', False)
         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
+        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.font_path = font_path
-        self.mirrored = mirrored
-        self.repetition = repetition
         self.locked = locked
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Text':

From 99ded5c1138f69e2d5df214e5dd882a827b778fd Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 11 Aug 2020 01:18:52 -0700
Subject: [PATCH 26/71] Don't bother checking that dose is a scalar

---
 masque/traits/doseable.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/masque/traits/doseable.py b/masque/traits/doseable.py
index 4b9daf3..419d0bd 100644
--- a/masque/traits/doseable.py
+++ b/masque/traits/doseable.py
@@ -67,8 +67,6 @@ class DoseableImpl(Doseable, metaclass=ABCMeta):
 
     @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

From b98553a7709c85084fb69ffcadf463d71d0eb3c9 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 12 Aug 2020 21:42:57 -0700
Subject: [PATCH 27/71] set repetition on subpattern

---
 masque/file/gdsii.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index a8a583d..353bb74 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -376,7 +376,8 @@ def _ref_to_subpat(element: Union[gdsii.elements.SRef,
                         offset=offset,
                         rotation=rotation,
                         scale=scale,
-                        mirrored=(mirror_across_x, False))
+                        mirrored=(mirror_across_x, False),
+                        repetition=repetition)
     subpat.identifier = (element.struct_name,)
     return subpat
 

From d14182998b0ec5c5024e3f8509887a7c3a46b3b0 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 12 Aug 2020 21:43:46 -0700
Subject: [PATCH 28/71] various fixes

---
 masque/repetition.py          |  4 ++--
 masque/shapes/shape.py        | 13 +++++++------
 masque/subpattern.py          | 21 +++++++++++----------
 masque/traits/positionable.py |  3 +--
 masque/traits/rotatable.py    |  1 +
 masque/utils.py               | 17 +++++++++++------
 6 files changed, 33 insertions(+), 26 deletions(-)

diff --git a/masque/repetition.py b/masque/repetition.py
index 5707a50..5c9f105 100644
--- a/masque/repetition.py
+++ b/masque/repetition.py
@@ -170,8 +170,8 @@ class Grid(LockableImpl, Repetition, metaclass=AutoSlots):
     @property
     def displacements(self) -> numpy.ndarray:
         aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
-        return (aa.flat[:, None] * self.a_vector[None, :] +
-                bb.flat[:, None] * self.b_vector[None, :])
+        return (aa.flatten()[:, None] * self.a_vector[None, :] +
+                bb.flatten()[:, None] * self.b_vector[None, :])
 
     def rotate(self, rotation: float) -> 'Grid':
         """
diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py
index c6a0c63..7a5e3f3 100644
--- a/masque/shapes/shape.py
+++ b/masque/shapes/shape.py
@@ -31,16 +31,17 @@ class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable
     """
     Abstract class specifying functions common to all shapes.
     """
+    __slots__ = ()      # Children should use AutoSlots
 
     identifier: Tuple
     """ An arbitrary identifier for the shape, usually empty but used by `Pattern.flatten()` """
 
-#    def __copy__(self) -> 'Shape':
-#        cls = self.__class__
-#        new = cls.__new__(cls)
-#        for name in Shape.__slots__ + self.__slots__:
-#            object.__setattr__(new, name, getattr(self, name))
-#        return new
+    def __copy__(self) -> 'Shape':
+        cls = self.__class__
+        new = cls.__new__(cls)
+        for name in self.__slots__:
+            object.__setattr__(new, name, getattr(self, name))
+        return new
 
     '''
     --- Abstract methods
diff --git a/masque/subpattern.py b/masque/subpattern.py
index e898349..6964676 100644
--- a/masque/subpattern.py
+++ b/masque/subpattern.py
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
 
 
 class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mirrorable,
-                 Pivotable, Copyable, RepeatableImpl, LockableImpl, metaclass=AutoSlots):
+                 PivotableImpl, Copyable, RepeatableImpl, LockableImpl, metaclass=AutoSlots):
     """
     SubPattern provides basic support for nesting Pattern objects within each other, by adding
      offset, rotation, scaling, and associated methods.
@@ -83,6 +83,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
                          dose=self.dose,
                          scale=self.scale,
                          mirrored=self.mirrored.copy(),
+                         repetition=copy.deepcopy(self.repetition),
                          locked=self.locked)
         return new
 
@@ -90,6 +91,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
         memo = {} if memo is None else memo
         new = copy.copy(self).unlock()
         new.pattern = copy.deepcopy(self.pattern, memo)
+        new.repetition = copy.deepcopy(self.repetition, memo)
         new.locked = self.locked
         return new
 
@@ -140,18 +142,17 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
 
         return pattern
 
+    def rotate(self, rotation: float) -> 'SubPattern':
+        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
-        """
         self.mirrored[axis] = not self.mirrored[axis]
         self.rotation *= -1
+        if self.repetition is not None:
+            self.repetiton.mirror(axis)
         return self
 
     def get_bounds(self) -> Optional[numpy.ndarray]:
diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py
index 4db6880..d433a91 100644
--- a/masque/traits/positionable.py
+++ b/masque/traits/positionable.py
@@ -94,7 +94,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
 
     @offset.setter
     def offset(self, val: vector2):
-        if not isinstance(val, numpy.ndarray):
+        if not isinstance(val, numpy.ndarray) or val.dtype != numpy.float64:
             val = numpy.array(val, dtype=float)
 
         if val.size != 2:
@@ -109,7 +109,6 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
         self.offset = offset
         return self
 
-
     def translate(self: I, offset: vector2) -> I:
         self._offset += offset
         return self
diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py
index e01c81d..64c2c51 100644
--- a/masque/traits/rotatable.py
+++ b/masque/traits/rotatable.py
@@ -109,6 +109,7 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta):
     """
     __slots__ = ()
 
+    @abstractmethod
     def rotate_around(self: J, pivot: vector2, rotation: float) -> J:
         pivot = numpy.array(pivot, dtype=float)
         self.translate(-pivot)
diff --git a/masque/utils.py b/masque/utils.py
index 979ffde..2f2e499 100644
--- a/masque/utils.py
+++ b/masque/utils.py
@@ -148,11 +148,16 @@ class AutoSlots(ABCMeta):
     can be used to generate a full `__slots__` for the concrete class.
     """
     def __new__(cls, name, bases, dctn):
-        slots = tuple(dctn.get('__slots__', tuple()))
+        parents = set()
         for base in bases:
-            if not hasattr(base, '__annotations__'):
-                continue
-            slots += tuple(getattr(base, '__annotations__').keys())
-        dctn['__slots__'] = slots
-        return super().__new__(cls,name,bases,dctn)
+            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)
 

From b4a19a3176575b89b6daa107873660f80406dbc0 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 15 Aug 2020 17:40:49 -0700
Subject: [PATCH 29/71] fix @abstractmethod on wrong function

---
 masque/traits/rotatable.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py
index 64c2c51..ef7e748 100644
--- a/masque/traits/rotatable.py
+++ b/masque/traits/rotatable.py
@@ -89,6 +89,7 @@ class Pivotable(metaclass=ABCMeta):
     """
     __slots__ = ()
 
+    @abstractmethod
     def rotate_around(self: P, pivot: vector2, rotation: float) -> P:
         """
         Rotate the object around a point.
@@ -109,7 +110,6 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta):
     """
     __slots__ = ()
 
-    @abstractmethod
     def rotate_around(self: J, pivot: vector2, rotation: float) -> J:
         pivot = numpy.array(pivot, dtype=float)
         self.translate(-pivot)

From cbb5462fcbdc90bba01d111631cf15439caadae8 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 15 Aug 2020 17:41:09 -0700
Subject: [PATCH 30/71] spelling fix and wrong import

---
 masque/subpattern.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/masque/subpattern.py b/masque/subpattern.py
index 6964676..41facd1 100644
--- a/masque/subpattern.py
+++ b/masque/subpattern.py
@@ -14,7 +14,7 @@ from .error import PatternError, PatternLockedError
 from .utils import is_scalar, rotation_matrix_2d, vector2, AutoSlots
 from .repetition import Repetition
 from .traits import (PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl,
-                     Mirrorable, Pivotable, Copyable, LockableImpl, RepeatableImpl)
+                     Mirrorable, PivotableImpl, Copyable, LockableImpl, RepeatableImpl)
 
 
 if TYPE_CHECKING:
@@ -152,7 +152,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
         self.mirrored[axis] = not self.mirrored[axis]
         self.rotation *= -1
         if self.repetition is not None:
-            self.repetiton.mirror(axis)
+            self.repetition.mirror(axis)
         return self
 
     def get_bounds(self) -> Optional[numpy.ndarray]:

From 352c03c0ae668f04eb34d7caac6c34b16cde5536 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 15 Aug 2020 18:20:04 -0700
Subject: [PATCH 31/71] remove use_dtype_as_dose arg

---
 masque/file/gdsii.py | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 353bb74..f255b49 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -202,7 +202,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]]:
     """
@@ -218,11 +217,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`.
@@ -298,10 +292,6 @@ def read(stream: io.BufferedIOBase,
                   isinstance(element, gdsii.elements.ARef)):
                 pat.subpatterns.append(_ref_to_subpat(element))
 
-        if use_dtype_as_dose:
-            logger.warning('use_dtype_as_dose will be removed in the future!')
-            pat = dtype2dose(pat)
-
         patterns.append(pat)
 
     # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries

From b845b0f7bc170f2ebaba43fb9f93f8e89cc50107 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 15 Aug 2020 18:20:37 -0700
Subject: [PATCH 32/71] move shape conversions to their own functions, and use
 raw mode

---
 masque/file/gdsii.py | 73 +++++++++++++++++++++++++-------------------
 1 file changed, 41 insertions(+), 32 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index f255b49..6e2eda0 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -233,57 +233,33 @@ 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)
-
+                poly = _boundary_to_polygon(element, raw_mode)
                 if clean_vertices:
                     try:
                         poly.clean_vertices()
                     except PatternError:
                         continue
-
                 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)
-
+                path = _gpath_to_mpath(element, raw_mode)
                 if clean_vertices:
                     try:
                         path.clean_vertices()
                     except PatternError as err:
                         continue
-
                 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)
@@ -333,9 +309,9 @@ def _ref_to_subpat(element: Union[gdsii.elements.SRef,
           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
 
@@ -372,6 +348,39 @@ def _ref_to_subpat(element: Union[gdsii.elements.SRef,
     return subpat
 
 
+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),
+            '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),
+            'raw': raw_mode,
+           }
+    return Polygon(**args)
+
+
 def _subpatterns_to_refs(subpatterns: List[SubPattern]
                         ) -> List[Union[gdsii.elements.ARef, gdsii.elements.SRef]]:
     refs = []

From 3ec28d472056b264516770d8ecbb47c85cf86502 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 15 Aug 2020 18:22:11 -0700
Subject: [PATCH 33/71] typo fixes

---
 masque/file/oasis.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 834949d..fa93388 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -298,7 +298,7 @@ def read(stream: io.BufferedIOBase,
                 path = Path(vertices=vertices,
                             layer=element.get_layer_tuple(),
                             offset=element.get_xy(),
-                            repeition=repetition,
+                            repetition=repetition,
                             width=element.get_half_width() * 2,
                             cap=cap,
                             **path_args)
@@ -566,7 +566,7 @@ def _labels_to_texts(labels: List[Label],
     texts = []
     for label in labels:
         layer, datatype = layer2oas(label.layer)
-        repetition, rep_offset = repetition_masq2fata(shape.repetition)
+        repetition, rep_offset = repetition_masq2fata(label.repetition)
         xy = numpy.round(label.offset + rep_offset).astype(int)
         texts.append(fatrec.Text(layer=layer,
                                  datatype=datatype,

From e7c8708f7f5c967c29349a609f2329da65da3317 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 15 Aug 2020 18:23:04 -0700
Subject: [PATCH 34/71] prefer f-strings

---
 masque/file/dxf.py    | 11 ++++++-----
 masque/file/gdsii.py  | 15 ++++++++-------
 masque/file/oasis.py  |  8 ++++----
 masque/file/svg.py    |  3 +--
 masque/shapes/path.py |  2 +-
 masque/subpattern.py  |  2 +-
 6 files changed, 21 insertions(+), 20 deletions(-)

diff --git a/masque/file/dxf.py b/masque/file/dxf.py
index 56b0bc5..07eb1db 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -370,17 +370,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 6e2eda0..b7885ac 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -484,8 +484,8 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
         # 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
 
@@ -502,19 +502,20 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
             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 = suffixed_name
         used_names.append(suffixed_name)
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index fa93388..9a27a9f 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -593,15 +593,15 @@ 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+encode,\n originally "{}"'.format(pat.name))
+            raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"')
 
         pat.name = suffixed_name
         used_names.append(suffixed_name)
diff --git a/masque/file/svg.py b/masque/file/svg.py
index deef59a..fb49b5b 100644
--- a/masque/file/svg.py
+++ b/masque/file/svg.py
@@ -79,8 +79,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/shapes/path.py b/masque/shapes/path.py
index 3f2f72f..7570c7c 100644
--- a/masque/shapes/path.py
+++ b/masque/shapes/path.py
@@ -322,7 +322,7 @@ class Path(Shape, metaclass=AutoSlots):
                 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
 
diff --git a/masque/subpattern.py b/masque/subpattern.py
index 41facd1..ebd6876 100644
--- a/masque/subpattern.py
+++ b/masque/subpattern.py
@@ -104,7 +104,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
     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
 
     # Mirrored property

From 92a3b9b72e0faffa7ef24b87482578dde43224a0 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 15 Aug 2020 18:23:16 -0700
Subject: [PATCH 35/71] documentation fixes/updates

---
 README.md            | 2 +-
 masque/file/gdsii.py | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index 38ad690..fae263e 100644
--- a/README.md
+++ b/README.md
@@ -39,4 +39,4 @@ pip3 install git+https://mpxd.net/code/jan/masque.git@release
 * Construct from bitmap
 * Boolean operations on polygons (using pyclipper)
 * Implement shape/cell properties
-* Implement OASIS-style repetitions for shapes
+* Deal with shape repetitions for dxf, svg
diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index b7885ac..b87271c 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -142,8 +142,8 @@ def write(patterns: Union[Pattern, List[Pattern]],
     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()`
+        *args: passed to `masque.file.gdsii.build()`
+        **kwargs: passed to `masque.file.gdsii.build()`
     """
     lib = build(patterns, *args, **kwargs)
     lib.save(stream)

From e330c34a0c24a5408766d167e75a3994ccc0a7c3 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 9 Sep 2020 19:40:50 -0700
Subject: [PATCH 36/71] import layer_t at top level

---
 masque/__init__.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/masque/__init__.py b/masque/__init__.py
index 7087b00..896d2ac 100644
--- a/masque/__init__.py
+++ b/masque/__init__.py
@@ -31,6 +31,7 @@ from .shapes import Shape
 from .label import Label
 from .subpattern import SubPattern
 from .pattern import Pattern
+from .utils import layer_t
 
 
 __author__ = 'Jan Petykiewicz'

From ea21353d2e9fc5d272521179af45d7305eedc263 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 9 Sep 2020 19:41:06 -0700
Subject: [PATCH 37/71] fix incorrect variable name

---
 masque/file/gdsii.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index b87271c..dcb9649 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -293,7 +293,7 @@ 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
 
 

From 5d83e0e5c03c4e6f3ab880cfc10b7d5c05d82962 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Wed, 9 Sep 2020 20:22:32 -0700
Subject: [PATCH 38/71] add package keywords

---
 setup.py | 37 +++++++++++++++++++++++++++++++++++--
 1 file changed, 35 insertions(+), 2 deletions(-)

diff --git a/setup.py b/setup.py
index 595c8e9..1d3138b 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()
 
@@ -41,10 +42,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',
+      ],
       )
 

From 49a3b4e322062e53fc90fadd288aaaf7d06d9fb5 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Thu, 10 Sep 2020 20:06:58 -0700
Subject: [PATCH 39/71] add support for annotations

and other fixes
---
 examples/test_rep.py          |   5 +-
 masque/__init__.py            |   2 +-
 masque/file/dxf.py            |  19 +++--
 masque/file/gdsii.py          |  64 ++++++++++++---
 masque/file/oasis.py          | 141 ++++++++++++++++++++++++++--------
 masque/file/svg.py            |   5 +-
 masque/label.py               |  18 +++--
 masque/pattern.py             |  24 +++---
 masque/repetition.py          |   2 +-
 masque/shapes/arc.py          |  16 ++--
 masque/shapes/circle.py       |  37 ++++++---
 masque/shapes/ellipse.py      |  16 ++--
 masque/shapes/path.py         |  16 ++--
 masque/shapes/polygon.py      |  16 ++--
 masque/shapes/shape.py        |  10 ++-
 masque/shapes/text.py         |  15 +++-
 masque/subpattern.py          |  21 +++--
 masque/traits/__init__.py     |   1 +
 masque/traits/annotatable.py  |  56 ++++++++++++++
 masque/traits/doseable.py     |   1 -
 masque/traits/layerable.py    |   1 -
 masque/traits/lockable.py     |  32 +++++++-
 masque/traits/mirrorable.py   |   2 +-
 masque/traits/positionable.py |   2 +-
 masque/traits/repeatable.py   |   1 -
 masque/traits/rotatable.py    |   2 +-
 masque/traits/scalable.py     |   1 -
 masque/utils.py               |   7 +-
 28 files changed, 400 insertions(+), 133 deletions(-)
 create mode 100644 masque/traits/annotatable.py

diff --git a/examples/test_rep.py b/examples/test_rep.py
index 80496ad..22e4b65 100644
--- a/examples/test_rep.py
+++ b/examples/test_rep.py
@@ -17,7 +17,8 @@ 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)
@@ -27,7 +28,7 @@ def main():
 
     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),
diff --git a/masque/__init__.py b/masque/__init__.py
index 896d2ac..9b8efb1 100644
--- a/masque/__init__.py
+++ b/masque/__init__.py
@@ -31,7 +31,7 @@ from .shapes import Shape
 from .label import Label
 from .subpattern import SubPattern
 from .pattern import Pattern
-from .utils import layer_t
+from .utils import layer_t, annotations_t
 
 
 __author__ = 'Jan Petykiewicz'
diff --git a/masque/file/dxf.py b/masque/file/dxf.py
index 07eb1db..7ae4b6d 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -10,10 +10,10 @@ import struct
 import logging
 import pathlib
 import gzip
-import numpy
-from numpy import pi
 
-import ezdxf
+import numpy        # type: ignore
+from numpy import pi
+import ezdxf        # type: ignore
 
 from .utils import mangle_name, make_dose_table
 from .. import Pattern, SubPattern, PatternError, Label, Shape
@@ -264,13 +264,12 @@ 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
diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index dcb9649..7192ecc 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -11,17 +11,18 @@ Note that GDSII references follow the same convention as `masque`,
   Scaling, rotation, and mirroring apply to individual instances, not grid
    vectors or offsets.
 """
-from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional, Sequence
+from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional
+from typing import Sequence, Mapping
 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
@@ -32,7 +33,7 @@ from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path
 from ..repetition import Grid
 from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
-from ..utils import remove_colinear_vertices, normalize_mirror
+from ..utils import remove_colinear_vertices, normalize_mirror, annotations_t
 
 #TODO absolute positioning
 
@@ -99,6 +100,7 @@ def build(patterns: Union[Pattern, List[Pattern]],
 
     if disambiguate_func is None:
         disambiguate_func = disambiguate_pattern_names
+    assert(disambiguate_func is not None)       # placate mypy
 
     if not modify_originals:
         patterns = [p.deepunlock() for p in copy.deepcopy(patterns)]
@@ -124,6 +126,8 @@ def build(patterns: Union[Pattern, List[Pattern]],
         structure = gdsii.structure.Structure(name=pat.name.encode('ASCII'))
         lib.append(structure)
 
+        structure.properties = _annotations_to_properties(pat.annotations, 512)
+
         structure += _shapes_to_elements(pat.shapes)
         structure += _labels_to_texts(pat.labels)
         structure += _subpatterns_to_refs(pat.subpatterns)
@@ -238,6 +242,9 @@ def read(stream: io.BufferedIOBase,
     patterns = []
     for structure in lib:
         pat = Pattern(name=structure.name.decode('ASCII'))
+        if pat.annotations:
+            logger.warning('Dropping Pattern-level annotations; they are not supported by python-gdsii')
+#        pat.annotations = {str(k): v for k, v in structure.properties}
         for element in structure:
             # Switch based on element type:
             if isinstance(element, gdsii.elements.Boundary):
@@ -343,6 +350,7 @@ def _ref_to_subpat(element: Union[gdsii.elements.SRef,
                         rotation=rotation,
                         scale=scale,
                         mirrored=(mirror_across_x, False),
+                        annotations=_properties_to_annotations(element.properties),
                         repetition=repetition)
     subpat.identifier = (element.struct_name,)
     return subpat
@@ -359,6 +367,7 @@ def _gpath_to_mpath(element: gdsii.elements.Path, raw_mode: bool) -> Path:
             '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,
            }
 
@@ -376,6 +385,7 @@ def _boundary_to_polygon(element: gdsii.elements.Boundary, raw_mode: bool) -> Po
     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)
@@ -420,11 +430,38 @@ def _subpatterns_to_refs(subpatterns: List[SubPattern]
             #  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 += 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:
+            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]]:
@@ -432,6 +469,7 @@ 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)
@@ -441,26 +479,32 @@ def _shapes_to_elements(shapes: List[Shape],
                                        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
 
 
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 9a27a9f..34208b0 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -20,19 +20,19 @@ import struct
 import logging
 import pathlib
 import gzip
-import numpy
-from numpy import pi
 
+import numpy        # type: ignore
+from numpy import pi
 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, PatternError, Label, Shape
 from ..shapes import Polygon, Path, Circle
 from ..repetition import Grid, Arbitrary, Repetition
 from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
-from ..utils import remove_colinear_vertices, normalize_mirror
+from ..utils import remove_colinear_vertices, normalize_mirror, annotations_t
 
 
 logger = logging.getLogger(__name__)
@@ -52,9 +52,11 @@ path_cap_map = {
 
 def build(patterns: Union[Pattern, List[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:
     """
     Convert a `Pattern` or list of patterns to an OASIS stream, writing patterns
@@ -91,6 +93,7 @@ def build(patterns: Union[Pattern, List[Pattern]],
             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`
@@ -104,11 +107,15 @@ def build(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=units_per_micron, validation=None)
+    lib.properties = annotations_to_properties(annotations)
 
     if layer_map:
         for name, layer_num in layer_map.items():
@@ -139,9 +146,11 @@ def build(patterns: Union[Pattern, List[Pattern]],
         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
 
@@ -226,6 +235,8 @@ def read(stream: io.BufferedIOBase,
 
     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.
@@ -242,6 +253,7 @@ def read(stream: io.BufferedIOBase,
 
     library_info: Dict[str, Any] = {
             'units_per_micrometer': lib.unit,
+            'annotations': properties_to_annotations(lib.properties, lib.propnames, lib.propstrings),
             }
 
     layer_map = {}
@@ -252,7 +264,7 @@ def read(stream: io.BufferedIOBase,
     patterns = []
     for cell in lib.cells:
         if isinstance(cell.name, int):
-            cell_name = lib.cellnames[cell.name].string
+            cell_name = lib.cellnames[cell.name].nstring.string
         else:
             cell_name = cell.name.string
 
@@ -263,15 +275,16 @@ def read(stream: io.BufferedIOBase,
                 # note XELEMENT has no repetition
                 continue
 
-
             repetition = repetition_fata2masq(element.repetition)
 
             # Switch based on element type:
             if isinstance(element, fatrec.Polygon):
                 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)
 
                 if clean_vertices:
@@ -295,10 +308,13 @@ def read(stream: io.BufferedIOBase,
                 if cap == Path.Cap.SquareCustom:
                     path_args['cap_extensions'] = numpy.array((element.get_extension_start()[1],
                                                                element.get_extension_end()[1]))
+
+                annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
                 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)
@@ -314,10 +330,12 @@ def read(stream: io.BufferedIOBase,
             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)
 
@@ -346,10 +364,12 @@ def read(stream: io.BufferedIOBase,
                     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)
 
@@ -399,24 +419,30 @@ def read(stream: io.BufferedIOBase,
                     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):
+                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)
 
@@ -425,7 +451,7 @@ def read(stream: io.BufferedIOBase,
                 continue
 
         for placement in cell.placements:
-            pat.subpatterns.append(_placement_to_subpat(placement))
+            pat.subpatterns.append(_placement_to_subpat(placement, lib))
 
         patterns.append(pat)
 
@@ -435,7 +461,7 @@ def read(stream: io.BufferedIOBase,
     for p in patterns_dict.values():
         for sp in p.subpatterns:
             ident = sp.identifier[0]
-            name = ident if isinstance(ident, str) else lib.cellnames[ident].string
+            name = ident if isinstance(ident, str) else lib.cellnames[ident].nstring.string
             sp.pattern = patterns_dict[name]
             del sp.identifier
 
@@ -459,7 +485,7 @@ def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]:
     return layer, data_type
 
 
-def _placement_to_subpat(placement: fatrec.Placement) -> SubPattern:
+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,).
@@ -468,21 +494,20 @@ def _placement_to_subpat(placement: fatrec.Placement) -> SubPattern:
     mag = placement.magnification if placement.magnification is not None else 1
     pname = placement.get_name()
     name = pname if isinstance(pname, int) else pname.string
-    args: Dict[str, Any] = {
-       'pattern': None,
-       'mirrored': (placement.flip, False),
-       'rotation': float(placement.angle * pi/180),
-       'scale': mag,
-       'identifier': (name,),
-       'repetition': repetition_fata2masq(placement.repetition),
-       }
-
-    subpat = SubPattern(offset=xy, **args)
+    annotations = properties_to_annotations(placement.properties, lib.propnames, lib.propstrings)
+    subpat = SubPattern(offset=xy,
+                        pattern=None,
+                        mirrored=(placement.flip, False),
+                        rotation=float(placement.angle * pi/180),
+                        scale=float(mag),
+                        identifier=(name,),
+                        repetition=repetition_fata2masq(placement.repetition),
+                        annotations=annotations)
     return subpat
 
 
-def _subpatterns_to_refs(subpatterns: List[SubPattern]
-                        ) -> List[fatrec.Placement]:
+def _subpatterns_to_placements(subpatterns: List[SubPattern]
+                               ) -> List[fatrec.Placement]:
     refs = []
     for subpat in subpatterns:
         if subpat.pattern is None:
@@ -493,19 +518,16 @@ def _subpatterns_to_refs(subpatterns: List[SubPattern]
         frep, rep_offset = repetition_masq2fata(subpat.repetition)
 
         offset = numpy.round(subpat.offset + rep_offset).astype(int)
-        args: Dict[str, Any] = {
-            'x': offset[0],
-            'y': offset[1],
-            'repetition': frep,
-            }
-
         angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360
         ref = fatrec.Placement(
                 name=subpat.pattern.name,
                 flip=mirror_across_x,
                 angle=angle,
                 magnification=subpat.scale,
-                **args)
+                properties=annotations_to_properties(subpat.annotations),
+                x=offset[0],
+                y=offset[1],
+                repetition=frep)
 
         refs.append(ref)
     return refs
@@ -519,6 +541,7 @@ def _shapes_to_elements(shapes: List[Shape],
     for shape in shapes:
         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)
@@ -527,6 +550,7 @@ def _shapes_to_elements(shapes: List[Shape],
                                    radius=radius,
                                    x=offset[0],
                                    y=offset[1],
+                                   properties=properties,
                                    repetition=repetition)
             elements.append(circle)
         elif isinstance(shape, Path):
@@ -544,6 +568,7 @@ def _shapes_to_elements(shapes: List[Shape],
                                y=xy[1],
                                extension_start=extension_start,       #TODO implement multiple cap types?
                                extension_end=extension_end,
+                               properties=properties,
                                repetition=repetition,
                                )
             elements.append(path)
@@ -556,6 +581,7 @@ def _shapes_to_elements(shapes: List[Shape],
                                                x=xy[0],
                                                y=xy[1],
                                                point_list=points,
+                                               properties=properties,
                                                repetition=repetition))
     return elements
 
@@ -568,11 +594,13 @@ def _labels_to_texts(labels: List[Label],
         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,
                                  datatype=datatype,
                                  x=xy[0],
                                  y=xy[1],
                                  string=label.string,
+                                 properties=properties,
                                  repetition=repetition))
     return texts
 
@@ -609,6 +637,7 @@ def disambiguate_pattern_names(patterns,
 
 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,
@@ -624,7 +653,12 @@ def repetition_fata2masq(rep: Union[fatamorgana.GridRepetition, fatamorgana.Arbi
     return mrep
 
 
-def repetition_masq2fata(rep: Optional[Repetition]):
+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),
@@ -642,3 +676,46 @@ def repetition_masq2fata(rep: Optional[Repetition]):
         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 fb49b5b..0ce5750 100644
--- a/masque/file/svg.py
+++ b/masque/file/svg.py
@@ -2,10 +2,11 @@
 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
 
diff --git a/masque/label.py b/masque/label.py
index 72b7266..f157f46 100644
--- a/masque/label.py
+++ b/masque/label.py
@@ -1,15 +1,16 @@
 from typing import List, Tuple, Dict, Optional
 import copy
-import numpy
+import numpy        # type: ignore
 from numpy import pi
 
 from .repetition import Repetition
 from .error import PatternError, PatternLockedError
-from .utils import is_scalar, vector2, rotation_matrix_2d, layer_t, AutoSlots
+from .utils import is_scalar, vector2, rotation_matrix_2d, layer_t, AutoSlots, annotations_t
 from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, LockableImpl, RepeatableImpl
+from .traits import AnnotatableImpl
 
 
-class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl,
+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)
@@ -42,14 +43,17 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl,
                  offset: vector2 = (0.0, 0.0),
                  layer: layer_t = 0,
                  repetition: Optional[Repetition] = None,
-                 locked: bool = False):
-        object.__setattr__(self, 'locked', False)
+                 annotations: Optional[annotations_t] = None,
+                 locked: bool = False,
+                 ):
+        LockableImpl.unlock(self)
         self.identifier = ()
         self.string = string
         self.offset = numpy.array(offset, dtype=float, copy=True)
         self.layer = layer
         self.repetition = repetition
-        self.locked = locked
+        self.annotations = annotations if annotations is not None else {}
+        self.set_locked(locked)
 
     def __copy__(self) -> 'Label':
         return Label(string=self.string,
@@ -62,7 +66,7 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl,
         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 rotate_around(self, pivot: vector2, rotation: float) -> 'Label':
diff --git a/masque/pattern.py b/masque/pattern.py
index 9c45749..de7ecd2 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -9,26 +9,27 @@ import itertools
 import pickle
 from collections import defaultdict
 
-import numpy
+import numpy        # type: ignore
 from numpy import inf
 # .visualize imports matplotlib and matplotlib.collections
 
 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
 
 
 visitor_function_t = Callable[['Pattern', Tuple['Pattern'], Dict, numpy.ndarray], 'Pattern']
 
 
-class Pattern:
+class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
     """
     2D layout consisting of some set of shapes, labels, and references to other Pattern objects
      (via SubPattern). Shapes are assumed to inherit from masque.shapes.Shape or provide equivalent functions.
     """
-    __slots__ = ('shapes', 'labels', 'subpatterns', 'name', 'locked')
+    __slots__ = ('shapes', 'labels', 'subpatterns', 'name')
 
     shapes: List[Shape]
     """ List of all shapes in this Pattern.
@@ -47,14 +48,12 @@ class Pattern:
     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] = (),
+                 annotations: Optional[annotations_t] = None,
                  locked: bool = False,
                  ):
         """
@@ -68,7 +67,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:
@@ -84,8 +83,9 @@ 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':
@@ -97,6 +97,7 @@ class Pattern:
                        shapes=copy.deepcopy(self.shapes),
                        labels=copy.deepcopy(self.labels),
                        subpatterns=[copy.copy(sp) for sp in self.subpatterns],
+                       annotations=copy.deepcopy(self.annotations),
                        locked=self.locked)
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Pattern':
@@ -105,6 +106,7 @@ class Pattern:
                 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
 
@@ -815,7 +817,7 @@ class Pattern:
             self.shapes = tuple(self.shapes)
             self.labels = tuple(self.labels)
             self.subpatterns = tuple(self.subpatterns)
-            object.__setattr__(self, 'locked', True)
+            LockableImpl.lock(self)
         return self
 
     def unlock(self) -> 'Pattern':
@@ -826,7 +828,7 @@ class Pattern:
             self
         """
         if self.locked:
-            object.__setattr__(self, 'locked', False)
+            LockableImpl.unlock(self)
             self.shapes = list(self.shapes)
             self.labels = list(self.labels)
             self.subpatterns = list(self.subpatterns)
diff --git a/masque/repetition.py b/masque/repetition.py
index 5c9f105..4ed29f4 100644
--- a/masque/repetition.py
+++ b/masque/repetition.py
@@ -7,7 +7,7 @@ from typing import Union, List, Dict, Tuple, Optional, Sequence, TYPE_CHECKING,
 import copy
 from abc import ABCMeta, abstractmethod
 
-import numpy
+import numpy        # type: ignore
 
 from .error import PatternError, PatternLockedError
 from .utils import rotation_matrix_2d, vector2, AutoSlots
diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py
index 4918ea7..ea9b4c7 100644
--- a/masque/shapes/arc.py
+++ b/masque/shapes/arc.py
@@ -1,13 +1,15 @@
 from typing import List, Tuple, 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 ..repetition import Repetition
-from ..utils import is_scalar, vector2, layer_t, AutoSlots
+from ..utils import is_scalar, vector2, layer_t, AutoSlots, annotations_t
+from ..traits import LockableImpl
 
 
 class Arc(Shape, metaclass=AutoSlots):
@@ -160,10 +162,11 @@ class Arc(Shape, metaclass=AutoSlots):
                  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 = ()
         if raw:
             self._radii = radii
@@ -172,6 +175,7 @@ class Arc(Shape, metaclass=AutoSlots):
             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:
@@ -181,12 +185,13 @@ class Arc(Shape, metaclass=AutoSlots):
             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.mirror(a) for a, do in enumerate(mirrored) if do]
-        self.locked = locked
+        self.set_locked(locked)
 
     def  __deepcopy__(self, memo: Dict = None) -> 'Arc':
         memo = {} if memo is None else memo
@@ -194,7 +199,8 @@ class Arc(Shape, metaclass=AutoSlots):
         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,
diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py
index 2834b2a..447145f 100644
--- a/masque/shapes/circle.py
+++ b/masque/shapes/circle.py
@@ -1,12 +1,14 @@
 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 ..repetition import Repetition
-from ..utils import is_scalar, vector2, layer_t, AutoSlots
+from ..utils import is_scalar, vector2, layer_t, AutoSlots, annotations_t
+from ..traits import LockableImpl
 
 
 class Circle(Shape, metaclass=AutoSlots):
@@ -48,23 +50,36 @@ class Circle(Shape, metaclass=AutoSlots):
                  layer: layer_t = 0,
                  dose: float = 1.0,
                  repetition: Optional[Repetition] = None,
-                 locked: bool = False):
-        object.__setattr__(self, 'locked', False)
+                 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.repetition = repetition
-        self.locked = locked
+        self.set_locked(locked)
 
     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,
diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py
index e3836a1..0b73dd1 100644
--- a/masque/shapes/ellipse.py
+++ b/masque/shapes/ellipse.py
@@ -1,13 +1,15 @@
 from typing import List, Tuple, 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 ..repetition import Repetition
-from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots
+from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots, annotations_t
+from ..traits import LockableImpl
 
 
 class Ellipse(Shape, metaclass=AutoSlots):
@@ -95,16 +97,18 @@ class Ellipse(Shape, metaclass=AutoSlots):
                  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 = ()
         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:
@@ -112,19 +116,21 @@ class Ellipse(Shape, metaclass=AutoSlots):
             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.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':
         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,
diff --git a/masque/shapes/path.py b/masque/shapes/path.py
index 7570c7c..4d11d9e 100644
--- a/masque/shapes/path.py
+++ b/masque/shapes/path.py
@@ -1,14 +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 ..repetition import Repetition
 from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots
-from ..utils import remove_colinear_vertices, remove_duplicate_vertices
+from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
+from ..traits import LockableImpl
 
 
 class PathCap(Enum):
@@ -149,10 +151,11 @@ class Path(Shape, metaclass=AutoSlots):
                  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 = ()
@@ -160,6 +163,7 @@ class Path(Shape, metaclass=AutoSlots):
             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
@@ -169,6 +173,7 @@ class Path(Shape, metaclass=AutoSlots):
             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
@@ -176,7 +181,7 @@ class Path(Shape, metaclass=AutoSlots):
             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':
         memo = {} if memo is None else memo
@@ -185,7 +190,8 @@ class Path(Shape, metaclass=AutoSlots):
         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
diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py
index 08da89e..24d609c 100644
--- a/masque/shapes/polygon.py
+++ b/masque/shapes/polygon.py
@@ -1,13 +1,15 @@
 from typing import List, Tuple, 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 ..repetition import Repetition
 from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t, AutoSlots
-from ..utils import remove_colinear_vertices, remove_duplicate_vertices
+from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
+from ..traits import LockableImpl
 
 
 class Polygon(Shape, metaclass=AutoSlots):
@@ -77,33 +79,37 @@ class Polygon(Shape, metaclass=AutoSlots):
                  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 = ()
         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':
         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
diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py
index 7a5e3f3..b9a0cdd 100644
--- a/masque/shapes/shape.py
+++ b/masque/shapes/shape.py
@@ -1,13 +1,15 @@
 from typing import List, Tuple, Callable, TypeVar, Optional, TYPE_CHECKING
 from abc import ABCMeta, abstractmethod
 import copy
-import numpy
+
+import numpy        # type: ignore
 
 from ..error import PatternError, PatternLockedError
 from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t
 from ..traits import (PositionableImpl, LayerableImpl, DoseableImpl,
                       Rotatable, Mirrorable, Copyable, Scalable,
-                      PivotableImpl, LockableImpl, RepeatableImpl)
+                      PivotableImpl, LockableImpl, RepeatableImpl,
+                      AnnotatableImpl)
 
 if TYPE_CHECKING:
     from . import Polygon
@@ -27,7 +29,7 @@ T = TypeVar('T', bound='Shape')
 
 
 class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable, Copyable, Scalable,
-            PivotableImpl, RepeatableImpl, LockableImpl, metaclass=ABCMeta):
+            PivotableImpl, RepeatableImpl, LockableImpl, AnnotatableImpl, metaclass=ABCMeta):
     """
     Abstract class specifying functions common to all shapes.
     """
@@ -39,7 +41,7 @@ class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable
     def __copy__(self) -> 'Shape':
         cls = self.__class__
         new = cls.__new__(cls)
-        for name in self.__slots__:
+        for name in self.__slots__:     # type: str
             object.__setattr__(new, name, getattr(self, name))
         return new
 
diff --git a/masque/shapes/text.py b/masque/shapes/text.py
index 599d377..c8af2cc 100644
--- a/masque/shapes/text.py
+++ b/masque/shapes/text.py
@@ -1,6 +1,7 @@
 from typing import List, Tuple, Dict, Sequence, Optional, MutableSequence
 import copy
-import numpy
+
+import numpy        # type: ignore
 from numpy import pi, inf
 
 from . import Shape, Polygon, normalized_shape_tuple
@@ -8,6 +9,8 @@ from .. import PatternError
 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
@@ -67,10 +70,11 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
                  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 = ()
         if raw:
             self._offset = offset
@@ -81,6 +85,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
             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
@@ -90,15 +95,17 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
             self.rotation = rotation
             self.mirrored = mirrored
             self.repetition = repetition
+            self.annotations = annotations if annotations is not None else {}
         self.font_path = font_path
-        self.locked = locked
+        self.set_locked(locked)
 
     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,
diff --git a/masque/subpattern.py b/masque/subpattern.py
index ebd6876..6462cff 100644
--- a/masque/subpattern.py
+++ b/masque/subpattern.py
@@ -7,14 +7,15 @@
 from typing import Union, List, Dict, Tuple, Optional, Sequence, TYPE_CHECKING, Any
 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, AutoSlots
+from .utils import is_scalar, rotation_matrix_2d, vector2, AutoSlots, annotations_t
 from .repetition import Repetition
 from .traits import (PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl,
-                     Mirrorable, PivotableImpl, Copyable, LockableImpl, RepeatableImpl)
+                     Mirrorable, PivotableImpl, Copyable, LockableImpl, RepeatableImpl,
+                     AnnotatableImpl)
 
 
 if TYPE_CHECKING:
@@ -22,7 +23,8 @@ if TYPE_CHECKING:
 
 
 class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mirrorable,
-                 PivotableImpl, Copyable, RepeatableImpl, LockableImpl, metaclass=AutoSlots):
+                 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.
@@ -49,8 +51,10 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
                  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, ...] = (),
+                 ):
         """
         Args:
             pattern: Pattern to reference.
@@ -74,7 +78,8 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
             mirrored = [False, False]
         self.mirrored = mirrored
         self.repetition = repetition
-        self.locked = locked
+        self.annotations = annotations if annotations is not None else {}
+        self.set_locked(locked)
 
     def  __copy__(self) -> 'SubPattern':
         new = SubPattern(pattern=self.pattern,
@@ -84,6 +89,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
                          scale=self.scale,
                          mirrored=self.mirrored.copy(),
                          repetition=copy.deepcopy(self.repetition),
+                         annotations=copy.deepcopy(self.annotations),
                          locked=self.locked)
         return new
 
@@ -92,7 +98,8 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
         new = copy.copy(self).unlock()
         new.pattern = copy.deepcopy(self.pattern, memo)
         new.repetition = copy.deepcopy(self.repetition, memo)
-        new.locked = self.locked
+        new.annotations = copy.deepcopy(self.annotations, memo)
+        new.set_locked(self.locked)
         return new
 
     # pattern property
diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py
index 8af6434..d885264 100644
--- a/masque/traits/__init__.py
+++ b/masque/traits/__init__.py
@@ -7,3 +7,4 @@ 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..0285cfd
--- /dev/null
+++ b/masque/traits/annotatable.py
@@ -0,0 +1,56 @@
+from typing import TypeVar
+from types import MappingProxyType
+from abc import ABCMeta, abstractmethod
+import copy
+
+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:
+        # 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)
+        return 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/doseable.py b/masque/traits/doseable.py
index 419d0bd..96c535c 100644
--- a/masque/traits/doseable.py
+++ b/masque/traits/doseable.py
@@ -1,7 +1,6 @@
 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
diff --git a/masque/traits/layerable.py b/masque/traits/layerable.py
index 5382450..e3d5f7b 100644
--- a/masque/traits/layerable.py
+++ b/masque/traits/layerable.py
@@ -1,7 +1,6 @@
 from typing import List, Tuple, Callable, TypeVar, Optional
 from abc import ABCMeta, abstractmethod
 import copy
-import numpy
 
 from ..error import PatternError, PatternLockedError
 from ..utils import layer_t
diff --git a/masque/traits/lockable.py b/masque/traits/lockable.py
index cc12760..fadaaa3 100644
--- a/masque/traits/lockable.py
+++ b/masque/traits/lockable.py
@@ -1,7 +1,6 @@
 from typing import List, Tuple, Callable, TypeVar, Optional
 from abc import ABCMeta, abstractmethod
 import copy
-import numpy
 
 from ..error import PatternError, PatternLockedError
 
@@ -19,6 +18,7 @@ class Lockable(metaclass=ABCMeta):
     '''
     ---- Methods
     '''
+    @abstractmethod
     def lock(self: T) -> T:
         """
         Lock the object, disallowing further changes
@@ -28,6 +28,7 @@ class Lockable(metaclass=ABCMeta):
         """
         pass
 
+    @abstractmethod
     def unlock(self: T) -> T:
         """
         Unlock the object, reallowing changes
@@ -37,6 +38,32 @@ class Lockable(metaclass=ABCMeta):
         """
         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):
     """
@@ -62,3 +89,6 @@ class LockableImpl(Lockable, metaclass=ABCMeta):
     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
index 1e10800..a66c074 100644
--- a/masque/traits/mirrorable.py
+++ b/masque/traits/mirrorable.py
@@ -1,10 +1,10 @@
 from typing import List, Tuple, Callable, TypeVar, Optional
 from abc import ABCMeta, abstractmethod
 import copy
-import numpy
 
 from ..error import PatternError, PatternLockedError
 
+
 T = TypeVar('T', bound='Mirrorable')
 #I = TypeVar('I', bound='MirrorableImpl')
 
diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py
index d433a91..150daf0 100644
--- a/masque/traits/positionable.py
+++ b/masque/traits/positionable.py
@@ -3,7 +3,7 @@
 from typing import List, Tuple, Callable, TypeVar, Optional
 from abc import ABCMeta, abstractmethod
 import copy
-import numpy
+import numpy        # type: ignore
 
 from ..error import PatternError, PatternLockedError
 from ..utils import is_scalar, rotation_matrix_2d, vector2
diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py
index 3971a94..67183ad 100644
--- a/masque/traits/repeatable.py
+++ b/masque/traits/repeatable.py
@@ -1,7 +1,6 @@
 from typing import List, Tuple, Callable, TypeVar, Optional, TYPE_CHECKING
 from abc import ABCMeta, abstractmethod
 import copy
-import numpy
 
 from ..error import PatternError, PatternLockedError
 
diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py
index ef7e748..c79e89e 100644
--- a/masque/traits/rotatable.py
+++ b/masque/traits/rotatable.py
@@ -2,7 +2,7 @@ from typing import List, Tuple, Callable, TypeVar, Optional
 from abc import ABCMeta, abstractmethod
 import copy
 
-import numpy
+import numpy        # type: ignore
 from numpy import pi
 
 from .positionable import Positionable
diff --git a/masque/traits/scalable.py b/masque/traits/scalable.py
index ac349a2..bebda69 100644
--- a/masque/traits/scalable.py
+++ b/masque/traits/scalable.py
@@ -1,7 +1,6 @@
 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
diff --git a/masque/utils.py b/masque/utils.py
index 2f2e499..c33b8c4 100644
--- a/masque/utils.py
+++ b/masque/utils.py
@@ -1,15 +1,16 @@
 """
 Various helper functions
 """
-
-from typing import Any, Union, Tuple, Sequence
+from typing import Any, Union, Tuple, Sequence, Dict, List
 from abc import ABCMeta
 
-import numpy
+import numpy        # type: ignore
+
 
 # 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:

From 2019c4a16bf754ec3931f04cd16a6dcac8af0bc6 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Thu, 10 Sep 2020 20:18:34 -0700
Subject: [PATCH 40/71] Update readme

---
 README.md | 17 +++++++++++++----
 1 file changed, 13 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index fae263e..214f7ad 100644
--- a/README.md
+++ b/README.md
@@ -33,10 +33,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)
-* Implement shape/cell properties
+* 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

From 2a8e43cbcd8472b8eca33521f2d11b11ef4a0151 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Thu, 10 Sep 2020 20:18:59 -0700
Subject: [PATCH 41/71] bump version to 2.0

---
 masque/VERSION | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/VERSION b/masque/VERSION
index c239c60..cd5ac03 100644
--- a/masque/VERSION
+++ b/masque/VERSION
@@ -1 +1 @@
-1.5
+2.0

From 0e04633f618b34e8e946f58f8fc90a78520c2973 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Thu, 10 Sep 2020 20:37:19 -0700
Subject: [PATCH 42/71] Force use of keyword args on most constructors

---
 masque/label.py          | 1 +
 masque/pattern.py        | 1 +
 masque/shapes/arc.py     | 1 +
 masque/shapes/circle.py  | 1 +
 masque/shapes/ellipse.py | 1 +
 masque/shapes/path.py    | 1 +
 masque/shapes/polygon.py | 1 +
 masque/shapes/text.py    | 1 +
 masque/subpattern.py     | 1 +
 9 files changed, 9 insertions(+)

diff --git a/masque/label.py b/masque/label.py
index f157f46..96c5aa1 100644
--- a/masque/label.py
+++ b/masque/label.py
@@ -40,6 +40,7 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, Annot
 
     def __init__(self,
                  string: str,
+                 *,
                  offset: vector2 = (0.0, 0.0),
                  layer: layer_t = 0,
                  repetition: Optional[Repetition] = None,
diff --git a/masque/pattern.py b/masque/pattern.py
index de7ecd2..5a7dd01 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -50,6 +50,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
 
     def __init__(self,
                  name: str = '',
+                 *,
                  shapes: Sequence[Shape] = (),
                  labels: Sequence[Label] = (),
                  subpatterns: Sequence[SubPattern] = (),
diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py
index ea9b4c7..6f75e7e 100644
--- a/masque/shapes/arc.py
+++ b/masque/shapes/arc.py
@@ -154,6 +154,7 @@ class Arc(Shape, metaclass=AutoSlots):
                  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),
diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py
index 447145f..b3e07ae 100644
--- a/masque/shapes/circle.py
+++ b/masque/shapes/circle.py
@@ -44,6 +44,7 @@ class Circle(Shape, metaclass=AutoSlots):
 
     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),
diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py
index 0b73dd1..b2ceec6 100644
--- a/masque/shapes/ellipse.py
+++ b/masque/shapes/ellipse.py
@@ -89,6 +89,7 @@ class Ellipse(Shape, metaclass=AutoSlots):
 
     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),
diff --git a/masque/shapes/path.py b/masque/shapes/path.py
index 4d11d9e..b25f464 100644
--- a/masque/shapes/path.py
+++ b/masque/shapes/path.py
@@ -143,6 +143,7 @@ class Path(Shape, metaclass=AutoSlots):
     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),
diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py
index 24d609c..c11b662 100644
--- a/masque/shapes/polygon.py
+++ b/masque/shapes/polygon.py
@@ -73,6 +73,7 @@ class Polygon(Shape, metaclass=AutoSlots):
 
     def __init__(self,
                  vertices: numpy.ndarray,
+                 *,
                  offset: vector2 = (0.0, 0.0),
                  rotation: float = 0.0,
                  mirrored: Sequence[bool] = (False, False),
diff --git a/masque/shapes/text.py b/masque/shapes/text.py
index c8af2cc..debd994 100644
--- a/masque/shapes/text.py
+++ b/masque/shapes/text.py
@@ -64,6 +64,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
                  string: str,
                  height: float,
                  font_path: str,
+                 *,
                  offset: vector2 = (0.0, 0.0),
                  rotation: float = 0.0,
                  mirrored: Tuple[bool, bool] = (False, False),
diff --git a/masque/subpattern.py b/masque/subpattern.py
index 6462cff..6d0c2a9 100644
--- a/masque/subpattern.py
+++ b/masque/subpattern.py
@@ -45,6 +45,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
 
     def __init__(self,
                  pattern: Optional['Pattern'],
+                 *,
                  offset: vector2 = (0.0, 0.0),
                  rotation: float = 0.0,
                  mirrored: Optional[Sequence[bool]] = None,

From f996a1629f070fb2756572ee1a25796eebe839d7 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Thu, 10 Sep 2020 20:47:00 -0700
Subject: [PATCH 43/71] limit number of arguments on more functions

---
 masque/file/dxf.py   | 1 +
 masque/file/gdsii.py | 1 +
 masque/file/svg.py   | 4 ++--
 3 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/masque/file/dxf.py b/masque/file/dxf.py
index 7ae4b6d..581bfcd 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -34,6 +34,7 @@ DEFAULT_LAYER = 'DEFAULT'
 
 def write(pattern: Pattern,
           stream: io.TextIOBase,
+          *,
           modify_originals: bool = False,
           dxf_version='AC1024',
           disambiguate_func: Callable[[Iterable[Pattern]], None] = None):
diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 7192ecc..df23293 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -54,6 +54,7 @@ def build(patterns: Union[Pattern, List[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,
           ) -> gdsii.library.Library:
diff --git a/masque/file/svg.py b/masque/file/svg.py
index 0ce5750..b719b0b 100644
--- a/masque/file/svg.py
+++ b/masque/file/svg.py
@@ -12,8 +12,8 @@ from .. import Pattern
 
 
 def writefile(pattern: Pattern,
-          filename: str,
-          custom_attributes: bool=False):
+              filename: str,
+              custom_attributes: bool=False):
     """
     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

From c4dfd06a424a0bd8c460f548a38a919b586eaf65 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Fri, 18 Sep 2020 19:06:44 -0700
Subject: [PATCH 44/71] improve type annotations

---
 masque/file/gdsii.py | 2 +-
 masque/file/oasis.py | 3 +++
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index df23293..1a29a8f 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -100,7 +100,7 @@ def build(patterns: Union[Pattern, List[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:
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 34208b0..d1cbc13 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -128,6 +128,7 @@ def build(patterns: Union[Pattern, List[Pattern]],
                     for tt in (True, False)]
 
         def layer2oas(mlayer: layer_t) -> Tuple[int, int]:
+            assert(layer_map is not None)
             layer_num = layer_map[mlayer] if isinstance(mlayer, str) else mlayer
             return _mlayer2oas(layer_num)
     else:
@@ -275,6 +276,7 @@ def read(stream: io.BufferedIOBase,
                 # note XELEMENT has no repetition
                 continue
 
+            assert(not isinstance(element.repetition, fatamorgana.ReuseRepetition))
             repetition = repetition_fata2masq(element.repetition)
 
             # Switch based on element type:
@@ -490,6 +492,7 @@ def _placement_to_subpat(placement: fatrec.Placement, lib: fatamorgana.OasisLayo
     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))
     mag = placement.magnification if placement.magnification is not None else 1
     pname = placement.get_name()

From 64fbd08cac83011a1b1dd7653f1975f5b74e13d3 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Fri, 18 Sep 2020 19:06:56 -0700
Subject: [PATCH 45/71] don't attempt to set structure properties

---
 masque/file/gdsii.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 1a29a8f..1aec686 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -127,7 +127,7 @@ def build(patterns: Union[Pattern, List[Pattern]],
         structure = gdsii.structure.Structure(name=pat.name.encode('ASCII'))
         lib.append(structure)
 
-        structure.properties = _annotations_to_properties(pat.annotations, 512)
+#        structure.properties = _annotations_to_properties(pat.annotations, 512)
 
         structure += _shapes_to_elements(pat.shapes)
         structure += _labels_to_texts(pat.labels)

From 5f72fe318f4c19ae055a9945627842f7c1779029 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Fri, 18 Sep 2020 19:07:14 -0700
Subject: [PATCH 46/71] Loosen requirements from List to Sequence

---
 masque/file/gdsii.py | 6 +++---
 masque/file/oasis.py | 6 +++---
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 1aec686..1b9c1d0 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -50,7 +50,7 @@ path_cap_map = {
                }
 
 
-def build(patterns: Union[Pattern, List[Pattern]],
+def build(patterns: Union[Pattern, Sequence[Pattern]],
           meters_per_unit: float,
           logical_units_per_unit: float = 1,
           library_name: str = 'masque-gdsii-write',
@@ -136,7 +136,7 @@ def build(patterns: Union[Pattern, List[Pattern]],
     return lib
 
 
-def write(patterns: Union[Pattern, List[Pattern]],
+def write(patterns: Union[Pattern, Sequence[Pattern]],
           stream: io.BufferedIOBase,
           *args,
           **kwargs):
@@ -154,7 +154,7 @@ def write(patterns: Union[Pattern, List[Pattern]],
     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,
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index d1cbc13..6e0284d 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -50,7 +50,7 @@ path_cap_map = {
 #TODO implement properties
 #TODO implement more shape types?
 
-def build(patterns: Union[Pattern, List[Pattern]],
+def build(patterns: Union[Pattern, Sequence[Pattern]],
           units_per_micron: int,
           layer_map: Optional[Dict[str, Union[int, Tuple[int, int]]]] = None,
           *,
@@ -156,7 +156,7 @@ def build(patterns: Union[Pattern, List[Pattern]],
     return lib
 
 
-def write(patterns: Union[List[Pattern], Pattern],
+def write(patterns: Union[Sequence[Pattern], Pattern],
           stream: io.BufferedIOBase,
           *args,
           **kwargs):
@@ -174,7 +174,7 @@ def write(patterns: Union[List[Pattern], Pattern],
     lib.write(stream)
 
 
-def writefile(patterns: Union[List[Pattern], Pattern],
+def writefile(patterns: Union[Sequence[Pattern], Pattern],
               filename: Union[str, pathlib.Path],
               *args,
               **kwargs,

From 3f59168cec875d58a9d0eb664000c55e82de2936 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Fri, 18 Sep 2020 19:46:57 -0700
Subject: [PATCH 47/71] Use chain() instead of adding lists

---
 masque/pattern.py | 32 ++++++++++++++++----------------
 1 file changed, 16 insertions(+), 16 deletions(-)

diff --git a/masque/pattern.py b/masque/pattern.py
index 5a7dd01..9edfdd2 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -5,8 +5,8 @@
 from typing import List, Callable, Tuple, Dict, Union, Set, Sequence, Optional, Type, overload
 from typing import MutableMapping, Iterable
 import copy
-import itertools
 import pickle
+from itertools import chain
 from collections import defaultdict
 
 import numpy        # type: ignore
@@ -321,7 +321,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
             self
         """
         old_shapes = self.shapes
-        self.shapes = list(itertools.chain.from_iterable(
+        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:
@@ -347,7 +347,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
 
         self.polygonize().flatten()
         old_shapes = self.shapes
-        self.shapes = list(itertools.chain.from_iterable(
+        self.shapes = list(chain.from_iterable(
                         (shape.manhattanize(grid_x, grid_y) for shape in old_shapes)))
         return self
 
@@ -525,13 +525,12 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         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
@@ -634,7 +633,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         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
 
@@ -648,7 +647,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         Returns:
             self
         """
-        for entry in self.shapes + self.subpatterns:
+        for entry in chain(self.shapes, self.subpatterns):
             entry.scale_by(c)
         return self
 
@@ -663,7 +662,8 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         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:
@@ -698,7 +698,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         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
 
@@ -712,7 +712,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         Returns:
             self
         """
-        for entry in self.shapes + self.subpatterns:
+        for entry in chain(self.shapes, self.subpatterns):
             entry.rotate(rotation)
         return self
 
@@ -727,7 +727,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         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
 
@@ -743,7 +743,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         Returns:
             self
         """
-        for entry in self.shapes + self.subpatterns:
+        for entry in chain(self.shapes, self.subpatterns):
             entry.mirror(axis)
         return self
 
@@ -772,7 +772,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         Return:
             self
         """
-        for entry in self.shapes + self.subpatterns:
+        for entry in chain(self.shapes, self.subpatterns):
             entry.dose *= c
         return self
 
@@ -843,7 +843,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
             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()
@@ -860,7 +860,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
             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()

From f51144ae6ac3dd28dfdd79fe44e57830be00011d Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Fri, 18 Sep 2020 19:47:31 -0700
Subject: [PATCH 48/71] misc doc/import/typing fixes

---
 masque/label.py           | 2 +-
 masque/pattern.py         | 4 ++--
 masque/shapes/shape.py    | 2 +-
 masque/traits/__init__.py | 3 +++
 4 files changed, 7 insertions(+), 4 deletions(-)

diff --git a/masque/label.py b/masque/label.py
index 96c5aa1..c8ca802 100644
--- a/masque/label.py
+++ b/masque/label.py
@@ -5,7 +5,7 @@ from numpy import pi
 
 from .repetition import Repetition
 from .error import PatternError, PatternLockedError
-from .utils import is_scalar, vector2, rotation_matrix_2d, layer_t, AutoSlots, annotations_t
+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
 
diff --git a/masque/pattern.py b/masque/pattern.py
index 9edfdd2..adbef2b 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -916,8 +916,8 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
             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)
 
diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py
index b9a0cdd..6c72263 100644
--- a/masque/shapes/shape.py
+++ b/masque/shapes/shape.py
@@ -5,7 +5,7 @@ import copy
 import numpy        # type: ignore
 
 from ..error import PatternError, PatternLockedError
-from ..utils import is_scalar, rotation_matrix_2d, vector2, layer_t
+from ..utils import rotation_matrix_2d, vector2, layer_t
 from ..traits import (PositionableImpl, LayerableImpl, DoseableImpl,
                       Rotatable, Mirrorable, Copyable, Scalable,
                       PivotableImpl, LockableImpl, RepeatableImpl,
diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py
index d885264..3f88554 100644
--- a/masque/traits/__init__.py
+++ b/masque/traits/__init__.py
@@ -1,3 +1,6 @@
+"""
+Traits (mixins) and default implementations
+"""
 from .positionable import Positionable, PositionableImpl
 from .layerable import Layerable, LayerableImpl
 from .doseable import Doseable, DoseableImpl

From a02dfdc9824387712218077cf420b9931c7def6f Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 00:44:56 -0700
Subject: [PATCH 49/71] remove dependency list from top-level comment

---
 masque/__init__.py | 7 -------
 1 file changed, 7 deletions(-)

diff --git a/masque/__init__.py b/masque/__init__.py
index 9b8efb1..0a13faa 100644
--- a/masque/__init__.py
+++ b/masque/__init__.py
@@ -15,13 +15,6 @@
 
  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

From 0e4b6828df671e3659617227cf231ac70011f734 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 17:24:04 -0700
Subject: [PATCH 50/71] Disable height warning for DXF

---
 masque/file/dxf.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/masque/file/dxf.py b/masque/file/dxf.py
index 581bfcd..a5e7bc1 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -237,9 +237,9 @@ def _read_block(block, clean_vertices):
                    }
             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.')
+#            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=????))

From 84f811e9d1570b8b170ecc06d7e9fd9eb58906c1 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 17:32:12 -0700
Subject: [PATCH 51/71] move clean_vertices functionality out into a common
 function

---
 masque/file/gdsii.py | 14 +++-----------
 masque/file/oasis.py | 16 +++-------------
 masque/file/utils.py | 25 +++++++++++++++++++++++++
 3 files changed, 31 insertions(+), 24 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 1b9c1d0..18fc9ff 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -28,7 +28,7 @@ import gdsii.library
 import gdsii.structure
 import gdsii.elements
 
-from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose
+from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose, clean_pattern_vertices
 from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path
 from ..repetition import Grid
@@ -250,20 +250,10 @@ def read(stream: io.BufferedIOBase,
             # Switch based on element type:
             if isinstance(element, gdsii.elements.Boundary):
                 poly = _boundary_to_polygon(element, raw_mode)
-                if clean_vertices:
-                    try:
-                        poly.clean_vertices()
-                    except PatternError:
-                        continue
                 pat.shapes.append(poly)
 
             if isinstance(element, gdsii.elements.Path):
                 path = _gpath_to_mpath(element, raw_mode)
-                if clean_vertices:
-                    try:
-                        path.clean_vertices()
-                    except PatternError as err:
-                        continue
                 pat.shapes.append(path)
 
             elif isinstance(element, gdsii.elements.Text):
@@ -276,6 +266,8 @@ def read(stream: io.BufferedIOBase,
                   isinstance(element, 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
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 6e0284d..142b4a1 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -27,7 +27,7 @@ import fatamorgana
 import fatamorgana.records as fatrec
 from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringReference
 
-from .utils import mangle_name, make_dose_table
+from .utils import mangle_name, make_dose_table, clean_pattern_vertices
 from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path, Circle
 from ..repetition import Grid, Arbitrary, Repetition
@@ -289,12 +289,6 @@ def read(stream: io.BufferedIOBase,
                                annotations=annotations,
                                repetition=repetition)
 
-                if clean_vertices:
-                    try:
-                        poly.clean_vertices()
-                    except PatternError:
-                        continue
-
                 pat.shapes.append(poly)
 
             elif isinstance(element, fatrec.Path):
@@ -321,12 +315,6 @@ def read(stream: io.BufferedIOBase,
                             cap=cap,
                             **path_args)
 
-                if clean_vertices:
-                    try:
-                        path.clean_vertices()
-                    except PatternError as err:
-                        continue
-
                 pat.shapes.append(path)
 
             elif isinstance(element, fatrec.Rectangle):
@@ -455,6 +443,8 @@ def read(stream: io.BufferedIOBase,
         for placement in cell.placements:
             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
diff --git a/masque/file/utils.py b/masque/file/utils.py
index e36765e..30b2808 100644
--- a/masque/file/utils.py
+++ b/masque/file/utils.py
@@ -6,6 +6,7 @@ import re
 import copy
 
 from .. import Pattern, PatternError
+from ..shapes import Polygon, Path
 
 
 def mangle_name(pattern: Pattern, dose_multiplier: float=1.0) -> str:
@@ -25,6 +26,30 @@ def mangle_name(pattern: Pattern, dose_multiplier: float=1.0) -> str:
     return sanitized_name
 
 
+def clean_pattern_vertices(pat: Pattern) -> Pattern:
+    """
+    Given a pattern, remove any redundant vertices in its polygons and paths.
+    The cleaning process completely removes any polygons with zero area or <3 vertices.
+
+    Args:
+        pat: Pattern to clean
+
+    Returns:
+        pat
+    """
+    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)

From c6684936cf2b3025f481c367e18ef0ca33f1237b Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 17:33:46 -0700
Subject: [PATCH 52/71] Improve docs, error messages, and type annotations

---
 masque/file/gdsii.py   | 23 ++++++++++-------------
 masque/file/oasis.py   |  1 -
 masque/pattern.py      |  2 +-
 masque/shapes/shape.py |  2 +-
 masque/shapes/text.py  |  4 ++--
 5 files changed, 14 insertions(+), 18 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 18fc9ff..3d97fe3 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -10,6 +10,12 @@ 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 Sequence, Mapping
@@ -35,8 +41,6 @@ from ..repetition import Grid
 from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
 from ..utils import remove_colinear_vertices, normalize_mirror, annotations_t
 
-#TODO absolute positioning
-
 
 logger = logging.getLogger(__name__)
 
@@ -127,8 +131,6 @@ def build(patterns: Union[Pattern, Sequence[Pattern]],
         structure = gdsii.structure.Structure(name=pat.name.encode('ASCII'))
         lib.append(structure)
 
-#        structure.properties = _annotations_to_properties(pat.annotations, 512)
-
         structure += _shapes_to_elements(pat.shapes)
         structure += _labels_to_texts(pat.labels)
         structure += _subpatterns_to_refs(pat.subpatterns)
@@ -243,9 +245,6 @@ def read(stream: io.BufferedIOBase,
     patterns = []
     for structure in lib:
         pat = Pattern(name=structure.name.decode('ASCII'))
-        if pat.annotations:
-            logger.warning('Dropping Pattern-level annotations; they are not supported by python-gdsii')
-#        pat.annotations = {str(k): v for k, v in structure.properties}
         for element in structure:
             # Switch based on element type:
             if isinstance(element, gdsii.elements.Boundary):
@@ -304,10 +303,8 @@ def _ref_to_subpat(element: Union[gdsii.elements.SRef,
     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).
     """
     rotation = 0.0
     offset = numpy.array(element.xy[0], dtype=float)
@@ -320,12 +317,12 @@ def _ref_to_subpat(element: Union[gdsii.elements.SRef,
             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 = 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
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 142b4a1..8d9e074 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -47,7 +47,6 @@ path_cap_map = {
                 PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom,
                }
 
-#TODO implement properties
 #TODO implement more shape types?
 
 def build(patterns: Union[Pattern, Sequence[Pattern]],
diff --git a/masque/pattern.py b/masque/pattern.py
index adbef2b..2393a6f 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -18,7 +18,7 @@ from .shapes import Shape, Polygon
 from .label import Label
 from .utils import rotation_matrix_2d, vector2, normalize_mirror, AutoSlots, annotations_t
 from .error import PatternError, PatternLockedError
-from .traits import LockableImpl, AnnotatableImpl
+from .traits import LockableImpl, AnnotatableImpl, Scalable
 
 
 visitor_function_t = Callable[['Pattern', Tuple['Pattern'], Dict, numpy.ndarray], 'Pattern']
diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py
index 6c72263..8ffb1a4 100644
--- a/masque/shapes/shape.py
+++ b/masque/shapes/shape.py
@@ -246,7 +246,7 @@ class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable
             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)
diff --git a/masque/shapes/text.py b/masque/shapes/text.py
index debd994..2384404 100644
--- a/masque/shapes/text.py
+++ b/masque/shapes/text.py
@@ -170,8 +170,8 @@ def get_char_as_polygons(font_path: str,
                          char: str,
                          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.

From 7cad46fa469edb550ff5bfa33e5f1b11fab550b0 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 17:35:05 -0700
Subject: [PATCH 53/71] add klamath-based gds read/write

---
 masque/file/klamath.py | 541 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 541 insertions(+)
 create mode 100644 masque/file/klamath.py

diff --git a/masque/file/klamath.py b/masque/file/klamath.py
new file mode 100644
index 0000000..4440457
--- /dev/null
+++ b/masque/file/klamath.py
@@ -0,0 +1,541 @@
+"""
+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, Sequence, Iterable, Optional
+from typing import Sequence, Mapping, BinaryIO
+import re
+import io
+import copy
+import base64
+import struct
+import logging
+import pathlib
+import gzip
+from itertools import chain
+
+import numpy        # type: ignore
+import klamath
+from klamath import records
+
+from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose
+from .. import Pattern, SubPattern, PatternError, Label, Shape
+from ..shapes import Polygon, Path
+from ..repetition import Grid
+from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
+from ..utils import remove_colinear_vertices, normalize_mirror, 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 `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 `masque.file.gdsii.write`
+        **kwargs: passed to `masque.file.gdsii.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 `masque.file.gdsii.read()` that takes a filename or path instead of a stream.
+
+    Will automatically decompress files with a .gz suffix.
+
+    Args:
+        filename: Filename to save to.
+        *args: passed to `masque.file.gdsii.read`
+        **kwargs: passed to `masque.file.gdsii.read`
+    """
+    path = pathlib.Path(filename)
+    if path.suffix == '.gz':
+        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'))
+        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:
+            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)
+        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('[^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)

From aa5696d884db3a08ab056c3dbf972651e607e85f Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 17:37:23 -0700
Subject: [PATCH 54/71] Add Library management functionality

---
 masque/__init__.py         |   1 +
 masque/error.py            |   9 ++
 masque/file/klamath.py     |  45 +++++++
 masque/library/__init__.py |   1 +
 masque/library/library.py  | 267 +++++++++++++++++++++++++++++++++++++
 masque/library/utils.py    |  48 +++++++
 6 files changed, 371 insertions(+)
 create mode 100644 masque/library/__init__.py
 create mode 100644 masque/library/library.py
 create mode 100644 masque/library/utils.py

diff --git a/masque/__init__.py b/masque/__init__.py
index 0a13faa..87ceb5d 100644
--- a/masque/__init__.py
+++ b/masque/__init__.py
@@ -25,6 +25,7 @@ from .label import Label
 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/klamath.py b/masque/file/klamath.py
index 4440457..192ce9f 100644
--- a/masque/file/klamath.py
+++ b/masque/file/klamath.py
@@ -539,3 +539,48 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
 
         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 file 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:
+        is_secondary = lambda k: 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
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..8e5ec11
--- /dev/null
+++ b/masque/library/library.py
@@ -0,0 +1,267 @@
+"""
+Library class for managing unique name->pattern mappings and
+ deferred loading or creation.
+"""
+from typing import Dict, Callable, TypeVar, Generic, TYPE_CHECKING, Any, Tuple, Union
+import logging
+from pprint import pformat
+from dataclasses import dataclass
+from functools import lru_cache
+
+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':
+        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_primary(self, key: str) -> 'Pattern':
+        return self[key]
+
+    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[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 __repr__(self) -> str:
+        return '<Library with keys ' + repr(list(self.primary.keys())) + '>'
+
+    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) -> 'Library':
+        """
+        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, other: 'Library') -> 'Library':
+        """
+        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 '<Library with keys ' + repr(set(self.keys())) + '>'
+
+    def set_const(self, key: Key, value: Value) -> None:
+        """
+        Convenience function to avoid having to manually wrap
+         constant values into callables.
+        """
+        self[key] = lambda: value

From b8ef80b9914dd3b81b598bdd73ae1198ef608c60 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 17:40:52 -0700
Subject: [PATCH 55/71] add klamath dependency

---
 README.md | 3 ++-
 setup.py  | 1 +
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 214f7ad..d524665 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ Requirements:
 * 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)
@@ -25,7 +26,7 @@ Requirements:
 
 Install with pip:
 ```bash
-pip3 install 'masque[visualization,gdsii,oasis,dxf,svg,text]'
+pip3 install 'masque[visualization,gdsii,oasis,dxf,svg,text,klamath]'
 ```
 
 Alternatively, install from git
diff --git a/setup.py b/setup.py
index 1d3138b..ff8aad9 100644
--- a/setup.py
+++ b/setup.py
@@ -28,6 +28,7 @@ setup(name='masque',
       ],
       extras_require={
           'gdsii': ['python-gdsii'],
+          'klamath': ['klamath'],
           'oasis': ['fatamorgana>=0.7'],
           'dxf': ['ezdxf'],
           'svg': ['svgwrite'],

From 91dcc4f04f12667074747247a4b8f65fdf685820 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 17:41:00 -0700
Subject: [PATCH 56/71] doc fix

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index d524665..69e7a7e 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ 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

From 682a99470f01adfbfd68926d0445d19fcad0ec7d Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 26 Sep 2020 17:41:08 -0700
Subject: [PATCH 57/71] Bump version to 2.1

---
 masque/VERSION | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/VERSION b/masque/VERSION
index cd5ac03..879b416 100644
--- a/masque/VERSION
+++ b/masque/VERSION
@@ -1 +1 @@
-2.0
+2.1

From b873a5ddf3701f58cef23aa29e384e0778dc7341 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Mon, 28 Sep 2020 23:49:33 -0700
Subject: [PATCH 58/71] make __getitem__ call get_primary rather than the other
 way around

this makes subclassing easier
---
 masque/library/library.py | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/masque/library/library.py b/masque/library/library.py
index 8e5ec11..f2151d2 100644
--- a/masque/library/library.py
+++ b/masque/library/library.py
@@ -2,7 +2,8 @@
 Library class for managing unique name->pattern mappings and
  deferred loading or creation.
 """
-from typing import Dict, Callable, TypeVar, Generic, TYPE_CHECKING, Any, Tuple, Union
+from typing import Dict, Callable, TypeVar, Generic, TYPE_CHECKING
+from typing import Any, Tuple, Union, Iterator
 import logging
 from pprint import pformat
 from dataclasses import dataclass
@@ -81,6 +82,10 @@ class Library:
             del self.cache[key]
 
     def __getitem__(self, key: str) -> 'Pattern':
+        return self.get_primary(key)
+
+
+    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]
@@ -92,9 +97,6 @@ class Library:
         self.cache[key] = pat
         return pat
 
-    def get_primary(self, key: str) -> 'Pattern':
-        return self[key]
-
     def get_secondary(self, key: str, tag: str) -> 'Pattern':
         logger.debug(f'get_secondary({key}, {tag})')
         key2 = (key, tag)
@@ -115,7 +117,7 @@ class Library:
 
             key = sp.identifier[0]
             if key in self.primary:
-                sp.pattern = self[key]
+                sp.pattern = self.get_primary(key)
                 continue
 
             if (key, tag) in self.secondary:

From 03a359e446fabf657352d2166b26250cda12b6b6 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Mon, 28 Sep 2020 23:49:42 -0700
Subject: [PATCH 59/71] add more dict-like methods

---
 masque/library/library.py | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/masque/library/library.py b/masque/library/library.py
index f2151d2..7fc5a86 100644
--- a/masque/library/library.py
+++ b/masque/library/library.py
@@ -84,6 +84,11 @@ class Library:
     def __getitem__(self, key: str) -> 'Pattern':
         return self.get_primary(key)
 
+    def __iter__(self) -> Iterator[str]:
+        return 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:
@@ -127,6 +132,15 @@ class Library:
             raise LibraryError(f'Broken reference to {key} (tag {tag})')
         return pat
 
+    def keys(self) -> Iterator[str]:
+        return self.primary.keys()
+
+    def values(self) -> Iterator['Pattern']:
+        return (self[key] for key in self.keys())
+
+    def items(self) -> Iterator[Tuple[str, 'Pattern']]:
+        return ((key, self[key]) for key in self.keys())
+
     def __repr__(self) -> str:
         return '<Library with keys ' + repr(list(self.primary.keys())) + '>'
 

From ce5d386a244e43e99749b7c85c56c0335407b9de Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 29 Sep 2020 00:57:26 -0700
Subject: [PATCH 60/71] Determine if an existing file is gzipped based on magic
 bytes, not suffix

---
 masque/file/gdsii.py   | 5 +++--
 masque/file/klamath.py | 6 +++---
 masque/file/oasis.py   | 6 +++---
 masque/file/utils.py   | 8 ++++++++
 4 files changed, 17 insertions(+), 8 deletions(-)

diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py
index 3d97fe3..68b69a9 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -35,6 +35,7 @@ import gdsii.structure
 import gdsii.elements
 
 from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose, clean_pattern_vertices
+from .utils import is_gzipped
 from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path
 from ..repetition import Grid
@@ -190,7 +191,7 @@ def readfile(filename: Union[str, pathlib.Path],
     """
     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.
@@ -198,7 +199,7 @@ def readfile(filename: Union[str, pathlib.Path],
         **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
diff --git a/masque/file/klamath.py b/masque/file/klamath.py
index 192ce9f..ba89638 100644
--- a/masque/file/klamath.py
+++ b/masque/file/klamath.py
@@ -34,7 +34,7 @@ import numpy        # type: ignore
 import klamath
 from klamath import records
 
-from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose
+from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose, is_gzipped
 from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path
 from ..repetition import Grid
@@ -170,7 +170,7 @@ def readfile(filename: Union[str, pathlib.Path],
     """
     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.
@@ -178,7 +178,7 @@ def readfile(filename: Union[str, pathlib.Path],
         **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
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 8d9e074..5e20121 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -27,7 +27,7 @@ import fatamorgana
 import fatamorgana.records as fatrec
 from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringReference
 
-from .utils import mangle_name, make_dose_table, clean_pattern_vertices
+from .utils import mangle_name, make_dose_table, clean_pattern_vertices, is_gzipped
 from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path, Circle
 from ..repetition import Grid, Arbitrary, Repetition
@@ -207,7 +207,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.
@@ -215,7 +215,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
diff --git a/masque/file/utils.py b/masque/file/utils.py
index 30b2808..6239a1a 100644
--- a/masque/file/utils.py
+++ b/masque/file/utils.py
@@ -4,6 +4,8 @@ Helper functions for file reading and writing
 from typing import Set, Tuple, List
 import re
 import copy
+import gzip
+import pathlib
 
 from .. import Pattern, PatternError
 from ..shapes import Polygon, Path
@@ -176,3 +178,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'

From de4726955b4ebd57b5ea9573c2f687132097254b Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 29 Sep 2020 01:00:37 -0700
Subject: [PATCH 61/71] add load_libraryfile convenience wrapper

---
 masque/file/klamath.py | 47 +++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 46 insertions(+), 1 deletion(-)

diff --git a/masque/file/klamath.py b/masque/file/klamath.py
index ba89638..97fe7e0 100644
--- a/masque/file/klamath.py
+++ b/masque/file/klamath.py
@@ -22,6 +22,7 @@ from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable,
 from typing import Sequence, Mapping, BinaryIO
 import re
 import io
+import mmap
 import copy
 import base64
 import struct
@@ -546,7 +547,7 @@ def load_library(stream: BinaryIO,
                  is_secondary: Optional[Callable[[str], bool]] = None,
                  ) -> Tuple[Library, Dict[str, Any]]:
     """
-    Scan a GDSII file to determine what structures are present, and create
+    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
@@ -584,3 +585,47 @@ def load_library(stream: BinaryIO,
         lib.set_value(name, tag, mkstruct, secondary=is_secondary(name))
 
     return lib
+
+
+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)

From 08cf7ca4b137f036f4cbc4109cffb55683b003ec Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Tue, 29 Sep 2020 01:01:10 -0700
Subject: [PATCH 62/71] avoid calling to_polygons on Polygons (for speed)

---
 masque/file/klamath.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/masque/file/klamath.py b/masque/file/klamath.py
index 97fe7e0..c70f704 100644
--- a/masque/file/klamath.py
+++ b/masque/file/klamath.py
@@ -454,6 +454,14 @@ def _shapes_to_elements(shapes: List[Shape],
                                          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)

From c23c391d830dd14961e5d851e1c690eae8753f87 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 10 Oct 2020 19:10:17 -0700
Subject: [PATCH 63/71] disable locking for annotations

until I can find a better way to do it
---
 masque/traits/annotatable.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/masque/traits/annotatable.py b/masque/traits/annotatable.py
index 0285cfd..e818b3b 100644
--- a/masque/traits/annotatable.py
+++ b/masque/traits/annotatable.py
@@ -44,9 +44,9 @@ class AnnotatableImpl(Annotatable, metaclass=ABCMeta):
     '''
     @property
     def annotations(self) -> annotations_t:
-        # 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)
+#        # 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)
         return self._annotations
 
     @annotations.setter

From ae71dc9a8f578989b99d1fde8289294c01803666 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 10 Oct 2020 19:10:35 -0700
Subject: [PATCH 64/71] use klamath for examples

---
 examples/ellip_grating.py | 4 ++--
 examples/test_rep.py      | 7 ++++---
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py
index 948df42..0c34cce 100644
--- a/examples/ellip_grating.py
+++ b/examples/ellip_grating.py
@@ -3,7 +3,7 @@
 import numpy
 
 import masque
-import masque.file.gdsii
+import masque.file.klamath
 from masque import shapes
 
 
@@ -24,7 +24,7 @@ def main():
     pat2 = pat.copy()
     pat2.name = 'grating2'
 
-    masque.file.gdsii.writefile((pat, pat2), 'out.gds.gz', 1e-9, 1e-3)
+    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 22e4b65..042e1af 100644
--- a/examples/test_rep.py
+++ b/examples/test_rep.py
@@ -3,6 +3,7 @@ from numpy import pi
 
 import masque
 import masque.file.gdsii
+import masque.file.klamath
 import masque.file.dxf
 import masque.file.oasis
 from masque import shapes, Pattern, SubPattern
@@ -83,10 +84,10 @@ def main():
         ]
 
     folder = 'layouts/'
-    masque.file.gdsii.writefile((pat, pat2, pat3, pat4), folder + 'rep.gds.gz', 1e-9, 1e-3)
+    masque.file.klamath.writefile((pat, pat2, pat3, pat4), folder + 'rep.gds.gz', 1e-9, 1e-3)
 
-    cells = list(masque.file.gdsii.readfile(folder + 'rep.gds.gz')[0].values())
-    masque.file.gdsii.writefile(cells, folder + 'rerep.gds.gz', 1e-9, 1e-3)
+    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, folder + 'rep.dxf.gz')
     dxf, info = masque.file.dxf.readfile(folder + 'rep.dxf.gz')

From 4a7e20d6baf9ed5cd17b3f5441c0d091dc9f1f3b Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 10 Oct 2020 19:11:22 -0700
Subject: [PATCH 65/71] improve type annotations in dxf writer

---
 masque/file/dxf.py | 19 ++++++++++---------
 1 file changed, 10 insertions(+), 9 deletions(-)

diff --git a/masque/file/dxf.py b/masque/file/dxf.py
index a5e7bc1..4429466 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -37,7 +37,8 @@ def write(pattern: Pattern,
           *,
           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,
@@ -105,7 +106,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.
 
@@ -131,7 +132,7 @@ def writefile(pattern: Pattern,
 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.
 
@@ -155,7 +156,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
@@ -193,7 +194,7 @@ 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()
@@ -277,7 +278,7 @@ def _read_block(block, clean_vertices):
 
 
 def _subpatterns_to_refs(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace],
-                         subpatterns: List[SubPattern]):
+                         subpatterns: List[SubPattern]) -> None:
     for subpat in subpatterns:
         if subpat.pattern is None:
             continue
@@ -335,7 +336,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
@@ -352,11 +353,11 @@ 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
-                               ):
+                               ) -> None:
     used_names = []
     for pat in patterns:
         sanitized_name = re.compile('[^A-Za-z0-9_\?\$]').sub('_', pat.name)

From 0f35eb5e588e1aa171fa2d2cdcab42641d14cc7d Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 10 Oct 2020 19:11:35 -0700
Subject: [PATCH 66/71] fix dxf reader

---
 masque/file/dxf.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/masque/file/dxf.py b/masque/file/dxf.py
index 4429466..95814ef 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -200,9 +200,9 @@ def _read_block(block, clean_vertices: bool) -> Pattern:
         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),
                    }

From 5bc82b9d4917bc6dccc82a5ad624241139488907 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 10 Oct 2020 19:11:52 -0700
Subject: [PATCH 67/71] __iter__ should actually return an iterator

---
 masque/library/library.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/library/library.py b/masque/library/library.py
index 7fc5a86..66d9f17 100644
--- a/masque/library/library.py
+++ b/masque/library/library.py
@@ -85,7 +85,7 @@ class Library:
         return self.get_primary(key)
 
     def __iter__(self) -> Iterator[str]:
-        return self.keys()
+        return iter(self.keys())
 
     def __contains__(self, key: str) -> bool:
         return key in self.primary

From 7ed3b26b02e740993bbfaa06da641d19dafc4af3 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 10 Oct 2020 19:12:17 -0700
Subject: [PATCH 68/71] skip assignment in dfs() to avoid PatternLockedError on
 unmodified patterns

---
 masque/pattern.py | 13 ++++++++-----
 1 file changed, 8 insertions(+), 5 deletions(-)

diff --git a/masque/pattern.py b/masque/pattern.py
index 2393a6f..31a331c 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -291,11 +291,14 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
                 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

From f6ad272c2c06fc3a1dfa1a5f2e7332181fe43185 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Sat, 10 Oct 2020 19:12:56 -0700
Subject: [PATCH 69/71] bump version to v2.2

---
 masque/VERSION | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/masque/VERSION b/masque/VERSION
index 879b416..8bbe6cf 100644
--- a/masque/VERSION
+++ b/masque/VERSION
@@ -1 +1 @@
-2.1
+2.2

From f3649704037202a5598f6880fcaf132db224e543 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Fri, 16 Oct 2020 19:00:50 -0700
Subject: [PATCH 70/71] style and type fixes (per flake8)

could potentially fix some bugs in `Library` class and dxf reader
---
 .flake8                       | 29 ++++++++++++
 masque/file/__init__.py       |  3 +-
 masque/file/dxf.py            | 60 ++++++++++++-------------
 masque/file/gdsii.py          | 63 +++++++++++++-------------
 masque/file/klamath.py        | 85 +++++++++++++++++------------------
 masque/file/oasis.py          | 72 +++++++++++++++--------------
 masque/file/svg.py            |  3 +-
 masque/file/utils.py          | 11 +++--
 masque/label.py               |  6 +--
 masque/library/library.py     | 11 +++--
 masque/pattern.py             | 33 +++++++-------
 masque/repetition.py          | 18 ++++----
 masque/shapes/arc.py          | 32 ++++++-------
 masque/shapes/circle.py       |  8 ++--
 masque/shapes/ellipse.py      | 10 ++---
 masque/shapes/path.py         | 24 +++++-----
 masque/shapes/polygon.py      | 13 +++---
 masque/shapes/shape.py        | 12 ++---
 masque/shapes/text.py         | 26 +++++------
 masque/subpattern.py          | 10 ++---
 masque/traits/annotatable.py  |  5 +--
 masque/traits/copyable.py     |  4 +-
 masque/traits/doseable.py     |  7 +--
 masque/traits/layerable.py    |  4 +-
 masque/traits/lockable.py     |  5 +--
 masque/traits/mirrorable.py   |  5 +--
 masque/traits/positionable.py |  8 ++--
 masque/traits/repeatable.py   |  5 +--
 masque/traits/rotatable.py    |  7 ++-
 masque/traits/scalable.py     |  5 +--
 masque/utils.py               |  6 +--
 31 files changed, 293 insertions(+), 297 deletions(-)
 create mode 100644 .flake8

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/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 95814ef..906fc2f 100644
--- a/masque/file/dxf.py
+++ b/masque/file/dxf.py
@@ -1,10 +1,9 @@
 """
 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
@@ -12,15 +11,12 @@ import pathlib
 import gzip
 
 import numpy        # type: ignore
-from numpy import pi
 import ezdxf        # type: ignore
 
-from .utils import mangle_name, make_dose_table
 from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path
 from ..repetition import Grid
-from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
-from ..utils import remove_colinear_vertices, normalize_mirror
+from ..utils import rotation_matrix_2d, layer_t
 
 
 logger = logging.getLogger(__name__)
@@ -75,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()
@@ -125,8 +122,7 @@ 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],
@@ -204,25 +200,26 @@ def _read_block(block, clean_vertices: bool) -> Pattern:
             else:
                 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:
@@ -237,7 +234,7 @@ def _read_block(block, clean_vertices: bool) -> Pattern:
                     'layer': element.dxfattribs().get('layer', DEFAULT_LAYER),
                    }
             string = element.dxfattribs().get('text', '')
-            height = element.dxfattribs().get('height', 0)
+#            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.')
@@ -252,7 +249,7 @@ def _read_block(block, clean_vertices: bool) -> Pattern:
                 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]
 
@@ -266,11 +263,10 @@ def _read_block(block, clean_vertices: bool) -> Pattern:
                 }
 
             if 'column_count' in attr:
-                args['repetition'] = Grid(
-                           a_vector=(attr['column_spacing'], 0),
-                           b_vector=(0, attr['row_spacing']),
-                           a_count=attr['column_count'],
-                           b_count=attr['row_count'])
+                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).')
@@ -356,11 +352,11 @@ def _mlayer2dxf(layer: layer_t) -> str:
 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
@@ -374,15 +370,15 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
             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}"')
+                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(f'Zero-length name after sanitize,\n originally "{pat.name}"')
         if len(suffixed_name) > max_name_length:
-            raise PatternError(f'Pattern name "{suffixed_name!r}" length > {max_name_length} after encode,\n' +
-                               f' originally "{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 68b69a9..c0ea11d 100644
--- a/masque/file/gdsii.py
+++ b/masque/file/gdsii.py
@@ -17,8 +17,8 @@ Notes:
  * 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 Sequence, Mapping
+from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional
+from typing import Sequence
 import re
 import io
 import copy
@@ -34,25 +34,23 @@ import gdsii.library
 import gdsii.structure
 import gdsii.elements
 
-from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose, clean_pattern_vertices
-from .utils import is_gzipped
+from .utils import clean_pattern_vertices, is_gzipped
 from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path
 from ..repetition import Grid
-from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
-from ..utils import remove_colinear_vertices, normalize_mirror, annotations_t
+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 build(patterns: Union[Pattern, Sequence[Pattern]],
@@ -262,8 +260,7 @@ def read(stream: io.BufferedIOBase,
                               string=element.string.decode('ASCII'))
                 pat.labels.append(label)
 
-            elif (isinstance(element, gdsii.elements.SRef) or
-                  isinstance(element, gdsii.elements.ARef)):
+            elif isinstance(element, (gdsii.elements.SRef, gdsii.elements.ARef)):
                 pat.subpatterns.append(_ref_to_subpat(element))
 
         if clean_vertices:
@@ -358,7 +355,7 @@ def _gpath_to_mpath(element: gdsii.elements.Path, raw_mode: bool) -> Path:
             'width': element.width if element.width is not None else 0.0,
             'cap': cap,
             'offset': numpy.zeros(2),
-            'annotations':_properties_to_annotations(element.properties),
+            'annotations': _properties_to_annotations(element.properties),
             'raw': raw_mode,
            }
 
@@ -376,7 +373,7 @@ def _boundary_to_polygon(element: gdsii.elements.Boundary, raw_mode: bool) -> Po
     args = {'vertices': element.xy[:-1].astype(float),
             'layer': (element.layer, element.data_type),
             'offset': numpy.zeros(2),
-            'annotations':_properties_to_annotations(element.properties),
+            'annotations': _properties_to_annotations(element.properties),
             'raw': raw_mode,
            }
     return Polygon(**args)
@@ -398,14 +395,14 @@ def _subpatterns_to_refs(subpatterns: List[SubPattern]
         ref: Union[gdsii.elements.SRef, gdsii.elements.ARef]
         if isinstance(rep, Grid):
             xy = numpy.array(subpat.offset) + [
-                  [0, 0],
-                  rep.a_vector * rep.a_count,
-                  rep.b_vector * rep.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(rep.a_count).astype(int),
-                                       rows=numpy.round(rep.b_count).astype(int))
+                                      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,
@@ -437,7 +434,7 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -
     for key, vals in annotations.items():
         try:
             i = int(key)
-        except:
+        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])')
@@ -464,7 +461,7 @@ def _shapes_to_elements(shapes: List[Shape],
         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)
@@ -502,7 +499,7 @@ def _labels_to_texts(labels: List[Label]) -> List[gdsii.elements.Text]:
 def disambiguate_pattern_names(patterns: Sequence[Pattern],
                                max_name_length: int = 32,
                                suffix_length: int = 6,
-                               dup_warn_filter: Optional[Callable[[str,], bool]] = None,
+                               dup_warn_filter: Optional[Callable[[str], bool]] = None,
                                ):
     """
     Args:
@@ -519,13 +516,13 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
         # 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')
+            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('[^A-Za-z0-9_\?\$]').sub('_', shortened_name)
+        sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name)
 
         # Add a suffix that makes the name unique
         i = 0
@@ -540,8 +537,8 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
             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}"')
+                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')
@@ -549,8 +546,8 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
             # 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}"')
+            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)
diff --git a/masque/file/klamath.py b/masque/file/klamath.py
index c70f704..0f858cf 100644
--- a/masque/file/klamath.py
+++ b/masque/file/klamath.py
@@ -18,8 +18,8 @@ Notes:
  * 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, Sequence, Iterable, Optional
-from typing import Sequence, Mapping, BinaryIO
+from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional
+from typing import Sequence, BinaryIO
 import re
 import io
 import mmap
@@ -29,29 +29,27 @@ import struct
 import logging
 import pathlib
 import gzip
-from itertools import chain
 
 import numpy        # type: ignore
 import klamath
 from klamath import records
 
-from .utils import mangle_name, make_dose_table, dose2dtype, dtype2dose, is_gzipped
+from .utils import is_gzipped
 from .. import Pattern, SubPattern, PatternError, Label, Shape
 from ..shapes import Polygon, Path
 from ..repetition import Grid
-from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
-from ..utils import remove_colinear_vertices, normalize_mirror, annotations_t
+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,
-               }
+    0: Path.Cap.Flush,
+    1: Path.Cap.Circle,
+    2: Path.Cap.Square,
+    4: Path.Cap.SquareCustom,
+    }
 
 
 def write(patterns: Union[Pattern, Sequence[Pattern]],
@@ -144,15 +142,15 @@ def writefile(patterns: Union[Sequence[Pattern], Pattern],
               **kwargs,
               ) -> None:
     """
-    Wrapper for `masque.file.gdsii.write()` that takes a filename or path instead of a stream.
+    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 `masque.file.gdsii.write`
-        **kwargs: passed to `masque.file.gdsii.write`
+        *args: passed to `write()`
+        **kwargs: passed to `write()`
     """
     path = pathlib.Path(filename)
     if path.suffix == '.gz':
@@ -169,14 +167,14 @@ def readfile(filename: Union[str, pathlib.Path],
              **kwargs,
              ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
     """
-    Wrapper for `masque.file.gdsii.read()` that takes a filename or path instead of a stream.
+    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 `masque.file.gdsii.read`
-        **kwargs: passed to `masque.file.gdsii.read`
+        *args: passed to `read()`
+        **kwargs: passed to `read()`
     """
     path = pathlib.Path(filename)
     if is_gzipped(path):
@@ -185,7 +183,7 @@ def readfile(filename: Union[str, pathlib.Path],
         open_func = open
 
     with io.BufferedReader(open_func(path, mode='rb')) as stream:
-        results = read(stream)#, *args, **kwargs)
+        results = read(stream, *args, **kwargs)
     return results
 
 
@@ -216,7 +214,7 @@ def read(stream: BinaryIO,
     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'))
+        pat = read_elements(stream, name=name.decode('ASCII'), raw_mode=raw_mode)
         patterns.append(pat)
         found_struct = records.BGNSTR.skip_past(stream)
 
@@ -368,10 +366,10 @@ def _subpatterns_to_refs(subpatterns: List[SubPattern]
 
         if isinstance(rep, Grid):
             xy = numpy.array(subpat.offset) + [
-                  [0, 0],
-                  rep.a_vector * rep.a_count,
-                  rep.b_vector * rep.b_count,
-                 ]
+                [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)),
@@ -412,7 +410,7 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -
     for key, vals in annotations.items():
         try:
             i = int(key)
-        except:
+        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])')
@@ -439,7 +437,7 @@ def _shapes_to_elements(shapes: List[Shape],
         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
 
             extension: Tuple[int, int]
             if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
@@ -455,13 +453,13 @@ def _shapes_to_elements(shapes: List[Shape],
                                          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)
+            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)
@@ -483,7 +481,7 @@ def _labels_to_texts(labels: List[Label]) -> List[klamath.elements.Text]:
                                      xy=xy,
                                      string=label.string.encode('ASCII'),
                                      properties=properties,
-                                     presentation=0, #TODO maybe set some of these?
+                                     presentation=0,  # TODO maybe set some of these?
                                      angle_deg=0,
                                      invert_y=False,
                                      width=0,
@@ -496,7 +494,7 @@ def _labels_to_texts(labels: List[Label]) -> List[klamath.elements.Text]:
 def disambiguate_pattern_names(patterns: Sequence[Pattern],
                                max_name_length: int = 32,
                                suffix_length: int = 6,
-                               dup_warn_filter: Optional[Callable[[str,], bool]] = None,
+                               dup_warn_filter: Optional[Callable[[str], bool]] = None,
                                ):
     """
     Args:
@@ -513,13 +511,13 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
         # 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')
+            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('[^A-Za-z0-9_\?\$]').sub('_', shortened_name)
+        sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name)
 
         # Add a suffix that makes the name unique
         i = 0
@@ -534,8 +532,8 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
             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}"')
+                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')
@@ -543,8 +541,8 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern],
             # 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}"')
+            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)
@@ -576,7 +574,8 @@ def load_library(stream: BinaryIO,
         Additional library info (dict, same format as from `read`).
     """
     if is_secondary is None:
-        is_secondary = lambda k: False
+        def is_secondary(k: str):
+            return False
 
     stream.seek(0)
     library_info = _read_header(stream)
@@ -592,7 +591,7 @@ def load_library(stream: BinaryIO,
 
         lib.set_value(name, tag, mkstruct, secondary=is_secondary(name))
 
-    return lib
+    return lib, library_info
 
 
 def load_libraryfile(filename: Union[str, pathlib.Path],
diff --git a/masque/file/oasis.py b/masque/file/oasis.py
index 5e20121..dea703d 100644
--- a/masque/file/oasis.py
+++ b/masque/file/oasis.py
@@ -22,17 +22,15 @@ import pathlib
 import gzip
 
 import numpy        # type: ignore
-from numpy import pi
 import fatamorgana
 import fatamorgana.records as fatrec
 from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringReference
 
-from .utils import mangle_name, make_dose_table, clean_pattern_vertices, is_gzipped
+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 rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t
-from ..utils import remove_colinear_vertices, normalize_mirror, annotations_t
+from ..utils import layer_t, normalize_mirror, annotations_t
 
 
 logger = logging.getLogger(__name__)
@@ -42,10 +40,10 @@ 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?
 
@@ -120,11 +118,11 @@ def build(patterns: Union[Pattern, Sequence[Pattern]],
         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)]
+                fatrec.LayerName(nstring=name,
+                                 layer_interval=(layer, layer),
+                                 type_interval=(data_type, data_type),
+                                 is_textlayer=tt)
+                for tt in (True, False)]
 
         def layer2oas(mlayer: layer_t) -> Tuple[int, int]:
             assert(layer_map is not None)
@@ -252,9 +250,9 @@ def read(stream: io.BufferedIOBase,
     lib = fatamorgana.OasisLayout.read(stream)
 
     library_info: Dict[str, Any] = {
-            'units_per_micrometer': lib.unit,
-            'annotations': properties_to_annotations(lib.properties, lib.propnames, lib.propstrings),
-            }
+        'units_per_micrometer': lib.unit,
+        'annotations': properties_to_annotations(lib.properties, lib.propnames, lib.propstrings),
+        }
 
     layer_map = {}
     for layer_name in lib.layers:
@@ -296,7 +294,7 @@ def read(stream: io.BufferedIOBase,
                 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
 
                 path_args: Dict[str, Any] = {}
@@ -472,7 +470,7 @@ def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]:
             data_type = 0
     else:
         raise PatternError(f'Invalid layer for OASIS: {layer}. Note that OASIS layers cannot be '
-                            'strings unless a layer map is provided.')
+                           f'strings unless a layer map is provided.')
     return layer, data_type
 
 
@@ -490,7 +488,7 @@ def _placement_to_subpat(placement: fatrec.Placement, lib: fatamorgana.OasisLayo
     subpat = SubPattern(offset=xy,
                         pattern=None,
                         mirrored=(placement.flip, False),
-                        rotation=float(placement.angle * pi/180),
+                        rotation=numpy.deg2rad(placement.angle),
                         scale=float(mag),
                         identifier=(name,),
                         repetition=repetition_fata2masq(placement.repetition),
@@ -512,14 +510,14 @@ def _subpatterns_to_placements(subpatterns: List[SubPattern]
         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=angle,
-                magnification=subpat.scale,
-                properties=annotations_to_properties(subpat.annotations),
-                x=offset[0],
-                y=offset[1],
-                repetition=frep)
+            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
@@ -549,7 +547,7 @@ def _shapes_to_elements(shapes: List[Shape],
             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,
@@ -558,7 +556,7 @@ def _shapes_to_elements(shapes: List[Shape],
                                half_width=half_width,
                                x=xy[0],
                                y=xy[1],
-                               extension_start=extension_start,       #TODO implement multiple cap types?
+                               extension_start=extension_start,       # TODO implement multiple cap types?
                                extension_end=extension_end,
                                properties=properties,
                                repetition=repetition,
@@ -598,11 +596,11 @@ def _labels_to_texts(labels: List[Label],
 
 
 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
@@ -616,8 +614,8 @@ def disambiguate_pattern_names(patterns,
             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}"')
+                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
@@ -653,10 +651,10 @@ def repetition_masq2fata(rep: Optional[Repetition]
     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))
+            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 --git a/masque/file/svg.py b/masque/file/svg.py
index b719b0b..a9f7c47 100644
--- a/masque/file/svg.py
+++ b/masque/file/svg.py
@@ -13,7 +13,8 @@ from .. import Pattern
 
 def writefile(pattern: Pattern,
               filename: str,
-              custom_attributes: bool=False):
+              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
diff --git a/masque/file/utils.py b/masque/file/utils.py
index 6239a1a..d183b39 100644
--- a/masque/file/utils.py
+++ b/masque/file/utils.py
@@ -4,14 +4,13 @@ Helper functions for file reading and writing
 from typing import Set, Tuple, List
 import re
 import copy
-import gzip
 import pathlib
 
 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.
 
@@ -22,7 +21,7 @@ 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
@@ -52,7 +51,7 @@ def clean_pattern_vertices(pat: Pattern) -> Pattern:
     return pat
 
 
-def make_dose_table(patterns: List[Pattern], dose_multiplier: float=1.0) -> Set[Tuple[int, float]]:
+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)
 
@@ -144,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)
 
diff --git a/masque/label.py b/masque/label.py
index c8ca802..5027af5 100644
--- a/masque/label.py
+++ b/masque/label.py
@@ -1,10 +1,8 @@
-from typing import List, Tuple, Dict, Optional
+from typing import Tuple, Dict, Optional
 import copy
 import numpy        # type: ignore
-from numpy import pi
 
 from .repetition import Repetition
-from .error import PatternError, PatternLockedError
 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
@@ -63,7 +61,7 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, Annot
                      repetition=self.repetition,
                      locked=self.locked)
 
-    def  __deepcopy__(self, memo: Dict = None) -> 'Label':
+    def __deepcopy__(self, memo: Dict = None) -> 'Label':
         memo = {} if memo is None else memo
         new = copy.copy(self).unlock()
         new._offset = self._offset.copy()
diff --git a/masque/library/library.py b/masque/library/library.py
index 66d9f17..694360f 100644
--- a/masque/library/library.py
+++ b/masque/library/library.py
@@ -2,12 +2,11 @@
 Library class for managing unique name->pattern mappings and
  deferred loading or creation.
 """
-from typing import Dict, Callable, TypeVar, Generic, TYPE_CHECKING
+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 functools import lru_cache
 
 from ..error import LibraryError
 
@@ -133,13 +132,13 @@ class Library:
         return pat
 
     def keys(self) -> Iterator[str]:
-        return self.primary.keys()
+        return iter(self.primary.keys())
 
     def values(self) -> Iterator['Pattern']:
-        return (self[key] for key in self.keys())
+        return iter(self[key] for key in self.keys())
 
     def items(self) -> Iterator[Tuple[str, 'Pattern']]:
-        return ((key, self[key]) for key in self.keys())
+        return iter((key, self[key]) for key in self.keys())
 
     def __repr__(self) -> str:
         return '<Library with keys ' + repr(list(self.primary.keys())) + '>'
@@ -191,7 +190,7 @@ class Library:
         for key in self.primary:
             _ = self.get_primary(key)
         for key2 in self.secondary:
-            _ = self.get_secondary(key2)
+            _ = self.get_secondary(*key2)
         return self
 
     def add(self, other: 'Library') -> 'Library':
diff --git a/masque/pattern.py b/masque/pattern.py
index 31a331c..33c5030 100644
--- a/masque/pattern.py
+++ b/masque/pattern.py
@@ -93,7 +93,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
             raise PatternLockedError()
         object.__setattr__(self, name, value)
 
-    def  __copy__(self, memo: Dict = None) -> 'Pattern':
+    def __copy__(self, memo: Dict = None) -> 'Pattern':
         return Pattern(name=self.name,
                        shapes=copy.deepcopy(self.shapes),
                        labels=copy.deepcopy(self.labels),
@@ -101,14 +101,15 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
                        annotations=copy.deepcopy(self.annotations),
                        locked=self.locked)
 
-    def  __deepcopy__(self, memo: Dict = None) -> 'Pattern':
+    def __deepcopy__(self, memo: Dict = None) -> 'Pattern':
         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),
-                annotations=copy.deepcopy(self.annotations, 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 rename(self, name: str) -> 'Pattern':
@@ -281,7 +282,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
             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
@@ -325,8 +326,8 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         """
         old_shapes = self.shapes
         self.shapes = list(chain.from_iterable(
-                        (shape.to_polygons(poly_num_points, poly_max_arclen)
-                         for shape in old_shapes)))
+            (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)
@@ -351,7 +352,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         self.polygonize().flatten()
         old_shapes = self.shapes
         self.shapes = list(chain.from_iterable(
-                        (shape.manhattanize(grid_x, grid_y) for shape in old_shapes)))
+            (shape.manhattanize(grid_x, grid_y) for shape in old_shapes)))
         return self
 
     def subpatternize(self,
@@ -518,7 +519,6 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
                 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
@@ -625,7 +625,6 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
 
         return self
 
-
     def translate_elements(self, offset: vector2) -> 'Pattern':
         """
         Translates all shapes, label, and subpatterns by the given offset.
@@ -805,9 +804,9 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots):
         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':
         """
diff --git a/masque/repetition.py b/masque/repetition.py
index 4ed29f4..396d351 100644
--- a/masque/repetition.py
+++ b/masque/repetition.py
@@ -3,13 +3,13 @@
      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        # type: ignore
 
-from .error import PatternError, PatternLockedError
+from .error import PatternError
 from .utils import rotation_matrix_2d, vector2, AutoSlots
 from .traits import LockableImpl, Copyable, Scalable, Rotatable, Mirrorable
 
@@ -103,7 +103,7 @@ class Grid(LockableImpl, Repetition, metaclass=AutoSlots):
         self.b_count = b_count
         self.locked = locked
 
-    def  __copy__(self) -> 'Grid':
+    def __copy__(self) -> 'Grid':
         new = Grid(a_vector=self.a_vector.copy(),
                    b_vector=copy.copy(self.b_vector),
                    a_count=self.a_count,
@@ -111,7 +111,7 @@ class Grid(LockableImpl, Repetition, metaclass=AutoSlots):
                    locked=self.locked)
         return new
 
-    def  __deepcopy__(self, memo: Dict = None) -> 'Grid':
+    def __deepcopy__(self, memo: Dict = None) -> 'Grid':
         memo = {} if memo is None else memo
         new = copy.copy(self).unlock()
         new.locked = self.locked
@@ -170,8 +170,8 @@ class Grid(LockableImpl, Repetition, metaclass=AutoSlots):
     @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, :])
+        return (aa.flatten()[:, None] * self.a_vector[None, :]
+              + bb.flatten()[:, None] * self.b_vector[None, :])             # noqa
 
     def rotate(self, rotation: float) -> 'Grid':
         """
@@ -199,9 +199,9 @@ class Grid(LockableImpl, Repetition, metaclass=AutoSlots):
         Returns:
             self
         """
-        self.a_vector[1-axis] *= -1
+        self.a_vector[1 - axis] *= -1
         if self.b_vector is not None:
-            self.b_vector[1-axis] *= -1
+            self.b_vector[1 - axis] *= -1
         return self
 
     def get_bounds(self) -> Optional[numpy.ndarray]:
@@ -377,7 +377,7 @@ class Arbitrary(LockableImpl, Repetition, metaclass=AutoSlots):
         Returns:
             self
         """
-        self.displacements[1-axis] *= -1
+        self.displacements[1 - axis] *= -1
         return self
 
     def get_bounds(self) -> Optional[numpy.ndarray]:
diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py
index 6f75e7e..0416d01 100644
--- a/masque/shapes/arc.py
+++ b/masque/shapes/arc.py
@@ -1,4 +1,4 @@
-from typing import List, Tuple, Dict, Optional, Sequence
+from typing import List, Dict, Optional, Sequence
 import copy
 import math
 
@@ -81,7 +81,7 @@ class Arc(Shape, metaclass=AutoSlots):
 
     # 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
@@ -194,7 +194,7 @@ class Arc(Shape, metaclass=AutoSlots):
         [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()
@@ -214,8 +214,8 @@ class Arc(Shape, metaclass=AutoSlots):
             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
 
@@ -273,7 +273,7 @@ class Arc(Shape, metaclass=AutoSlots):
         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
 
@@ -287,7 +287,7 @@ class Arc(Shape, metaclass=AutoSlots):
 
             # 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
 
@@ -356,9 +356,9 @@ class Arc(Shape, metaclass=AutoSlots):
         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:
         '''
@@ -373,7 +373,7 @@ class Arc(Shape, metaclass=AutoSlots):
         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
 
@@ -388,7 +388,7 @@ class Arc(Shape, metaclass=AutoSlots):
 
             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:
         '''
@@ -398,12 +398,12 @@ class Arc(Shape, metaclass=AutoSlots):
         '''
         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
@@ -424,8 +424,8 @@ class Arc(Shape, metaclass=AutoSlots):
         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'<Arc l{self.layer} o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{dose}{locked}>'
diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py
index b3e07ae..d7aaba4 100644
--- a/masque/shapes/circle.py
+++ b/masque/shapes/circle.py
@@ -75,7 +75,7 @@ class Circle(Shape, metaclass=AutoSlots):
         self.poly_max_arclen = poly_max_arclen
         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()
@@ -127,9 +127,9 @@ class Circle(Shape, metaclass=AutoSlots):
     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 b2ceec6..140f590 100644
--- a/masque/shapes/ellipse.py
+++ b/masque/shapes/ellipse.py
@@ -1,4 +1,4 @@
-from typing import List, Tuple, Dict, Sequence, Optional
+from typing import List, Dict, Sequence, Optional
 import copy
 import math
 
@@ -125,7 +125,7 @@ class Ellipse(Shape, metaclass=AutoSlots):
         self.poly_max_arclen = poly_max_arclen
         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()
@@ -198,9 +198,9 @@ class Ellipse(Shape, metaclass=AutoSlots):
             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 b25f464..1cad032 100644
--- a/masque/shapes/path.py
+++ b/masque/shapes/path.py
@@ -18,7 +18,7 @@ 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, metaclass=AutoSlots):
@@ -103,7 +103,7 @@ class Path(Shape, metaclass=AutoSlots):
 
     @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:
@@ -184,7 +184,7 @@ class Path(Shape, metaclass=AutoSlots):
         [self.mirror(a) for a, do in enumerate(mirrored) if do]
         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()
@@ -199,7 +199,7 @@ class Path(Shape, metaclass=AutoSlots):
     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),
@@ -275,9 +275,9 @@ class Path(Shape, metaclass=AutoSlots):
         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]]
@@ -370,10 +370,10 @@ class Path(Shape, metaclass=AutoSlots):
 
         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':
         """
@@ -409,7 +409,7 @@ class Path(Shape, metaclass=AutoSlots):
         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 c11b662..59ab82d 100644
--- a/masque/shapes/polygon.py
+++ b/masque/shapes/polygon.py
@@ -1,4 +1,4 @@
-from typing import List, Tuple, Dict, Optional, Sequence
+from typing import List, Dict, Optional, Sequence
 import copy
 
 import numpy        # type: ignore
@@ -34,7 +34,7 @@ class Polygon(Shape, metaclass=AutoSlots):
 
     @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:
@@ -104,7 +104,7 @@ class Polygon(Shape, metaclass=AutoSlots):
         [self.mirror(a) for a, do in enumerate(mirrored) if do]
         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()
@@ -269,7 +269,6 @@ class Polygon(Shape, metaclass=AutoSlots):
                                  layer=layer, dose=dose)
         return poly
 
-
     def to_polygons(self,
                     poly_num_points: int = None,        # unused
                     poly_max_arclen: float = None,      # unused
@@ -316,9 +315,9 @@ class Polygon(Shape, metaclass=AutoSlots):
 
         # 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 8ffb1a4..0603ec7 100644
--- a/masque/shapes/shape.py
+++ b/masque/shapes/shape.py
@@ -1,11 +1,8 @@
 from typing import List, Tuple, Callable, TypeVar, Optional, TYPE_CHECKING
 from abc import ABCMeta, abstractmethod
-import copy
 
 import numpy        # type: ignore
 
-from ..error import PatternError, PatternLockedError
-from ..utils import rotation_matrix_2d, vector2, layer_t
 from ..traits import (PositionableImpl, LayerableImpl, DoseableImpl,
                       Rotatable, Mirrorable, Copyable, Scalable,
                       PivotableImpl, LockableImpl, RepeatableImpl,
@@ -142,7 +139,6 @@ class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable
                 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]
@@ -155,8 +151,9 @@ class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable
                     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
@@ -178,7 +175,7 @@ class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable
                 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()
@@ -202,7 +199,6 @@ class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable
 
         return manhattan_polygons
 
-
     def manhattanize(self,
                      grid_x: numpy.ndarray,
                      grid_y: numpy.ndarray
diff --git a/masque/shapes/text.py b/masque/shapes/text.py
index 2384404..07cc1a7 100644
--- a/masque/shapes/text.py
+++ b/masque/shapes/text.py
@@ -1,4 +1,4 @@
-from typing import List, Tuple, Dict, Sequence, Optional, MutableSequence
+from typing import List, Tuple, Dict, Sequence, Optional
 import copy
 
 import numpy        # type: ignore
@@ -26,7 +26,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
 
     _string: str
     _height: float
-    _mirrored: numpy.ndarray        #ndarray[bool]
+    _mirrored: numpy.ndarray        # ndarray[bool]
     font_path: str
 
     # vertices property
@@ -51,7 +51,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
 
     # Mirrored property
     @property
-    def mirrored(self) -> numpy.ndarray:        #ndarray[bool]
+    def mirrored(self) -> numpy.ndarray:        # ndarray[bool]
         return self._mirrored
 
     @mirrored.setter
@@ -100,7 +100,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
         self.font_path = font_path
         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()
@@ -144,14 +144,14 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
         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
@@ -168,7 +168,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots):
 
 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           # type: ignore
     from matplotlib.path import Path    # type: ignore
diff --git a/masque/subpattern.py b/masque/subpattern.py
index 6d0c2a9..8eebc95 100644
--- a/masque/subpattern.py
+++ b/masque/subpattern.py
@@ -4,14 +4,14 @@
 """
 #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
 import copy
 
 import numpy        # type: ignore
 from numpy import pi
 
-from .error import PatternError, PatternLockedError
-from .utils import is_scalar, rotation_matrix_2d, vector2, AutoSlots, annotations_t
+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,
@@ -82,7 +82,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
         self.annotations = annotations if annotations is not None else {}
         self.set_locked(locked)
 
-    def  __copy__(self) -> 'SubPattern':
+    def __copy__(self) -> 'SubPattern':
         new = SubPattern(pattern=self.pattern,
                          offset=self.offset.copy(),
                          rotation=self.rotation,
@@ -94,7 +94,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi
                          locked=self.locked)
         return new
 
-    def  __deepcopy__(self, memo: Dict = None) -> 'SubPattern':
+    def __deepcopy__(self, memo: Dict = None) -> 'SubPattern':
         memo = {} if memo is None else memo
         new = copy.copy(self).unlock()
         new.pattern = copy.deepcopy(self.pattern, memo)
diff --git a/masque/traits/annotatable.py b/masque/traits/annotatable.py
index e818b3b..9d49018 100644
--- a/masque/traits/annotatable.py
+++ b/masque/traits/annotatable.py
@@ -1,7 +1,6 @@
 from typing import TypeVar
-from types import MappingProxyType
+#from types import MappingProxyType
 from abc import ABCMeta, abstractmethod
-import copy
 
 from ..utils import annotations_t
 from ..error import PatternError
@@ -44,10 +43,10 @@ class AnnotatableImpl(Annotatable, metaclass=ABCMeta):
     '''
     @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)
-        return self._annotations
 
     @annotations.setter
     def annotations(self, annotations: annotations_t):
diff --git a/masque/traits/copyable.py b/masque/traits/copyable.py
index 5a318d7..aa84356 100644
--- a/masque/traits/copyable.py
+++ b/masque/traits/copyable.py
@@ -1,5 +1,5 @@
-from typing import List, Tuple, Callable, TypeVar, Optional
-from abc import ABCMeta, abstractmethod
+from typing import TypeVar
+from abc import ABCMeta
 import copy
 
 
diff --git a/masque/traits/doseable.py b/masque/traits/doseable.py
index 96c535c..217872c 100644
--- a/masque/traits/doseable.py
+++ b/masque/traits/doseable.py
@@ -1,9 +1,7 @@
-from typing import List, Tuple, Callable, TypeVar, Optional
+from typing import TypeVar
 from abc import ABCMeta, abstractmethod
-import copy
 
-from ..error import PatternError, PatternLockedError
-from ..utils import is_scalar
+from ..error import PatternError
 
 
 T = TypeVar('T', bound='Doseable')
@@ -70,7 +68,6 @@ class DoseableImpl(Doseable, metaclass=ABCMeta):
             raise PatternError('Dose must be non-negative')
         self._dose = val
 
-
     '''
     ---- Non-abstract methods
     '''
diff --git a/masque/traits/layerable.py b/masque/traits/layerable.py
index e3d5f7b..812511b 100644
--- a/masque/traits/layerable.py
+++ b/masque/traits/layerable.py
@@ -1,8 +1,6 @@
-from typing import List, Tuple, Callable, TypeVar, Optional
+from typing import TypeVar
 from abc import ABCMeta, abstractmethod
-import copy
 
-from ..error import PatternError, PatternLockedError
 from ..utils import layer_t
 
 
diff --git a/masque/traits/lockable.py b/masque/traits/lockable.py
index fadaaa3..5a0f06f 100644
--- a/masque/traits/lockable.py
+++ b/masque/traits/lockable.py
@@ -1,8 +1,7 @@
-from typing import List, Tuple, Callable, TypeVar, Optional
+from typing import TypeVar
 from abc import ABCMeta, abstractmethod
-import copy
 
-from ..error import PatternError, PatternLockedError
+from ..error import PatternLockedError
 
 
 T = TypeVar('T', bound='Lockable')
diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py
index a66c074..1ec54f6 100644
--- a/masque/traits/mirrorable.py
+++ b/masque/traits/mirrorable.py
@@ -1,8 +1,5 @@
-from typing import List, Tuple, Callable, TypeVar, Optional
+from typing import TypeVar
 from abc import ABCMeta, abstractmethod
-import copy
-
-from ..error import PatternError, PatternLockedError
 
 
 T = TypeVar('T', bound='Mirrorable')
diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py
index 150daf0..71f90ec 100644
--- a/masque/traits/positionable.py
+++ b/masque/traits/positionable.py
@@ -1,12 +1,11 @@
 # TODO top-level comment about how traits should set __slots__ = (), and how to use AutoSlots
 
-from typing import List, Tuple, Callable, TypeVar, Optional
+from typing import TypeVar
 from abc import ABCMeta, abstractmethod
-import copy
 import numpy        # type: ignore
 
-from ..error import PatternError, PatternLockedError
-from ..utils import is_scalar, rotation_matrix_2d, vector2
+from ..error import PatternError
+from ..utils import vector2
 
 
 T = TypeVar('T', bound='Positionable')
@@ -101,7 +100,6 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
             raise PatternError('Offset must be convertible to size-2 ndarray')
         self._offset = val.flatten()
 
-
     '''
     ---- Methods
     '''
diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py
index 67183ad..4a7f391 100644
--- a/masque/traits/repeatable.py
+++ b/masque/traits/repeatable.py
@@ -1,8 +1,7 @@
-from typing import List, Tuple, Callable, TypeVar, Optional, TYPE_CHECKING
+from typing import TypeVar, Optional, TYPE_CHECKING
 from abc import ABCMeta, abstractmethod
-import copy
 
-from ..error import PatternError, PatternLockedError
+from ..error import PatternError
 
 
 if TYPE_CHECKING:
diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py
index c79e89e..c0641f0 100644
--- a/masque/traits/rotatable.py
+++ b/masque/traits/rotatable.py
@@ -1,12 +1,11 @@
-from typing import List, Tuple, Callable, TypeVar, Optional
+from typing import TypeVar
 from abc import ABCMeta, abstractmethod
-import copy
 
 import numpy        # type: ignore
 from numpy import pi
 
-from .positionable import Positionable
-from ..error import PatternError, PatternLockedError
+#from .positionable import Positionable
+from ..error import PatternError
 from ..utils import is_scalar, rotation_matrix_2d, vector2
 
 T = TypeVar('T', bound='Rotatable')
diff --git a/masque/traits/scalable.py b/masque/traits/scalable.py
index bebda69..b31c2f9 100644
--- a/masque/traits/scalable.py
+++ b/masque/traits/scalable.py
@@ -1,8 +1,7 @@
-from typing import List, Tuple, Callable, TypeVar, Optional
+from typing import TypeVar
 from abc import ABCMeta, abstractmethod
-import copy
 
-from ..error import PatternError, PatternLockedError
+from ..error import PatternError
 from ..utils import is_scalar
 
 
diff --git a/masque/utils.py b/masque/utils.py
index c33b8c4..a09a09c 100644
--- a/masque/utils.py
+++ b/masque/utils.py
@@ -84,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
 
@@ -124,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

From eb11f31960ab1b49da3be066e307e48eca5b0b09 Mon Sep 17 00:00:00 2001
From: Jan Petykiewicz <anewusername@gmail.com>
Date: Mon, 26 Oct 2020 19:45:46 -0700
Subject: [PATCH 71/71] improve type hints for Library

---
 masque/library/library.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/masque/library/library.py b/masque/library/library.py
index 694360f..91fd6a8 100644
--- a/masque/library/library.py
+++ b/masque/library/library.py
@@ -180,7 +180,7 @@ class Library:
         else:
             self.primary[key] = pg
 
-    def precache(self) -> 'Library':
+    def precache(self: L) -> L:
         """
         Force all patterns into the cache
 
@@ -193,7 +193,7 @@ class Library:
             _ = self.get_secondary(*key2)
         return self
 
-    def add(self, other: 'Library') -> 'Library':
+    def add(self: L, other: L) -> L:
         """
         Add keys from another library into this one.