Add all files to repository

This commit is contained in:
jan 2016-03-15 19:12:39 -07:00
commit 5bf486ac81
19 changed files with 1998 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.pyc
__pycache__
*.idea

19
README Normal file
View File

@ -0,0 +1,19 @@
# Masque README
Masque is a Python module for designing lithography masks.
The general idea is to implement something resembling the GDSII file-format, but
with some vectorized element types (eg. circles, not just polygons), better support for
E-beam doses, and the ability to output to multiple formats.
## Installation
Requirements:
* python 3 (written and tested with 3.5)
* numpy
* matplotlib (optional, used for visualization functions)
* python-gdsii (optional, used for gdsii i/o)
* svgwrite (optional, used for svg output)

32
masque/__init__.py Normal file
View File

@ -0,0 +1,32 @@
"""
masque 2D CAD library
masque is an attempt to make a relatively small library for designing lithography
masks. The general idea is to implement something resembling the GDSII file-format, but
with some vectorized element types (eg. circles, not just polygons), better support for
E-beam doses, and the ability to output to multiple formats.
Pattern is a basic object containing a 2D lithography mask, composed of a list of Shape
objects and a list of SubPattern objects.
SubPattern provides basic support for nesting Pattern objects within each other, by adding
offset, rotation, scaling, and other such properties to a Pattern reference.
Note that the methods for these classes try to avoid copying wherever possible, so unless
otherwise noted, assume that arguments are stored by-reference.
Dependencies:
- numpy
- matplotlib [Pattern.visualize(...)]
- python-gdsii [masque.file.gdsii]
- svgwrite [masque.file.svgwrite]
"""
from .error import PatternError
from .shapes import Shape
from .subpattern import SubPattern
from .pattern import Pattern
__author__ = 'Jan Petykiewicz'

9
masque/error.py Normal file
View File

@ -0,0 +1,9 @@
class PatternError(Exception):
"""
Simple Exception for Pattern objects and their contents
"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)

3
masque/file/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
Functions for reading from and writing to various file formats.
"""

171
masque/file/gdsii.py Normal file
View File

@ -0,0 +1,171 @@
"""
GDSII file format readers and writers
"""
import gdsii.library
import gdsii.structure
import gdsii.elements
from typing import List, Any, Dict
import numpy
from .utils import mangle_name, make_dose_table
from .. import Pattern, SubPattern, PatternError
from ..shapes import Polygon
from ..utils import rotation_matrix_2d, get_bit, vector2
__author__ = 'Jan Petykiewicz'
def write_dose2dtype(pattern: Pattern,
filename: str,
meters_per_unit: float):
"""
Write a Pattern to a GDSII file, by first calling .polygonize() on it
to change the shapes into polygons, and then writing patterns as GDSII
structures, polygons as boundary elements, and subpatterns as structure
references (sref).
Note that this function modifies the Pattern.
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.
:param pattern: A Pattern to write to file. Modified by this function.
:param filename: Filename to write to.
:param meters_per_unit: Written into the GDSII file, meters per length unit.
"""
# Create library
lib = gdsii.library.Library(version=600,
name='masque-write_dose2dtype'.encode('ASCII'),
logical_unit=1,
physical_unit=meters_per_unit)
# Polygonize pattern
pattern.polygonize()
# Get a dict of id(pattern) -> pattern
patterns_by_id = {**(pattern.referenced_patterns_by_id()), id(pattern): pattern}
# Get a table of (id(subpat.pattern), written_dose) for each subpattern
sd_table = make_dose_table(pattern)
# 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]
[dose_vals.add(shape.dose * pat_dose) for shape in pat.shapes]
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)
# Now create a structure for each row in sd_table (ie, each pattern + dose combination)
# and add in any Boundary and SREF elements
for pat_id, pat_dose in sd_table:
pat = patterns_by_id[pat_id]
structure = gdsii.structure.Structure(name=mangle_name(pat, pat_dose).encode('ASCII'))
lib.append(structure)
# Add a Boundary element for each shape
for shape in pat.shapes:
for polygon in shape.to_polygons():
data_type = dose_vals_list.index(polygon.dose * pat_dose)
xy_open = numpy.round(polygon.vertices + polygon.offset).astype(int)
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
structure.append(gdsii.elements.Boundary(layer=polygon.layer,
data_type=data_type,
xy=xy_closed))
# Add an SREF for each subpattern entry
# strans must be set for angle and mag to take effect
for subpat in pat.subpatterns:
dose_mult = subpat.dose * pat_dose
sref = gdsii.elements.SRef(struct_name=mangle_name(subpat.pattern, dose_mult).encode('ASCII'),
xy=numpy.round([subpat.offset]).astype(int))
sref.strans = 0
sref.angle = subpat.rotation
sref.mag = subpat.scale
structure.append(sref)
with open(filename, mode='wb') as stream:
lib.save(stream)
return dose_vals_list
def read_dtype2dose(filename: str) -> (List[Pattern], Dict[str, Any]):
"""
Read a gdsii file and translate it into a list of Pattern objects. GDSII structures are
translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
are translated into SubPattern objects.
:param filename: Filename specifying a GDSII file to read from.
:return: Tuple: (List of Patterns generated GDSII structures, Dict of GDSII library info)
"""
with open(filename, mode='rb') as stream:
lib = gdsii.library.Library.load(stream)
library_info = {'name': lib.name.decode('ASCII'),
'physical_unit': lib.physical_unit,
'logical_unit': lib.logical_unit,
}
def ref_element_to_subpat(element, offset: vector2) -> SubPattern:
# Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None
# and sets the instance attribute .ref_name to the struct_name.
#
# BUG: Figure out what "absolute" means in the context of elements and if the current
# behavior is correct
# BUG: Need to check STRANS bit 0 to handle x-reflection
subpat = SubPattern(pattern=None, offset=offset)
subpat.ref_name = 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, 13):
subpat.offset *= subpat.scale
if element.angle is not None:
subpat.rotation = element.angle
# Bit 14 means absolute rotation
if get_bit(element.strans, 14):
subpat.offset = numpy.dot(rotation_matrix_2d(subpat.rotation), subpat.offset)
return subpat
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):
pat.shapes.append(
Polygon(vertices=element.xy[:-1],
dose=element.data_type,
layer=element.layer))
elif isinstance(element, gdsii.elements.SRef):
pat.subpatterns.append(ref_element_to_subpat(element, element.xy))
elif isinstance(element, gdsii.elements.ARef):
for offset in element.xy:
pat.subpatterns.append(ref_element_to_subpat(element, offset))
patterns.append(pat)
# Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries
# according to the subpattern.ref_name (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.ref_name.decode('ASCII')]
del sp.ref_name
return patterns_dict, library_info

