Source code for dphox.pattern

import numpy as np
from dataclasses import dataclass

from shapely.affinity import rotate
from shapely.geometry import box, GeometryCollection, LineString, LinearRing, Point, JOIN_STYLE, CAP_STYLE
from shapely.ops import split, unary_union, polygonize
from copy import deepcopy as copy

from .foundry import CommonLayer, Foundry, DEFAULT_FOUNDRY
from .geometry import Geometry
from .port import Port
from typing import List, Optional, Iterable, Union
from .typing import Float2, Float4, MultiPolygon, Polygon, PolygonLike, Shape, Spacing
from .utils import DECIMALS, fix_dataclass_init_docs, min_aspect_bounds, poly_points, shapely_patch, split_holes

SHAPELYVEC_IMPORTED = True
GDSPY_IMPORTED = True


try:
    from shapely.vectorized import contains
except ImportError:
    SHAPELYVEC_IMPORTED = False

try:
    import gdspy as gy
except ImportError:
    GDSPY_IMPORTED = False


[docs]class Pattern(Geometry): """Pattern corresponding to a patterned layer of material that may be used in a layout. A :code:`Pattern` is a core object in DPhox, which enables composition of multiple polygons or patterns into a single Pattern. It allows for composition of a myriad of different objects such as GDSPY Polygons and Shapely polygons into a single pattern. Since :code:`Pattern` is a simple wrapper around Shapely's MultiPolygon, this class interfaces easily with other libraries such as :code:`Trimesh` and simulators codes such as MEEP and simphox for simulating the generated designs straightforwardly. Attributes: polygons: the numpy array representation for the polygons in this pattern. decimals: decimal places for rounding (in case of tiny errors in polygons) """ def __init__(self, *patterns: Union["Pattern", PolygonLike, List[Union[PolygonLike, "Pattern"]]], decimals: int = 6): """Initializer for the pattern class. Args: *patterns: The patterns (Gdspy, Shapely, numpy array, Pattern) decimals: decimal places for rounding (in case of tiny errors in polygons) """ self.decimals = decimals super().__init__(get_ndarray_polygons(patterns), {}, []) @property def shapely(self) -> MultiPolygon: return MultiPolygon([Polygon(np.around(p.T, decimals=self.decimals)) for p in self.geoms]) @property def shapely_union(self) -> MultiPolygon: pattern = unary_union(self.shapely.geoms) return pattern if isinstance(pattern, MultiPolygon) else MultiPolygon([pattern])
[docs] def mask(self, shape: Shape, spacing: Spacing, smooth_feature: float = 0) -> np.ndarray: """Pixelized mask used for simulating this component Args: shape: Shape of the mask spacing: The grid spacing resolution to use for the pixelated mask smooth_feature: The shapely smooth feature factor, which erodes, dilates twice, then erodes the geometry by :code:`smooth_feature` units. Returns: An array of indicators of whether a volumetric image contains the mask """ if not SHAPELYVEC_IMPORTED: raise NotImplementedError('shapely.vectorized is not imported, but this relies on its contains method.') x_, y_ = np.mgrid[0:spacing[0] * shape[0]:spacing[0], 0:spacing[1] * shape[1]:spacing[1]] geom = self.shapely_union if smooth_feature: # erode geom = geom.buffer(-smooth_feature, join_style=JOIN_STYLE.round, cap_style=CAP_STYLE.square) # dilate twice geom = geom.buffer(2 * smooth_feature, join_style=JOIN_STYLE.round, cap_style=CAP_STYLE.square) # erode geom = geom.buffer(-smooth_feature, join_style=JOIN_STYLE.round, cap_style=CAP_STYLE.square) return contains(geom.buffer(np.max(spacing)), x_, y_) # need to use union!
[docs] def intersection(self, other_pattern: "Pattern") -> "Pattern": """Intersection between this pattern and provided pattern. Apply an intersection operation provided by Shapely's interface. See Also: https://shapely.readthedocs.io/en/stable/manual.html#object.intersection Args: other_pattern: Other pattern Returns: The new intersected pattern """ return Pattern(self.shapely_union.intersection(other_pattern.shapely_union))
[docs] def difference(self, other_pattern: "Pattern") -> "Pattern": """Difference between this pattern and provided pattern. Apply an difference operation provided by Shapely's interface. Note the distinction between :code:`difference` and :code:`symmetric_difference`. See Also: https://shapely.readthedocs.io/en/stable/manual.html#object.difference Args: other_pattern: Other pattern Returns: The new differenced pattern """ return Pattern(self.shapely_union.difference(other_pattern.shapely_union))
[docs] def union(self, other_pattern: "Pattern") -> "Pattern": """Union between this pattern and provided pattern. Apply an union operation provided by Shapely's interface. See Also: https://shapely.readthedocs.io/en/stable/manual.html#object.union Args: other_pattern: Other pattern Returns: The new union pattern """ return Pattern(self.shapely_union.union(other_pattern.shapely_union))
[docs] def symmetric_difference(self, other_pattern: "Pattern") -> "Pattern": """Union between this pattern and provided pattern. Apply a symmetric difference operation provided by Shapely's interface. See Also: https://shapely.readthedocs.io/en/stable/manual.html#object.symmetric_difference Args: other_pattern: Other pattern Returns: The new symmetric difference pattern """ return Pattern(self.shapely_union.symmetric_difference(other_pattern.shapely_union))
[docs] def to_gdspy(self, cell): """Add to an existing GDSPY cell. Args: cell: GDSPY cell to add polygon Returns: The GDSPY cell corresponding to the :code:`Pattern` """ import gdspy as gy for poly in self.geoms: cell.add(gy.Polygon(poly) if isinstance(poly, Polygon) else cell.add(poly))
@property def bbox_pattern(self) -> "Box": """ Returns: The linestring along the diagonal of the bbox """ bbox = Box(self.size).align(self.center) bbox.port = self.port_copy return bbox
[docs] def replace(self, pattern: "Pattern", center: Optional[Float2] = None, raise_port: bool = True): """Replace the polygons in some part of the image with :code:`pattern`. This is a useful property for inverse design. Note that the entire bounding box of the pattern is replaced by :code:`pattern` which may not always be desirable, but we estimate this is sufficient for most inverse design use cases. Args: pattern: The pattern which replaces this pattern. center: The center where to replace the pattern. raise_port: Whether to raise the port of this current pattern and add those ports to the new pattern. Returns: The new pattern with :code:`pattern` replacing the appropriate region of the image. """ align = self if center is None else center diff = self.difference(Box(pattern.size).align(align)) new_pattern = Pattern(diff, pattern.align(align)) if raise_port: new_pattern.port = self.port return new_pattern
[docs] def plot(self, ax: Optional = None, color: str = 'gray', plot_ports: bool = True, alpha: float = 1): """Plot the pattern Args: ax: Axis for plotting (if none, use the default matplotlib plot axis color: Color of the pattern plot_ports: Whether to plot the ports. alpha: the alpha property for plotting. Returns: """ # import locally since this import takes some time. if ax is None: import matplotlib.pyplot as plt ax = plt.gca() ax.add_patch(shapely_patch(self.shapely_union, facecolor=color, edgecolor='none', alpha=alpha)) if plot_ports: for name, port in self.port.items(): port_xy = port.xy - port.tangent(port.w) ax.add_patch(shapely_patch(port.shapely, facecolor='red', edgecolor='none', alpha=alpha)) ax.text(*port_xy, name) b = min_aspect_bounds(self.bounds) ax.set_xlim((b[0], b[2])) ax.set_ylim((b[1], b[3])) ax.set_aspect('equal')
[docs] def buffer(self, distance: float, join_style=JOIN_STYLE.round, cap_style=CAP_STYLE.square) -> "Pattern": pattern = self.shapely_union.buffer(distance, join_style=join_style, cap_style=cap_style) return Pattern(pattern if isinstance(pattern, MultiPolygon) else pattern)
[docs] def smooth(self, distance: float, min_area: float = None, join_style=JOIN_STYLE.round, cap_style=CAP_STYLE.square) -> "Pattern": smoothed = self.buffer(distance, join_style=join_style, cap_style=cap_style).buffer(-distance, join_style=join_style, cap_style=cap_style) smoothed_exclude = Pattern(smoothed.shapely_union.union(self.shapely_union) - self.shapely_union) min_area = distance ** 2 / 4 if min_area is None else min_area self.geoms += [p for p in smoothed_exclude.geoms if Polygon(p.T).area > min_area] return self
[docs] def nazca_cell(self, cell_name: str, layer: Union[int, str]): try: import nazca as nd except ImportError: raise ImportError('Nazca not installed! Please install nazca prior to running nazca_cell().') with nd.Cell(cell_name) as cell: for poly in self.geoms: nd.Polygon(points=poly, layer=layer).to() for name, port in self.port.items(): nd.Pin(name).to(*port.xya) nd.put_stub() return cell
[docs] def hvplot(self, color: str = 'black', name: str = 'pattern', alpha: float = 0.5, bounds: Optional[Float4] = None, plot_ports: bool = True): """Plot this device on a matplotlib plot. Args: color: The color for plotting the pattern. name: Name of the pattern / label of the plot. alpha: The transparency factor for the plot (to see overlay of structures from many layers). plot_ports: Plot the ports (triangle indicators and text labels). Returns: The holoviews Overlay for displaying all of the polygons. """ import holoviews as hv plots_to_overlay = [] b = min_aspect_bounds(self.bounds) if bounds is None else bounds geom = self.shapely_union def _holoviews_poly(shapely_poly): x, y = poly_points(shapely_poly).T holes = [[np.array(hole.coords.xy).T for hole in shapely_poly.interiors]] return {'x': x, 'y': y, 'holes': holes} polys = [_holoviews_poly(poly) for poly in geom.geoms] if isinstance(geom, MultiPolygon) else [_holoviews_poly(geom)] plots_to_overlay.append( hv.Polygons(polys, label=name).opts(data_aspect=1, frame_height=200, fill_alpha=alpha, ylim=(b[1], b[3]), xlim=(b[0], b[2]), color=color, line_alpha=0, tools=['hover'])) if plot_ports: for name, port in self.port.items(): plots_to_overlay.append(port.hvplot(name)) return hv.Overlay(plots_to_overlay)
[docs] def trimesh(self, foundry: Union[str, Foundry] = DEFAULT_FOUNDRY, layer: CommonLayer = CommonLayer.RIDGE_SI): """Fabricate this pattern based on a :code:`Foundry`. This method is fairly rudimentary and will not implement things like conformal deposition. At the moment, you can implement things like rib etches which can be determined using 2d shape operations. Depositions in layers above etched layers will just start from the maximum z extent of the previous layer. This is specified by the :code:`Foundry` stack. Args: foundry: The foundry for each layer. layer: The layer for this pattern. Returns: The device :code:`Scene` to visualize. """ return foundry.fabricate( layer_to_geom={layer: MultiPolygon([Polygon(p.T) for p in self.geoms])} )
def __sub__(self, other: "Pattern"): return self.difference(other) def __or__(self, other: "Pattern"): return self.union(other) def __add__(self, other: "Pattern"): return self.union(other) # TODO: switch to Pattern(self.geoms + other.geoms) after testing def __and__(self, other: "Pattern"): return self.intersection(other) def __xor__(self, other: "Pattern"): return self.symmetric_difference(other) @property def copy(self) -> "Pattern": return copy(self)
[docs]def get_ndarray_polygons(polylike_list: Iterable[Union["Pattern", PolygonLike, List[Union[PolygonLike, "Pattern"]]]], decimals: int = DECIMALS): """A recursive list of lists of polylike objects, which turned into a flat list of 2d ndarray polygons. Args: polylike_list: List of polygon-like objects including :code:`Pattern`, shapely geometry collections, GDSPY geometries, and more. decimals: decimal precision of the resulting polygons Returns: A list of :math:`M` polygons that are each represented as :math:`2 \\times N_m` :code:`ndarray`'s. """ polygons = [] for pattern in polylike_list: if isinstance(pattern, list): # recursively apply to the list. polygons.extend(sum((get_ndarray_polygons([p]) for p in pattern), [])) elif isinstance(pattern, Pattern): polygons.extend(pattern.geoms) elif isinstance(pattern, np.ndarray): polygons.append(pattern) elif isinstance(pattern, Polygon): pattern = split_holes(pattern) polygons.extend([poly_points(geom).T for geom in pattern.geoms]) elif isinstance(pattern, MultiPolygon): polygons.extend([poly_points(geom).T for geom in split_holes(pattern).geoms]) elif isinstance(pattern, GeometryCollection): polygons.extend([poly_points(geom).T for geom in split_holes(pattern).geoms]) elif GDSPY_IMPORTED: if isinstance(pattern, gy.FlexPath): polygons.extend(pattern.get_polygons()) elif isinstance(pattern, gy.Path): polygons.extend(pattern.polygons) else: raise TypeError(f'Pattern does not accept type {type(pattern)}') return [np.around(p, decimals=decimals) for p in polygons]
[docs]@fix_dataclass_init_docs @dataclass class Box(Pattern): """Box with default center at origin Attributes: extent: Dimension (box width, box height) decimals: The decimal places to resolve the box. """ extent: Float2 = (1, 1) decimals: int = 6 def __post_init__(self): super(Box, self).__init__(box(-self.extent[0] / 2, -self.extent[1] / 2, self.extent[0] / 2, self.extent[1] / 2), decimals=self.decimals) self.port = { 'c': Port(*self.center), 'n': Port(self.center[0], self.bounds[3], 90, self.extent[0]), 'w': Port(self.bounds[0], self.center[1], -180, self.extent[1]), 'e': Port(self.bounds[2], self.center[1], 0, self.extent[1]), 's': Port(self.center[0], self.bounds[1], -90, self.extent[0]) }
[docs] def expand(self, grow: float) -> "Box": """An aligned box that grows by amount :code:`grow` Args: grow: The amount to grow the box Returns: The box after the grow transformation """ big_extent = (self.extent[0] + grow, self.extent[1] + grow) return Box(big_extent).align(self)
[docs] def hollow(self, thickness: float) -> Pattern: """A hollow box of thickness :code:`thickness` on all four sides within the confines of the box extent. Args: thickness: thickness of the box. Returns: A box of specified :code:`thickness` with no filling inside. """ return Pattern( self.difference(Box((self.extent[0] - 2 * thickness, self.extent[1])).align(self)), self.difference(Box((self.extent[0], self.extent[1] - 2 * thickness)).align(self)), )
[docs] def cup(self, thickness: float) -> Pattern: """Return a cup-shaped (U-shaped) within the confines of the box extent. Args: thickness: thickness of the border. Returns: A cup-shaped block of thickness :code:`thickness`. """ return Pattern( self.difference(Box((self.extent[0] - 2 * thickness, self.extent[1])).align(self)), self.difference(Box((self.extent[0], self.extent[1] - thickness)).align(self).valign(self)), )
[docs] def ell(self, thickness: float) -> Pattern: """Return an ell-shaped (L-shaped) pattern within the confines of the box extent. Args: thickness: thickness of the border. Returns: An L-shaped block of thickness :code:`thickness`. """ return Pattern( self.difference(Box((self.extent[0] - thickness, self.extent[1])).align(self).halign(self)), self.difference(Box((self.extent[0], self.extent[1] - thickness)).align(self).valign(self)), )
[docs] def striped(self, stripe_w: float, pitch: Optional[Float2] = None, along_x: bool = True, along_y: bool = True, include_boundary: bool = True) -> Pattern: """A stripe hatch pattern in the confines of the box, useful for etching and arrays of square holes. Args: stripe_w: Stripe width (useful for etch holes). pitch: Pitch of the stripes along_x: Stripes / grating along x along_y: Stripes / grating along y Returns: The striped :code:`Pattern` """ pitch = (stripe_w * 2, stripe_w * 2) if pitch is None else pitch patterns = [] if include_boundary: patterns = [self.hollow(stripe_w)] if pitch[0] > 0 and pitch[1] > 0 else [] if pitch[0] > 0 and not 3 * pitch[1] >= self.size[0] and along_x: xs = np.mgrid[self.bounds[0] + pitch[0]:self.bounds[2]:pitch[0]] patterns.append( Pattern(*[Box(extent=(stripe_w, self.size[1])).halign(x) for x in xs]).align( self.center)) if pitch[1] > 0 and not 3 * pitch[1] >= self.size[1] and along_y: ys = np.mgrid[self.bounds[1] + pitch[1]:self.bounds[3]:pitch[1]] patterns.append( Pattern(*[Box(extent=(self.size[0], stripe_w)).valign(y) for y in ys]).align( self.center)) return Pattern(*patterns)
[docs] def flexure(self, spring_extent: Float2, connector_extent: Float2 = None, stripe_w: float = 1, symmetric: bool = True, spring_center: bool = False) -> Pattern: """A crab-leg flexure (useful for MEMS actuation). Args: spring_extent: Spring extent (x, y). connector_extent: Connector extent (x, y). stripe_w: Stripe width (useful for etch holes, calls the striped method). symmetric: Whether to specify a symmetric connector. Returns: The flexure :code:`Pattern` """ spring = Box(extent=spring_extent).align(self) connectors = [] if connector_extent is not None: connector = Box(extent=connector_extent).align(self) if symmetric: connectors += [connector.copy.halign(self), connector.copy.halign(self, left=False)] else: connectors += [ connector.copy.valign(self).halign(self), connector.copy.valign(self).halign(self, left=False) ] springs = [ spring.copy.valign(self), spring.copy if spring_center else spring.copy.valign(self, bottom=False) ] return Pattern(self.striped(stripe_w), *springs, *connectors)
[docs]@fix_dataclass_init_docs @dataclass class Ellipse(Pattern): """Ellipse with default center at origin. Attributes: radius_extent: Dimension (ellipse x radius, ellipse y radius). resolution: Resolution is (number of points on circle) / 2. """ radius_extent: Float2 = (1, 1) resolution: int = 16 def __post_init__(self): super(Ellipse, self).__init__(Point(0, 0).buffer(1, resolution=self.resolution)) self.scale(*self.radius_extent)
[docs]@fix_dataclass_init_docs @dataclass class Circle(Pattern): """Ellipse with default center at origin. Attributes: diameter: diameter of the circle. resolution: Resolution is (number of points on circle) / 2. """ radius: float = 1 resolution: int = 16 def __post_init__(self): super(Circle, self).__init__(Point(0, 0).buffer(self.radius, resolution=self.resolution))
[docs]@fix_dataclass_init_docs @dataclass class Sector(Pattern): """Sector of a circle with center at origin. Attributes: radius: radius of the circle boundary of the sector. angle: angle of the sector. resolution: Resolution is (number of points on circle) / 2. """ radius: float = 1 angle: float = 180 resolution: int = 16 def __post_init__(self): circle = Point(0, 0).buffer(self.radius, resolution=self.resolution) top_splitter = rotate(LineString([(0, self.radius), (0, 0)]), angle=self.angle / 2, origin=(0, 0)) bottom_splitter = rotate(LineString([(0, 0), (0, self.radius)]), angle=-self.angle / 2, origin=(0, 0)) super(Sector, self).__init__( split(circle, LineString([*top_splitter.coords, (0, 0), *bottom_splitter.coords]))[1])
[docs]@fix_dataclass_init_docs @dataclass class StripedBox(Pattern): extent: Float2 stripe_w: float pitch: Union[float, Float2] def __post_init__(self): super(StripedBox, self).__init__(Box(self.extent).striped(self.stripe_w, self.pitch))
[docs]@fix_dataclass_init_docs @dataclass class MEMSFlexure(Pattern): extent: Float2 stripe_w: float pitch: Union[float, Float2] spring_extent: Float2 connector_extent: Float2 = None spring_center: bool = False def __post_init__(self): super(MEMSFlexure, self).__init__(Box(self.extent).flexure(self.spring_extent, self.connector_extent, self.stripe_w, self.spring_center)) self.box = Box(self.extent) self.refs.append(self.box)
[docs]@fix_dataclass_init_docs @dataclass class Quad(Pattern): start: Port end: Port def __post_init__(self): super(Quad, self).__init__(Pattern(np.hstack((self.start.line, self.end.line)))) self.port = {'a0': self.start.copy, 'b0': self.end.copy}
[docs]def text(string: str, size: float = 12): """A simple function to generate text pattern from a string. Args: string: The string to turn into a pattern size: The fontsize size of the string. Returns: The text :code:`Pattern`. """ import matplotlib.pyplot as plt from matplotlib.textpath import TextPath plt.rc('text.latex', preamble=r'\usepackage{sfmath}') try: usetex = True path = TextPath((0, 0), string, size=size, usetex=True) except FileNotFoundError: usetex = False path = TextPath((0, 0), string, size=size, usetex=False) rings = [LinearRing(p) for p in path.to_polygons()] multipoly = MultiPolygon(polygonize(rings)) filtered_polys = [] for ring in rings: filtered_polys.extend(poly for poly in multipoly.geoms if (Polygon(poly.exterior) ^ Polygon(ring)).area < 1e-6 and ring.is_ccw == usetex) return Pattern(filtered_polys)