From fa66e09f9b43a0a74d7aa74f5a952a0b8bbafec4 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 20 Apr 2019 13:26:42 -0700 Subject: [PATCH] snapshot 2019-04-20 13:26:42.161005 --- masque/file/gdsii.py | 1 + masque/shapes/arc.py | 17 +- masque/shapes/ellipse.py | 13 +- masque/shapes/path.py | 332 +++++++++++++++++++++++++++++++++++++++ masque/shapes/polygon.py | 18 ++- masque/shapes/shape.py | 6 +- masque/shapes/text.py | 8 +- 7 files changed, 367 insertions(+), 28 deletions(-) create mode 100644 masque/shapes/path.py diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index 9f344a2..cefb5eb 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -273,6 +273,7 @@ def read(filename: str, if isinstance(element, gdsii.elements.Path): if element.path_type == 0: + #cap = Path.Cap.Flush extension = 0.0 elif element.path_type in (1, 4): raise PatternError('Round-ended and custom paths (types 1 and 4) are not implemented yet') diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 74f0ec0..d44a450 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -141,19 +141,21 @@ class Arc(Shape): radii: vector2, angles: vector2, width: float, - rotation: float=0, poly_num_points: int=DEFAULT_POLY_NUM_POINTS, poly_max_arclen: float=None, offset: vector2=(0.0, 0.0), + rotation: float=0, + mirrored: Tuple[bool] = (False, False), layer: int=0, dose: float=1.0): - self.offset = offset - self.layer = layer - self.dose = dose self.radii = radii self.angles = angles self.width = width + self.offset = offset self.rotation = rotation + [self.mirror(a) for a in 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 @@ -201,8 +203,7 @@ class Arc(Shape): ys = numpy.hstack((ys1, ys2)) xys = numpy.vstack((xs, ys)).T - poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset) - poly.rotate(self.rotation) + poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset, rotation=self.rotation) return [poly] def get_bounds(self) -> numpy.ndarray: @@ -306,9 +307,9 @@ class Arc(Shape): rotation %= 2 * pi width = self.width - return (type(self), radii, angles, width, self.layer), \ + return (type(self), radii, angles, width/norm_value, self.layer), \ (self.offset, scale/norm_value, rotation, self.dose), \ - lambda: Arc(radii=radii*norm_value, angles=angles, width=width, layer=self.layer) + lambda: Arc(radii=radii*norm_value, angles=angles, width=width*norm_value, layer=self.layer) def get_cap_edges(self) -> numpy.ndarray: ''' diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 6b7317f..043d155 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Tuple import math import numpy from numpy import pi @@ -82,17 +82,19 @@ class Ellipse(Shape): 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), + rotation: float=0, + mirrored: Tuple[bool] = (False, False), layer: int=0, dose: float=1.0): + self.radii = radii self.offset = offset + self.rotation = rotation + [self.mirror(a) for a in a, do in enumerate(mirrored) if do] 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 @@ -129,8 +131,7 @@ class Ellipse(Shape): ys = r1 * sin_th xys = numpy.vstack((xs, ys)).T - poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset) - poly.rotate(self.rotation) + poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset, rotation=self.rotation) return [poly] def get_bounds(self) -> numpy.ndarray: diff --git a/masque/shapes/path.py b/masque/shapes/path.py new file mode 100644 index 0000000..511dcb6 --- /dev/null +++ b/masque/shapes/path.py @@ -0,0 +1,332 @@ +from typing import List, Tuple +import copy +from enum import Enum +import numpy +from numpy import pi + +from . import Shape, normalized_shape_tuple +from .. import PatternError +from ..utils import is_scalar, rotation_matrix_2d, vector2 +from ..utils import remove_colinear_vertices, remove_duplicate_vertices + +__author__ = 'Jan Petykiewicz' + + +class Path(Shape): + """ + A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape, + and an offset. + + A normalized_form(...) is available, but can be quite slow with lots of vertices. + """ + _vertices = None # type: numpy.ndarray + _width = None # type: float + _cap = None # type: Path.Cap + + class Cap(Enum): + Flush = 0 + Circle = 1 + Square = 2 + + + # width property + @property + def width(self) -> float: + """ + Path width (float, >= 0) + + :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 non-negative') + self._width = val + + # cap property + @property + def cap(self) -> 'Path.Cap': + """ + Path end-cap + + :return: Path.Cap enum + """ + return self._cap + + @cap.setter + def cap(self, val: 'Path.Cap'): + self._cap = Path.Cap(val) + + # vertices property + @property + def vertices(self) -> numpy.ndarray: + """ + Vertices of the path (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 len(val.shape) < 2 or val.shape[1] != 2: + raise PatternError('Vertices must be an Nx2 array') + if val.shape[0] < 2: + raise PatternError('Must have at least 2 vertices (Nx2 where N>1)') + self._vertices = val + + # xs property + @property + def xs(self) -> numpy.ndarray: + """ + All vertex x coords as 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 vertex y coords as 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, + width: float = 0.0, + cap: 'Path.Cap' = Path.Cap.Flush, + offset: vector2=(0.0, 0.0), + rotation: float = 0 + mirrored: Tuple[bool] = (False, False), + layer: int=0, + dose: float=1.0, + ) -> 'Path': + self.offset = offset + self.layer = layer + self.dose = dose + self.vertices = vertices + self.width = width + self.cap = cap + self.rotate(rotation) + [self.mirror(a) for a in a, do in enumerate(mirrored) if do] + + @staticmethod + def travel(travel_pairs: Tuple[Tuple[float, float]], + width: float = 0.0, + cap: 'Path.Cap' = Path.Cap.Flush, + offset: vector2=(0.0, 0.0), + rotation: float = 0 + mirrored: Tuple[bool] = (False, False), + layer: int=0, + dose: float=1.0, + ) -> 'Path': + """ + TODO + + :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 + """ + direction = numpy.array([1, 0]) + + verts = [[0, 0]] + for angle, distance in travel_pairs: + direction = numpy.dot(rotation_matrix_2d(angle), direction.T).T + verts.append(verts[-1] + direction * distance) + + return Path(vertices=verts, width=width, cap=cap, + offset=offset, rotation=rotation, mirrored=mirrored, + layer=layer, dose=dose) + + def to_polygons(self, + poly_num_points: int=None, + poly_max_arclen: float=None, + ) -> List['Polygon']: + if self.cap in (Path.Cap.Flush, Path.Cap.Circle + extension = 0.0 + elif self.cap == Path.Cap.Square: + extension = self.width / 2 + else: + raise PatternError('Unrecognized path endcap: {}'.format(self.cap)) + + v = remove_colinear_vertices(numpy.array(element.xy, dtype=float), closed_path=False) + dv = numpy.diff(v, axis=0) + dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] + + if self.width == 0: + verts = numpy.vstack((v, v[::-1])) + return [Polygon(offset=self.offset, vertices=verts, dose=self.dose, layer=self.layer)] + + perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2 + + # add extension + if extension != 0 + v[0] -= dvdir[0] * extension + v[-1] += dvdir[-1] * extension + dv = numpy.diff(v, axis=0) # recalculate dv; dvdir and perp should stay the same + + + # Find intersections of expanded sides + As = numpy.stack((dv[:-1], -dv[1:]), axis=2) + bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] + ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] + + rp = numpy.linalg.solve(As, bs)[:, 0, None] + rn = numpy.linalg.solve(As, ds)[:, 0, None] + + 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? + + # Build vertices + o0 = [v[0] + perp[0]] + o1 = [v[0] - perp[0]] + for i in range(dv.shape[0] - 1): + if towards_perp[i]: + o0.append(intersection_p[i]) + if acute[i]: + o1.append(intersection_n[i]) + else: + # Opposite is >270 + pt0 = v[i + 1] - perp[i + 0] + dvdir[i + 0] * element.width / 2 + pt1 = v[i + 1] - perp[i + 1] - dvdir[i + 1] * element.width / 2 + o1 += [pt0, pt1] + else: + o1.append(intersection_n[i]) + if acute[i]: + # > 270 + pt0 = v[i + 1] + perp[i + 0] + dvdir[i + 0] * element.width / 2 + pt1 = v[i + 1] + perp[i + 1] - dvdir[i + 1] * element.width / 2 + o0 += [pt0, pt1] + else: + o0.append(intersection_p[i]) + o0.append(v[-1] + perp[-1]) + o1.append(v[-1] - perp[-1]) + verts = numpy.vstack((o0, o1[::-1])) + + polys = [Polygon(offset=self.offset, vertices=verts, dose=self.dose, layer=self.layer)] + + if self.cap == Path.Cap.Circle: + for vert in v: + circ = Circle(offset=vert, radius=self.width / 2, dose=self.dose, layer=self.layer) + polys += circ.to_polygons(poly_num_points=poly_num_points, poly_max_arclen=poly_max_arclen) + + return polys + + def get_bounds(self) -> numpy.ndarray: + if self.cap == Path.Cap.Circle: + bounds = self.offset + numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2, + numpy.max(self.vertices, axis=0) + self.width / 2)) + elif self.cap in (Path.Cap.Flush, + Path.Cap.Square): + if self.cap == Path.Cap.Flush: + extension = 0 + elif self.cap == Path.Cap.Square: + extension = element.width / 2 + + v = remove_colinear_vertices(self.vertices, closed_path=False) + dv = numpy.diff(v, axis=0) + dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] + perp = dvdir[:, ::-1] * [[1, -1]] * element.width / 2 + + v[0] -= dvdir * extension + v[-1] += dvdir * extension + + bounds = self.offset + numpy.vstack((numpy.min(v - numpy.abs(perp), axis=0), + numpy.max(v + numpy.abs(perp), axis=0))) + else: + raise PatternError('get_bounds() not implemented for endcaps: {}'.format(self.cap)) + + return bounds + + def rotate(self, theta: float) -> 'Path': + self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T + return self + + def mirror(self, axis: int) -> 'Path': + self.vertices[:, axis - 1] *= -1 + return self + + def scale_by(self, c: float) -> 'Path': + self.vertices *= c + self.width *= 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 paths, 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) + + width0 = self.width / norm_value + + return (type(self), reordered_vertices.data.tobytes(), width0, self.cap, self.layer), \ + (offset, scale/norm_value, rotation, self.dose), \ + lambda: Polygon(reordered_vertices*norm_value, width=self.width*norm_value, + cap=self.cap, layer=self.layer) + + def clean_vertices(self) -> 'Path': + """ + Removes duplicate, co-linear and otherwise redundant vertices. + + :returns: self + """ + self.remove_colinear_vertices() + return self + + def remove_duplicate_vertices(self) -> 'Path': + ''' + Removes all consecutive duplicate (repeated) vertices. + + :returns: self + ''' + self.vertices = remove_duplicate_vertices(self.vertices, closed_path=False) + return self + + def remove_colinear_vertices(self) -> 'Path': + ''' + Removes consecutive co-linear vertices. + + :returns: self + ''' + self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) + return self diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index a0b214b..c060990 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -13,7 +13,8 @@ __author__ = 'Jan Petykiewicz' class Polygon(Shape): """ - A polygon, consisting of a bunch of vertices (Nx2 ndarray) along with an offset. + A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an + implicitly-closed boundary, and an offset. A normalized_form(...) is available, but can be quite slow with lots of vertices. """ @@ -35,14 +36,14 @@ class Polygon(Shape): if len(val.shape) < 2 or 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)') + raise PatternError('Must have at least 3 vertices (Nx2 where N>2)') self._vertices = val # xs property @property def xs(self) -> numpy.ndarray: """ - All x vertices in a 1D ndarray + All vertex x coords as a 1D ndarray """ return self.vertices[:, 0] @@ -57,7 +58,7 @@ class Polygon(Shape): @property def ys(self) -> numpy.ndarray: """ - All y vertices in a 1D ndarray + All vertex y coords as a 1D ndarray """ return self.vertices[:, 1] @@ -71,12 +72,17 @@ class Polygon(Shape): def __init__(self, vertices: numpy.ndarray, offset: vector2=(0.0, 0.0), + rotation: float=0.0, + mirrored: Tuple[bool] = (False, False), layer: int=0, - dose: float=1.0): - self.offset = offset + dose: float=1.0, + ): self.layer = layer self.dose = dose self.vertices = vertices + self.offset = offset + self.rotate(rotation) + [self.mirror(a) for a in a, do in enumerate(mirrored) if do] @staticmethod def square(side_length: float, diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 981e2ce..7e26c87 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -94,16 +94,16 @@ class Shape(metaclass=ABCMeta): 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 + :param norm_value: This value is used to normalize lengths intrinsic to the shape; + eg. for a circle, the returned intrinsic radius 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). + extrinsic: ([x_offset, y_offset], scale, rotation, dose) constructor: A callable (no arguments) which returns an instance of type(self) with internal state equivalent to 'intrinsic'. """ diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 64b7468..167e2c2 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -61,15 +61,15 @@ class Text(Shape): def mirrored(self, val: List[bool]): if is_scalar(val): raise PatternError('Mirrored must be a 2-element list of booleans') - self._mirrored = val + self._mirrored = list(val) def __init__(self, string: str, height: float, font_path: str, - mirrored: List[bool]=None, - rotation: float=0.0, offset: vector2=(0.0, 0.0), + rotation: float=0.0, + mirrored: Tuple[bool]=(False, False), layer: int=0, dose: float=1.0): self.offset = offset @@ -79,8 +79,6 @@ class Text(Shape): self.height = height self.rotation = rotation self.font_path = font_path - if mirrored is None: - mirrored = [False, False] self.mirrored = mirrored def to_polygons(self,