139
masque/file/svg.py Normal file
View File

@ -0,0 +1,139 @@
"""
SVG file format readers and writers
"""
import svgwrite
import numpy
from .utils import mangle_name
from .. import Pattern
__author__ = 'Jan Petykiewicz'
def write(pattern: Pattern,
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
groups (<g>, inside <defs>), polygons as paths (<path>), and subpatterns
as <use> elements.
Note that this function modifies the Pattern.
If custom_attributes is True, non-standard pattern_layer and pattern_dose attributes
are written to the relevant elements.
It is often a good idea to run pattern.subpatternize() on pattern 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.
:param pattern: Pattern to write to file. Modified by this function.
:param filename: Filename to write to.
:param custom_attributes: Whether to write non-standard pattern_layer and
pattern_dose attributes to the SVG elements.
"""
# Polygonize pattern
pattern.polygonize()
[bounds_min, bounds_max] = pattern.get_bounds()
viewbox = numpy.hstack((bounds_min - 1, (bounds_max - bounds_min) + 2))
viewbox_string = '{:g} {:g} {:g} {:g}'.format(*viewbox)
# Create file
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
debug=(not custom_attributes))
# Get a dict of id(pattern) -> pattern
patterns_by_id = {**(pattern.referenced_patterns_by_id()), id(pattern): pattern}
# Now create a group for each row in sd_table (ie, each pattern + dose combination)
# and add in any Boundary and Use elements
for pat in patterns_by_id.values():
svg_group = svg.g(id=mangle_name(pat), fill='blue', stroke='red')
for shape in pat.shapes:
for polygon in shape.to_polygons():
path_spec = poly2path(polygon.vertices + polygon.offset)
path = svg.path(d=path_spec)
if custom_attributes:
path['pattern_layer'] = polygon.layer
path['pattern_dose'] = polygon.dose
svg_group.add(path)
for subpat in pat.subpatterns:
transform = 'scale({:g}) rotate({:g}) translate({:g},{:g})'.format(
subpat.scale, subpat.rotation, subpat.offset[0], subpat.offset[1])
use = svg.use(href='#' + mangle_name(subpat.pattern), transform=transform)
if custom_attributes:
use['pattern_dose'] = subpat.dose
svg_group.add(use)
svg.defs.add(svg_group)
svg.add(svg.use(href='#' + mangle_name(pattern)))
svg.save()
def write_inverted(pattern: Pattern, filename: str):
"""
Write an inverted Pattern to an SVG file, by first calling .polygonize() and
.flatten() on it to change the shapes into polygons, then drawing a bounding
box and drawing the polygons with reverse vertex order inside it, all within
one <path> element.
Note that this function modifies the Pattern.
If you want pattern polygonized with non-default arguments, just call pattern.polygonize()
prior to calling this function.
:param pattern: Pattern to write to file. Modified by this function.
:param filename: Filename to write to.
"""
# Polygonize and flatten pattern
pattern.polygonize().flatten()
[bounds_min, bounds_max] = pattern.get_bounds()
viewbox = numpy.hstack((bounds_min - 1, (bounds_max - bounds_min) + 2))
viewbox_string = '{:g} {:g} {:g} {:g}'.format(*viewbox)
# Create file
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string)
# Draw bounding box
slab_edge = [[bounds_min[0] - 1, bounds_max[1] + 1],
[bounds_max[0] + 1, bounds_max[1] + 1],
[bounds_max[0] + 1, bounds_min[1] - 1],
[bounds_min[0] - 1, bounds_min[1] - 1]]
path_spec = poly2path(slab_edge)
# Draw polygons with reversed vertex order
for shape in pattern.shapes:
for polygon in shape.to_polygons():
path_spec += poly2path(polygon.vertices[::-1] + polygon.offset)
svg.add(svg.path(d=path_spec, fill='blue', stroke='red'))
svg.save()
def poly2path(vertices: numpy.ndarray) -> str:
"""
Create an SVG path string from an Nx2 list of vertices.
:param vertices: Nx2 array of vertices.
:return: SVG path-string.
"""
commands = 'M{:g},{:g} '.format(vertices[0][0], vertices[0][1])
for vertex in vertices[1:]:
commands += 'L{:g},{:g}'.format(vertex[0], vertex[1])
commands += ' Z '
return commands

41
masque/file/utils.py Normal file
View File

@ -0,0 +1,41 @@
"""
Helper functions for file reading and writing
"""
import re
from typing import Set, Tuple
from masque.pattern import Pattern
__author__ = 'Jan Petykiewicz'
def mangle_name(pattern: Pattern, dose_multiplier: float=1.0) -> str:
"""
Create a name using pattern.name, id(pattern), and the dose multiplier.
:param pattern: Pattern whose name we want to mangle.
:param dose_multiplier: Dose multiplier to mangle with.
:return: Mangled name.
"""
expression = re.compile('[^A-Za-z0-9_\?\$]')
sanitized_name = expression.sub('_', pattern.name)
full_name = '{}_{}_{}'.format(sanitized_name, dose_multiplier, id(pattern))
return full_name
def make_dose_table(pattern: Pattern, dose_multiplier: float=1.0) -> Set[Tuple[int, float]]:
"""
Create a set containing (id(subpat.pattern), written_dose) for each subpattern
:param pattern: Source Pattern.
:param dose_multiplier: Multiplier for all written_dose entries.
:return: {(id(subpat.pattern), written_dose), ...}
"""
dose_table = {(id(pattern), dose_multiplier)}
for subpat in pattern.subpatterns:
subpat_dose_entry = (id(subpat.pattern), subpat.dose * dose_multiplier)
if subpat_dose_entry not in dose_table:
subpat_dose_table = make_dose_table(subpat.pattern, subpat.dose * dose_multiplier)
dose_table = dose_table.union(subpat_dose_table)
return dose_table

412
masque/pattern.py Normal file
View File

@ -0,0 +1,412 @@
"""
Base object for containing a lithography mask.
"""
from typing import List, Callable, Tuple, Dict, Union
import copy
import itertools
import pickle
from collections import defaultdict
import numpy
# .visualize imports matplotlib and matplotlib.collections
from .subpattern import SubPattern
from .shapes import Shape, Polygon
from .utils import rotation_matrix_2d, vector2
__author__ = 'Jan Petykiewicz'
class Pattern(object):
"""
2D layout consisting of some set of shapes and references to other Pattern objects
(via SubPattern). Shapes are assumed to inherit from .shapes.Shape or provide equivalent
functions.
:var shapes: List of all shapes in this Pattern. Elements in this list are assumed to inherit
from Shape or provide equivalent functions.
:var subpatterns: List of all SubPattern objects in this Pattern. Multiple SubPattern objects
may reference the same Pattern object.
:var name: An identifier for this object. Not necessarily unique.
"""
shapes = List[Shape]
subpatterns = List[SubPattern]
name = str
def __init__(self,
shapes: List[Shape]=(),
subpatterns: List[SubPattern]=(),
name: str='',
):
"""
Basic init; arguments get assigned to member variables.
Non-list inputs for shapes and subpatterns get converted to lists.
:param shapes: Initial shapes in the Pattern
:param subpatterns: Initial subpatterns in the Pattern
:param name: An identifier for the Pattern
"""
if isinstance(shapes, list):
self.shapes = shapes
else:
self.shapes = list(shapes)
if isinstance(subpatterns, list):
self.subpatterns = subpatterns
else:
self.subpatterns = list(subpatterns)
self.name = name
def append(self, other_pattern: 'Pattern') -> 'Pattern':
"""
Appends all shapes and subpatterns from other_pattern to self's shapes and subpatterns.
:param other_pattern: The Pattern to append
:return: self
"""
self.subpatterns += other_pattern.subpatterns
self.shapes += other_pattern.shapes
return self
def subset(self,
shapes_func: Callable[[Shape], bool]=None,
subpatterns_func: Callable[[SubPattern], bool]=None,
) -> 'Pattern':
"""
Returns a Pattern containing only the shapes and subpatterns for which shapes_func or
subpatterns_func returns True.
Self is _not_ altered, but shapes and subpatterns are _not_ copied.
:param shapes_func: Given a shape, returns a boolean denoting whether the shape is a member
of the subset
:param subpatterns_func: Given a subpattern, returns a boolean denoting if it is a member
of the subset
:return: A Pattern containing all the shapes and subpatterns for which the parameter
functions return True
"""
pat = Pattern()
if shapes_func is not None:
pat.shapes = [s for s in self.shapes if shapes_func(s)]
if subpatterns_func is not None:
pat.subpatterns = [s for s in self.subpatterns if subpatterns_func(s)]
return pat
def polygonize(self,
poly_num_points: int=None,
poly_max_arclen: float=None
) -> 'Pattern':
"""
Calls .to_polygons(...) on all the shapes in this Pattern and any referenced patterns,
replacing them with the returned polygons.
Arguments are passed directly to shape.to_polygons(...).
:param poly_num_points: Number of points to use for each polygon. Can be overridden by
poly_max_arclen if that results in more points. Optional, defaults to shapes'
internal defaults.
:param poly_max_arclen: Maximum arclength which can be approximated by a single line
segment. Optional, defaults to shapes' internal defaults.
:return: self
"""
old_shapes = self.shapes
self.shapes = list(itertools.chain.from_iterable(
(shape.to_polygons(poly_num_points, poly_max_arclen)
for shape in old_shapes)))
for subpat in self.subpatterns:
subpat.pattern.polygonize(poly_num_points, poly_max_arclen)
return self
def subpatternize(self,
recursive: bool=True,
norm_value: int=1e6,
exclude_types: Tuple[Shape]=(Polygon,)
) -> 'Pattern':
"""
Iterates through this Pattern and all referenced Patterns. Within each Pattern, it iterates
over all shapes, calling .normalized_form(norm_value) on them to retrieve a scale-,
offset-, dose-, and rotation-independent form. Each shape whose normalized form appears
more than once is removed and re-added using subpattern objects referencing a newly-created
Pattern containing only the normalized form of the shape.
Note that the default norm_value was chosen to give a reasonable precision when converting
to GDSII, which uses integer values for pixel coordinates.
:param recursive: Whether to call recursively on self's subpatterns. Default True.
:param norm_value: Passed to shape.normalized_form(norm_value). Default 1e6 (see function
note about GDSII)
:param exclude_types: Shape types passed in this argument are always left untouched, for
speed or convenience. Default: (Shapes.Polygon,)
:return: self
"""
if exclude_types is None:
exclude_types = ()
if recursive:
for subpat in self.subpatterns:
subpat.pattern.subpatternize(recursive=True,
norm_value=norm_value,
exclude_types=exclude_types)
# Create a dict which uses the label tuple from .normalized_form() as a key, and which
# stores (function_to_create_normalized_shape, [(index_in_shapes, values), ...]), where
# values are the (offset, scale, rotation, dose) values as calculated by .normalized_form()
shape_table = defaultdict(lambda: [None, list()])
for i, shape in enumerate(self.shapes):
if not any((isinstance(shape, t) for t in exclude_types)):
label, values, func = shape.normalized_form(norm_value)
shape_table[label][0] = func
shape_table[label][1].append((i, values))
# Iterate over the normalized shapes in the table. If any normalized shape occurs more than
# once, create a Pattern holding a normalized shape object, and add self.subpatterns
# entries for each occurrence in self. Also, note down that we should delete the
# self.shapes entries for which we made SubPatterns.
shapes_to_remove = []
for label in shape_table:
if len(shape_table[label][1]) > 1:
shape = shape_table[label][0]()
pat = Pattern(shapes=[shape])
for i, values in shape_table[label][1]:
(offset, scale, rotation, dose) = values
subpat = SubPattern(pattern=pat, offset=offset, scale=scale,
rotation=rotation, dose=dose)
self.subpatterns.append(subpat)
shapes_to_remove.append(i)
# Remove any shapes for which we have created subpatterns.
for i in sorted(shapes_to_remove, reverse=True):
del self.shapes[i]
return self
def as_polygons(self) -> List[numpy.ndarray]:
"""
Represents the pattern as a list of polygons.
Deep-copies the pattern, then calls .polygonize() and .flatten() on the copy in order to
generate the list of polygons.
:return: A list of (Ni, 2) numpy.ndarrays specifying vertices of the polygons. Each ndarray
is of the form [[x0, y0], [x1, y1],...].
"""
pat = copy.deepcopy(self).polygonize().flatten()
return [shape.vertices + shape.offset for shape in pat.shapes]
def referenced_patterns_by_id(self) -> Dict[int, 'Pattern']:
"""
Create a dictionary of {id(pat): pat} for all Pattern objects referenced by this
Pattern (operates recursively on all referenced Patterns as well)
:return: Dictionary of {id(pat): pat} for all referenced Pattern objects
"""
ids = {}
for subpat in self.subpatterns:
if id(subpat.pattern) not in ids:
ids[id(subpat.pattern)] = subpat.pattern
ids.update(subpat.pattern.referenced_patterns_by_id())
return 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
extent of the Pattern's contents in each dimension.
Returns None if the Pattern is empty.
:return: [[x_min, y_min], [x_max, y_max]] or None
"""
entries = self.shapes + self.subpatterns
if not entries:
return None
init_bounds = entries[0].get_bounds()
min_bounds = init_bounds[0, :]
max_bounds = init_bounds[1, :]
for entry in entries[1:]:
bounds = entry.get_bounds()
min_bounds = numpy.minimum(min_bounds, bounds[0, :])
max_bounds = numpy.maximum(max_bounds, bounds[1, :])
return numpy.vstack((min_bounds, max_bounds))
def flatten(self) -> 'Pattern':
"""
Removes all subpatterns and adds equivalent shapes.
:return: self
"""
subpatterns = copy.deepcopy(self.subpatterns)
self.subpatterns = []
for subpat in subpatterns:
subpat.pattern.flatten()
self.shapes += subpat.as_pattern().shapes
return self
def translate_elements(self, offset: vector2) -> 'Pattern':
"""
Translates all shapes and subpatterns by the given offset.
:param offset: Offset to translate by
:return: self
"""
for entry in self.shapes + self.subpatterns:
entry.translate(offset)
return self
def scale_elements(self, scale: float) -> 'Pattern':
""""
Scales all shapes and subpatterns by the given value.
:param scale: value to scale by
:return: self
"""
for entry in self.shapes + self.subpatterns:
entry.scale(scale)
return self
def scale_by(self, c: float) -> 'Pattern':
"""
Scale this Pattern by the given value
(all shapes and subpatterns and their offsets are scaled)
:param c: value to scale by
:return: self
"""
for entry in self.shapes + self.subpatterns:
entry.offset *= c
entry.scale_by(c)
return self
def rotate_around(self, pivot: vector2, rotation: float) -> 'Pattern':
"""
Rotate the Pattern around the a location.
:param pivot: Location to rotate around
:param rotation: Angle to rotate by (counter-clockwise, radians)
:return: self
"""
pivot = numpy.array(pivot)
self.translate_elements(-pivot)
self.rotate_elements(rotation)
self.rotate_element_centers(rotation)
self.translate_elements(+pivot)
return self
def rotate_element_centers(self, rotation: float) -> 'Pattern':
"""
Rotate the offsets of all shapes and subpatterns around (0, 0)
:param rotation: Angle to rotate by (counter-clockwise, radians)
:return: self
"""
for entry in self.shapes + self.subpatterns:
entry.offset = numpy.dot(rotation_matrix_2d(rotation), entry.offset)
return self
def rotate_elements(self, rotation: float) -> 'Pattern':
"""
Rotate each shape and subpattern around its center (offset)
:param rotation: Angle to rotate by (counter-clockwise, radians)
:return: self
"""
for entry in self.shapes + self.subpatterns:
entry.rotate(rotation)
return self
def scale_element_doses(self, factor: float) -> 'Pattern':
"""
Multiply all shape and subpattern doses by a factor
:param factor: Factor to multiply doses by
:return: self
"""
for entry in self.shapes + self.subpatterns:
entry.dose *= factor
return self
def copy(self) -> 'Pattern':
"""
Return a copy of the Pattern, deep-copying shapes and copying subpattern entries, but not
deep-copying any referenced patterns.
:return: A copy of the current Pattern.
"""
cp = copy.copy(self)
cp.shapes = copy.deepcopy(cp.shapes)
cp.subpatterns = [copy.copy(subpat) for subpat in cp.subpatterns]
return cp
@staticmethod
def load(filename: str) -> 'Pattern':
"""
Load a Pattern from a file
:param filename: Filename to load from
:return: Loaded Pattern
"""
with open(filename, 'rb') as f:
tmp_dict = pickle.load(f)
pattern = Pattern()
pattern.__dict__.update(tmp_dict)
return pattern
def save(self, filename: str) -> 'Pattern':
"""
Save the Pattern to a file
:param filename: Filename to save to
:return: self
"""
with open(filename, 'wb') as f:
pickle.dump(self.__dict__, f, protocol=2)
return self
def visualize(self,
offset: vector2=(0., 0.),
line_color: str='k',
fill_color: str='none',
overdraw: bool=False):
"""
Draw a picture of the Pattern and wait for the user to inspect it
Imports matplotlib.
:param offset: Coordinates to offset by before drawing
:param line_color: Outlines are drawn with this color (passed to matplotlib PolyCollection)
:param fill_color: Interiors are drawn with this color (passed to matplotlib PolyCollection)
:param overdraw: Whether to create a new figure or draw on a pre-existing one
"""
from matplotlib import pyplot
import matplotlib.collections
offset = numpy.array(offset, dtype=float)
if not overdraw:
figure = pyplot.figure()
pyplot.axis('equal')
else:
figure = pyplot.gcf()
axes = figure.gca()
polygons = []
for shape in self.shapes:
polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()]
mpl_poly_collection = matplotlib.collections.PolyCollection(polygons,
facecolors=fill_color,
edgecolors=line_color)
axes.add_collection(mpl_poly_collection)
pyplot.axis('equal')
for subpat in self.subpatterns:
subpat.as_pattern().visualize(offset=offset, overdraw=True,
line_color=line_color, fill_color=fill_color)
if not overdraw:
pyplot.show()

14
masque/shapes/__init__.py Normal file
View File

@ -0,0 +1,14 @@
"""
Shapes for use with the Pattern class, as well as the Shape abstract class from
which they are derived.
"""
from .shape import Shape, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
from .polygon import Polygon
from .circle import Circle
from .ellipse import Ellipse
from .arc import Arc

273
masque/shapes/arc.py Normal file
View File

@ -0,0 +1,273 @@
from typing import List
import math
import numpy
from numpy import pi
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
from .. import PatternError
from ..utils import is_scalar, vector2
__author__ = 'Jan Petykiewicz'
class Arc(Shape):
"""
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
The radii define an ellipse; the ring is formed with radii +/- width/2.
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
The start and stop angle are measure counterclockwise from the first (x) radius.
"""
_radii = None # type: numpy.ndarray
_angles = None # type: numpy.ndarray
_width = 1.0 # type: float
_rotation = 0.0 # type: float
# Defaults for to_polygons
poly_num_points = DEFAULT_POLY_NUM_POINTS # type: int
poly_max_arclen = None # type: float
# radius properties
@property
def radii(self) -> numpy.ndarray:
"""
Return the radii [rx, ry]
:return: [rx, ry]
"""
return self.radii
@radii.setter
def radii(self, val: vector2):
val = numpy.array(val, dtype=float).flatten()
if not val.size == 2:
raise PatternError('Radii must have length 2')
if not val.min() >= 0:
raise PatternError('Radii must be non-negative')
self.radii = val
@property
def radius_x(self) -> float:
return self.radii[0]
@radius_x.setter
def radius_x(self, val: float):
if not val >= 0:
raise PatternError('Radius must be non-negative')
self.radii[0] = val
@property
def radius_y(self) -> float:
return self.radii[1]
@radius_y.setter
def radius_y(self, val: float):
if not val >= 0:
raise PatternError('Radius must be non-negative')
self.radii[1] = val
# arc start/stop angle properties
@property
def angles(self) -> vector2:
"""
Return the start and stop angles [a_start, a_stop].
Angles are measured from x-axis after rotation, and are stored mod 2*pi
:return: [a_start, a_stop]
"""
return self._angles
@angles.setter
def angles(self, val: vector2):
val = numpy.array(val, dtype=float).flatten()
if not val.size == 2:
raise PatternError('Angles must have length 2')
angles = val % (2 * pi)
if angles[0] > pi:
self.rotation += pi
angles -= pi
self._angles = angles
@property
def start_angle(self) -> float:
return self.angles[0]
@start_angle.setter
def start_angle(self, val: float):
self.angles[0] = val % (2 * pi)
@property
def stop_angle(self) -> float:
return self.angles[1]
@stop_angle.setter
def stop_angle(self, val: float):
self.angles[1] = val % (2 * pi)
# Rotation property
@property
def rotation(self) -> float:
"""
Rotation of radius_x from x_axis, counterclockwise, in radians. Stored mod 2*pi
:return: rotation counterclockwise in radians
"""
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)
# Width
@property
def width(self) -> float:
"""
Width of the arc (difference between inner and outer radii)
:return: width
"""
return self._width
@width.setter
def width(self, val: float):
if not is_scalar(val):
raise PatternError('Width must be a scalar')
if not val > 0:
raise PatternError('Width must be positive')
self._width = val
def __init__(self,
radii: vector2,
angles: vector2,
rotation: float=0,
poly_num_points: int=DEFAULT_POLY_NUM_POINTS,
poly_max_arclen: float=None,
offset: vector2=(0.0, 0.0),
layer: int=0,
dose: float=1.0):
self.offset = offset
self.layer = layer
self.dose = dose
self.radii = radii
self.angles = angles
self.rotation = rotation
self.poly_num_points = poly_num_points
self.poly_max_arclen = poly_max_arclen
def to_polygons(self, poly_num_points: int=None, poly_max_arclen: float=None) -> List[Polygon]:
if poly_num_points is None:
poly_num_points = self.poly_num_points
if poly_max_arclen is None:
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)')
rxy = self.radii
ang = self.angles
# Approximate perimeter
# Ramanujan, S., "Modular Equations and Approximations to ,"
# Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372
h = ((rxy[1] - rxy[0]) / rxy.sum()) ** 2
ellipse_perimeter = pi * rxy.sum() * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
perimeter = abs(ang[0] - ang[1]) / (2 * pi) * ellipse_perimeter
n = []
if poly_num_points is not None:
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)
sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
wh = self.width / 2.0
xs1 = (rxy[0] + wh) * cos_th - (rxy[1] + wh) * sin_th
ys1 = (rxy[0] + wh) * cos_th - (rxy[1] + wh) * sin_th
xs2 = (rxy[0] - wh) * cos_th - (rxy[1] - wh) * sin_th
ys2 = (rxy[0] - wh) * cos_th - (rxy[1] - wh) * sin_th
xs = numpy.hstack((xs1, xs2[::-1]))
ys = numpy.hstack((ys1, ys2[::-1]))
xys = numpy.vstack((xs, ys)).T
poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset)
poly.rotate(self.rotation)
return [poly]
def get_bounds(self) -> numpy.ndarray:
a = self.angles - 0.5 * pi
mins = []
maxs = []
for sgn in (+1, -1):
wh = sgn * self.width/2
rx = self.radius_x + wh
ry = self.radius_y + wh
sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation)
tan_r = numpy.tan(self.rotation)
sin_a = numpy.sin(a)
cos_a = numpy.cos(a)
xpt = numpy.arctan(-ry / rx * tan_r)
ypt = numpy.arctan(+ry / rx / tan_r)
xnt = numpy.arcsin(numpy.sin(xpt - pi))
ynt = numpy.arcsin(numpy.sin(ypt - pi))
xr = numpy.sqrt((rx * cos_r) ** 2 + (ry * sin_r) ** 2)
yr = numpy.sqrt((rx * sin_r) ** 2 + (ry * cos_r) ** 2)
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
yn, yp = sorted(rx * sin_r * cos_a - ry * cos_r * sin_a)
if min(a) < xpt < max(a):
xp = xr
if min(a) < xnt < max(a):
xn = -xr
if min(a) < ypt < max(a):
yp = yr
if min(a) < ynt < max(a):
yn = -yr
mins.append([xn, yn])
maxs.append([xp, yp])
return numpy.vstack((numpy.min(mins, axis=0) + self.offset,
numpy.max(maxs, axis=0) + self.offset))
def rotate(self, theta: float) -> 'Arc':
self.rotation += theta
return self
def scale_by(self, c: float) -> 'Arc':
self.radii *= c
self.width *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
if self.radius_x < self.radius_y:
radii = self.radii / self.radius_x
scale = self.radius_x
rotation = self.rotation
angles = self.angles
else: # rotate by 90 degrees and swap radii
radii = self.radii[::-1] / self.radius_y
scale = self.radius_y
rotation = self.rotation + pi / 2
angles = self.angles - pi / 2
return (type(self), radii, angles, self.layer), \
(self.offset, scale/norm_value, rotation, self.dose), \
lambda: Arc(radii=radii*norm_value, angles=angles, layer=self.layer)

95
masque/shapes/circle.py Normal file
View File

@ -0,0 +1,95 @@
from typing import List
import numpy
from numpy import pi
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
from .. import PatternError
from ..utils import is_scalar, vector2
__author__ = 'Jan Petykiewicz'
class Circle(Shape):
"""
A circle, which has a position and radius.
"""
_radius = None # type: float
# Defaults for to_polygons
poly_num_points = DEFAULT_POLY_NUM_POINTS # type: int
poly_max_arclen = None # type: float
# radius property
@property
def radius(self) -> float:
"""
Circle's radius (float, >= 0)
:return: radius
"""
return self._radius
@radius.setter
def radius(self, val: float):
if not is_scalar(val):
raise PatternError('Radius must be a scalar')
if not val >= 0:
raise PatternError('Radius must be non-negative')
self._radius = val
def __init__(self,
radius: float,
poly_num_points: int=DEFAULT_POLY_NUM_POINTS,
poly_max_arclen: float=None,
offset: vector2=(0.0, 0.0),
layer: int=0,
dose: float=1.0):
self.offset = numpy.array(offset, dtype=float)
self.layer = layer
self.dose = dose
self.radius = radius
self.poly_num_points = poly_num_points
self.poly_max_arclen = poly_max_arclen
def to_polygons(self, poly_num_points: int=None, poly_max_arclen: float=None) -> List[Polygon]:
if poly_num_points is None:
poly_num_points = self.poly_num_points
if poly_max_arclen is None:
poly_max_arclen = self.poly_max_arclen
if (poly_num_points is None) and (poly_max_arclen is None):
raise PatternError('Number of points and arclength left '
'unspecified (default was also overridden)')
n = []
if poly_num_points is not None:
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)
xs = numpy.cos(thetas) * self.radius
ys = numpy.sin(thetas) * self.radius
xys = numpy.vstack((xs, ys)).T
return [Polygon(xys, offset=self.offset, dose=self.dose, layer=self.layer)]
def get_bounds(self) -> numpy.ndarray:
return numpy.vstack((self.offset - self.radius,
self.offset + self.radius))
def rotate(self, theta: float) -> 'Circle':
return self
def scale_by(self, c: float) -> 'Circle':
self.radius *= c
return self
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, self.dose), \
lambda: Circle(radius=norm_value, layer=self.layer)

161
masque/shapes/ellipse.py Normal file
View File

@ -0,0 +1,161 @@
from typing import List
import math
import numpy
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
__author__ = 'Jan Petykiewicz'
class Ellipse(Shape):
"""
An ellipse, which has a position, two radii, and a rotation.
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
"""
_radii = None # type: numpy.ndarray
_rotation = 0.0 # type: float
# Defaults for to_polygons
poly_num_points = DEFAULT_POLY_NUM_POINTS # type: int
poly_max_arclen = None # type: float
# radius properties
@property
def radii(self) -> numpy.ndarray:
"""
Return the radii [rx, ry]
:return: [rx, ry]
"""
return self.radii
@radii.setter
def radii(self, val: vector2):
val = numpy.array(val).flatten()
if not val.size == 2:
raise PatternError('Radii must have length 2')
if not val.min() >= 0:
raise PatternError('Radii must be non-negative')
self.radii = val
@property
def radius_x(self) -> float:
return self.radii[0]
@radius_x.setter
def radius_x(self, val: float):
if not val >= 0:
raise PatternError('Radius must be non-negative')
self.radii[0] = val
@property
def radius_y(self) -> float:
return self.radii[1]
@radius_y.setter
def radius_y(self, val: float):
if not val >= 0:
raise PatternError('Radius must be non-negative')
self.radii[1] = val
# Rotation property
@property
def rotation(self) -> float:
"""
Rotation of rx from the x axis. Uses the interval [0, pi) in radians (counterclockwise
is positive)
:return: counterclockwise rotation in radians
"""
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 % pi
def __init__(self,
radii: vector2,
rotation: float=0,
poly_num_points: int=DEFAULT_POLY_NUM_POINTS,
poly_max_arclen: float=None,
offset: vector2=(0.0, 0.0),
layer: int=0,
dose: float=1.0):
self.offset = offset
self.layer = layer
self.dose = dose
self.radii = radii
self.rotation = rotation
self.poly_num_points = poly_num_points
self.poly_max_arclen = poly_max_arclen
def to_polygons(self,
poly_num_points: int=None,
poly_max_arclen: float=None
) -> List[Polygon]:
if poly_num_points is None:
poly_num_points = self.poly_num_points
if poly_max_arclen is None:
poly_max_arclen = self.poly_max_arclen
if (poly_num_points is None) and (poly_max_arclen is None):
raise PatternError('Number of points and arclength left unspecified'
' (default was also overridden)')
rxy = self.radii
# Approximate perimeter
# Ramanujan, S., "Modular Equations and Approximations to ,"
# Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372
h = ((rxy[1] - rxy[0]) / rxy.sum()) ** 2
perimeter = pi * rxy.sum() * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
n = []
if poly_num_points is not None:
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)
sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
xs = rxy[0] * cos_th - rxy[1] * sin_th
ys = rxy[0] * sin_th - rxy[1] * cos_th
xys = numpy.vstack((xs, ys)).T
poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset)
poly.rotate(self.rotation)
return [poly]
def get_bounds(self) -> numpy.ndarray:
rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii)
return numpy.vstack((self.offset - rot_radii[0],
self.offset + rot_radii[1]))
def rotate(self, theta: float) -> 'Ellipse':
self.rotation += theta
return self
def scale_by(self, c: float) -> 'Ellipse':
self.radii *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
if self.radius_x < self.radius_y:
radii = self.radii / self.radius_x
scale = self.radius_x
angle = self.rotation
else:
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, self.dose), \
lambda: Ellipse(radii=radii*norm_value, layer=self.layer)

174
masque/shapes/polygon.py Normal file
View File

@ -0,0 +1,174 @@
from typing import List
import copy
import numpy
from numpy import pi
from . import Shape, normalized_shape_tuple
from .. import PatternError
from ..utils import is_scalar, rotation_matrix_2d, vector2
__author__ = 'Jan Petykiewicz'
class Polygon(Shape):
"""
A polygon, consisting of a bunch of vertices (Nx2 ndarray) along with an offset.
A normalized_form(...) is available, but can be quite slow with lots of vertices.
"""
_vertices = None # type: numpy.ndarray
# vertices property
@property
def vertices(self) -> numpy.ndarray:
"""
Vertices of the polygon (Nx2 ndarray: [[x0, y0], [x1, y1], ...]
:return: vertices
"""
return self._vertices
@vertices.setter
def vertices(self, val: numpy.ndarray):
val = numpy.array(val, dtype=float)
if val.shape[1] != 2:
raise PatternError('Vertices must be an Nx2 array')
if val.shape[0] < 3:
raise PatternError('Must have at least 3 vertices (Nx2, N>3)')
self._vertices = val
# xs property
@property
def xs(self) -> numpy.ndarray:
"""
All x vertices in a 1D ndarray
"""
return self.vertices[:, 0]
@xs.setter
def xs(self, val: numpy.ndarray):
val = numpy.array(val, dtype=float).flatten()
if val.size != self.vertices.shape[0]:
raise PatternError('Wrong number of vertices')
self.vertices[:, 0] = val
# ys property
@property
def ys(self) -> numpy.ndarray:
"""
All y vertices in a 1D ndarray
"""
return self.vertices[:, 1]
@ys.setter
def ys(self, val: numpy.ndarray):
val = numpy.array(val, dtype=float).flatten()
if val.size != self.vertices.shape[0]:
raise PatternError('Wrong number of vertices')
self.vertices[:, 1] = val
def __init__(self,
vertices: numpy.ndarray,
offset: vector2=(0.0, 0.0),
layer: int=0,
dose: float=1.0):
self.offset = offset
self.layer = layer
self.dose = dose
self.vertices = vertices
@staticmethod
def square(side_length: float,
rotation: float=0.0,
offset: vector2=(0.0, 0.0),
layer: int=0,
dose: float=1.0
) -> 'Polygon':
"""
Draw a square given side_length
:param side_length: Length of one side
:param rotation: Rotation counterclockwise, in radians
:param offset: Offset, default (0, 0)
:param layer: Layer, default 0
:param dose: Dose, default 1.0
:return: A Polygon object containing the requested square
"""
norm_square = numpy.array([[-1, -1],
[-1, +1],
[+1, +1],
[+1, -1]], dtype=float)
vertices = 0.5 * side_length * norm_square
poly = Polygon(vertices, offset, layer, dose)
poly.rotate(rotation)
return poly
@staticmethod
def rectangle(lx: float,
ly: float,
rotation: float=0,
offset: vector2=(0.0, 0.0),
layer: int=0,
dose: float=1.0
) -> 'Polygon':
"""
Draw a rectangle with side lengths lx and ly
:param lx: Length along x (before rotation)
:param ly: Length along y (before rotation)
:param rotation: Rotation counterclockwise, in radians
:param offset: Offset, default (0, 0)
:param layer: Layer, default 0
:param dose: Dose, default 1.0
:return: A Polygon object containing the requested rectangle
"""
vertices = 0.5 * numpy.array([[-lx, -ly],
[-lx, +ly],
[+lx, +ly],
[+lx, -ly]], dtype=float)
poly = Polygon(vertices, offset, layer, dose)
poly.rotate(rotation)
return poly
def to_polygons(self,
_poly_num_points: int=None,
_poly_max_arclen: float=None,
) -> List['Polygon']:
return [copy.deepcopy(self)]
def get_bounds(self) -> numpy.ndarray:
return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0),
self.offset + numpy.max(self.vertices, axis=0)))
def rotate(self, theta: float) -> 'Polygon':
self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
return self
def scale_by(self, c: float) -> 'Polygon':
self.vertices *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
# Note: this function is going to be pretty slow for many-vertexed polygons, relative to
# other shapes
offset = self.vertices.mean(axis=0) + self.offset
zeroed_vertices = self.vertices - offset
scale = zeroed_vertices.std()
normed_vertices = zeroed_vertices / scale
_, _, vertex_axis = numpy.linalg.svd(zeroed_vertices)
rotation = numpy.arctan2(vertex_axis[0][1], vertex_axis[0][0]) % (2 * pi)
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
for v in normed_vertices])
# Reorder the vertices so that the one with lowest x, then y, comes first.
x_min = rotated_vertices[:, 0].argmin()
if not is_scalar(x_min):
y_min = rotated_vertices[x_min, 1].argmin()
x_min = x_min[y_min]
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
return (type(self), reordered_vertices.data.tobytes(), self.layer), \
(offset, scale/norm_value, rotation, self.dose), \
lambda: Polygon(reordered_vertices*norm_value, layer=self.layer)

182
masque/shapes/shape.py Normal file
View File

@ -0,0 +1,182 @@
from typing import List, Tuple, Callable
from abc import ABCMeta, abstractmethod
import numpy
from .. import PatternError
from ..utils import is_scalar, rotation_matrix_2d, vector2
__author__ = 'Jan Petykiewicz'
# Type definitions
normalized_shape_tuple = Tuple[Tuple,
Tuple[numpy.ndarray, float, float, float],
Callable[[], 'Shape']]
# ## Module-wide defaults
# Default number of points per polygon for shapes
DEFAULT_POLY_NUM_POINTS = 24
class Shape(object, metaclass=ABCMeta):
"""
Abstract class specifying functions common to all shapes.
"""
# [x_offset, y_offset]
_offset = numpy.array([0.0, 0.0]) # type: numpy.ndarray
# Layer (integer >= 0)
_layer = 0 # type: int
# Dose
_dose = 1.0 # type: float
# --- Abstract methods
@abstractmethod
def to_polygons(self, num_vertices: int, max_arclen: float) -> List['Polygon']:
"""
Returns a list of polygons which approximate the shape.
:param num_vertices: Number of points to use for each polygon. Can be overridden by
max_arclen if that results in more points. Optional, defaults to shapes'
internal defaults.
:param max_arclen: Maximum arclength which can be approximated by a single line
segment. Optional, defaults to shapes' internal defaults.
:return: List of polygons equivalent to the shape
"""
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.
:return: [[x_min, y_min], [x_max, y_max]]
"""
pass
@abstractmethod
def rotate(self, theta: float) -> 'Shape':
"""
Rotate the shape around its center (0, 0), ignoring its offset.
:param theta: Angle to rotate by (counterclockwise, radians)
:return: self
"""
pass
@abstractmethod
def scale_by(self, c: float) -> 'Shape':
"""
Scale the shape's size (eg. radius, for a circle) by a constant factor.
:param c: Factor to scale by
:return: self
"""
pass
@abstractmethod
def normalized_form(self, norm_value: int) -> normalized_shape_tuple:
"""
Writes the shape in a standardized notation, with offset, scale, rotation, and dose
information separated out from the remaining values.
:param norm_value: This value is used to normalize lengths intrinsic to teh shape;
eg. for a circle, the returned magnitude value will be (radius / norm_value), and
the returned callable will create a Circle(radius=norm_value, ...). This is useful
when you find it important for quantities to remain in a certain range, eg. for
GDSII where vertex locations are stored as integers.
:return: The returned information takes the form of a 3-element tuple,
(intrinsic, extrinsic, constructor). These are further broken down as:
extrinsic: ([x_offset, y_offset], scale, rotation, dose)
intrinsic: A tuple of basic types containing all information about the instance that
is not contained in 'extrinsic'. Usually, intrinsic[0] == type(self).
constructor: A callable (no arguments) which returns an instance of type(self) with
internal state equivalent to 'intrinsic'.
"""
pass
# ---- Non-abstract properties
# offset property
@property
def offset(self) -> numpy.ndarray:
"""
[x, y] offset
:return: [x_offset, 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) -> int:
"""
Layer number (int, >=0)
:return: Layer
"""
return self._layer
@layer.setter
def layer(self, val: int):
if not isinstance(val, int):
raise PatternError('Layer must be an integer')
if not val >= 0:
raise PatternError('Layer must be non-negative')
self._layer = val
# dose property
@property
def dose(self) -> float:
"""
Dose (float >= 0)
:return: Dose value
"""
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 translate(self, offset: vector2) -> 'Shape':
"""
Translate the shape by the given offset
:param offset: [x_offset, y,offset]
:return: self
"""
self.offset += offset
return self
def rotate_around(self, pivot: vector2, rotation: float) -> 'Shape':
"""
Rotate the shape around a point.
:param pivot: Point (x, y) to rotate around
:param rotation: Angle to rotate by (counterclockwise, radians)
:return: 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

57
masque/shapes/text.py Normal file
View File

@ -0,0 +1,57 @@
#
# class Text(Shape):
# _string = ''
# _height = 1.0
# _rotation = 0.0
# font_path = ''
#
# # vertices property
# @property
# def string(self):
# return self._string
#
# @string.setter
# def string(self, val):
# self._string = val
#
# # Rotation property
# @property
# def rotation(self):
# return self._rotation
#
# @rotation.setter
# def rotation(self, val):
# if not is_scalar(val):
# raise PatternError('Rotation must be a scalar')
# self._rotation = val % (2 * pi)
#
# # Height property
# @property
# def height(self):
# return self._height
#
# @height.setter
# def height(self, val):
# if not is_scalar(val):
# raise PatternError('Height must be a scalar')
# self._height = val
#
# def __init__(self, text, height, font_path, rotation=0.0, offset=(0.0, 0.0), layer=0, dose=1.0):
# self.offset = offset
# self.layer = layer
# self.dose = dose
# self.text = text
# self.height = height
# self.rotation = rotation
# self.font_path = font_path
#
# def to_polygon(self, _poly_num_points=None, _poly_max_arclen=None):
#
# return copy.deepcopy(self)
#
# def rotate(self, theta):
# self.rotation += theta
#
# def scale_by(self, c):
# self.height *= c

159
masque/subpattern.py Normal file
View File

@ -0,0 +1,159 @@
"""
SubPattern provides basic support for nesting Pattern objects within each other, by adding
offset, rotation, scaling, and other such properties to the reference.
"""
from typing import Union
import numpy
from numpy import pi
from .error import PatternError
from .utils import is_scalar, rotation_matrix_2d, vector2
__author__ = 'Jan Petykiewicz'
class SubPattern(object):
"""
SubPattern provides basic support for nesting Pattern objects within each other, by adding
offset, rotation, scaling, and associated methods.
"""
pattern = None # type: Pattern
_offset = (0.0, 0.0) # type: numpy.ndarray
_rotation = 0.0 # type: float
_dose = 1.0 # type: float
_scale = 1.0 # type: float
def __init__(self,
pattern: 'Pattern',
offset: vector2=(0.0, 0.0),
rotation: float=0.0,
dose: float=1.0,
scale: float=1.0):
self.pattern = pattern
self.offset = offset
self.rotation = rotation
self.dose = dose
self.scale = scale
# 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()
# 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)
def as_pattern(self) -> 'Pattern':
"""
Returns a copy of self.pattern which has been scaled, rotated, etc. according to this
SubPattern's properties.
:return: Copy of self.pattern that has been altered to reflect the SubPattern's properties.
"""
pattern = self.pattern.copy()
pattern.scale_by(self.scale)
pattern.rotate_around((0.0, 0.0), self.rotation)
pattern.translate_elements(self.offset)
pattern.scale_element_doses(self.dose)
return pattern
def translate(self, offset: vector2) -> 'SubPattern':
"""
Translate by the given offset
:param offset: Translate by this offset
:return: self
"""
self.offset += offset
return self
def rotate_around(self, pivot: vector2, rotation: float) -> 'SubPattern':
"""
Rotate around a point
:param pivot: Point to rotate around
:param rotation: Angle to rotate by (counterclockwise, radians)
:return: 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 around (0, 0)
:param rotation: Angle to rotate by (counterclockwise, radians)
:return: self
"""
self.rotation += rotation
return self
def get_bounds(self) -> numpy.ndarray or None:
"""
Return a numpy.ndarray containing [[x_min, y_min], [x_max, y_max]], corresponding to the
extent of the SubPattern in each dimension.
Returns None if the contained Pattern is empty.
:return: [[x_min, y_min], [x_max, y_max]] or None
"""
return self.as_pattern().get_bounds()
def scale_by(self, c: float) -> 'SubPattern':
"""
Scale the subpattern by a factor
:param c: scaling factor
"""
self.scale *= c
return self

41
masque/utils.py Normal file
View File

@ -0,0 +1,41 @@
"""
Various helper functions
"""
from typing import Any, Union, Tuple
import numpy
# Type definitions
vector2 = Union[numpy.ndarray, Tuple[float, float]]
def is_scalar(var: Any) -> bool:
"""
Alias for 'not hasattr(var, "__len__")'
:param var: Checks if var has a length.
"""
return not hasattr(var, "__len__")
def get_bit(bit_string: Any, bit_id: int) -> bool:
"""
Returns true iff bit number 'bit_id' from the right of 'bitstring' is 1
:param bit_string: st
:param bit_id:
:return: value of the requested bit (bool)
"""
return bit_string & (1 << bit_id) != 0
def rotation_matrix_2d(theta: float) -> numpy.ndarray:
"""
2D rotation matrix for rotating counterclockwise around the origin.
:param theta: Angle to rotate, in radians
:return: rotation matrix
"""
return numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
[numpy.sin(theta), +numpy.cos(theta)]])

13
setup.py Normal file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env python
from distutils.core import setup
setup(name='masque',
version='0.1',
description='Lithography mask library',
author='Jan Petykiewicz',
author_email='anewusername@gmail.com',
url='https://mpxd.net/gogs/jan/masque',
packages=['masque'],
)