diff --git a/README.md b/README.md index 6ebc5ab..62b13bb 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,12 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...) ## TODO +* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath) * PolyCollection & arrow-based read/write +* pather and renderpather examples, including .at() (PortPather) * Bus-to-bus connections? +* Tests tests tests +* Better interface for polygon operations (e.g. with `pyclipper`) + - de-embedding + - boolean ops * tuple / string layer auto-translation diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py index 57b170c..a51a27e 100644 --- a/examples/ellip_grating.py +++ b/examples/ellip_grating.py @@ -6,7 +6,7 @@ from masque.file import gdsii from masque import Arc, Pattern -def main() -> None: +def main(): pat = Pattern() layer = (0, 0) pat.shapes[layer].extend([ diff --git a/examples/nested_poly_test.py b/examples/nested_poly_test.py index 60e0a3e..de51d6a 100644 --- a/examples/nested_poly_test.py +++ b/examples/nested_poly_test.py @@ -1,5 +1,7 @@ +import numpy from pyclipper import ( - Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO, + Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO, + scale_to_clipper, scale_from_clipper, ) p = Pyclipper() p.AddPaths([ @@ -10,8 +12,8 @@ p.AddPaths([ ], PT_SUBJECT, closed=True) #p.Execute2? #p.Execute? -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) +p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO) +p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO) p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) p = Pyclipper() diff --git a/examples/test_rep.py b/examples/test_rep.py index d25fb55..f82575d 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis -def main() -> None: +def main(): lib = Library() cell_name = 'ellip_grating' diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 6e5730b..7210a93 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -18,14 +18,11 @@ Contents * Design a pattern which is meant to plug into an existing pattern (via `.interface()`) - [pather](pather.py) * Use `Pather` to route individual wires and wire bundles - * Use `AutoTool` to generate paths - * Use `AutoTool` to automatically transition between path types -- [renderpather](renderpather.py) + * Use `BasicTool` to generate paths + * Use `BasicTool` to automatically transition between path types +- [renderpather](rendpather.py) * Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py), but using `Path` shapes instead of `Polygon`s. -- [port_pather](port_pather.py) - * Use `PortPather` and the `.at()` syntax for more concise routing - * Advanced port manipulation and connections Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices. diff --git a/examples/tutorial/basic_shapes.py b/examples/tutorial/basic_shapes.py index d8f7e1e..87baaf0 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -1,9 +1,12 @@ +from collections.abc import Sequence import numpy from numpy import pi -from masque import layer_t, Pattern, Circle, Arc, Ref -from masque.repetition import Grid +from masque import ( + layer_t, Pattern, Label, Port, + Circle, Arc, Polygon, + ) import masque.file.gdsii @@ -36,45 +39,6 @@ def hole( return pat -def hole_array( - radius: float, - num_x: int = 5, - num_y: int = 3, - pitch: float = 2000, - layer: layer_t = (1, 0), - ) -> Pattern: - """ - Generate an array of circular holes using `Repetition`. - - Args: - radius: Circle radius. - num_x, num_y: Number of holes in x and y. - pitch: Center-to-center spacing. - layer: Layer to draw the holes on. - - Returns: - Pattern containing a grid of holes. - """ - # First, make a pattern for a single hole - hpat = hole(radius, layer) - - # Now, create a pattern that references it multiple times using a Grid - pat = Pattern() - pat.refs['hole'] = [ - Ref( - offset=(0, 0), - repetition=Grid(a_vector=(pitch, 0), a_count=num_x, - b_vector=(0, pitch), b_count=num_y) - )] - - # We can also add transformed references (rotation, mirroring, etc.) - pat.refs['hole'].append( - Ref(offset=(0, -pitch), rotation=pi / 4, mirrored=True) - ) - - return pat, hpat - - def triangle( radius: float, layer: layer_t = (1, 0), @@ -96,7 +60,9 @@ def triangle( ]) * radius pat = Pattern() - pat.polygon(layer, vertices=vertices) + pat.shapes[layer].extend([ + Polygon(offset=(0, 0), vertices=vertices), + ]) return pat @@ -145,13 +111,9 @@ def main() -> None: lib['smile'] = smile(1000) lib['triangle'] = triangle(1000) - # Use a Grid to make many holes efficiently - lib['grid'], lib['hole'] = hole_array(1000) - masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS) lib['triangle'].visualize() - lib['grid'].visualize(lib) if __name__ == '__main__': diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index 79d318a..6b9cfa2 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -4,8 +4,8 @@ import numpy from numpy import pi from masque import ( - layer_t, Pattern, Ref, Builder, Port, Polygon, - Library, + layer_t, Pattern, Ref, Label, Builder, Port, Polygon, + Library, ILibraryView, ) from masque.utils import ports2data from masque.file.gdsii import writefile, check_valid_names diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index abfbbf1..eab8a12 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,13 +1,17 @@ from typing import Any +from collections.abc import Sequence, Callable from pprint import pformat +import numpy +from numpy import pi -from masque import Builder, LazyLibrary +from masque import Pattern, Builder, LazyLibrary from masque.file.gdsii import writefile, load_libraryfile +import pcgen import basic_shapes import devices -from devices import data_to_ports +from devices import ports_to_data, data_to_ports from basic_shapes import GDS_OPTS diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index f7bbdb2..101fbb5 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -1,9 +1,10 @@ """ -Manual wire routing tutorial: Pather and AutoTool +Manual wire routing tutorial: Pather and BasicTool """ +from collections.abc import Callable from numpy import pi -from masque import Pather, Library, Pattern, Port, layer_t -from masque.builder.tools import AutoTool, Tool +from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers +from masque.builder.tools import BasicTool, PathTool from masque.file.gdsii import writefile from basic_shapes import GDS_OPTS @@ -106,99 +107,7 @@ def map_layer(layer: layer_t) -> layer_t: 'M2': (20, 0), 'V1': (30, 0), } - if isinstance(layer, str): - return layer_mapping.get(layer, layer) - return layer - - -def prepare_tools() -> tuple[Library, Tool, Tool]: - """ - Create some basic library elements and tools for drawing M1 and M2 - """ - # Build some patterns (static cells) using the above functions and store them in a library - library = Library() - library['pad'] = make_pad() - library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH) - library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH) - library['v1_via'] = make_via( - layer_top = 'M2', - layer_via = 'V1', - layer_bot = 'M1', - width_top = M2_WIDTH, - width_via = V1_WIDTH, - width_bot = M1_WIDTH, - ptype_bot = 'm1wire', - ptype_top = 'm2wire', - ) - - # - # Now, define two tools. - # M1_tool will route on M1, using wires with M1_WIDTH - # M2_tool will route on M2, using wires with M2_WIDTH - # Both tools are able to automatically transition from the other wire type (with a via) - # - # Note that while we use AutoTool for this tutorial, you can define your own `Tool` - # with arbitrary logic inside -- e.g. with single-use bends, complex transition rules, - # transmission line geometry, or other features. - # - M1_tool = AutoTool( - # First, we need a function which takes in a length and spits out an M1 wire - straights = [ - AutoTool.Straight( - ptype = 'm1wire', - fn = lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length), - in_port_name = 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input - out_port_name = 'output', # and use the port named 'output' as the output - ), - ], - bends = [ - AutoTool.Bend( - abstract = library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier - in_port_name = 'input', - out_port_name = 'output', - clockwise = True, - ), - ], - transitions = { # We can automate transitions for different (normally incompatible) port types - ('m2wire', 'm1wire'): AutoTool.Transition( # For example, when we're attaching to a port with type 'm2wire' - library.abstract('v1_via'), # we can place a V1 via - 'top', # using the port named 'top' as the input (i.e. the M2 side of the via) - 'bottom', # and using the port named 'bottom' as the output - ), - }, - sbends = [], - default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1 - ) - - M2_tool = AutoTool( - straights = [ - # Again, we use make_straight_wire, but this time we set parameters for M2 - AutoTool.Straight( - ptype = 'm2wire', - fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length), - in_port_name = 'input', - out_port_name = 'output', - ), - ], - bends = [ - # and we use an M2 bend - AutoTool.Bend( - abstract = library.abstract('m2_bend'), - in_port_name = 'input', - out_port_name = 'output', - ), - ], - transitions = { - ('m1wire', 'm2wire'): AutoTool.Transition( - library.abstract('v1_via'), # We still use the same via, - 'bottom', # but the input port is now 'bottom' - 'top', # and the output port is now 'top' - ), - }, - sbends = [], - default_out_ptype = 'm2wire', # We default to trying to stay on M2 - ) - return library, M1_tool, M2_tool + return layer_mapping.get(layer, layer) # @@ -209,7 +118,75 @@ def prepare_tools() -> tuple[Library, Tool, Tool]: # (e.g. geometry definition). # def main() -> None: - library, M1_tool, M2_tool = prepare_tools() + # Build some patterns (static cells) using the above functions and store them in a library + library = Library() + library['pad'] = make_pad() + library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH) + library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH) + library['v1_via'] = make_via( + layer_top='M2', + layer_via='V1', + layer_bot='M1', + width_top=M2_WIDTH, + width_via=V1_WIDTH, + width_bot=M1_WIDTH, + ptype_bot='m1wire', + ptype_top='m2wire', + ) + + # + # Now, define two tools. + # M1_tool will route on M1, using wires with M1_WIDTH + # M2_tool will route on M2, using wires with M2_WIDTH + # Both tools are able to automatically transition from the other wire type (with a via) + # + # Note that while we use BasicTool for this tutorial, you can define your own `Tool` + # with arbitrary logic inside -- e.g. with single-use bends, complex transition rules, + # transmission line geometry, or other features. + # + M1_tool = BasicTool( + straight = ( + # First, we need a function which takes in a length and spits out an M1 wire + lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length), + 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input + 'output', # and use the port named 'output' as the output + ), + bend = ( + library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier + 'input', # To orient it clockwise, use the port named 'input' as the input + 'output', # and 'output' as the output + ), + transitions = { # We can automate transitions for different (normally incompatible) port types + 'm2wire': ( # For example, when we're attaching to a port with type 'm2wire' + library.abstract('v1_via'), # we can place a V1 via + 'top', # using the port named 'top' as the input (i.e. the M2 side of the via) + 'bottom', # and using the port named 'bottom' as the output + ), + }, + default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1 + ) + + M2_tool = BasicTool( + straight = ( + # Again, we use make_straight_wire, but this time we set parameters for M2 + lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length), + 'input', + 'output', + ), + bend = ( + library.abstract('m2_bend'), # and we use an M2 bend + 'input', + 'output', + ), + transitions = { + 'm1wire': ( + library.abstract('v1_via'), # We still use the same via, + 'bottom', # but the input port is now 'bottom' + 'top', # and the output port is now 'top' + ), + }, + default_out_ptype = 'm2wire', # We default to trying to stay on M2 + ) # # Create a new pather which writes to `library` and uses `M2_tool` as its default tool. @@ -226,25 +203,27 @@ def main() -> None: # Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False) # The total distance forward (including the bend's forward component) must be 6um - pather.cw('VCC', 6_000) + pather.path('VCC', ccw=False, length=6_000) - # Now path VCC to x=0. This time, don't include any bend. + # Now path VCC to x=0. This time, don't include any bend (ccw=None). # Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction. - pather.straight('VCC', x=0) + pather.path_to('VCC', ccw=None, x=0) # Path GND forward by 5um, turning clockwise 90 degrees. - pather.cw('GND', 5_000) + # This time we use shorthand (bool(0) == False) and omit the parameter labels + # Note that although ccw=0 is equivalent to ccw=False, ccw=None is not! + pather.path('GND', 0, 5_000) # This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend. - pather.straight('GND', x=pather['VCC'].offset[0]) + pather.path_to('GND', None, x=pather['VCC'].offset[0]) # Now, start using M1_tool for GND. - # Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves. + # Since we have defined an M2-to-M1 transition for BasicPather, we don't need to place one ourselves. # If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here # and achieve the same result without having to define any transitions in M1_tool. # Note that even though we have changed the tool used for GND, the via doesn't get placed until # the next time we draw a path on GND (the pather.mpath() statement below). - pather.retool(M1_tool, keys='GND') + pather.retool(M1_tool, keys=['GND']) # Bundle together GND and VCC, and path the bundle forward and counterclockwise. # Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000. @@ -252,7 +231,7 @@ def main() -> None: # # Since we recently retooled GND, its path starts with a via down to M1 (included in the distance # calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2. - pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) + pather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000) # Now use M1_tool as the default tool for all ports/signals. # Since VCC does not have an explicitly assigned tool, it will now transition down to M1. @@ -262,37 +241,38 @@ def main() -> None: # The total extension (travel distance along the forward direction) for the longest segment (in # this case the segment being added to GND) should be exactly 50um. # After turning, the wire pitch should be reduced only 1.2um. - pather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200) + pather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200) # Make a U-turn with the bundle and expand back out to 4.5um wire pitch. - # Here, emin specifies the travel distance for the shortest segment. For the first call - # that applies to VCC, and for the second call, that applies to GND; the relative lengths of the + # Here, emin specifies the travel distance for the shortest segment. For the first mpath() call + # that applies to VCC, and for teh second call, that applies to GND; the relative lengths of the # segments depend on their starting positions and their ordering within the bundle. - pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200) - pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500) + pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) + pather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500) # Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been - # explicitly assigned a tool. + # explicitly assigned a tool. We could `del pather.tools['GND']` to force it to use the default. pather.retool(M2_tool) # Now path both ports to x=-28_000. - # With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is + # When ccw is not None, xmin constrains the trailing/innermost port to stop at the target x coordinate, + # However, with ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is # equivalent. - pather.straight(['GND', 'VCC'], xmin=-28_000) + pather.mpath(['GND', 'VCC'], None, xmin=-28_000) # Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1. # This results in a via at the end of the wire (instead of having one at the start like we got # when using pather.retool(). - pather.straight('VCC', x=-50_000, out_ptype='m1wire') + pather.path_to('VCC', None, -50_000, out_ptype='m1wire') # Now extend GND out to x=-50_000, using M2 for a portion of the path. # We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice. - with pather.toolctx(M2_tool, keys='GND'): - pather.straight('GND', x=-40_000) - pather.straight('GND', x=-50_000) + with pather.toolctx(M2_tool, keys=['GND']): + pather.path_to('GND', None, -40_000) + pather.path_to('GND', None, -50_000) # Save the pather's pattern into our library - library['Pather_and_AutoTool'] = pather.pattern + library['Pather_and_BasicTool'] = pather.pattern # Convert from text-based layers to numeric layers for GDS, and output the file library.map_layers(map_layer) diff --git a/examples/tutorial/pcgen.py b/examples/tutorial/pcgen.py index 5c5c31b..023079c 100644 --- a/examples/tutorial/pcgen.py +++ b/examples/tutorial/pcgen.py @@ -2,7 +2,7 @@ Routines for creating normalized 2D lattices and common photonic crystal cavity designs. """ -from collections.abc import Sequence +from collection.abc import Sequence import numpy from numpy.typing import ArrayLike, NDArray @@ -50,7 +50,7 @@ def triangular_lattice( elif origin == 'corner': pass else: - raise ValueError(f'Invalid value for `origin`: {origin}') + raise Exception(f'Invalid value for `origin`: {origin}') return xy[xy[:, 0].argsort(), :] @@ -197,12 +197,12 @@ def ln_defect( `[[x0, y0], [x1, y1], ...]` for all the holes """ if defect_length % 2 != 1: - raise ValueError('defect_length must be odd!') - pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims]) + raise Exception('defect_length must be odd!') + p = triangular_lattice([2 * d + 1 for d in mirror_dims]) half_length = numpy.floor(defect_length / 2) hole_nums = numpy.arange(-half_length, half_length + 1) - holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True) - return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :] + holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True) + return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ] def ln_shift_defect( @@ -248,7 +248,7 @@ def ln_shift_defect( for sign in (-1, 1): x_val = sign * (x_removed + ind + 1) which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0) - xyr[which, :] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) + xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) return xyr @@ -309,7 +309,7 @@ def l3_shift_perturbed_defect( # which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2]) perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2))) - for xy in perturbed_holes: - which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1) - xyr[which, 2] = perturbed_radius + for row in xyr: + if numpy.fabs(row) in perturbed_holes: + row[2] = perturbed_radius return xyr diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py deleted file mode 100644 index 3fad6e7..0000000 --- a/examples/tutorial/port_pather.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -PortPather tutorial: Using .at() syntax -""" -from masque import RenderPather, Pattern, Port, R90 -from masque.file.gdsii import writefile - -from basic_shapes import GDS_OPTS -from pather import map_layer, prepare_tools - - -def main() -> None: - # Reuse the same patterns (pads, bends, vias) and tools as in pather.py - library, M1_tool, M2_tool = prepare_tools() - - # Create a RenderPather and place some initial pads (same as Pather tutorial) - rpather = RenderPather(library, tools=M2_tool) - - rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) - rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) - rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) - rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) - - # - # Routing with .at() chaining - # - # The .at(port_name) method returns a PortPather object which wraps the Pather - # and remembers the selected port(s). This allows method chaining. - - # Route VCC: 6um South, then West to x=0. - # (Note: since the port points North into the pad, path() moves South by default) - (rpather.at('VCC') - .path(ccw=False, length=6_000) # Move South, turn West (Clockwise) - .path_to(ccw=None, x=0) # Continue West to x=0 - ) - - # Route GND: 5um South, then West to match VCC's x-coordinate. - rpather.at('GND').path(ccw=False, length=5_000).path_to(ccw=None, x=rpather['VCC'].x) - - - # - # Tool management and manual plugging - # - # We can use .retool() to change the tool for specific ports. - # We can also use .plug() directly on a PortPather. - - # Manually add a via to GND and switch to M1_tool for subsequent segments - (rpather.at('GND') - .plug('v1_via', 'top') - .retool(M1_tool) # this only retools the 'GND' port - ) - - # We can also pass multiple ports to .at(), and then use .mpath() on them. - # Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via). - (rpather.at(['GND', 'VCC']) - .mpath(ccw=True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South - .retool(M1_tool) # Retools both GND and VCC - .mpath(ccw=True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension - .mpath(ccw=False, emin=1_000, spacing=1_200) # U-turn back South - .mpath(ccw=False, emin=2_000, spacing=4_500) # U-turn back West - ) - - # Retool VCC back to M2 and move both to x=-28k - rpather.at('VCC').retool(M2_tool) - rpather.at(['GND', 'VCC']).mpath(ccw=None, xmin=-28_000) - - # Final segments to -50k - rpather.at('VCC').path_to(ccw=None, x=-50_000, out_ptype='m1wire') - with rpather.at('GND').toolctx(M2_tool): - rpather.at('GND').path_to(ccw=None, x=-40_000) - rpather.at('GND').path_to(ccw=None, x=-50_000) - - - # - # Branching with save_copy and into_copy - # - # .save_copy(new_name) creates a port copy and keeps the original selected. - # .into_copy(new_name) creates a port copy and selects the new one. - - # Create a tap on GND - (rpather.at('GND') - .path(ccw=None, length=5_000) # Move GND further West - .save_copy('GND_TAP') # Mark this location for a later branch - .pathS(length=10_000, jog=-10_000) # Continue GND with an S-bend - ) - - # Branch VCC and follow the new branch - (rpather.at('VCC') - .path(ccw=None, length=5_000) - .into_copy('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH' - .path(ccw=True, length=5_000) # VCC_BRANCH turns South - ) - # The original 'VCC' port remains at x=-55k, y=VCC.y - - - # - # Port set management: add, drop, rename, delete - # - - # Route the GND_TAP we saved earlier. - (rpather.at('GND_TAP') - .retool(M1_tool) - .path(ccw=True, length=10_000) # Turn South - .rename_to('GND_FEED') # Give it a more descriptive name - .retool(M1_tool) # Re-apply tool to the new name - ) - - # We can manage the active set of ports in a PortPather - pp = rpather.at(['VCC_BRANCH', 'GND_FEED']) - pp.add_port('GND') # Now tracking 3 ports - pp.drop_port('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND - pp.path_each(ccw=None, length=5_000) # Move both 5um forward (length > transition size) - - # We can also delete ports from the pather entirely - rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead) - - - # - # Advanced Connections: path_into and path_from - # - - # path_into routes FROM the selected port TO a target port. - # path_from routes TO the selected port FROM a source port. - - # Create a destination component - dest_ports = { - 'in_A': Port((0, 0), rotation=R90, ptype='m2wire'), - 'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire') - } - library['dest'] = Pattern(ports=dest_ports) - # Place dest so that its ports are to the West and South of our current wires. - # Rotating by pi/2 makes the ports face West (pointing East). - rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'}) - - # Connect GND_FEED to DEST_A - # Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice. - rpather.at('GND_FEED').path_into('DEST_A') - - # Connect VCC_BRANCH to DEST_B using path_from - rpather.at('DEST_B').path_from('VCC_BRANCH') - - - # - # Direct Port Transformations and Metadata - # - (rpather.at('GND') - .set_ptype('m1wire') # Change metadata - .translate((1000, 0)) # Shift the port 1um East - .rotate(R90 / 2) # Rotate it 45 degrees - .set_rotation(R90) # Force it to face West - ) - - # Demonstrate .plugged() to acknowledge a manual connection - # (Normally used when you place components so their ports perfectly overlap) - rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2')) - rpather.at('TMP1').plugged('TMP2') # Removes both ports - - - # - # Rendering and Saving - # - # Since we used RenderPather, we must call .render() to generate the geometry. - rpather.render() - - library['PortPather_Tutorial'] = rpather.pattern - library.map_layers(map_layer) - writefile(library, 'port_pather.gds', **GDS_OPTS) - print("Tutorial complete. Output written to port_pather.gds") - - -if __name__ == '__main__': - main() diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py index 7b75f5d..cb002f3 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -1,7 +1,8 @@ """ Manual wire routing tutorial: RenderPather an PathTool """ -from masque import RenderPather, Library +from collections.abc import Callable +from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers from masque.builder.tools import PathTool from masque.file.gdsii import writefile @@ -12,7 +13,7 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via def main() -> None: # # To illustrate the advantages of using `RenderPather`, we use `PathTool` instead - # of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) + # of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) # but when used with `RenderPather`, it can consolidate multiple routing steps into # a single `Path` shape. # @@ -24,66 +25,64 @@ def main() -> None: library = Library() library['pad'] = make_pad() library['v1_via'] = make_via( - layer_top = 'M2', - layer_via = 'V1', - layer_bot = 'M1', - width_top = M2_WIDTH, - width_via = V1_WIDTH, - width_bot = M1_WIDTH, - ptype_bot = 'm1wire', - ptype_top = 'm2wire', + layer_top='M2', + layer_via='V1', + layer_bot='M1', + width_top=M2_WIDTH, + width_via=V1_WIDTH, + width_bot=M1_WIDTH, + ptype_bot='m1wire', + ptype_top='m2wire', ) - # `PathTool` is more limited than `AutoTool`. It only generates one type of shape + # `PathTool` is more limited than `BasicTool`. It only generates one type of shape # (`Path`), so it only needs to know what layer to draw on, what width to draw with, # and what port type to present. M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire') M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire') rpather = RenderPather(tools=M2_ptool, library=library) - # As in the pather tutorial, we make some pads and labels... + # As in the pather tutorial, we make soem pads and labels... rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) # ...and start routing the signals. - rpather.cw('VCC', 6_000) - rpather.straight('VCC', x=0) - rpather.cw('GND', 5_000) - rpather.straight('GND', x=rpather.pattern['VCC'].x) + rpather.path('VCC', ccw=False, length=6_000) + rpather.path_to('VCC', ccw=None, x=0) + rpather.path('GND', 0, 5_000) + rpather.path_to('GND', None, x=rpather['VCC'].offset[0]) # `PathTool` doesn't know how to transition betwen metal layers, so we have to # `plug` the via into the GND wire ourselves. rpather.plug('v1_via', {'GND': 'top'}) - rpather.retool(M1_ptool, keys='GND') - rpather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) + rpather.retool(M1_ptool, keys=['GND']) + rpather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000) # Same thing on the VCC wire when it goes down to M1. rpather.plug('v1_via', {'VCC': 'top'}) rpather.retool(M1_ptool) - rpather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200) - rpather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200) - rpather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500) + rpather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200) + rpather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) + rpather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500) # And again when VCC goes back up to M2. rpather.plug('v1_via', {'VCC': 'bottom'}) rpather.retool(M2_ptool) - rpather.straight(['GND', 'VCC'], xmin=-28_000) + rpather.mpath(['GND', 'VCC'], None, xmin=-28_000) # Finally, since PathTool has no conception of transitions, we can't # just ask it to transition to an 'm1wire' port at the end of the final VCC segment. # Instead, we have to calculate the via size ourselves, and adjust the final position # to account for it. - v1pat = library['v1_via'] - via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x) - - # alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0] - # would take into account the port orientations if we didn't already know they're along x - rpather.straight('VCC', x=-50_000 + via_size) + via_size = abs( + library['v1_via'].ports['top'].offset[0] + - library['v1_via'].ports['bottom'].offset[0] + ) + rpather.path_to('VCC', None, -50_000 + via_size) rpather.plug('v1_via', {'VCC': 'top'}) - # Render the path we defined rpather.render() library['RenderPather_and_PathTool'] = rpather.pattern diff --git a/masque/__init__.py b/masque/__init__.py index e435fac..4ad7e69 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -55,7 +55,6 @@ from .pattern import ( map_targets as map_targets, chain_elements as chain_elements, ) -from .utils.boolean import boolean as boolean from .library import ( ILibraryView as ILibraryView, diff --git a/masque/abstract.py b/masque/abstract.py index d23d7c7..7135eba 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -8,13 +8,16 @@ from numpy.typing import ArrayLike from .ref import Ref from .ports import PortList, Port from .utils import rotation_matrix_2d -from .traits import Mirrorable + +#if TYPE_CHECKING: +# from .builder import Builder, Tool +# from .library import ILibrary logger = logging.getLogger(__name__) -class Abstract(PortList, Mirrorable): +class Abstract(PortList): """ An `Abstract` is a container for a name and associated ports. @@ -128,18 +131,50 @@ class Abstract(PortList, Mirrorable): port.rotate(rotation) return self - def mirror(self, axis: int = 0) -> Self: + def mirror_port_offsets(self, across_axis: int = 0) -> Self: """ - Mirror the Abstract across an axis through its origin. + Mirror the offsets of all shapes, labels, and refs across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ for port in self.ports.values(): - port.flip_across(axis=axis) + port.offset[across_axis - 1] *= -1 + return self + + def mirror_ports(self, across_axis: int = 0) -> Self: + """ + Mirror each port's rotation across an axis, relative to its + offset + + Args: + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) + + Returns: + self + """ + for port in self.ports.values(): + port.mirror(across_axis) + return self + + def mirror(self, across_axis: int = 0) -> Self: + """ + Mirror the Pattern across an axis + + Args: + axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) + + Returns: + self + """ + self.mirror_ports(across_axis) + self.mirror_port_offsets(across_axis) return self def apply_ref_transform(self, ref: Ref) -> Self: @@ -157,8 +192,6 @@ class Abstract(PortList, Mirrorable): self.mirror() self.rotate_ports(ref.rotation) self.rotate_port_offsets(ref.rotation) - if ref.scale != 1: - self.scale_by(ref.scale) self.translate_ports(ref.offset) return self @@ -176,8 +209,6 @@ class Abstract(PortList, Mirrorable): # TODO test undo_ref_transform """ self.translate_ports(-ref.offset) - if ref.scale != 1: - self.scale_by(1 / ref.scale) self.rotate_port_offsets(-ref.rotation) self.rotate_ports(-ref.rotation) if ref.mirrored: diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 65958c1..2fd00a4 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,9 +1,7 @@ -from .pather import ( - Pather as Pather, - PortPather as PortPather, - Builder as Builder, - RenderPather as RenderPather, -) +from .builder import Builder as Builder +from .pather import Pather as Pather +from .renderpather import RenderPather as RenderPather +from .pather_mixin import PortPather as PortPather from .utils import ell as ell from .tools import ( Tool as Tool, @@ -11,5 +9,4 @@ from .tools import ( SimpleTool as SimpleTool, AutoTool as AutoTool, PathTool as PathTool, -) -from .logging import logged_op as logged_op + ) diff --git a/masque/builder/builder.py b/masque/builder/builder.py new file mode 100644 index 0000000..1b534b5 --- /dev/null +++ b/masque/builder/builder.py @@ -0,0 +1,448 @@ +""" +Simplified Pattern assembly (`Builder`) +""" +from typing import Self +from collections.abc import Iterable, Sequence, Mapping +import copy +import logging +from functools import wraps + +from numpy.typing import ArrayLike + +from ..pattern import Pattern +from ..library import ILibrary, TreeView +from ..error import BuildError +from ..ports import PortList, Port +from ..abstract import Abstract + + +logger = logging.getLogger(__name__) + + +class Builder(PortList): + """ + A `Builder` is a helper object used for snapping together multiple + lower-level patterns at their `Port`s. + + The `Builder` mostly just holds context, in the form of a `Library`, + in addition to its underlying pattern. This simplifies some calls + to `plug` and `place`, by making the library implicit. + + `Builder` can also be `set_dead()`, at which point further calls to `plug()` + and `place()` are ignored (intended for debugging). + + + Examples: Creating a Builder + =========================== + - `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes + an empty pattern, adds the given ports, and places it into `library` + under the name `'mypat'`. + + - `Builder(library)` makes an empty pattern with no ports. The pattern + is not added into `library` and must later be added with e.g. + `library['mypat'] = builder.pattern` + + - `Builder(library, pattern=pattern, name='mypat')` uses an existing + pattern (including its ports) and sets `library['mypat'] = pattern`. + + - `Builder.interface(other_pat, port_map=['A', 'B'], library=library)` + makes a new (empty) pattern, copies over ports 'A' and 'B' from + `other_pat`, and creates additional ports 'in_A' and 'in_B' facing + in the opposite directions. This can be used to build a device which + can plug into `other_pat` (using the 'in_*' ports) but which does not + itself include `other_pat` as a subcomponent. + + - `Builder.interface(other_builder, ...)` does the same thing as + `Builder.interface(other_builder.pattern, ...)` but also uses + `other_builder.library` as its library by default. + + + Examples: Adding to a pattern + ============================= + - `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` + instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B' + of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports + are removed and any unconnected ports from `subdevice` are added to + `my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'. + + - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' + of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, + argument is provided, and the `thru` argument is not explicitly + set to `False`, the unconnected port of `wire` is automatically renamed to + 'myport'. This allows easy extension of existing ports without changing + their names or having to provide `map_out` each time `plug` is called. + + - `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` + instantiates `pad` at the specified (x, y) offset and with the specified + rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is + renamed to 'gnd' so that further routing can use this signal or net name + rather than the port name on the original `pad` device. + """ + __slots__ = ('pattern', 'library', '_dead') + + pattern: Pattern + """ Layout of this device """ + + library: ILibrary + """ + Library from which patterns should be referenced + """ + + _dead: bool + """ If True, plug()/place() are skipped (for debugging)""" + + @property + def ports(self) -> dict[str, Port]: + return self.pattern.ports + + @ports.setter + def ports(self, value: dict[str, Port]) -> None: + self.pattern.ports = value + + def __init__( + self, + library: ILibrary, + *, + pattern: Pattern | None = None, + ports: str | Mapping[str, Port] | None = None, + name: str | None = None, + ) -> None: + """ + Args: + library: The library from which referenced patterns will be taken + pattern: The pattern which will be modified by subsequent operations. + If `None` (default), a new pattern is created. + ports: Allows specifying the initial set of ports, if `pattern` does + not already have any ports (or is not provided). May be a string, + in which case it is interpreted as a name in `library`. + Default `None` (no ports). + name: If specified, `library[name]` is set to `self.pattern`. + """ + self._dead = False + self.library = library + if pattern is not None: + self.pattern = pattern + else: + self.pattern = Pattern() + + if ports is not None: + if self.pattern.ports: + raise BuildError('Ports supplied for pattern with pre-existing ports!') + if isinstance(ports, str): + ports = library.abstract(ports).ports + + self.pattern.ports.update(copy.deepcopy(dict(ports))) + + if name is not None: + library[name] = self.pattern + + @classmethod + def interface( + cls: type['Builder'], + source: PortList | Mapping[str, Port] | str, + *, + library: ILibrary | None = None, + in_prefix: str = 'in_', + out_prefix: str = '', + port_map: dict[str, str] | Sequence[str] | None = None, + name: str | None = None, + ) -> 'Builder': + """ + Wrapper for `Pattern.interface()`, which returns a Builder instead. + + Args: + source: A collection of ports (e.g. Pattern, Builder, or dict) + from which to create the interface. May be a pattern name if + `library` is provided. + library: Library from which existing patterns should be referenced, + and to which the new one should be added (if named). If not provided, + `source.library` must exist and will be used. + in_prefix: Prepended to port names for newly-created ports with + reversed directions compared to the current device. + out_prefix: Prepended to port names for ports which are directly + copied from the current device. + port_map: Specification for ports to copy into the new device: + - If `None`, all ports are copied. + - If a sequence, only the listed ports are copied + - If a mapping, the listed ports (keys) are copied and + renamed (to the values). + + Returns: + The new builder, with an empty pattern and 2x as many ports as + listed in port_map. + + Raises: + `PortError` if `port_map` contains port names not present in the + current device. + `PortError` if applying the prefixes results in duplicate port + names. + """ + if library is None: + if hasattr(source, 'library') and isinstance(source.library, ILibrary): + library = source.library + else: + raise BuildError('No library was given, and `source.library` does not have one either.') + + if isinstance(source, str): + source = library.abstract(source).ports + + pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) + new = Builder(library=library, pattern=pat, name=name) + return new + + @wraps(Pattern.label) + def label(self, *args, **kwargs) -> Self: + self.pattern.label(*args, **kwargs) + return self + + @wraps(Pattern.ref) + def ref(self, *args, **kwargs) -> Self: + self.pattern.ref(*args, **kwargs) + return self + + @wraps(Pattern.polygon) + def polygon(self, *args, **kwargs) -> Self: + self.pattern.polygon(*args, **kwargs) + return self + + @wraps(Pattern.rect) + def rect(self, *args, **kwargs) -> Self: + self.pattern.rect(*args, **kwargs) + return self + + # Note: We're a superclass of `Pather`, where path() means something different, + # so we shouldn't wrap Pattern.path() + #@wraps(Pattern.path) + #def path(self, *args, **kwargs) -> Self: + # self.pattern.path(*args, **kwargs) + # return self + + def plug( + self, + other: Abstract | str | Pattern | TreeView, + map_in: dict[str, str], + map_out: dict[str, str | None] | None = None, + *, + mirrored: bool = False, + thru: bool | str = True, + set_rotation: bool | None = None, + append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), + ) -> Self: + """ + Wrapper around `Pattern.plug` which allows a string for `other`. + + The `Builder`'s library is used to dereference the string (or `Abstract`, if + one is passed with `append=True`). If a `TreeView` is passed, it is first + added into `self.library`. + + Args: + other: An `Abstract`, string, `Pattern`, or `TreeView` describing the + device to be instatiated. If it is a `TreeView`, it is first + added into `self.library`, after which the topcell is plugged; + an equivalent statement is `self.plug(self.library << other, ...)`. + map_in: dict of `{'self_port': 'other_port'}` mappings, specifying + port connections between the two devices. + map_out: dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in `other`. + mirrored: Enables mirroring `other` across the x axis prior to + connecting any ports. + thru: If map_in specifies only a single port, `thru` provides a mechainsm + to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, + - If True (default), and `other` has only two ports total, and map_out + doesn't specify a name for the other port, its name is set to the key + in `map_in`, i.e. 'myport'. + - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). + An error is raised if that entry already exists. + + This makes it easy to extend a pattern with simple 2-port devices + (e.g. wires) without providing `map_out` each time `plug` is + called. See "Examples" above for more info. Default `True`. + set_rotation: If the necessary rotation cannot be determined from + the ports being connected (i.e. all pairs have at least one + port with `rotation=None`), `set_rotation` must be provided + to indicate how much `other` should be rotated. Otherwise, + `set_rotation` must remain `None`. + append: If `True`, `other` is appended instead of being referenced. + Note that this does not flatten `other`, so its refs will still + be refs (now inside `self`). + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. + + Returns: + self + + Raises: + `PortError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `PortError` if there are any duplicate names after `map_in` and `map_out` + are applied. + `PortError` if the specified port mapping is not achieveable (the ports + do not line up) + """ + if self._dead: + logger.error('Skipping plug() since device is dead') + return self + + if not isinstance(other, str | Abstract | Pattern): + # We got a Tree; add it into self.library and grab an Abstract for it + other = self.library << other + + if isinstance(other, str): + other = self.library.abstract(other) + if append and isinstance(other, Abstract): + other = self.library[other.name] + + self.pattern.plug( + other = other, + map_in = map_in, + map_out = map_out, + mirrored = mirrored, + thru = thru, + set_rotation = set_rotation, + append = append, + ok_connections = ok_connections, + ) + return self + + def place( + self, + other: Abstract | str | Pattern | TreeView, + *, + offset: ArrayLike = (0, 0), + rotation: float = 0, + pivot: ArrayLike = (0, 0), + mirrored: bool = False, + port_map: dict[str, str | None] | None = None, + skip_port_check: bool = False, + append: bool = False, + ) -> Self: + """ + Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`. + + The `Builder`'s library is used to dereference the string (or `Abstract`, if + one is passed with `append=True`). If a `TreeView` is passed, it is first + added into `self.library`. + + Args: + other: An `Abstract`, string, `Pattern`, or `TreeView` describing the + device to be instatiated. If it is a `TreeView`, it is first + added into `self.library`, after which the topcell is plugged; + an equivalent statement is `self.plug(self.library << other, ...)`. + offset: Offset at which to place the instance. Default (0, 0). + rotation: Rotation applied to the instance before placement. Default 0. + pivot: Rotation is applied around this pivot point (default (0, 0)). + Rotation is applied prior to translation (`offset`). + mirrored: Whether theinstance should be mirrored across the x axis. + Mirroring is applied before translation and rotation. + port_map: dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in the instantiated device. New names can be + `None`, which will delete those ports. + skip_port_check: Can be used to skip the internal call to `check_ports`, + in case it has already been performed elsewhere. + append: If `True`, `other` is appended instead of being referenced. + Note that this does not flatten `other`, so its refs will still + be refs (now inside `self`). + + Returns: + self + + Raises: + `PortError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other.ports`. + `PortError` if there are any duplicate names after `map_in` and `map_out` + are applied. + """ + if self._dead: + logger.error('Skipping place() since device is dead') + return self + + if not isinstance(other, str | Abstract | Pattern): + # We got a Tree; add it into self.library and grab an Abstract for it + other = self.library << other + + if isinstance(other, str): + other = self.library.abstract(other) + if append and isinstance(other, Abstract): + other = self.library[other.name] + + self.pattern.place( + other = other, + offset = offset, + rotation = rotation, + pivot = pivot, + mirrored = mirrored, + port_map = port_map, + skip_port_check = skip_port_check, + append = append, + ) + return self + + def translate(self, offset: ArrayLike) -> Self: + """ + Translate the pattern and all ports. + + Args: + offset: (x, y) distance to translate by + + Returns: + self + """ + self.pattern.translate_elements(offset) + return self + + def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: + """ + Rotate the pattern and all ports. + + Args: + angle: angle (radians, counterclockwise) to rotate by + pivot: location to rotate around + + Returns: + self + """ + self.pattern.rotate_around(pivot, angle) + for port in self.ports.values(): + port.rotate_around(pivot, angle) + return self + + def mirror(self, axis: int = 0) -> Self: + """ + Mirror the pattern and all ports across the specified axis. + + Args: + axis: Axis to mirror across (x=0, y=1) + + Returns: + self + """ + self.pattern.mirror(axis) + return self + + def set_dead(self) -> Self: + """ + Disallows further changes through `plug()` or `place()`. + This is meant for debugging: + ``` + dev.plug(a, ...) + dev.set_dead() # added for debug purposes + dev.plug(b, ...) # usually raises an error, but now skipped + dev.plug(c, ...) # also skipped + dev.pattern.visualize() # shows the device as of the set_dead() call + ``` + + Returns: + self + """ + self._dead = True + return self + + def __repr__(self) -> str: + s = f'' + return s + + diff --git a/masque/builder/logging.py b/masque/builder/logging.py deleted file mode 100644 index 78a566e..0000000 --- a/masque/builder/logging.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Logging and operation decorators for Builder/Pather -""" -from typing import TYPE_CHECKING, Any -from collections.abc import Iterator, Sequence, Callable -import logging -from functools import wraps -import inspect -import numpy -from contextlib import contextmanager - -if TYPE_CHECKING: - from .pather import Pather - -logger = logging.getLogger(__name__) - - -def _format_log_args(**kwargs) -> str: - arg_strs = [] - for k, v in kwargs.items(): - if isinstance(v, str | int | float | bool | None): - arg_strs.append(f"{k}={v}") - elif isinstance(v, numpy.ndarray): - arg_strs.append(f"{k}={v.tolist()}") - elif isinstance(v, list | tuple) and len(v) <= 10: - arg_strs.append(f"{k}={v}") - else: - arg_strs.append(f"{k}=...") - return ", ".join(arg_strs) - - -class PatherLogger: - """ - Encapsulates state for Pather/Builder diagnostic logging. - """ - debug: bool - indent: int - depth: int - - def __init__(self, debug: bool = False) -> None: - self.debug = debug - self.indent = 0 - self.depth = 0 - - def _log(self, module_name: str, msg: str) -> None: - if self.debug and self.depth <= 1: - log_obj = logging.getLogger(module_name) - log_obj.info(' ' * self.indent + msg) - - @contextmanager - def log_operation( - self, - pather: 'Pather', - op: str, - portspec: str | Sequence[str] | None = None, - **kwargs: Any, - ) -> Iterator[None]: - if not self.debug or self.depth > 0: - self.depth += 1 - try: - yield - finally: - self.depth -= 1 - return - - target = f"({portspec})" if portspec else "" - module_name = pather.__class__.__module__ - self._log(module_name, f"Operation: {op}{target} {_format_log_args(**kwargs)}") - - before_ports = {name: port.copy() for name, port in pather.ports.items()} - self.depth += 1 - self.indent += 1 - - try: - yield - finally: - after_ports = pather.ports - for name in sorted(after_ports.keys()): - if name not in before_ports or after_ports[name] != before_ports[name]: - self._log(module_name, f"Port {name}: {pather.ports[name].describe()}") - for name in sorted(before_ports.keys()): - if name not in after_ports: - self._log(module_name, f"Port {name}: removed") - - self.indent -= 1 - self.depth -= 1 - - -def logged_op( - portspec_getter: Callable[[dict[str, Any]], str | Sequence[str] | None] | None = None, - ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """ - Decorator to wrap Builder methods with logging. - """ - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - sig = inspect.signature(func) - - @wraps(func) - def wrapper(self: 'Pather', *args: Any, **kwargs: Any) -> Any: - logger_obj = getattr(self, '_logger', None) - if logger_obj is None or not logger_obj.debug: - return func(self, *args, **kwargs) - - bound = sig.bind(self, *args, **kwargs) - bound.apply_defaults() - all_args = bound.arguments - # remove 'self' from logged args - logged_args = {k: v for k, v in all_args.items() if k != 'self'} - - ps = portspec_getter(all_args) if portspec_getter else None - - # Remove portspec from logged_args if it's there to avoid duplicate arg to log_operation - logged_args.pop('portspec', None) - - with logger_obj.log_operation(self, func.__name__, ps, **logged_args): - if getattr(self, '_dead', False) and func.__name__ in ('plug', 'place'): - logger.warning(f"Skipping geometry for {func.__name__}() since device is dead") - return func(self, *args, **kwargs) - return wrapper - return decorator diff --git a/masque/builder/pather.py b/masque/builder/pather.py index e8804d1..9af473d 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -1,106 +1,124 @@ """ -Unified Pattern assembly and routing (`Pather`) +Manual wire/waveguide routing (`Pather`) """ -from typing import Self, Literal, Any, overload -from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence +from typing import Self +from collections.abc import Sequence, Mapping, MutableMapping import copy import logging -from collections import defaultdict -from functools import wraps from pprint import pformat -from itertools import chain -from contextlib import contextmanager - -import numpy -from numpy import pi -from numpy.typing import ArrayLike from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import BuildError, PortError +from ..library import ILibrary +from ..error import BuildError from ..ports import PortList, Port -from ..abstract import Abstract from ..utils import SupportsBool -from .tools import Tool, RenderStep -from .utils import ell -from .logging import logged_op, PatherLogger +from .tools import Tool +from .pather_mixin import PatherMixin +from .builder import Builder logger = logging.getLogger(__name__) -class Pather(PortList): +class Pather(Builder, PatherMixin): """ - A `Pather` is a helper object used for snapping together multiple - lower-level patterns at their `Port`s, and for routing single-use - patterns (e.g. wires or waveguides) between them. + An extension of `Builder` which provides functionality for routing and attaching + single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns. - The `Pather` holds context in the form of a `Library`, its underlying - pattern, and a set of `Tool`s for generating routing segments. + `Pather` is mostly concerned with calculating how long each wire should be. It calls + out to `Tool.path` functions provided by subclasses of `Tool` to build the actual patterns. + `Tool`s are assigned on a per-port basis and stored in `.tools`; a key of `None` represents + a "default" `Tool` used for all ports which do not have a port-specific `Tool` assigned. - Routing operations (`trace`, `jog`, `uturn`, etc.) are by default - deferred: they record the intended path but do not immediately generate - geometry. `render()` must be called to generate the final layout. - Alternatively, setting `auto_render=True` in the constructor will - cause geometry to be generated incrementally after each routing step. Examples: Creating a Pather =========================== - - `Pather(library, tools=my_tool)` makes an empty pattern with no ports. - The default routing tool for all ports is set to `my_tool`. + - `Pather(library, tools=my_tool)` makes an empty pattern with no ports. The pattern + is not added into `library` and must later be added with e.g. + `library['mypat'] = pather.pattern`. + The default wire/waveguide generating tool for all ports is set to `my_tool`. + + - `Pather(library, ports={'in': Port(...), 'out': ...}, name='mypat', tools=my_tool)` + makes an empty pattern, adds the given ports, and places it into `library` + under the name `'mypat'`. The default wire/waveguide generating tool + for all ports is set to `my_tool` + + - `Pather(..., tools={'in': top_metal_40um, 'out': bottom_metal_1um, None: my_tool})` + assigns specific tools to individual ports, and `my_tool` as a default for ports + which are not specified. + + - `Pather.interface(other_pat, port_map=['A', 'B'], library=library, tools=my_tool)` + makes a new (empty) pattern, copies over ports 'A' and 'B' from + `other_pat`, and creates additional ports 'in_A' and 'in_B' facing + in the opposite directions. This can be used to build a device which + can plug into `other_pat` (using the 'in_*' ports) but which does not + itself include `other_pat` as a subcomponent. + + - `Pather.interface(other_pather, ...)` does the same thing as + `Builder.interface(other_builder.pattern, ...)` but also uses + `other_builder.library` as its library by default. - - `Pather(library, name='mypat')` makes an empty pattern and adds it to - `library` under the name `'mypat'`. Examples: Adding to a pattern ============================= - - `pather.plug(subdevice, {'A': 'C'})` instantiates `subdevice` and - connects port 'A' of the current pattern to port 'C' of `subdevice`. + - `pather.path('my_port', ccw=True, distance)` creates a "wire" for which the output + port is `distance` units away along the axis of `'my_port'` and rotated 90 degrees + counterclockwise (since `ccw=True`) relative to `'my_port'`. The wire is `plug`ged + into the existing `'my_port'`, causing the port to move to the wire's output. - - `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend - starting at 'my_port'. If `auto_render=True`, geometry is added - immediately. Otherwise, call `pather.render()` later. + There is no formal guarantee about how far off-axis the output will be located; + there may be a significant width to the bend that is used to accomplish the 90 degree + turn. However, an error is raised if `distance` is too small to fit the bend. + + - `pather.path('my_port', ccw=None, distance)` creates a straight wire with a length + of `distance` and `plug`s it into `'my_port'`. + + - `pather.path_to('my_port', ccw=False, position)` creates a wire which starts at + `'my_port'` and has its output at the specified `position`, pointing 90 degrees + clockwise relative to the input. Again, the off-axis position or distance to the + output is not specified, so `position` takes the form of a single coordinate. To + ease debugging, position may be specified as `x=position` or `y=position` and an + error will be raised if the wrong coordinate is given. + + - `pather.mpath(['A', 'B', 'C'], ..., spacing=spacing)` is a superset of `path` + and `path_to` which can act on multiple ports simultaneously. Each port's wire is + generated using its own `Tool` (or the default tool if left unspecified). + The output ports are spaced out by `spacing` along the input ports' axis, unless + `ccw=None` is specified (i.e. no bends) in which case they all end at the same + destination coordinate. + + - `pather.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' + of `pather.pattern`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, + argument is provided, and the `inherit_name` argument is not explicitly + set to `False`, the unconnected port of `wire` is automatically renamed to + 'myport'. This allows easy extension of existing ports without changing + their names or having to provide `map_out` each time `plug` is called. + + - `pather.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` + instantiates `pad` at the specified (x, y) offset and with the specified + rotation, adding its ports to those of `pather.pattern`. Port 'A' of `pad` is + renamed to 'gnd' so that further routing can use this signal or net name + rather than the port name on the original `pad` device. + + - `pather.retool(tool)` or `pather.retool(tool, ['in', 'out', None])` can change + which tool is used for the given ports (or as the default tool). Useful + when placing vias or using multiple waveguide types along a route. """ - __slots__ = ( - 'pattern', 'library', 'tools', 'paths', - '_dead', '_logger', '_auto_render', '_auto_render_append' - ) - - pattern: Pattern - """ Layout of this device """ + __slots__ = ('tools',) library: ILibrary - """ Library from which patterns should be referenced """ + """ + Library from which existing patterns should be referenced, and to which + new ones should be added + """ tools: dict[str | None, Tool] """ - Tool objects used to dynamically generate new routing segments. - A key of `None` indicates the default `Tool`. + Tool objects are used to dynamically generate new single-use `Pattern`s + (e.g wires or waveguides) to be plugged into this device. A key of `None` + indicates the default `Tool`. """ - paths: defaultdict[str, list[RenderStep]] - """ Per-port list of planned operations, to be used by `render()` """ - - _dead: bool - """ If True, geometry generation is skipped (for debugging) """ - - _logger: PatherLogger - """ Handles diagnostic logging of operations """ - - _auto_render: bool - """ If True, routing operations call render() immediately """ - - PROBE_LENGTH: float = 1e6 - """ Large length used when probing tools for their lateral displacement """ - - @property - def ports(self) -> dict[str, Port]: - return self.pattern.ports - - @ports.setter - def ports(self, value: dict[str, Port]) -> None: - self.pattern.ports = value - def __init__( self, library: ILibrary, @@ -109,38 +127,40 @@ class Pather(PortList): ports: str | Mapping[str, Port] | None = None, tools: Tool | MutableMapping[str | None, Tool] | None = None, name: str | None = None, - debug: bool = False, - auto_render: bool = False, - auto_render_append: bool = True, ) -> None: """ Args: - library: The library for pattern references and generated segments. - pattern: The pattern to modify. If `None`, a new one is created. - ports: Initial set of ports. May be a string (name in `library`) - or a port mapping. - tools: Tool(s) to use for routing segments. + library: The library from which referenced patterns will be taken, + and where new patterns (e.g. generated by the `tools`) will be placed. + pattern: The pattern which will be modified by subsequent operations. + If `None` (default), a new pattern is created. + ports: Allows specifying the initial set of ports, if `pattern` does + not already have any ports (or is not provided). May be a string, + in which case it is interpreted as a name in `library`. + Default `None` (no ports). + tools: A mapping of {port: tool} which specifies what `Tool` should be used + to generate waveguide or wire segments when `path`/`path_to`/`mpath` + are called. Relies on `Tool.path` implementations. name: If specified, `library[name]` is set to `self.pattern`. - debug: If True, enables detailed logging. - auto_render: If True, enables immediate rendering of routing steps. - auto_render_append: If `auto_render` is True, determines whether - to append geometry or add a reference. """ self._dead = False - self._logger = PatherLogger(debug=debug) - self._auto_render = auto_render - self._auto_render_append = auto_render_append self.library = library - self.pattern = pattern if pattern is not None else Pattern() - self.paths = defaultdict(list) + if pattern is not None: + self.pattern = pattern + else: + self.pattern = Pattern() if ports is not None: if self.pattern.ports: raise BuildError('Ports supplied for pattern with pre-existing ports!') if isinstance(ports, str): ports = library.abstract(ports).ports + self.pattern.ports.update(copy.deepcopy(dict(ports))) + if name is not None: + library[name] = self.pattern + if tools is None: self.tools = {} elif isinstance(tools, Tool): @@ -148,527 +168,29 @@ class Pather(PortList): else: self.tools = dict(tools) - if name is not None: - library[name] = self.pattern - - def __del__(self) -> None: - if any(self.paths.values()): - logger.warning(f'Pather {self} had unrendered paths', stack_info=True) - - def __repr__(self) -> str: - s = f'' - return s - - # - # Core Pattern Operations (Immediate) - # - def _record_break(self, names: Iterable[str | None]) -> None: - """ Record a batch-breaking step for the specified ports. """ - if not self._dead: - for n in names: - if n is not None and n in self.paths: - port = self.ports[n] - self.paths[n].append(RenderStep('P', None, port.copy(), port.copy(), None)) - - @logged_op(lambda args: list(args['map_in'].keys())) - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - **kwargs, - ) -> Self: - if not self._dead: - other_res = self.library.resolve(other, append=kwargs.get('append', False)) - other_ports = other_res.ports - affected = set(map_in.keys()) - plugged = set(map_in.values()) - for name in other_ports: - if name not in plugged: - new_name = (map_out or {}).get(name, name) - if new_name is not None: - affected.add(new_name) - self._record_break(affected) - - # Resolve into Abstract or Pattern - other = self.library.resolve(other, append=kwargs.get('append', False)) - - self.pattern.plug(other=other, map_in=map_in, map_out=map_out, skip_geometry=self._dead, **kwargs) - return self - - @logged_op() - def place( - self, - other: Abstract | str | Pattern | TreeView, - port_map: dict[str, str | None] | None = None, - **kwargs, - ) -> Self: - if not self._dead: - other_res = self.library.resolve(other, append=kwargs.get('append', False)) - other_ports = other_res.ports - affected = set() - for name in other_ports: - new_name = (port_map or {}).get(name, name) - if new_name is not None: - affected.add(new_name) - self._record_break(affected) - - # Resolve into Abstract or Pattern - other = self.library.resolve(other, append=kwargs.get('append', False)) - - self.pattern.place(other=other, port_map=port_map, skip_geometry=self._dead, **kwargs) - return self - - @logged_op(lambda args: list(args['connections'].keys())) - def plugged(self, connections: dict[str, str]) -> Self: - self._record_break(chain(connections.keys(), connections.values())) - self.pattern.plugged(connections) - return self - - @logged_op(lambda args: list(args['mapping'].keys())) - def rename_ports(self, mapping: dict[str, str | None], overwrite: bool = False) -> Self: - self.pattern.rename_ports(mapping, overwrite) - renamed: dict[str, list[RenderStep]] = {vv: self.paths.pop(kk) for kk, vv in mapping.items() if kk in self.paths and vv is not None} - self.paths.update(renamed) - return self - - def set_dead(self) -> Self: - self._dead = True - return self - - # - # Pattern Wrappers - # - @wraps(Pattern.label) - def label(self, *args, **kwargs) -> Self: - self.pattern.label(*args, **kwargs) - return self - - @wraps(Pattern.ref) - def ref(self, *args, **kwargs) -> Self: - self.pattern.ref(*args, **kwargs) - return self - - @wraps(Pattern.polygon) - def polygon(self, *args, **kwargs) -> Self: - self.pattern.polygon(*args, **kwargs) - return self - - @wraps(Pattern.rect) - def rect(self, *args, **kwargs) -> Self: - self.pattern.rect(*args, **kwargs) - return self - - @wraps(Pattern.path) - def path(self, *args, **kwargs) -> Self: - self.pattern.path(*args, **kwargs) - return self - - @logged_op(lambda args: list(args['self'].ports.keys())) - def translate(self, offset: ArrayLike) -> Self: - offset_arr = numpy.asarray(offset) - self.pattern.translate_elements(offset_arr) - for steps in self.paths.values(): - for i, step in enumerate(steps): - steps[i] = step.transformed(offset_arr, 0, numpy.zeros(2)) - return self - - @logged_op(lambda args: list(args['self'].ports.keys())) - def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: - pivot_arr = numpy.asarray(pivot) - self.pattern.rotate_around(pivot_arr, angle) - for steps in self.paths.values(): - for i, step in enumerate(steps): - steps[i] = step.transformed(numpy.zeros(2), angle, pivot_arr) - return self - - @logged_op(lambda args: list(args['self'].ports.keys())) - def mirror(self, axis: int = 0) -> Self: - self.pattern.mirror(axis) - for steps in self.paths.values(): - for i, step in enumerate(steps): - steps[i] = step.mirrored(axis) - return self - - @logged_op(lambda args: args['name']) - def mkport(self, name: str, value: Port) -> Self: - super().mkport(name, value) - return self - - # - # Routing Logic (Deferred / Incremental) - # - def _apply_step( - self, - opcode: Literal['L', 'S', 'U'], - portspec: str, - out_port: Port, - data: Any, - tool: Tool, - plug_into: str | None = None, - ) -> None: - """ Common logic for applying a planned step to a port. """ - port = self.pattern[portspec] - port_rot = port.rotation - assert port_rot is not None - - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - - if not self._dead: - step = RenderStep(opcode, tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - - self.pattern.ports[portspec] = out_port.copy() - - if plug_into is not None: - self.plugged({portspec: plug_into}) - - if self._auto_render: - self.render(append=self._auto_render_append) - - def _get_tool_R(self, tool: Tool, ccw: SupportsBool, in_ptype: str | None, **kwargs) -> float: - """ Probe a tool to find the lateral displacement (radius) of its bend. """ - kwargs_no_out = kwargs | {'out_ptype': None} - probe_len = kwargs.get('probe_length', self.PROBE_LENGTH) - try: - out_port, _ = tool.planL(ccw, probe_len, in_ptype=in_ptype, **kwargs_no_out) - return abs(out_port.y) - except (BuildError, NotImplementedError): - # Fallback for tools without planL: use traceL and measure the result - port_names = ('A', 'B') - tree = tool.traceL(ccw, probe_len, in_ptype=in_ptype, port_names=port_names, **kwargs_no_out) - pat = tree.top_pattern() - (_, R), _ = pat[port_names[0]].measure_travel(pat[port_names[1]]) - return abs(R) - - def _apply_dead_fallback( - self, - portspec: str, - length: float, - jog: float, - ccw: SupportsBool | None, - in_ptype: str, - plug_into: str | None = None, + @classmethod + def from_builder( + cls: type['Pather'], + builder: Builder, *, - out_rot: float | None = None, - ) -> None: - if out_rot is None: - if ccw is None: - out_rot = pi - elif bool(ccw): - out_rot = -pi / 2 - else: - out_rot = pi / 2 - logger.warning(f"Tool planning failed for dead pather. Using dummy extension for {portspec}.") - port = self.pattern[portspec] - port_rot = port.rotation - assert port_rot is not None - out_port = Port((length, jog), rotation=out_rot, ptype=in_ptype) - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - self.pattern.ports[portspec] = out_port - if plug_into is not None: - self.plugged({portspec: plug_into}) + tools: Tool | MutableMapping[str | None, Tool] | None = None, + ) -> 'Pather': + """ + Construct a `Pather` by adding tools to a `Builder`. - @logged_op(lambda args: args['portspec']) - def _traceL(self, portspec: str, ccw: SupportsBool | None, length: float, *, plug_into: str | None = None, **kwargs: Any) -> Self: - tool = self.tools.get(portspec, self.tools.get(None)) - if tool is None: - raise BuildError(f'No tool assigned for port {portspec}') - in_ptype = self.pattern[portspec].ptype - try: - out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) - except (BuildError, NotImplementedError): - if not self._dead: - raise - self._apply_dead_fallback(portspec, length, 0, ccw, in_ptype, plug_into) - return self - if out_port is not None: - self._apply_step('L', portspec, out_port, data, tool, plug_into) - return self + Args: + builder: Builder to turn into a Pather + tools: Tools for the `Pather` - @logged_op(lambda args: args['portspec']) - def _traceS(self, portspec: str, length: float, jog: float, *, plug_into: str | None = None, **kwargs: Any) -> Self: - tool = self.tools.get(portspec, self.tools.get(None)) - if tool is None: - raise BuildError(f'No tool assigned for port {portspec}') - in_ptype = self.pattern[portspec].ptype - try: - out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs) - except (BuildError, NotImplementedError): - # Try S-bend fallback (two L-bends) - ccw0 = jog > 0 - try: - R1 = self._get_tool_R(tool, ccw0, in_ptype, **kwargs) - R2 = self._get_tool_R(tool, not ccw0, in_ptype, **kwargs) - L1, L2 = length - R2, abs(jog) - R1 - except (BuildError, NotImplementedError): - if not self._dead: - raise - self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) - return self + Returns: + A new Pather object, using `builder.library` and `builder.pattern`. + """ + new = Pather(library=builder.library, tools=tools, pattern=builder.pattern) + return new - if L1 < 0 or L2 < 0: - if not self._dead: - raise BuildError(f"Jog {jog} or length {length} too small for double-L fallback") from None - self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) - return self - - try: - out_port0, data0 = tool.planL(ccw0, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None})) - out_port1, data1 = tool.planL(not ccw0, L2, in_ptype=out_port0.ptype, **kwargs) - except (BuildError, NotImplementedError): - if not self._dead: - raise - self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) - return self - - self._apply_step('L', portspec, out_port0, data0, tool) - self._apply_step('L', portspec, out_port1, data1, tool, plug_into) - return self - if out_port is not None: - self._apply_step('S', portspec, out_port, data, tool, plug_into) - return self - - @logged_op(lambda args: args['portspec']) - def _traceU(self, portspec: str, jog: float, *, length: float = 0, plug_into: str | None = None, **kwargs: Any) -> Self: - tool = self.tools.get(portspec, self.tools.get(None)) - if tool is None: - raise BuildError(f'No tool assigned for port {portspec}') - in_ptype = self.pattern[portspec].ptype - try: - out_port, data = tool.planU(jog, length=length, in_ptype=in_ptype, **kwargs) - except (BuildError, NotImplementedError): - # Try U-turn fallback (two L-bends) - ccw = jog > 0 - try: - R = self._get_tool_R(tool, ccw, in_ptype, **kwargs) - L1, L2 = length + R, abs(jog) - R - out_port0, data0 = tool.planL(ccw, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None})) - out_port1, data1 = tool.planL(ccw, L2, in_ptype=out_port0.ptype, **kwargs) - except (BuildError, NotImplementedError): - if not self._dead: - raise - self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=0) - return self - else: - self._apply_step('L', portspec, out_port0, data0, tool) - self._apply_step('L', portspec, out_port1, data1, tool, plug_into) - return self - if out_port is not None: - self._apply_step('U', portspec, out_port, data, tool, plug_into) - return self - - # - # High-level Routing Methods - # - def trace( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - **bounds: Any, - ) -> Self: - with self._logger.log_operation(self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, **bounds): - if isinstance(portspec, str): - portspec = [portspec] - if length is not None: - if len(portspec) > 1: - raise BuildError('length only allowed with a single port') - return self._traceL(portspec[0], ccw, length, **bounds) - if 'each' in bounds: - each = bounds.pop('each') - for p in portspec: - self._traceL(p, ccw, each, **bounds) - return self - # Bundle routing - bt_keys = {'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'} - bt = next((k for k in bounds if k in bt_keys), None) - if not bt: - raise BuildError('No bound type specified for trace()') - bval = bounds.pop(bt) - set_rot = bounds.pop('set_rotation', None) - exts = ell(self.pattern[tuple(portspec)], ccw, spacing=spacing, bound=bval, bound_type=bt, set_rotation=set_rot) - for p, length_val in exts.items(): - self._traceL(p, ccw, length_val, **bounds) - return self - - def trace_to( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - **bounds: Any, - ) -> Self: - with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, **bounds): - if isinstance(portspec, str): - portspec = [portspec] - pos_keys = {'p', 'x', 'y', 'pos', 'position'} - pb = {k: bounds[k] for k in bounds if k in pos_keys} - if pb: - if len(portspec) > 1: - raise BuildError('Position bounds only allowed with a single port') - k, v = next(iter(pb.items())) - port = self.pattern[portspec[0]] - assert port.rotation is not None - is_horiz = numpy.isclose(port.rotation % pi, 0) - if is_horiz: - if k == 'y': - raise BuildError('Port is horizontal') - target = Port((v, port.offset[1]), rotation=None) - else: - if k == 'x': - raise BuildError('Port is vertical') - target = Port((port.offset[0], v), rotation=None) - (travel, jog), _ = port.measure_travel(target) - other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in pos_keys and bk != 'length'} - return self._traceL(portspec[0], ccw, -travel, **other_bounds) - return self.trace(portspec, ccw, spacing=spacing, **bounds) - - def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - return self.trace_to(portspec, None, length=length, **bounds) - - def bend(self, portspec: str | Sequence[str], ccw: SupportsBool, length: float | None = None, **bounds) -> Self: - return self.trace_to(portspec, ccw, length=length, **bounds) - - def ccw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - return self.bend(portspec, True, length, **bounds) - - def cw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - return self.bend(portspec, False, length, **bounds) - - def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self: - with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, **bounds): - if isinstance(portspec, str): - portspec = [portspec] - for p in portspec: - self._traceS(p, length, offset, **bounds) - return self - - def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self: - with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, **bounds): - if isinstance(portspec, str): - portspec = [portspec] - for p in portspec: - self._traceU(p, offset, length=length if length else 0, **bounds) - return self - - def trace_into( - self, - portspec_src: str, - portspec_dst: str, - *, - out_ptype: str | None = None, - plug_destination: bool = True, - thru: str | None = None, - **kwargs: Any, - ) -> Self: - with self._logger.log_operation( - self, - 'trace_into', - [portspec_src, portspec_dst], - out_ptype=out_ptype, - plug_destination=plug_destination, - thru=thru, - **kwargs, - ): - if self._dead: - return self - port_src, port_dst = self.pattern[portspec_src], self.pattern[portspec_dst] - if out_ptype is None: - out_ptype = port_dst.ptype - if port_src.rotation is None or port_dst.rotation is None: - raise PortError('Ports must have rotation') - src_horiz = numpy.isclose(port_src.rotation % pi, 0) - dst_horiz = numpy.isclose(port_dst.rotation % pi, 0) - xd, yd = port_dst.offset - angle = (port_dst.rotation - port_src.rotation) % (2 * pi) - dst_args = {**kwargs, 'out_ptype': out_ptype} - if plug_destination: - dst_args['plug_into'] = portspec_dst - if src_horiz and not dst_horiz: - self.trace_to(portspec_src, angle > pi, x=xd, **kwargs) - self.trace_to(portspec_src, None, y=yd, **dst_args) - elif dst_horiz and not src_horiz: - self.trace_to(portspec_src, angle > pi, y=yd, **kwargs) - self.trace_to(portspec_src, None, x=xd, **dst_args) - elif numpy.isclose(angle, pi): - (travel, jog), _ = port_src.measure_travel(port_dst) - if numpy.isclose(jog, 0): - self.trace_to( - portspec_src, - None, - x=xd if src_horiz else None, - y=yd if not src_horiz else None, - **dst_args, - ) - else: - self.jog(portspec_src, -jog, -travel, **dst_args) - elif numpy.isclose(angle, 0): - (travel, jog), _ = port_src.measure_travel(port_dst) - self.uturn(portspec_src, -jog, length=-travel, **dst_args) - else: - raise BuildError(f"Cannot route relative angle {angle}") - if thru: - self.rename_ports({thru: portspec_src}) - return self - - # - # Rendering - # - def render(self, append: bool = True) -> Self: - """ Generate geometry for all planned paths. """ - with self._logger.log_operation(self, 'render', None, append=append): - tool_port_names = ('A', 'B') - pat = Pattern() - - def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: - assert batch[0].tool is not None - tree = batch[0].tool.render(batch, port_names=tool_port_names) - name = self.library << tree - if portspec in pat.ports: - del pat.ports[portspec] - pat.ports[portspec] = batch[0].start_port.copy() - if append: - pat.plug(self.library[name], {portspec: tool_port_names[0]}, append=True) - del self.library[name] - else: - pat.plug(self.library.abstract(name), {portspec: tool_port_names[0]}, append=False) - if portspec not in pat.ports and tool_port_names[1] in pat.ports: - pat.rename_ports({tool_port_names[1]: portspec}, overwrite=True) - - for portspec, steps in self.paths.items(): - if not steps: - continue - batch: list[RenderStep] = [] - for step in steps: - appendable = step.opcode in ('L', 'S', 'U') - same_tool = batch and step.tool == batch[0].tool - if batch and (not appendable or not same_tool or not batch[-1].is_continuous_with(step)): - render_batch(portspec, batch, append) - batch = [] - if appendable: - batch.append(step) - elif step.opcode == 'P' and portspec in pat.ports: - del pat.ports[portspec] - if batch: - render_batch(portspec, batch, append) - - self.paths.clear() - pat.ports.clear() - self.pattern.append(pat) - return self - - # - # Utilities - # @classmethod def interface( - cls, + cls: type['Pather'], source: PortList | Mapping[str, Port] | str, *, library: ILibrary | None = None, @@ -677,259 +199,177 @@ class Pather(PortList): out_prefix: str = '', port_map: dict[str, str] | Sequence[str] | None = None, name: str | None = None, - **kwargs: Any, - ) -> Self: + ) -> 'Pather': + """ + Wrapper for `Pattern.interface()`, which returns a Pather instead. + + Args: + source: A collection of ports (e.g. Pattern, Builder, or dict) + from which to create the interface. May be a pattern name if + `library` is provided. + library: Library from which existing patterns should be referenced, + and to which the new one should be added (if named). If not provided, + `source.library` must exist and will be used. + tools: `Tool`s which will be used by the pather for generating new wires + or waveguides (via `path`/`path_to`/`mpath`). + in_prefix: Prepended to port names for newly-created ports with + reversed directions compared to the current device. + out_prefix: Prepended to port names for ports which are directly + copied from the current device. + port_map: Specification for ports to copy into the new device: + - If `None`, all ports are copied. + - If a sequence, only the listed ports are copied + - If a mapping, the listed ports (keys) are copied and + renamed (to the values). + + Returns: + The new pather, with an empty pattern and 2x as many ports as + listed in port_map. + + Raises: + `PortError` if `port_map` contains port names not present in the + current device. + `PortError` if applying the prefixes results in duplicate port + names. + """ if library is None: if hasattr(source, 'library') and isinstance(source.library, ILibrary): library = source.library else: - raise BuildError('No library provided') + raise BuildError('No library provided (and not present in `source.library`') + if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict): tools = source.tools + if isinstance(source, str): source = library.abstract(source).ports + pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) - return cls(library=library, pattern=pat, name=name, tools=tools, **kwargs) + new = Pather(library=library, pattern=pat, name=name, tools=tools) + return new - def retool(self, tool: Tool, keys: str | Sequence[str | None] | None = None) -> Self: - if keys is None or isinstance(keys, str): - self.tools[keys] = tool + def __repr__(self) -> str: + s = f'' + return s + + + def path( + self, + portspec: str, + ccw: SupportsBool | None, + length: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of traveling exactly `length` distance. + + The wire will travel `length` distance along the port's axis, and an unspecified + (tool-dependent) distance in the perpendicular direction. The output port will + be rotated (or not) based on the `ccw` parameter. + + Args: + portspec: The name of the port into which the wire will be plugged. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + length: The total distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `distance` is too small to fit the bend (if a bend is present). + LibraryError if no valid name could be picked for the pattern. + """ + if self._dead: + logger.error('Skipping path() since device is dead') + return self + + tool_port_names = ('A', 'B') + + tool = self.tools.get(portspec, self.tools[None]) + in_ptype = self.pattern[portspec].ptype + tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + tname = self.library << tree + if plug_into is not None: + output = {plug_into: tool_port_names[1]} else: - for k in keys: - self.tools[k] = tool + output = {} + self.plug(tname, {portspec: tool_port_names[0], **output}) return self - @contextmanager - def toolctx(self, tool: Tool, keys: str | Sequence[str | None] | None = None) -> Iterator[Self]: - if keys is None or isinstance(keys, str): - keys = [keys] - saved = {k: self.tools.get(k) for k in keys} + def pathS( + self, + portspec: str, + length: float, + jog: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is + left of direction of travel). + + The output port will have the same orientation as the source port (`portspec`). + + This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former + raises a NotImplementedError. + + Args: + portspec: The name of the port into which the wire will be plugged. + jog: Total manhattan distance perpendicular to the direction of travel. + Positive values are to the left of the direction of travel. + length: The total manhattan distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `distance` is too small to fit the s-bend (for nonzero jog). + LibraryError if no valid name could be picked for the pattern. + """ + if self._dead: + logger.error('Skipping pathS() since device is dead') + return self + + tool_port_names = ('A', 'B') + + tool = self.tools.get(portspec, self.tools[None]) + in_ptype = self.pattern[portspec].ptype try: - yield self.retool(tool, keys) - finally: - for k, t in saved.items(): - if t is None: - self.tools.pop(k, None) - else: - self.tools[k] = t + tree = tool.pathS(length, jog, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + except NotImplementedError: + # Fall back to drawing two L-bends + ccw0 = jog > 0 + kwargs_no_out = kwargs | {'out_ptype': None} + t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) + t_pat0 = t_tree0.top_pattern() + (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) + t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs) + t_pat1 = t_tree1.top_pattern() + (_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]]) - def flatten(self) -> Self: - self.pattern.flatten(self.library) - return self + kwargs_plug = kwargs | {'plug_into': plug_into} + self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) + return self - def at(self, portspec: str | Iterable[str]) -> 'PortPather': - return PortPather(portspec, self) - - -class PortPather: - """ Port state manager for fluent pathing. """ - def __init__(self, ports: str | Iterable[str], pather: Pather) -> None: - self.ports = [ports] if isinstance(ports, str) else list(ports) - self.pather = pather - - def retool(self, tool: Tool) -> Self: - self.pather.retool(tool, self.ports) - return self - - @contextmanager - def toolctx(self, tool: Tool) -> Iterator[Self]: - with self.pather.toolctx(tool, keys=self.ports): - yield self - - def trace(self, ccw: SupportsBool | None, length: float | None = None, **kw: Any) -> Self: - self.pather.trace(self.ports, ccw, length, **kw) - return self - - def trace_to(self, ccw: SupportsBool | None, **kw: Any) -> Self: - self.pather.trace_to(self.ports, ccw, **kw) - return self - - def straight(self, length: float | None = None, **kw: Any) -> Self: - return self.trace_to(None, length=length, **kw) - - def bend(self, ccw: SupportsBool, length: float | None = None, **kw: Any) -> Self: - return self.trace_to(ccw, length=length, **kw) - - def ccw(self, length: float | None = None, **kw: Any) -> Self: - return self.bend(True, length, **kw) - - def cw(self, length: float | None = None, **kw: Any) -> Self: - return self.bend(False, length, **kw) - - def jog(self, offset: float, length: float | None = None, **kw: Any) -> Self: - self.pather.jog(self.ports, offset, length, **kw) - return self - - def uturn(self, offset: float, length: float | None = None, **kw: Any) -> Self: - self.pather.uturn(self.ports, offset, length, **kw) - return self - - def trace_into(self, target_port: str, **kwargs) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit trace_into() with {len(self.ports)} (>1) ports.') - self.pather.trace_into(self.ports[0], target_port, **kwargs) - return self - - def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' - 'Use the pather or pattern directly to plug multiple ports.') - self.pather.plug(other, {self.ports[0]: other_port}, **kwargs) - return self - - def plugged(self, other_port: str | Mapping[str, str]) -> Self: - if isinstance(other_port, Mapping): - self.pather.plugged(dict(other_port)) - elif len(self.ports) > 1: - raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.') + tname = self.library << tree + if plug_into is not None: + output = {plug_into: tool_port_names[1]} else: - self.pather.plugged({self.ports[0]: other_port}) + output = {} + self.plug(tname, {portspec: tool_port_names[0], **output}) return self - # - # Delegate to port - # - def set_ptype(self, ptype: str) -> Self: - for port in self.ports: - self.pather.pattern[port].set_ptype(ptype) - return self - - def translate(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.pattern[port].translate(*args, **kwargs) - return self - - def mirror(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.pattern[port].mirror(*args, **kwargs) - return self - - def rotate(self, rotation: float) -> Self: - for port in self.ports: - self.pather.pattern[port].rotate(rotation) - return self - - def set_rotation(self, rotation: float | None) -> Self: - for port in self.ports: - self.pather.pattern[port].set_rotation(rotation) - return self - - def rename(self, name: str | Mapping[str, str | None]) -> Self: - """ Rename active ports. """ - name_map: dict[str, str | None] - if isinstance(name, str): - if len(self.ports) > 1: - raise BuildError('Use a mapping to rename >1 port') - name_map = {self.ports[0]: name} - else: - name_map = dict(name) - self.pather.rename_ports(name_map) - self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None] - return self - - def select(self, ports: str | Iterable[str]) -> Self: - """ Add ports to the selection. """ - if isinstance(ports, str): - ports = [ports] - for port in ports: - if port not in self.ports: - self.ports.append(port) - return self - - def deselect(self, ports: str | Iterable[str]) -> Self: - """ Remove ports from the selection. """ - if isinstance(ports, str): - ports = [ports] - ports_set = set(ports) - self.ports = [pp for pp in self.ports if pp not in ports_set] - return self - - def mark(self, name: str | Mapping[str, str]) -> Self: - """ Bookmark current port(s). """ - name_map: Mapping[str, str] = {self.ports[0]: name} if isinstance(name, str) else name - if isinstance(name, str) and len(self.ports) > 1: - raise BuildError('Use a mapping to mark >1 port') - for src, dst in name_map.items(): - self.pather.pattern.ports[dst] = self.pather.pattern[src].copy() - return self - - def fork(self, name: str | Mapping[str, str]) -> Self: - """ Split and follow new name. """ - name_map: Mapping[str, str] = {self.ports[0]: name} if isinstance(name, str) else name - if isinstance(name, str) and len(self.ports) > 1: - raise BuildError('Use a mapping to fork >1 port') - for src, dst in name_map.items(): - self.pather.pattern.ports[dst] = self.pather.pattern[src].copy() - self.ports = [(dst if pp == src else pp) for pp in self.ports] - return self - - def drop(self) -> Self: - """ Remove selected ports from the pattern and the PortPather. """ - for pp in self.ports: - del self.pather.pattern.ports[pp] - self.ports = [] - return self - - @overload - def delete(self, name: None) -> None: ... - - @overload - def delete(self, name: str) -> Self: ... - - def delete(self, name: str | None = None) -> Self | None: - if name is None: - self.drop() - return None - del self.pather.pattern.ports[name] - self.ports = [pp for pp in self.ports if pp != name] - return self - - -class Builder(Pather): - """ - Backward-compatible wrapper for Pather with auto_render=True. - """ - def __init__( - self, - library: ILibrary, - *, - pattern: Pattern | None = None, - ports: str | Mapping[str, Port] | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - name: str | None = None, - debug: bool = False, - ) -> None: - super().__init__( - library=library, - pattern=pattern, - ports=ports, - tools=tools, - name=name, - debug=debug, - auto_render=True, - ) - - -class RenderPather(Pather): - """ - Backward-compatible wrapper for Pather with auto_render=False. - """ - def __init__( - self, - library: ILibrary, - *, - pattern: Pattern | None = None, - ports: str | Mapping[str, Port] | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - name: str | None = None, - debug: bool = False, - ) -> None: - super().__init__( - library=library, - pattern=pattern, - ports=ports, - tools=tools, - name=name, - debug=debug, - auto_render=False, - ) diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py new file mode 100644 index 0000000..1655329 --- /dev/null +++ b/masque/builder/pather_mixin.py @@ -0,0 +1,677 @@ +from typing import Self, overload +from collections.abc import Sequence, Iterator, Iterable +import logging +from contextlib import contextmanager +from abc import abstractmethod, ABCMeta + +import numpy +from numpy import pi +from numpy.typing import ArrayLike + +from ..pattern import Pattern +from ..library import ILibrary, TreeView +from ..error import PortError, BuildError +from ..utils import SupportsBool +from ..abstract import Abstract +from .tools import Tool +from .utils import ell +from ..ports import PortList + + +logger = logging.getLogger(__name__) + + +class PatherMixin(PortList, metaclass=ABCMeta): + pattern: Pattern + """ Layout of this device """ + + library: ILibrary + """ Library from which patterns should be referenced """ + + _dead: bool + """ If True, plug()/place() are skipped (for debugging) """ + + tools: dict[str | None, Tool] + """ + Tool objects are used to dynamically generate new single-use Devices + (e.g wires or waveguides) to be plugged into this device. + """ + + @abstractmethod + def path( + self, + portspec: str, + ccw: SupportsBool | None, + length: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + pass + + @abstractmethod + def pathS( + self, + portspec: str, + length: float, + jog: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + pass + + @abstractmethod + def plug( + self, + other: Abstract | str | Pattern | TreeView, + map_in: dict[str, str], + map_out: dict[str, str | None] | None = None, + *, + mirrored: bool = False, + thru: bool | str = True, + set_rotation: bool | None = None, + append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), + ) -> Self: + pass + + def retool( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Self: + """ + Update the `Tool` which will be used when generating `Pattern`s for the ports + given by `keys`. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + if keys is None or isinstance(keys, str): + self.tools[keys] = tool + else: + for key in keys: + self.tools[key] = tool + return self + + @contextmanager + def toolctx( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Iterator[Self]: + """ + Context manager for temporarily `retool`-ing and reverting the `retool` + upon exiting the context. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + if keys is None or isinstance(keys, str): + keys = [keys] + saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` + try: + yield self.retool(tool=tool, keys=keys) + finally: + for kk, tt in saved_tools.items(): + if tt is None: + # delete if present + self.tools.pop(kk, None) + else: + self.tools[kk] = tt + + def path_to( + self, + portspec: str, + ccw: SupportsBool | None, + position: float | None = None, + *, + x: float | None = None, + y: float | None = None, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Build a "wire"/"waveguide" extending from the port `portspec`, with the aim + of ending exactly at a target position. + + The wire will travel so that the output port will be placed at exactly the target + position along the input port's axis. There can be an unspecified (tool-dependent) + offset in the perpendicular direction. The output port will be rotated (or not) + based on the `ccw` parameter. + + If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec: The name of the port into which the wire will be plugged. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + position: The final port position, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + Only one of `position`, `x`, and `y` may be specified. + x: The final port position along the x axis. + `portspec` must refer to a horizontal port if `x` is passed, otherwise a + BuildError will be raised. + y: The final port position along the y axis. + `portspec` must refer to a vertical port if `y` is passed, otherwise a + BuildError will be raised. + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend + is present). + BuildError if `x` or `y` is specified but does not match the axis of `portspec`. + BuildError if more than one of `x`, `y`, and `position` is specified. + """ + if self._dead: + logger.error('Skipping path_to() since device is dead') + return self + + pos_count = sum(vv is not None for vv in (position, x, y)) + if pos_count > 1: + raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') + if pos_count < 1: + raise BuildError('One of `position`, `x`, and `y` must be specified') + + port = self.pattern[portspec] + if port.rotation is None: + raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') + + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise BuildError('path_to was asked to route from non-manhattan port') + + is_horizontal = numpy.isclose(port.rotation % pi, 0) + if is_horizontal: + if y is not None: + raise BuildError('Asked to path to y-coordinate, but port is horizontal') + if position is None: + position = x + else: + if x is not None: + raise BuildError('Asked to path to x-coordinate, but port is vertical') + if position is None: + position = y + + x0, y0 = port.offset + if is_horizontal: + if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): + raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') + length = numpy.abs(position - x0) + else: + if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): + raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') + length = numpy.abs(position - y0) + + return self.path( + portspec, + ccw, + length, + plug_into = plug_into, + **kwargs, + ) + + def path_into( + self, + portspec_src: str, + portspec_dst: str, + *, + out_ptype: str | None = None, + plug_destination: bool = True, + thru: str | None = None, + **kwargs, + ) -> Self: + """ + Create a "wire"/"waveguide" traveling between the ports `portspec_src` and + `portspec_dst`, and `plug` it into both (or just the source port). + + Only unambiguous scenarios are allowed: + - Straight connector between facing ports + - Single 90 degree bend + - Jog between facing ports + (jog is done as late as possible, i.e. only 2 L-shaped segments are used) + + By default, the destination's `pytpe` will be used as the `out_ptype` for the + wire, and the `portspec_dst` will be plugged (i.e. removed). + + If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec_src: The name of the starting port into which the wire will be plugged. + portspec_dst: The name of the destination port. + out_ptype: Passed to the pathing tool in order to specify the desired port type + to be generated at the destination end. If `None` (default), the destination + port's `ptype` will be used. + thru: If not `None`, the port by this name will be rename to `portspec_src`. + This can be used when routing a signal through a pre-placed 2-port device. + + Returns: + self + + Raises: + PortError if either port does not have a specified rotation. + BuildError if and invalid port config is encountered: + - Non-manhattan ports + - U-bend + - Destination too close to (or behind) source + """ + if self._dead: + logger.error('Skipping path_into() since device is dead') + return self + + port_src = self.pattern[portspec_src] + port_dst = self.pattern[portspec_dst] + + if out_ptype is None: + out_ptype = port_dst.ptype + + if port_src.rotation is None: + raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()') + if port_dst.rotation is None: + raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()') + + if not numpy.isclose(port_src.rotation % (pi / 2), 0): + raise BuildError('path_into was asked to route from non-manhattan port') + if not numpy.isclose(port_dst.rotation % (pi / 2), 0): + raise BuildError('path_into was asked to route to non-manhattan port') + + src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0) + dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0) + xs, ys = port_src.offset + xd, yd = port_dst.offset + + angle = (port_dst.rotation - port_src.rotation) % (2 * pi) + + dst_extra_args = {'out_ptype': out_ptype} + if plug_destination: + dst_extra_args['plug_into'] = portspec_dst + + src_args = {**kwargs} + dst_args = {**src_args, **dst_extra_args} + if src_is_horizontal and not dst_is_horizontal: + # single bend should suffice + self.path_to(portspec_src, angle > pi, x=xd, **src_args) + self.path_to(portspec_src, None, y=yd, **dst_args) + elif dst_is_horizontal and not src_is_horizontal: + # single bend should suffice + self.path_to(portspec_src, angle > pi, y=yd, **src_args) + self.path_to(portspec_src, None, x=xd, **dst_args) + elif numpy.isclose(angle, pi): + if src_is_horizontal and ys == yd: + # straight connector + self.path_to(portspec_src, None, x=xd, **dst_args) + elif not src_is_horizontal and xs == xd: + # straight connector + self.path_to(portspec_src, None, y=yd, **dst_args) + else: + # S-bend, delegate to implementations + (travel, jog), _ = port_src.measure_travel(port_dst) + self.pathS(portspec_src, -travel, -jog, **dst_args) + elif numpy.isclose(angle, 0): + raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') + else: + raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') + + if thru is not None: + self.rename_ports({thru: portspec_src}) + + return self + + def mpath( + self, + portspec: str | Sequence[str], + ccw: SupportsBool | None, + *, + spacing: float | ArrayLike | None = None, + set_rotation: float | None = None, + **kwargs, + ) -> Self: + """ + `mpath` is a superset of `path` and `path_to` which can act on bundles or buses + of "wires or "waveguides". + + The wires will travel so that the output ports will be placed at well-defined + locations along the axis of their input ports, but may have arbitrary (tool- + dependent) offsets in the perpendicular direction. + + If `ccw` is not `None`, the wire bundle will turn 90 degres in either the + clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the + bundle, the center-to-center wire spacings after the turn are set by `spacing`, + which is required when `ccw` is not `None`. The final position of bundle as a + whole can be set in a number of ways: + + =A>---------------------------V turn direction: `ccw=False` + =B>-------------V | + =C>-----------------------V | + =D=>----------------V | + | + + x---x---x---x `spacing` (can be scalar or array) + + <--------------> `emin=` + <------> `bound_type='min_past_furthest', bound=` + <--------------------------------> `emax=` + x `pmin=` + x `pmax=` + + - `emin=`, equivalent to `bound_type='min_extension', bound=` + The total extension value for the furthest-out port (B in the diagram). + - `emax=`, equivalent to `bound_type='max_extension', bound=`: + The total extension value for the closest-in port (C in the diagram). + - `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`: + The coordinate of the innermost bend (D's bend). + The x/y versions throw an error if they do not match the port axis (for debug) + - `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`: + The coordinate of the outermost bend (A's bend). + The x/y versions throw an error if they do not match the port axis (for debug) + - `bound_type='min_past_furthest', bound=`: + The distance between furthest out-port (B) and the innermost bend (D's bend). + + If `ccw=None`, final output positions (along the input axis) of all wires will be + identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is + required. In this case, `emin=` and `emax=` are equivalent to each other, and + `pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other. + + If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec: The names of the ports which are to be routed. + ccw: If `None`, the outputs should be along the same axis as the inputs. + Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` + and clockwise otherwise. + spacing: Center-to-center distance between output ports along the input port's axis. + Must be provided if (and only if) `ccw` is not `None`. + set_rotation: If the provided ports have `rotation=None`, this can be used + to set a rotation for them. + + Returns: + self + + Raises: + BuildError if the implied length for any wire is too close to fit the bend + (if a bend is requested). + BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not + match the axis of `portspec`. + BuildError if an incorrect bound type or spacing is specified. + """ + if self._dead: + logger.error('Skipping mpath() since device is dead') + return self + + bound_types = set() + if 'bound_type' in kwargs: + bound_types.add(kwargs.pop('bound_type')) + bound = kwargs.pop('bound') + for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): + if bt in kwargs: + bound_types.add(bt) + bound = kwargs.pop(bt) + + if not bound_types: + raise BuildError('No bound type specified for mpath') + if len(bound_types) > 1: + raise BuildError(f'Too many bound types specified for mpath: {bound_types}') + bound_type = tuple(bound_types)[0] + + if isinstance(portspec, str): + portspec = [portspec] + ports = self.pattern[tuple(portspec)] + + extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) + + #if container: + # assert not getattr(self, 'render'), 'Containers not implemented for RenderPather' + # bld = self.interface(source=ports, library=self.library, tools=self.tools) + # for port_name, length in extensions.items(): + # bld.path(port_name, ccw, length, **kwargs) + # self.library[container] = bld.pattern + # self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'? + #else: + for port_name, length in extensions.items(): + self.path(port_name, ccw, length, **kwargs) + return self + + # TODO def bus_join()? + + def flatten(self) -> Self: + """ + Flatten the contained pattern, using the contained library to resolve references. + + Returns: + self + """ + self.pattern.flatten(self.library) + return self + + def at(self, portspec: str | Iterable[str]) -> 'PortPather': + return PortPather(portspec, self) + + +class PortPather: + """ + Port state manager + + This class provides a convenient way to perform multiple pathing operations on a + set of ports without needing to repeatedly pass their names. + """ + ports: list[str] + pather: PatherMixin + + def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None: + self.ports = [ports] if isinstance(ports, str) else list(ports) + self.pather = pather + + # + # Delegate to pather + # + def retool(self, tool: Tool) -> Self: + self.pather.retool(tool, keys=self.ports) + return self + + @contextmanager + def toolctx(self, tool: Tool) -> Iterator[Self]: + with self.pather.toolctx(tool, keys=self.ports): + yield self + + def path(self, *args, **kwargs) -> Self: + if len(self.ports) > 1: + logger.warning('Use path_each() when pathing multiple ports independently') + for port in self.ports: + self.pather.path(port, *args, **kwargs) + return self + + def path_each(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather.path(port, *args, **kwargs) + return self + + def pathS(self, *args, **kwargs) -> Self: + if len(self.ports) > 1: + logger.warning('Use pathS_each() when pathing multiple ports independently') + for port in self.ports: + self.pather.pathS(port, *args, **kwargs) + return self + + def pathS_each(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather.pathS(port, *args, **kwargs) + return self + + def path_to(self, *args, **kwargs) -> Self: + if len(self.ports) > 1: + logger.warning('Use path_each_to() when pathing multiple ports independently') + for port in self.ports: + self.pather.path_to(port, *args, **kwargs) + return self + + def path_each_to(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather.path_to(port, *args, **kwargs) + return self + + def mpath(self, *args, **kwargs) -> Self: + self.pather.mpath(self.ports, *args, **kwargs) + return self + + def path_into(self, *args, **kwargs) -> Self: + """ Path_into, using the current port as the source """ + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.') + self.pather.path_into(self.ports[0], *args, **kwargs) + return self + + def path_from(self, *args, **kwargs) -> Self: + """ Path_into, using the current port as the destination """ + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.') + thru = kwargs.pop('thru', None) + self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs) + if thru is not None: + self.rename_from(thru) + return self + + def plug( + self, + other: Abstract | str, + other_port: str, + *args, + **kwargs, + ) -> Self: + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' + 'Use the pather or pattern directly to plug multiple ports.') + self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs) + return self + + def plugged(self, other_port: str) -> Self: + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.') + self.pather.plugged({self.ports[0]: other_port}) + return self + + # + # Delegate to port + # + def set_ptype(self, ptype: str) -> Self: + for port in self.ports: + self.pather[port].set_ptype(ptype) + return self + + def translate(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather[port].translate(*args, **kwargs) + return self + + def mirror(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather[port].mirror(*args, **kwargs) + return self + + def rotate(self, rotation: float) -> Self: + for port in self.ports: + self.pather[port].rotate(rotation) + return self + + def set_rotation(self, rotation: float | None) -> Self: + for port in self.ports: + self.pather[port].set_rotation(rotation) + return self + + def rename_to(self, new_name: str) -> Self: + if len(self.ports) > 1: + BuildError('Use rename_ports() for >1 port') + self.pather.rename_ports({self.ports[0]: new_name}) + self.ports[0] = new_name + return self + + def rename_from(self, old_name: str) -> Self: + if len(self.ports) > 1: + BuildError('Use rename_ports() for >1 port') + self.pather.rename_ports({old_name: self.ports[0]}) + return self + + def rename_ports(self, name_map: dict[str, str | None]) -> Self: + self.pather.rename_ports(name_map) + self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None] + return self + + def add_ports(self, ports: Iterable[str]) -> Self: + ports = list(ports) + conflicts = set(ports) & set(self.ports) + if conflicts: + raise BuildError(f'ports {conflicts} already selected') + self.ports += ports + return self + + def add_port(self, port: str, index: int | None = None) -> Self: + if port in self.ports: + raise BuildError(f'{port=} already selected') + if index is not None: + self.ports.insert(index, port) + else: + self.ports.append(port) + return self + + def drop_port(self, port: str) -> Self: + if port not in self.ports: + raise BuildError(f'{port=} already not selected') + self.ports = [pp for pp in self.ports if pp != port] + return self + + def into_copy(self, new_name: str, src: str | None = None) -> Self: + """ Copy a port and replace it with the copy """ + if not self.ports: + raise BuildError('Have no ports to copy') + if len(self.ports) == 1: + src = self.ports[0] + elif src is None: + raise BuildError('Must specify src when >1 port is available') + if src not in self.ports: + raise BuildError(f'{src=} not available') + self.pather.ports[new_name] = self.pather[src].copy() + self.ports = [(new_name if pp == src else pp) for pp in self.ports] + return self + + def save_copy(self, new_name: str, src: str | None = None) -> Self: + """ Copy a port and but keep using the original """ + if not self.ports: + raise BuildError('Have no ports to copy') + if len(self.ports) == 1: + src = self.ports[0] + elif src is None: + raise BuildError('Must specify src when >1 port is available') + if src not in self.ports: + raise BuildError(f'{src=} not available') + self.pather.ports[new_name] = self.pather[src].copy() + return self + + @overload + def delete(self, name: None) -> None: ... + + @overload + def delete(self, name: str) -> Self: ... + + def delete(self, name: str | None = None) -> Self | None: + if name is None: + for pp in self.ports: + del self.pather.ports[pp] + return None + del self.pather.ports[name] + self.ports = [pp for pp in self.ports if pp != name] + return self + diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py new file mode 100644 index 0000000..7f18e77 --- /dev/null +++ b/masque/builder/renderpather.py @@ -0,0 +1,646 @@ +""" +Pather with batched (multi-step) rendering +""" +from typing import Self +from collections.abc import Sequence, Mapping, MutableMapping, Iterable +import copy +import logging +from collections import defaultdict +from functools import wraps +from pprint import pformat + +from numpy import pi +from numpy.typing import ArrayLike + +from ..pattern import Pattern +from ..library import ILibrary, TreeView +from ..error import BuildError +from ..ports import PortList, Port +from ..abstract import Abstract +from ..utils import SupportsBool +from .tools import Tool, RenderStep +from .pather_mixin import PatherMixin + + +logger = logging.getLogger(__name__) + + +class RenderPather(PatherMixin): + """ + `RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath` + functions to plan out wire paths without incrementally generating the layout. Instead, + it waits until `render` is called, at which point it draws all the planned segments + simultaneously. This allows it to e.g. draw each wire using a single `Path` or + `Polygon` shape instead of multiple rectangles. + + `RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific + dimensions and build the final geometry for each wire. `Tool.planL` provides the + output port data (relative to the input) for each segment. The tool, input and output + ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for + each port. When `render` is called, it bundles `RenderStep`s into batches which use + the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build + the geometry. + + See `Pather` for routing examples. After routing is complete, `render` must be called + to generate the final geometry. + """ + __slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', ) + + pattern: Pattern + """ Layout of this device """ + + library: ILibrary + """ Library from which patterns should be referenced """ + + _dead: bool + """ If True, plug()/place() are skipped (for debugging) """ + + paths: defaultdict[str, list[RenderStep]] + """ Per-port list of operations, to be used by `render` """ + + tools: dict[str | None, Tool] + """ + Tool objects are used to dynamically generate new single-use Devices + (e.g wires or waveguides) to be plugged into this device. + """ + + @property + def ports(self) -> dict[str, Port]: + return self.pattern.ports + + @ports.setter + def ports(self, value: dict[str, Port]) -> None: + self.pattern.ports = value + + def __init__( + self, + library: ILibrary, + *, + pattern: Pattern | None = None, + ports: str | Mapping[str, Port] | None = None, + tools: Tool | MutableMapping[str | None, Tool] | None = None, + name: str | None = None, + ) -> None: + """ + Args: + library: The library from which referenced patterns will be taken, + and where new patterns (e.g. generated by the `tools`) will be placed. + pattern: The pattern which will be modified by subsequent operations. + If `None` (default), a new pattern is created. + ports: Allows specifying the initial set of ports, if `pattern` does + not already have any ports (or is not provided). May be a string, + in which case it is interpreted as a name in `library`. + Default `None` (no ports). + tools: A mapping of {port: tool} which specifies what `Tool` should be used + to generate waveguide or wire segments when `path`/`path_to`/`mpath` + are called. Relies on `Tool.planL` and `Tool.render` implementations. + name: If specified, `library[name]` is set to `self.pattern`. + """ + self._dead = False + self.paths = defaultdict(list) + self.library = library + if pattern is not None: + self.pattern = pattern + else: + self.pattern = Pattern() + + if ports is not None: + if self.pattern.ports: + raise BuildError('Ports supplied for pattern with pre-existing ports!') + if isinstance(ports, str): + ports = library.abstract(ports).ports + + self.pattern.ports.update(copy.deepcopy(dict(ports))) + + if name is not None: + library[name] = self.pattern + + if tools is None: + self.tools = {} + elif isinstance(tools, Tool): + self.tools = {None: tools} + else: + self.tools = dict(tools) + + @classmethod + def interface( + cls: type['RenderPather'], + source: PortList | Mapping[str, Port] | str, + *, + library: ILibrary | None = None, + tools: Tool | MutableMapping[str | None, Tool] | None = None, + in_prefix: str = 'in_', + out_prefix: str = '', + port_map: dict[str, str] | Sequence[str] | None = None, + name: str | None = None, + ) -> 'RenderPather': + """ + Wrapper for `Pattern.interface()`, which returns a RenderPather instead. + + Args: + source: A collection of ports (e.g. Pattern, Builder, or dict) + from which to create the interface. May be a pattern name if + `library` is provided. + library: Library from which existing patterns should be referenced, + and to which the new one should be added (if named). If not provided, + `source.library` must exist and will be used. + tools: `Tool`s which will be used by the pather for generating new wires + or waveguides (via `path`/`path_to`/`mpath`). + in_prefix: Prepended to port names for newly-created ports with + reversed directions compared to the current device. + out_prefix: Prepended to port names for ports which are directly + copied from the current device. + port_map: Specification for ports to copy into the new device: + - If `None`, all ports are copied. + - If a sequence, only the listed ports are copied + - If a mapping, the listed ports (keys) are copied and + renamed (to the values). + + Returns: + The new `RenderPather`, with an empty pattern and 2x as many ports as + listed in port_map. + + Raises: + `PortError` if `port_map` contains port names not present in the + current device. + `PortError` if applying the prefixes results in duplicate port + names. + """ + if library is None: + if hasattr(source, 'library') and isinstance(source.library, ILibrary): + library = source.library + else: + raise BuildError('No library provided (and not present in `source.library`') + + if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict): + tools = source.tools + + if isinstance(source, str): + source = library.abstract(source).ports + + pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) + new = RenderPather(library=library, pattern=pat, name=name, tools=tools) + return new + + def __repr__(self) -> str: + s = f'' + return s + + def plug( + self, + other: Abstract | str | Pattern | TreeView, + map_in: dict[str, str], + map_out: dict[str, str | None] | None = None, + *, + mirrored: bool = False, + thru: bool | str = True, + set_rotation: bool | None = None, + append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), + ) -> Self: + """ + Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P' + for any affected ports. This separates any future `RenderStep`s on the + same port into a new batch, since the plugged device interferes with drawing. + + Args: + other: An `Abstract`, string, or `Pattern` describing the device to be instatiated. + map_in: dict of `{'self_port': 'other_port'}` mappings, specifying + port connections between the two devices. + map_out: dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in `other`. + mirrored: Enables mirroring `other` across the x axis prior to + connecting any ports. + thru: If map_in specifies only a single port, `thru` provides a mechainsm + to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, + - If True (default), and `other` has only two ports total, and map_out + doesn't specify a name for the other port, its name is set to the key + in `map_in`, i.e. 'myport'. + - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). + An error is raised if that entry already exists. + + This makes it easy to extend a pattern with simple 2-port devices + (e.g. wires) without providing `map_out` each time `plug` is + called. See "Examples" above for more info. Default `True`. + set_rotation: If the necessary rotation cannot be determined from + the ports being connected (i.e. all pairs have at least one + port with `rotation=None`), `set_rotation` must be provided + to indicate how much `other` should be rotated. Otherwise, + `set_rotation` must remain `None`. + append: If `True`, `other` is appended instead of being referenced. + Note that this does not flatten `other`, so its refs will still + be refs (now inside `self`). + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. + + + Returns: + self + + Raises: + `PortError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `PortError` if there are any duplicate names after `map_in` and `map_out` + are applied. + `PortError` if the specified port mapping is not achieveable (the ports + do not line up) + """ + if self._dead: + logger.error('Skipping plug() since device is dead') + return self + + other_tgt: Pattern | Abstract + if isinstance(other, str): + other_tgt = self.library.abstract(other) + if append and isinstance(other, Abstract): + other_tgt = self.library[other.name] + + # get rid of plugged ports + for kk in map_in: + if kk in self.paths: + self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None)) + + plugged = map_in.values() + for name, port in other_tgt.ports.items(): + if name in plugged: + continue + new_name = map_out.get(name, name) if map_out is not None else name + if new_name is not None and new_name in self.paths: + self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) + + self.pattern.plug( + other = other_tgt, + map_in = map_in, + map_out = map_out, + mirrored = mirrored, + thru = thru, + set_rotation = set_rotation, + append = append, + ok_connections = ok_connections, + ) + + return self + + def place( + self, + other: Abstract | str, + *, + offset: ArrayLike = (0, 0), + rotation: float = 0, + pivot: ArrayLike = (0, 0), + mirrored: bool = False, + port_map: dict[str, str | None] | None = None, + skip_port_check: bool = False, + append: bool = False, + ) -> Self: + """ + Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P' + for any affected ports. This separates any future `RenderStep`s on the + same port into a new batch, since the placed device interferes with drawing. + + Note that mirroring is applied before rotation; translation (`offset`) is applied last. + + Args: + other: An `Abstract` or `Pattern` describing the device to be instatiated. + offset: Offset at which to place the instance. Default (0, 0). + rotation: Rotation applied to the instance before placement. Default 0. + pivot: Rotation is applied around this pivot point (default (0, 0)). + Rotation is applied prior to translation (`offset`). + mirrored: Whether theinstance should be mirrored across the x axis. + Mirroring is applied before translation and rotation. + port_map: dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in the instantiated pattern. New names can be + `None`, which will delete those ports. + skip_port_check: Can be used to skip the internal call to `check_ports`, + in case it has already been performed elsewhere. + append: If `True`, `other` is appended instead of being referenced. + Note that this does not flatten `other`, so its refs will still + be refs (now inside `self`). + + Returns: + self + + Raises: + `PortError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other.ports`. + `PortError` if there are any duplicate names after `map_in` and `map_out` + are applied. + """ + if self._dead: + logger.error('Skipping place() since device is dead') + return self + + other_tgt: Pattern | Abstract + if isinstance(other, str): + other_tgt = self.library.abstract(other) + if append and isinstance(other, Abstract): + other_tgt = self.library[other.name] + + for name, port in other_tgt.ports.items(): + new_name = port_map.get(name, name) if port_map is not None else name + if new_name is not None and new_name in self.paths: + self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) + + self.pattern.place( + other = other_tgt, + offset = offset, + rotation = rotation, + pivot = pivot, + mirrored = mirrored, + port_map = port_map, + skip_port_check = skip_port_check, + append = append, + ) + + return self + + def plugged( + self, + connections: dict[str, str], + ) -> Self: + for aa, bb in connections.items(): + porta = self.ports[aa] + portb = self.ports[bb] + self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None)) + self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None)) + PortList.plugged(self, connections) + return self + + def path( + self, + portspec: str, + ccw: SupportsBool | None, + length: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim + of traveling exactly `length` distance. + + The wire will travel `length` distance along the port's axis, an an unspecified + (tool-dependent) distance in the perpendicular direction. The output port will + be rotated (or not) based on the `ccw` parameter. + + `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec: The name of the port into which the wire will be plugged. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + length: The total distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `distance` is too small to fit the bend (if a bend is present). + LibraryError if no valid name could be picked for the pattern. + """ + if self._dead: + logger.error('Skipping path() since device is dead') + return self + + port = self.pattern[portspec] + in_ptype = port.ptype + port_rot = port.rotation + assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? + + tool = self.tools.get(portspec, self.tools[None]) + # ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype + out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) + + # Update port + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + + step = RenderStep('L', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) + + self.pattern.ports[portspec] = out_port.copy() + + if plug_into is not None: + self.plugged({portspec: plug_into}) + + return self + + def pathS( + self, + portspec: str, + length: float, + jog: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is + left of direction of travel). + + The output port will have the same orientation as the source port (`portspec`). + + `RenderPather.render` must be called after all paths have been fully planned. + + This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former + raises a NotImplementedError. + + Args: + portspec: The name of the port into which the wire will be plugged. + jog: Total manhattan distance perpendicular to the direction of travel. + Positive values are to the left of the direction of travel. + length: The total manhattan distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `distance` is too small to fit the s-bend (for nonzero jog). + LibraryError if no valid name could be picked for the pattern. + """ + if self._dead: + logger.error('Skipping pathS() since device is dead') + return self + + port = self.pattern[portspec] + in_ptype = port.ptype + port_rot = port.rotation + assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? + + tool = self.tools.get(portspec, self.tools[None]) + + # check feasibility, get output port and data + try: + out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs) + except NotImplementedError: + # Fall back to drawing two L-bends + ccw0 = jog > 0 + kwargs_no_out = (kwargs | {'out_ptype': None}) + t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes + jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1] + t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs) + jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1] + + kwargs_plug = kwargs | {'plug_into': plug_into} + self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) + return self + + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + step = RenderStep('S', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) + self.pattern.ports[portspec] = out_port.copy() + + if plug_into is not None: + self.plugged({portspec: plug_into}) + return self + + + def render( + self, + append: bool = True, + ) -> Self: + """ + Generate the geometry which has been planned out with `path`/`path_to`/etc. + + Args: + append: If `True`, the rendered geometry will be directly appended to + `self.pattern`. Note that it will not be flattened, so if only one + layer of hierarchy is eliminated. + + Returns: + self + """ + lib = self.library + tool_port_names = ('A', 'B') + pat = Pattern() + + def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: + assert batch[0].tool is not None + name = lib << batch[0].tool.render(batch, port_names=tool_port_names) + pat.ports[portspec] = batch[0].start_port.copy() + if append: + pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append) + del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened + else: + pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append) + + for portspec, steps in self.paths.items(): + batch: list[RenderStep] = [] + for step in steps: + appendable_op = step.opcode in ('L', 'S', 'U') + same_tool = batch and step.tool == batch[0].tool + + # If we can't continue a batch, render it + if batch and (not appendable_op or not same_tool): + render_batch(portspec, batch, append) + batch = [] + + # batch is emptied already if we couldn't continue it + if appendable_op: + batch.append(step) + + # Opcodes which break the batch go below this line + if not appendable_op and portspec in pat.ports: + del pat.ports[portspec] + + #If the last batch didn't end yet + if batch: + render_batch(portspec, batch, append) + + self.paths.clear() + pat.ports.clear() + self.pattern.append(pat) + + return self + + def translate(self, offset: ArrayLike) -> Self: + """ + Translate the pattern and all ports. + + Args: + offset: (x, y) distance to translate by + + Returns: + self + """ + self.pattern.translate_elements(offset) + return self + + def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: + """ + Rotate the pattern and all ports. + + Args: + angle: angle (radians, counterclockwise) to rotate by + pivot: location to rotate around + + Returns: + self + """ + self.pattern.rotate_around(pivot, angle) + return self + + def mirror(self, axis: int) -> Self: + """ + Mirror the pattern and all ports across the specified axis. + + Args: + axis: Axis to mirror across (x=0, y=1) + + Returns: + self + """ + self.pattern.mirror(axis) + return self + + def set_dead(self) -> Self: + """ + Disallows further changes through `plug()` or `place()`. + This is meant for debugging: + ``` + dev.plug(a, ...) + dev.set_dead() # added for debug purposes + dev.plug(b, ...) # usually raises an error, but now skipped + dev.plug(c, ...) # also skipped + dev.pattern.visualize() # shows the device as of the set_dead() call + ``` + + Returns: + self + """ + self._dead = True + return self + + @wraps(Pattern.label) + def label(self, *args, **kwargs) -> Self: + self.pattern.label(*args, **kwargs) + return self + + @wraps(Pattern.ref) + def ref(self, *args, **kwargs) -> Self: + self.pattern.ref(*args, **kwargs) + return self + + @wraps(Pattern.polygon) + def polygon(self, *args, **kwargs) -> Self: + self.pattern.polygon(*args, **kwargs) + return self + + @wraps(Pattern.rect) + def rect(self, *args, **kwargs) -> Self: + self.pattern.rect(*args, **kwargs) + return self + diff --git a/masque/builder/tools.py b/masque/builder/tools.py index f8779bd..6bd7547 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -3,8 +3,8 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir # TODO document all tools """ -from typing import Literal, Any, Self, cast -from collections.abc import Sequence, Callable, Iterator +from typing import Literal, Any, Self +from collections.abc import Sequence, Callable from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? from dataclasses import dataclass @@ -47,72 +47,16 @@ class RenderStep: if self.opcode != 'P' and self.tool is None: raise BuildError('Got tool=None but the opcode is not "P"') - def is_continuous_with(self, other: 'RenderStep') -> bool: - """ - Check if another RenderStep can be appended to this one. - """ - # Check continuity with tolerance - offsets_match = bool(numpy.allclose(other.start_port.offset, self.end_port.offset)) - rotations_match = (other.start_port.rotation is None and self.end_port.rotation is None) or ( - other.start_port.rotation is not None and self.end_port.rotation is not None and - bool(numpy.isclose(other.start_port.rotation, self.end_port.rotation)) - ) - return offsets_match and rotations_match - - def transformed(self, translation: NDArray[numpy.float64], rotation: float, pivot: NDArray[numpy.float64]) -> 'RenderStep': - """ - Return a new RenderStep with transformed start and end ports. - """ - new_start = self.start_port.copy() - new_end = self.end_port.copy() - - for pp in (new_start, new_end): - pp.rotate_around(pivot, rotation) - pp.translate(translation) - - return RenderStep( - opcode = self.opcode, - tool = self.tool, - start_port = new_start, - end_port = new_end, - data = self.data, - ) - - def mirrored(self, axis: int) -> 'RenderStep': - """ - Return a new RenderStep with mirrored start and end ports. - """ - new_start = self.start_port.copy() - new_end = self.end_port.copy() - - new_start.mirror(axis) - new_end.mirror(axis) - - return RenderStep( - opcode = self.opcode, - tool = self.tool, - start_port = new_start, - end_port = new_end, - data = self.data, - ) - - -def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port, Any]: - """ - Extracts a Port and returns the tree (as data) for tool planning fallbacks. - """ - pat = tree.top_pattern() - in_p = pat[port_names[0]] - out_p = pat[port_names[1]] - (travel, jog), rot = in_p.measure_travel(out_p) - return Port((travel, jog), rotation=rot, ptype=out_p.ptype), tree - class Tool: """ Interface for path (e.g. wire or waveguide) generation. + + Note that subclasses may implement only a subset of the methods and leave others + unimplemented (e.g. in cases where they don't make sense or the required components + are impractical or unavailable). """ - def traceL( + def path( self, ccw: SupportsBool | None, length: float, @@ -155,9 +99,9 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - raise NotImplementedError(f'traceL() not implemented for {type(self)}') + raise NotImplementedError(f'path() not implemented for {type(self)}') - def traceS( + def pathS( self, length: float, jog: float, @@ -197,7 +141,7 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - raise NotImplementedError(f'traceS() not implemented for {type(self)}') + raise NotImplementedError(f'path() not implemented for {type(self)}') def planL( self, @@ -239,17 +183,7 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - # Fallback implementation using traceL - port_names = kwargs.get('port_names', ('A', 'B')) - tree = self.traceL( - ccw, - length, - in_ptype=in_ptype, - out_ptype=out_ptype, - port_names=port_names, - **kwargs, - ) - return measure_tool_plan(tree, port_names) + raise NotImplementedError(f'planL() not implemented for {type(self)}') def planS( self, @@ -287,57 +221,7 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - # Fallback implementation using traceS - port_names = kwargs.get('port_names', ('A', 'B')) - tree = self.traceS( - length, - jog, - in_ptype=in_ptype, - out_ptype=out_ptype, - port_names=port_names, - **kwargs, - ) - return measure_tool_plan(tree, port_names) - - def traceU( - self, - jog: float, - *, - length: float = 0, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - """ - Create a wire or waveguide that travels exactly `jog` distance along the axis - perpendicular to its input port (i.e. a U-bend). - - Used by `Pather` and `RenderPather`. - - The output port must have an orientation identical to the input port. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftwards shift (i.e. counterclockwise bend - followed by a clockwise bend) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested U-shaped wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'traceU() not implemented for {type(self)}') + raise NotImplementedError(f'planS() not implemented for {type(self)}') def planU( self, @@ -362,7 +246,7 @@ class Tool: Args: jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftwards shift (i.e. counterclockwise_bend + A positive number implies a leftwards shift (i.e. counterclockwise bend followed by a clockwise bend) in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. @@ -375,26 +259,14 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - # Fallback implementation using traceU - kwargs = dict(kwargs) - length = kwargs.pop('length', 0) - port_names = kwargs.pop('port_names', ('A', 'B')) - tree = self.traceU( - jog, - length=length, - in_ptype=in_ptype, - out_ptype=out_ptype, - port_names=port_names, - **kwargs, - ) - return measure_tool_plan(tree, port_names) + raise NotImplementedError(f'planU() not implemented for {type(self)}') def render( self, batch: Sequence[RenderStep], *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, + port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused) + **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: """ Render the provided `batch` of `RenderStep`s into geometry, returning a tree @@ -408,48 +280,7 @@ class Tool: kwargs: Custom tool-specific parameters. """ assert not batch or batch[0].tool == self - # Fallback: render each step individually - lib, pat = Library.mktree(SINGLE_USE_PREFIX + 'batch') - pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk') - - for step in batch: - if step.opcode == 'L': - if isinstance(step.data, ILibrary): - seg_tree = step.data - else: - # extract parameters from kwargs or data - seg_tree = self.traceL( - ccw=step.data.get('ccw') if isinstance(step.data, dict) else None, - length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0, - port_names=port_names, - **kwargs, - ) - elif step.opcode == 'S': - if isinstance(step.data, ILibrary): - seg_tree = step.data - else: - seg_tree = self.traceS( - length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0, - jog=float(step.data.get('jog', 0)) if isinstance(step.data, dict) else 0.0, - port_names=port_names, - **kwargs, - ) - elif step.opcode == 'U': - if isinstance(step.data, ILibrary): - seg_tree = step.data - else: - seg_tree = self.traceU( - jog=float(step.data.get('jog', 0)) if isinstance(step.data, dict) else 0.0, - length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0, - port_names=port_names, - **kwargs, - ) - else: - continue - - pat.plug(seg_tree.top_pattern(), {port_names[1]: port_names[0]}, append=True) - - return lib + raise NotImplementedError(f'render() not implemented for {type(self)}') abstract_tuple_t = tuple[Abstract, str, str] @@ -559,7 +390,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored) return tree - def traceL( + def path( self, ccw: SupportsBool | None, length: float, @@ -576,7 +407,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): out_ptype = out_ptype, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) return tree @@ -589,7 +420,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): **kwargs, ) -> ILibrary: - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=(port_names[0], port_names[1])) for step in batch: @@ -666,19 +497,6 @@ class AutoTool(Tool, metaclass=ABCMeta): def reversed(self) -> Self: return type(self)(self.abstract, self.our_port_name, self.their_port_name) - @dataclass(frozen=True, slots=True) - class LPlan: - """ Template for an L-path configuration """ - straight: 'AutoTool.Straight' - bend: 'AutoTool.Bend | None' - in_trans: 'AutoTool.Transition | None' - b_trans: 'AutoTool.Transition | None' - out_trans: 'AutoTool.Transition | None' - overhead_x: float - overhead_y: float - bend_angle: float - out_ptype: str - @dataclass(frozen=True, slots=True) class LData: """ Data for planL """ @@ -691,65 +509,6 @@ class AutoTool(Tool, metaclass=ABCMeta): b_transition: 'AutoTool.Transition | None' out_transition: 'AutoTool.Transition | None' - def _iter_l_plans( - self, - ccw: SupportsBool | None, - in_ptype: str | None, - out_ptype: str | None, - ) -> Iterator[LPlan]: - """ - Iterate over all possible combinations of straights and bends that - could form an L-path. - """ - bends = cast('list[AutoTool.Bend | None]', self.bends) - if ccw is None and not bends: - bends = [None] - - for straight in self.straights: - for bend in bends: - bend_dxy, bend_angle = self._bend2dxy(bend, ccw) - - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if ccw is None else cast('AutoTool.Bend', bend).out_port.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, bend_angle) - - b_transition = None - if ccw is not None: - assert bend is not None - if bend.in_port.ptype != straight.ptype: - b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) - - overhead_x = bend_dxy[0] + itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0] - overhead_y = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] - - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif ccw is not None: - assert bend is not None - out_ptype_actual = bend.out_port.ptype - else: - out_ptype_actual = straight.ptype - - yield self.LPlan( - straight = straight, - bend = bend, - in_trans = in_transition, - b_trans = b_transition, - out_trans = out_transition, - overhead_x = overhead_x, - overhead_y = overhead_y, - bend_angle = bend_angle, - out_ptype = out_ptype_actual, - ) - @dataclass(frozen=True, slots=True) class SData: """ Data for planS """ @@ -762,80 +521,6 @@ class AutoTool(Tool, metaclass=ABCMeta): b_transition: 'AutoTool.Transition | None' out_transition: 'AutoTool.Transition | None' - @dataclass(frozen=True, slots=True) - class UData: - """ Data for planU or planS (double-L) """ - ldata0: 'AutoTool.LData' - ldata1: 'AutoTool.LData' - straight2: 'AutoTool.Straight' - l2_length: float - mid_transition: 'AutoTool.Transition | None' - - def _solve_double_l( - self, - length: float, - jog: float, - ccw1: SupportsBool, - ccw2: SupportsBool, - in_ptype: str | None, - out_ptype: str | None, - **kwargs, - ) -> tuple[Port, UData]: - """ - Solve for a path consisting of two L-bends connected by a straight segment. - Used for both U-turns (ccw1 == ccw2) and S-bends (ccw1 != ccw2). - """ - for plan1 in self._iter_l_plans(ccw1, in_ptype, None): - for plan2 in self._iter_l_plans(ccw2, plan1.out_ptype, out_ptype): - # Solving for: - # X = L1_total +/- R2_actual = length - # Y = R1_actual + L2_straight + overhead_mid + overhead_b2 + L3_total = jog - - # Sign for overhead_y2 depends on whether it's a U-turn or S-bend - is_u = bool(ccw1) == bool(ccw2) - # U-turn: X = L1_total - R2 = length => L1_total = length + R2 - # S-bend: X = L1_total + R2 = length => L1_total = length - R2 - l1_total = length + (abs(plan2.overhead_y) if is_u else -abs(plan2.overhead_y)) - l1_straight = l1_total - plan1.overhead_x - - - if plan1.straight.length_range[0] <= l1_straight < plan1.straight.length_range[1]: - for straight_mid in self.straights: - # overhead_mid accounts for the transition from bend1 to straight_mid - mid_ptype_pair = (plan1.out_ptype, straight_mid.ptype) - mid_trans = self.transitions.get(mid_ptype_pair, None) - mid_trans_dxy = self._itransition2dxy(mid_trans) - - # b_trans2 accounts for the transition from straight_mid to bend2 - b2_trans = None - if plan2.bend is not None and plan2.bend.in_port.ptype != straight_mid.ptype: - b2_trans = self.transitions.get((plan2.bend.in_port.ptype, straight_mid.ptype), None) - b2_trans_dxy = self._itransition2dxy(b2_trans) - - l2_straight = abs(jog) - abs(plan1.overhead_y) - plan2.overhead_x - mid_trans_dxy[0] - b2_trans_dxy[0] - - if straight_mid.length_range[0] <= l2_straight < straight_mid.length_range[1]: - # Found a solution! - # For plan2, we assume l3_straight = 0. - # We need to verify if l3=0 is valid for plan2.straight. - l3_straight = 0 - if plan2.straight.length_range[0] <= l3_straight < plan2.straight.length_range[1]: - ldata0 = self.LData( - l1_straight, plan1.straight, kwargs, ccw1, plan1.bend, - plan1.in_trans, plan1.b_trans, plan1.out_trans, - ) - ldata1 = self.LData( - l3_straight, plan2.straight, kwargs, ccw2, plan2.bend, - b2_trans, None, plan2.out_trans, - ) - - data = self.UData(ldata0, ldata1, straight_mid, l2_straight, mid_trans) - # out_port is at (length, jog) rot pi (for S-bend) or 0 (for U-turn) relative to input - out_rot = 0 if is_u else pi - out_port = Port((length, jog), rotation=out_rot, ptype=plan2.out_ptype) - return out_port, data - raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}") - straights: list[Straight] """ List of straight-generators to choose from, in order of priority """ @@ -858,10 +543,9 @@ class AutoTool(Tool, metaclass=ABCMeta): return self @staticmethod - def _bend2dxy(bend: Bend | None, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: + def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: if ccw is None: return numpy.zeros(2), pi - assert bend is not None bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) assert bend_angle is not None if bool(ccw): @@ -905,23 +589,54 @@ class AutoTool(Tool, metaclass=ABCMeta): **kwargs, ) -> tuple[Port, LData]: - for plan in self._iter_l_plans(ccw, in_ptype, out_ptype): - straight_length = length - plan.overhead_x - if plan.straight.length_range[0] <= straight_length < plan.straight.length_range[1]: - data = self.LData( - straight_length = straight_length, - straight = plan.straight, - straight_kwargs = kwargs, - ccw = ccw, - bend = plan.bend, - in_transition = plan.in_trans, - b_transition = plan.b_trans, - out_transition = plan.out_trans, - ) - out_port = Port((length, plan.overhead_y), rotation=plan.bend_angle, ptype=plan.out_ptype) - return out_port, data + success = False + for straight in self.straights: + for bend in self.bends: + bend_dxy, bend_angle = self._bend2dxy(bend, ccw) - raise BuildError(f'Failed to find a valid L-path configuration for {length=:,g}, {ccw=}, {in_ptype=}, {out_ptype=}') + in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) + in_transition = self.transitions.get(in_ptype_pair, None) + itrans_dxy = self._itransition2dxy(in_transition) + + out_ptype_pair = ( + 'unk' if out_ptype is None else out_ptype, + straight.ptype if ccw is None else bend.out_port.ptype + ) + out_transition = self.transitions.get(out_ptype_pair, None) + otrans_dxy = self._otransition2dxy(out_transition, bend_angle) + + b_transition = None + if ccw is not None and bend.in_port.ptype != straight.ptype: + b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) + btrans_dxy = self._itransition2dxy(b_transition) + + straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] + bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] + success = straight.length_range[0] <= straight_length < straight.length_range[1] + if success: + break + if success: + break + else: + # Failed to break + raise BuildError( + f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n' + f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' + f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' + ) + + if out_transition is not None: + out_ptype_actual = out_transition.their_port.ptype + elif ccw is not None: + out_ptype_actual = bend.out_port.ptype + elif not numpy.isclose(straight_length, 0): + out_ptype_actual = straight.ptype + else: + out_ptype_actual = self.default_out_ptype + + data = self.LData(straight_length, straight, kwargs, ccw, bend, in_transition, b_transition, out_transition) + out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) + return out_port, data def _renderL( self, @@ -958,7 +673,7 @@ class AutoTool(Tool, metaclass=ABCMeta): pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) return tree - def traceL( + def path( self, ccw: SupportsBool | None, length: float, @@ -975,7 +690,7 @@ class AutoTool(Tool, metaclass=ABCMeta): out_ptype = out_ptype, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) return tree @@ -1031,7 +746,7 @@ class AutoTool(Tool, metaclass=ABCMeta): jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]: sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[0] + otrans_dxy[0]) + success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) if success: b_transition = None straight_length = 0 @@ -1040,8 +755,26 @@ class AutoTool(Tool, metaclass=ABCMeta): break if not success: - ccw0 = jog > 0 - return self._solve_double_l(length, jog, ccw0, not ccw0, in_ptype, out_ptype, **kwargs) + try: + ccw0 = jog > 0 + p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype) + p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype) + + dx = p_test1.x - length / 2 + p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype) + p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype) + success = True + except BuildError as err: + l2_err: BuildError | None = err + else: + l2_err = None + raise NotImplementedError('TODO need to handle ldata below') + + if not success: + # Failed to break + raise BuildError( + f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}' + ) from l2_err if out_transition is not None: out_ptype_actual = out_transition.their_port.ptype @@ -1096,7 +829,7 @@ class AutoTool(Tool, metaclass=ABCMeta): pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) return tree - def traceS( + def pathS( self, length: float, jog: float, @@ -1112,74 +845,9 @@ class AutoTool(Tool, metaclass=ABCMeta): in_ptype = in_ptype, out_ptype = out_ptype, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - if isinstance(data, self.UData): - self._renderU(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - else: - self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - return tree - - def planU( - self, - jog: float, - *, - length: float = 0, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, UData]: - ccw = jog > 0 - return self._solve_double_l(length, jog, ccw, ccw, in_ptype, out_ptype, **kwargs) - - def _renderU( - self, - data: UData, - tree: ILibrary, - port_names: tuple[str, str], - gen_kwargs: dict[str, Any], - ) -> ILibrary: - pat = tree.top_pattern() - # 1. First L-bend - self._renderL(data.ldata0, tree, port_names, gen_kwargs) - # 2. Connecting straight - if data.mid_transition: - pat.plug(data.mid_transition.abstract, {port_names[1]: data.mid_transition.their_port_name}) - if not numpy.isclose(data.l2_length, 0): - s2_pat_or_tree = data.straight2.fn(data.l2_length, **(gen_kwargs | data.ldata0.straight_kwargs)) - pmap = {port_names[1]: data.straight2.in_port_name} - if isinstance(s2_pat_or_tree, Pattern): - pat.plug(s2_pat_or_tree, pmap, append=True) - else: - s2_tree = s2_pat_or_tree - top = s2_tree.top() - s2_tree.flatten(top, dangling_ok=True) - pat.plug(s2_tree[top], pmap, append=True) - # 3. Second L-bend - self._renderL(data.ldata1, tree, port_names, gen_kwargs) - return tree - - def traceU( - self, - jog: float, - *, - length: float = 0, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planU( - jog, - length = length, - in_ptype = in_ptype, - out_ptype = out_ptype, - **kwargs, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceU') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderU(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) return tree def render( @@ -1190,7 +858,7 @@ class AutoTool(Tool, metaclass=ABCMeta): **kwargs, ) -> ILibrary: - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.add_port_pair(names=(port_names[0], port_names[1])) for step in batch: @@ -1198,12 +866,7 @@ class AutoTool(Tool, metaclass=ABCMeta): if step.opcode == 'L': self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) elif step.opcode == 'S': - if isinstance(step.data, self.UData): - self._renderU(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - else: - self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - elif step.opcode == 'U': - self._renderU(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) return tree @@ -1234,7 +897,7 @@ class PathTool(Tool, metaclass=ABCMeta): # self.width = width # self.ptype: str - def traceL( + def path( self, ccw: SupportsBool | None, length: float, @@ -1244,20 +907,15 @@ class PathTool(Tool, metaclass=ABCMeta): port_names: tuple[str, str] = ('A', 'B'), **kwargs, # noqa: ARG002 (unused) ) -> Library: - out_port, _data = self.planL( + out_port, dxy = self.planL( ccw, length, in_ptype=in_ptype, out_ptype=out_ptype, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - vertices: list[tuple[float, float]] - if ccw is None: - vertices = [(0.0, 0.0), (length, 0.0)] - else: - vertices = [(0.0, 0.0), (length, 0.0), tuple(out_port.offset)] - pat.path(layer=self.layer, width=self.width, vertices=vertices) + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)]) if ccw is None: out_rot = pi @@ -1268,7 +926,7 @@ class PathTool(Tool, metaclass=ABCMeta): pat.ports = { port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), - port_names[1]: Port(out_port.offset, rotation=out_rot, ptype=self.ptype), + port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype), } return tree @@ -1317,44 +975,29 @@ class PathTool(Tool, metaclass=ABCMeta): **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: - # Transform the batch so the first port is local (at 0,0) but retains its global rotation. - # This allows the path to be rendered with its original orientation, simplified by - # translation to the origin. RenderPather.render will handle the final placement - # (including rotation alignment) via `pat.plug`. - first_port = batch[0].start_port - translation = -first_port.offset - rotation = 0 - pivot = first_port.offset - - # Localize the batch for rendering - local_batch = [step.transformed(translation, rotation, pivot) for step in batch] - - path_vertices = [local_batch[0].start_port.offset] - for step in local_batch: + path_vertices = [batch[0].start_port.offset] + for step in batch: assert step.tool == self port_rot = step.start_port.rotation - # Masque convention: Port rotation points INTO the device. - # So the direction of travel for the path is AWAY from the port, i.e., port_rot + pi. assert port_rot is not None if step.opcode == 'L': - - length, _ = step.data + length, bend_run = step.data dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0) + #path_vertices.append(step.start_port.offset) path_vertices.append(step.start_port.offset + dxy) else: raise BuildError(f'Unrecognized opcode "{step.opcode}"') - # Check if the last vertex added is already at the end port location - if not numpy.allclose(path_vertices[-1], local_batch[-1].end_port.offset): + if (path_vertices[-1] != batch[-1].end_port.offset).any(): # If the path ends in a bend, we need to add the final vertex - path_vertices.append(local_batch[-1].end_port.offset) + path_vertices.append(batch[-1].end_port.offset) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.path(layer=self.layer, width=self.width, vertices=path_vertices) pat.ports = { - port_names[0]: local_batch[0].start_port.copy().rotate(pi), - port_names[1]: local_batch[-1].end_port.copy().rotate(pi), + port_names[0]: batch[0].start_port.copy().rotate(pi), + port_names[1]: batch[-1].end_port.copy().rotate(pi), } return tree diff --git a/masque/builder/utils.py b/masque/builder/utils.py index 5680694..3109f46 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -106,7 +106,7 @@ def ell( raise BuildError('Asked to find aggregation for ports that face in different directions:\n' + pformat(port_rotations)) else: - if set_rotation is None: + if set_rotation is not None: raise BuildError('set_rotation must be specified if no ports have rotations!') rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0c19b5a..0f6dd32 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -16,7 +16,7 @@ import gzip import numpy import ezdxf from ezdxf.enums import TextEntityAlignment -from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace +from ezdxf.entities import LWPolyline, Polyline, Text, Insert from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -55,7 +55,8 @@ def write( tuple: (1, 2) -> '1.2' str: '1.2' -> '1.2' (no change) - Shape repetitions are expanded into individual DXF entities. + DXF does not support shape repetition (only block repeptition). Please call + library.wrap_repeated_shapes() before writing to file. Other functions you may want to call: - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names @@ -212,60 +213,32 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if isinstance(element, LWPolyline | Polyline): if isinstance(element, LWPolyline): points = numpy.asarray(element.get_points()) - is_closed = element.closed - else: + elif isinstance(element, Polyline): points = numpy.asarray([pp.xyz for pp in element.points()]) - is_closed = element.is_closed attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) - width = 0 - if isinstance(element, LWPolyline): - # ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge) - if points.shape[1] >= 5: - if (points[:, 4] != 0).any(): - raise PatternError('LWPolyline has bulge (not yet representable in masque!)') - if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any(): - raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)') - width = points[0, 2] - elif points.shape[1] == 3: - # width used to be in column 2 - width = points[0, 2] + if points.shape[1] == 2: + raise PatternError('Invalid or unimplemented polygon?') - if width == 0: - width = attr.get('const_width', 0) + if points.shape[1] > 2: + if (points[0, 2] != points[:, 2]).any(): + raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') + if points.shape[1] == 4 and (points[:, 3] != 0).any(): + raise PatternError('LWPolyLine has bulge (not yet representable in masque!)') - verts = points[:, :2] - if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): - verts = numpy.vstack((verts, verts[0])) + width = points[0, 2] + if width == 0: + width = attr.get('const_width', 0) - shape: Path | Polygon - if width == 0 and is_closed: - # Use Polygon if it has at least 3 unique vertices - shape_verts = verts[:-1] if len(verts) > 1 else verts - if len(shape_verts) >= 3: - shape = Polygon(vertices=shape_verts) + shape: Path | Polygon + if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]): + shape = Polygon(vertices=points[:-1, :2]) else: - shape = Path(width=width, vertices=verts) - else: - shape = Path(width=width, vertices=verts) + shape = Path(width=width, vertices=points[:, :2]) pat.shapes[layer].append(shape) - elif isinstance(element, Solid | Trace): - attr = element.dxfattribs() - layer = attr.get('layer', DEFAULT_LAYER) - points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4) - if element.has_dxf_attrib(f'vtx{i}')]) - if len(points) >= 3: - # If vtx2 == vtx3, it's a triangle. ezdxf handles this. - if len(points) == 4 and numpy.allclose(points[2], points[3]): - verts = points[:3, :2] - # DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals! - elif len(points) == 4: - verts = points[[0, 1, 3, 2], :2] - else: - verts = points[:, :2] - pat.shapes[layer].append(Polygon(vertices=verts)) + elif isinstance(element, Text): args = dict( offset=numpy.asarray(element.get_placement()[1])[:2], @@ -330,23 +303,15 @@ def _mrefs_to_drefs( elif isinstance(rep, Grid): a = rep.a_vector b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) - # In masque, the grid basis vectors are NOT rotated by the reference's rotation. - # In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing], - # which ARE then rotated by the block reference's rotation. - # Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90) - # AND the grid is already manhattan. - - # Rotate basis vectors by the reference rotation to see where they end up in the DXF frame - rotated_a = rotation_matrix_2d(ref.rotation) @ a - rotated_b = rotation_matrix_2d(ref.rotation) @ b - - if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8): + rotated_a = rotation_matrix_2d(-ref.rotation) @ a + rotated_b = rotation_matrix_2d(-ref.rotation) @ b + if rotated_a[1] == 0 and rotated_b[0] == 0: attribs['column_count'] = rep.a_count attribs['row_count'] = rep.b_count attribs['column_spacing'] = rotated_a[0] attribs['row_spacing'] = rotated_b[1] block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) - elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8): + elif rotated_a[0] == 0 and rotated_b[1] == 0: attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] @@ -379,23 +344,16 @@ def _shapes_to_elements( for layer, sseq in shapes.items(): attribs = dict(layer=_mlayer2dxf(layer)) for shape in sseq: - displacements = [numpy.zeros(2)] if shape.repetition is not None: - displacements = shape.repetition.displacements + raise PatternError( + 'Shape repetitions are not supported by DXF.' + ' Please call library.wrap_repeated_shapes() before writing to file.' + ) - for dd in displacements: - if isinstance(shape, Path): - # preserve path. - # Note: DXF paths don't support endcaps well, so this is still a bit limited. - xy = shape.vertices + dd - attribs_path = {**attribs} - if shape.width > 0: - attribs_path['const_width'] = shape.width - block.add_lwpolyline(xy, dxfattribs=attribs_path) - else: - for polygon in shape.to_polygons(): - xy_open = polygon.vertices + dd - block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs) + for polygon in shape.to_polygons(): + xy_open = polygon.vertices + xy_closed = numpy.vstack((xy_open, xy_open[0, :])) + block.add_lwpolyline(xy_closed, dxfattribs=attribs) def _labels_to_texts( @@ -405,17 +363,11 @@ def _labels_to_texts( for layer, lseq in labels.items(): attribs = dict(layer=_mlayer2dxf(layer)) for label in lseq: - if label.repetition is None: - block.add_text( - label.string, - dxfattribs=attribs - ).set_placement(label.offset, align=TextEntityAlignment.BOTTOM_LEFT) - else: - for dd in label.repetition.displacements: - block.add_text( - label.string, - dxfattribs=attribs - ).set_placement(label.offset + dd, align=TextEntityAlignment.BOTTOM_LEFT) + xy = label.offset + block.add_text( + label.string, + dxfattribs=attribs + ).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT) def _mlayer2dxf(layer: layer_t) -> str: diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index f589ad8..6972cfa 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -82,7 +82,7 @@ def write( datatype is chosen to be `shape.layer[1]` if available, otherwise `0` - GDS does not support shape repetition (only cell repetition). Please call + GDS does not support shape repetition (only cell repeptition). Please call `library.wrap_repeated_shapes()` before writing to file. Other functions you may want to call: @@ -453,7 +453,7 @@ def _shapes_to_elements( extension: tuple[int, int] if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: - extension = tuple(rint_cast(shape.cap_extensions)) + extension = tuple(shape.cap_extensions) # type: ignore else: extension = (0, 0) @@ -617,12 +617,7 @@ def load_libraryfile( stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore else: stream = path.open(mode='rb') # noqa: SIM115 - - try: - return load_library(stream, full_load=full_load, postprocess=postprocess) - finally: - if full_load: - stream.close() + return load_library(stream, full_load=full_load, postprocess=postprocess) def check_valid_names( @@ -653,7 +648,7 @@ def check_valid_names( logger.error('Names contain invalid characters:\n' + pformat(bad_chars)) if bad_lengths: - logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths)) + logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars)) if bad_chars or bad_lengths: raise LibraryError('Library contains invalid names, see log above') diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 5e343ea..672af25 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -120,10 +120,10 @@ def build( layer, data_type = _mlayer2oas(layer_num) lib.layers += [ fatrec.LayerName( - nstring = name, - layer_interval = (layer, layer), - type_interval = (data_type, data_type), - is_textlayer = tt, + nstring=name, + layer_interval=(layer, layer), + type_interval=(data_type, data_type), + is_textlayer=tt, ) for tt in (True, False)] @@ -182,8 +182,8 @@ def writefile( Args: library: A {name: Pattern} mapping of patterns to write. filename: Filename to save to. - *args: passed to `oasis.build()` - **kwargs: passed to `oasis.build()` + *args: passed to `oasis.write` + **kwargs: passed to `oasis.write` """ path = pathlib.Path(filename) @@ -213,9 +213,9 @@ def readfile( Will automatically decompress gzipped files. Args: - filename: Filename to load from. - *args: passed to `oasis.read()` - **kwargs: passed to `oasis.read()` + filename: Filename to save to. + *args: passed to `oasis.read` + **kwargs: passed to `oasis.read` """ path = pathlib.Path(filename) if is_gzipped(path): @@ -286,11 +286,11 @@ def read( annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.polygon( - vertices = vertices, - layer = element.get_layer_tuple(), - offset = element.get_xy(), - annotations = annotations, - repetition = repetition, + vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + annotations=annotations, + repetition=repetition, ) elif isinstance(element, fatrec.Path): vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) @@ -310,13 +310,13 @@ def read( annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.path( - vertices = vertices, - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - annotations = annotations, - width = element.get_half_width() * 2, - cap = cap, + vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + width=element.get_half_width() * 2, + cap=cap, **path_args, ) @@ -325,11 +325,11 @@ def read( height = element.get_height() annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.polygon( - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), - annotations = annotations, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), + annotations=annotations, ) elif isinstance(element, fatrec.Trapezoid): @@ -440,11 +440,11 @@ def read( else: string = str_or_ref.string pat.label( - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - annotations = annotations, - string = string, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + string=string, ) else: @@ -549,13 +549,13 @@ def _shapes_to_elements( offset = rint_cast(shape.offset + rep_offset) radius = rint_cast(shape.radius) circle = fatrec.Circle( - layer = layer, - datatype = datatype, - radius = cast('int', radius), - x = offset[0], - y = offset[1], - properties = properties, - repetition = repetition, + layer=layer, + datatype=datatype, + radius=cast('int', radius), + x=offset[0], + y=offset[1], + properties=properties, + repetition=repetition, ) elements.append(circle) elif isinstance(shape, Path): @@ -566,16 +566,16 @@ def _shapes_to_elements( extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None) extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) path = fatrec.Path( - layer = layer, - datatype = datatype, - point_list = cast('Sequence[Sequence[int]]', deltas), - half_width = cast('int', half_width), - x = xy[0], - y = xy[1], - extension_start = extension_start, # TODO implement multiple cap types? - extension_end = extension_end, - properties = properties, - repetition = repetition, + layer=layer, + datatype=datatype, + point_list=cast('Sequence[Sequence[int]]', deltas), + half_width=cast('int', half_width), + x=xy[0], + y=xy[1], + extension_start=extension_start, # TODO implement multiple cap types? + extension_end=extension_end, + properties=properties, + repetition=repetition, ) elements.append(path) else: @@ -583,13 +583,13 @@ def _shapes_to_elements( xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset) points = rint_cast(numpy.diff(polygon.vertices, axis=0)) elements.append(fatrec.Polygon( - layer = layer, - datatype = datatype, - x = xy[0], - y = xy[1], - point_list = cast('list[list[int]]', points), - properties = properties, - repetition = repetition, + layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + point_list=cast('list[list[int]]', points), + properties=properties, + repetition=repetition, )) return elements @@ -606,13 +606,13 @@ def _labels_to_texts( xy = rint_cast(label.offset + rep_offset) properties = annotations_to_properties(label.annotations) texts.append(fatrec.Text( - layer = layer, - datatype = datatype, - x = xy[0], - y = xy[1], - string = label.string, - properties = properties, - repetition = repetition, + layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + string=label.string, + properties=properties, + repetition=repetition, )) return texts @@ -622,12 +622,10 @@ def repetition_fata2masq( ) -> Repetition | None: mrep: Repetition | None if isinstance(rep, fatamorgana.GridRepetition): - mrep = Grid( - a_vector = rep.a_vector, - b_vector = rep.b_vector, - a_count = rep.a_count, - b_count = rep.b_count, - ) + mrep = Grid(a_vector=rep.a_vector, + b_vector=rep.b_vector, + a_count=rep.a_count, + b_count=rep.b_count) elif isinstance(rep, fatamorgana.ArbitraryRepetition): displacements = numpy.cumsum(numpy.column_stack(( rep.x_displacements, @@ -649,19 +647,14 @@ def repetition_masq2fata( frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None if isinstance(rep, Grid): a_vector = rint_cast(rep.a_vector) - a_count = int(rep.a_count) - if rep.b_count > 1: - b_vector = rint_cast(rep.b_vector) - b_count = int(rep.b_count) - else: - b_vector = None - b_count = None - + b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None + a_count = rint_cast(rep.a_count) + b_count = rint_cast(rep.b_count) if rep.b_count is not None else None frep = fatamorgana.GridRepetition( - a_vector = a_vector, - b_vector = b_vector, - a_count = a_count, - b_count = b_count, + a_vector=cast('list[int]', a_vector), + b_vector=cast('list[int] | None', b_vector), + a_count=cast('int', a_count), + b_count=cast('int | None', b_count), ) offset = (0, 0) elif isinstance(rep, Arbitrary): @@ -717,6 +710,10 @@ def properties_to_annotations( annotations[key] = values return annotations + properties = [fatrec.Property(key, vals, is_standard=False) + for key, vals in annotations.items()] + return properties + def check_valid_names( names: Iterable[str], diff --git a/masque/file/svg.py b/masque/file/svg.py index f235b50..859c074 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -10,32 +10,16 @@ import svgwrite # type: ignore from .utils import mangle_name from .. import Pattern -from ..utils import rotation_matrix_2d logger = logging.getLogger(__name__) -def _ref_to_svg_transform(ref) -> str: - linear = rotation_matrix_2d(ref.rotation) * ref.scale - if ref.mirrored: - linear = linear @ numpy.diag((1.0, -1.0)) - - a = linear[0, 0] - b = linear[1, 0] - c = linear[0, 1] - d = linear[1, 1] - e = ref.offset[0] - f = ref.offset[1] - return f'matrix({a:g} {b:g} {c:g} {d:g} {e:g} {f:g})' - - def writefile( library: Mapping[str, Pattern], top: str, filename: str, custom_attributes: bool = False, - annotate_ports: bool = False, ) -> None: """ Write a Pattern to an SVG file, by first calling .polygonize() on it @@ -60,8 +44,6 @@ def writefile( filename: Filename to write to. custom_attributes: Whether to write non-standard `pattern_layer` attribute to the SVG elements. - annotate_ports: If True, draw an arrow for each port (similar to - `Pattern.visualize(..., ports=True)`). """ pattern = library[top] @@ -97,32 +79,11 @@ def writefile( svg_group.add(path) - if annotate_ports: - # Draw arrows for the ports, pointing into the device (per port definition) - for port_name, port in pat.ports.items(): - if port.rotation is not None: - p1 = port.offset - angle = port.rotation - size = 1.0 # arrow size - p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) - - # head - head_angle = 0.5 - h1 = p1 + 0.7 * size * numpy.array([numpy.cos(angle + head_angle), numpy.sin(angle + head_angle)]) - h2 = p1 + 0.7 * size * numpy.array([numpy.cos(angle - head_angle), numpy.sin(angle - head_angle)]) - - line = svg.line(start=p1, end=p2, stroke='green', stroke_width=0.2) - head = svg.polyline(points=[h1, p1, h2], fill='none', stroke='green', stroke_width=0.2) - - svg_group.add(line) - svg_group.add(head) - svg_group.add(svg.text(port_name, insert=p2, font_size=0.5, fill='green')) - for target, refs in pat.refs.items(): if target is None: continue for ref in refs: - transform = _ref_to_svg_transform(ref) + transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})' use = svg.use(href='#' + mangle_name(target), transform=transform) svg_group.add(use) diff --git a/masque/file/utils.py b/masque/file/utils.py index 25bc61d..33f68d4 100644 --- a/masque/file/utils.py +++ b/masque/file/utils.py @@ -75,8 +75,7 @@ def preflight( raise PatternError('Non-numeric layers found:' + pformat(named_layers)) if prune_empty_patterns: - prune_dangling = 'error' if allow_dangling_refs is False else 'ignore' - pruned = lib.prune_empty(dangling=prune_dangling) + pruned = lib.prune_empty() if pruned: logger.info(f'Preflight pruned {len(pruned)} empty patterns') logger.debug('Pruned: ' + pformat(pruned)) diff --git a/masque/label.py b/masque/label.py index b662035..711ef35 100644 --- a/masque/label.py +++ b/masque/label.py @@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray from .repetition import Repetition from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key -from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable +from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded from .traits import AnnotatableImpl @functools.total_ordering -class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable): +class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): """ A text annotation with a position (but no size; it is not drawn) """ @@ -58,14 +58,12 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl string=self.string, offset=self.offset.copy(), repetition=self.repetition, - annotations=copy.copy(self.annotations), ) def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() - new._annotations = copy.deepcopy(self._annotations, memo) return new def __lt__(self, other: 'Label') -> bool: @@ -98,34 +96,10 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl """ pivot = numpy.asarray(pivot, dtype=float) self.translate(-pivot) - if self.repetition is not None: - self.repetition.rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) self.translate(+pivot) return self - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - """ - Extrinsic transformation: Flip the label across a line in the pattern's - coordinate system. This affects both the label's offset and its - repetition grid. - - Args: - axis: Axis to mirror across. 0: x-axis (flip y), 1: y-axis (flip x). - x: Vertical line x=val to mirror across. - y: Horizontal line y=val to mirror across. - - Returns: - self - """ - axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) - self.translate(-pivot) - if self.repetition is not None: - self.repetition.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self - def get_bounds_single(self) -> NDArray[numpy.float64]: """ Return the bounds of the label. diff --git a/masque/library.py b/masque/library.py index bb2e3d2..0ed5271 100644 --- a/masque/library.py +++ b/masque/library.py @@ -59,9 +59,6 @@ TreeView: TypeAlias = Mapping[str, 'Pattern'] Tree: TypeAlias = MutableMapping[str, 'Pattern'] """ A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """ -dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include'] -""" How helpers should handle refs whose targets are not present in the library. """ - SINGLE_USE_PREFIX = '_' """ @@ -189,9 +186,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): # Perform recursive lookups, but only once for each name for target in targets - skip: assert target is not None - skip.add(target) if target in self: targets |= self.referenced_patterns(target, skip=skip) + skip.add(target) return targets @@ -294,9 +291,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): def flatten_single(name: str) -> None: flattened[name] = None pat = self[name].deepcopy() - refs_by_target = tuple((target, tuple(refs)) for target, refs in pat.refs.items()) - for target, refs in refs_by_target: + for target in pat.refs: if target is None: continue if dangling_ok and target not in self: @@ -307,16 +303,10 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): target_pat = flattened[target] if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - ports_only = flatten_ports and bool(target_pat.ports) - if target_pat.is_empty() and not ports_only: # avoid some extra allocations + if target_pat.is_empty(): # avoid some extra allocations continue - for ref in refs: - if flatten_ports and ref.repetition is not None and target_pat.ports: - raise PatternError( - f'Cannot flatten ports from repeated ref to {target!r}; ' - 'flatten with flatten_ports=False or expand/rename the ports manually first.' - ) + for ref in pat.refs[target]: p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() @@ -422,21 +412,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ return self[self.top()] - @staticmethod - def _dangling_refs_error(dangling: set[str], context: str) -> LibraryError: - dangling_list = sorted(dangling) - return LibraryError(f'Dangling refs found while {context}: ' + pformat(dangling_list)) - - def _raw_child_graph(self) -> tuple[dict[str, set[str]], set[str]]: - existing = set(self.keys()) - graph: dict[str, set[str]] = {} - dangling: set[str] = set() - for name, pat in self.items(): - children = {child for child, refs in pat.refs.items() if child is not None and refs} - graph[name] = children - dangling |= children - existing - return graph, dangling - def dfs( self, pattern: 'Pattern', @@ -491,11 +466,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): memo = {} if transform is None or transform is True: - transform = numpy.array([0, 0, 0, 0, 1], dtype=float) + transform = numpy.zeros(4) elif transform is not False: transform = numpy.asarray(transform, dtype=float) - if transform.size == 4: - transform = numpy.append(transform, 1.0) original_pattern = pattern @@ -542,88 +515,46 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): return self - def child_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: + def child_graph(self) -> dict[str, set[str | None]]: """ Return a mapping from pattern name to a set of all child patterns (patterns it references). - Only non-empty ref lists with non-`None` targets are treated as graph edges. - - Args: - dangling: How refs to missing targets are handled. `'error'` raises, - `'ignore'` drops those edges, and `'include'` exposes them as - synthetic leaf nodes. - Returns: Mapping from pattern name to a set of all pattern names it references. """ - graph, dangling_refs = self._raw_child_graph() - if dangling == 'error': - if dangling_refs: - raise self._dangling_refs_error(dangling_refs, 'building child graph') - return graph - if dangling == 'ignore': - existing = set(graph) - return {name: {child for child in children if child in existing} for name, children in graph.items()} - - for target in dangling_refs: - graph.setdefault(target, set()) + graph = {name: set(pat.refs.keys()) for name, pat in self.items()} return graph - def parent_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: + def parent_graph(self) -> dict[str, set[str]]: """ Return a mapping from pattern name to a set of all parent patterns (patterns which reference it). - Args: - dangling: How refs to missing targets are handled. `'error'` raises, - `'ignore'` drops those targets, and `'include'` adds them as - synthetic keys whose values are their existing parents. - Returns: Mapping from pattern name to a set of all patterns which reference it. """ - child_graph, dangling_refs = self._raw_child_graph() - if dangling == 'error' and dangling_refs: - raise self._dangling_refs_error(dangling_refs, 'building parent graph') - - existing = set(child_graph) - igraph: dict[str, set[str]] = {name: set() for name in existing} - for parent, children in child_graph.items(): - for child in children: - if child in existing: - igraph[child].add(parent) - elif dangling == 'include': - igraph.setdefault(child, set()).add(parent) + igraph: dict[str, set[str]] = {name: set() for name in self} + for name, pat in self.items(): + for child, reflist in pat.refs.items(): + if reflist and child is not None: + igraph[child].add(name) return igraph - def child_order( - self, - dangling: dangling_mode_t = 'error', - ) -> list[str]: + def child_order(self) -> list[str]: """ - Return a topologically sorted list of graph node names. + Return a topologically sorted list of all contained pattern names. Child (referenced) patterns will appear before their parents. - Args: - dangling: Passed to `child_graph()`. - Return: Topologically sorted list of pattern names. """ - return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order())) + return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order())) def find_refs_local( self, name: str, parent_graph: dict[str, set[str]] | None = None, - dangling: dangling_mode_t = 'error', ) -> dict[str, list[NDArray[numpy.float64]]]: """ Find the location and orientation of all refs pointing to `name`. @@ -636,8 +567,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): The provided graph may be for a superset of `self` (i.e. it may contain additional patterns which are not present in self; they will be ignored). - dangling: How refs to missing targets are handled if `parent_graph` - is not provided. `'include'` also allows querying missing names. Returns: Mapping of {parent_name: transform_list}, where transform_list @@ -646,18 +575,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ instances = defaultdict(list) if parent_graph is None: - graph_mode = 'ignore' if dangling == 'ignore' else 'include' - parent_graph = self.parent_graph(dangling=graph_mode) - - if name not in self: - if name not in parent_graph: - return instances - if dangling == 'error': - raise self._dangling_refs_error({name}, f'finding local refs for {name!r}') - if dangling == 'ignore': - return instances - - for parent in parent_graph.get(name, set()): + parent_graph = self.parent_graph() + for parent in parent_graph[name]: if parent not in self: # parent_graph may be a for a superset of self continue for ref in self[parent].refs[name]: @@ -670,7 +589,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): name: str, order: list[str] | None = None, parent_graph: dict[str, set[str]] | None = None, - dangling: dangling_mode_t = 'error', ) -> dict[tuple[str, ...], NDArray[numpy.float64]]: """ Find the absolute (top-level) location and orientation of all refs (including @@ -687,28 +605,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): The provided graph may be for a superset of `self` (i.e. it may contain additional patterns which are not present in self; they will be ignored). - dangling: How refs to missing targets are handled if `order` or - `parent_graph` are not provided. `'include'` also allows - querying missing names. Returns: Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form `(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. """ - graph_mode = 'ignore' if dangling == 'ignore' else 'include' - if order is None: - order = self.child_order(dangling=graph_mode) - if parent_graph is None: - parent_graph = self.parent_graph(dangling=graph_mode) - if name not in self: - if name not in parent_graph: - return {} - if dangling == 'error': - raise self._dangling_refs_error({name}, f'finding global refs for {name!r}') - if dangling == 'ignore': - return {} + return {} + if order is None: + order = self.child_order() + if parent_graph is None: + parent_graph = self.parent_graph() self_keys = set(self.keys()) @@ -717,16 +625,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): NDArray[numpy.float64] ]]] transforms = defaultdict(list) - for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items(): + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items(): transforms[parent] = [((name,), numpy.concatenate(vals))] for next_name in order: if next_name not in transforms: continue - if not parent_graph.get(next_name, set()) & self_keys: + if not parent_graph[next_name] & self_keys: continue - outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) + outers = self.find_refs_local(next_name, parent_graph=parent_graph) inners = transforms.pop(next_name) for parent, outer in outers.items(): for path, inner in inners: @@ -774,33 +682,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: pass - def resolve( - self, - other: 'Abstract | str | Pattern | TreeView', - append: bool = False, - ) -> 'Abstract | Pattern': - """ - Resolve another device (name, Abstract, Pattern, or TreeView) into an Abstract or Pattern. - If it is a TreeView, it is first added into this library. - - Args: - other: The device to resolve. - append: If True and `other` is an `Abstract`, returns the full `Pattern` from the library. - - Returns: - An `Abstract` or `Pattern` object. - """ - from .pattern import Pattern #noqa: PLC0415 - if not isinstance(other, (str, Abstract, Pattern)): - # We got a TreeView; add it into self and grab its topcell as an Abstract - other = self << other - - if isinstance(other, str): - other = self.abstract(other) - if append and isinstance(other, Abstract): - other = self[other.name] - return other - def rename( self, old_name: str, @@ -882,7 +763,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: (name, pattern) tuple """ - from .pattern import Pattern #noqa: PLC0415 + from .pattern import Pattern pat = Pattern() self[name] = pat return name, pat @@ -922,7 +803,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Raises: `LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`. """ - from .pattern import map_targets #noqa: PLC0415 + from .pattern import map_targets duplicates = set(self.keys()) & set(other.keys()) if not duplicates: @@ -1028,7 +909,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): # This currently simplifies globally (same shape in different patterns is # merged into the same ref target). - from .pattern import Pattern #noqa: PLC0415 + from .pattern import Pattern if exclude_types is None: exclude_types = () @@ -1121,7 +1002,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: self """ - from .pattern import Pattern #noqa: PLC0415 + from .pattern import Pattern if name_func is None: def name_func(_pat: Pattern, _shape: Shape | Label) -> str: @@ -1155,25 +1036,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): return self - def resolve_repeated_refs(self, name: str | None = None) -> Self: - """ - Expand all repeated references into multiple individual references. - Alters the library in-place. - - Args: - name: If specified, only resolve repeated refs in this pattern. - Otherwise, resolve in all patterns. - - Returns: - self - """ - if name is not None: - self[name].resolve_repeated_refs() - else: - for pat in self.values(): - pat.resolve_repeated_refs() - return self - def subtree( self, tops: str | Sequence[str], @@ -1203,19 +1065,17 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): def prune_empty( self, repeat: bool = True, - dangling: dangling_mode_t = 'error', ) -> set[str]: """ Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`). Args: repeat: Also recursively delete any patterns which only contain(ed) empty patterns. - dangling: Passed to `parent_graph()`. Returns: A set containing the names of all deleted patterns """ - parent_graph = self.parent_graph(dangling=dangling) + parent_graph = self.parent_graph() empty = {name for name, pat in self.items() if pat.is_empty()} trimmed = set() while empty: @@ -1345,7 +1205,7 @@ class Library(ILibrary): Returns: The newly created `Library` and the newly created `Pattern` """ - from .pattern import Pattern #noqa: PLC0415 + from .pattern import Pattern tree = cls() pat = Pattern() tree[name] = pat @@ -1361,12 +1221,12 @@ class LazyLibrary(ILibrary): """ mapping: dict[str, Callable[[], 'Pattern']] cache: dict[str, 'Pattern'] - _lookups_in_progress: list[str] + _lookups_in_progress: set[str] def __init__(self) -> None: self.mapping = {} self.cache = {} - self._lookups_in_progress = [] + self._lookups_in_progress = set() def __setitem__( self, @@ -1397,20 +1257,16 @@ class LazyLibrary(ILibrary): return self.cache[key] if key in self._lookups_in_progress: - chain = ' -> '.join(self._lookups_in_progress + [key]) raise LibraryError( - f'Detected circular reference or recursive lookup of "{key}".\n' - f'Lookup chain: {chain}\n' + f'Detected multiple simultaneous lookups of "{key}".\n' 'This may be caused by an invalid (cyclical) reference, or buggy code.\n' - 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' + 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' # TODO give advice on finding cycles ) - self._lookups_in_progress.append(key) - try: - func = self.mapping[key] - pat = func() - finally: - self._lookups_in_progress.pop() + self._lookups_in_progress.add(key) + func = self.mapping[key] + pat = func() + self._lookups_in_progress.remove(key) self.cache[key] = pat return pat diff --git a/masque/pattern.py b/masque/pattern.py index ab5f55a..dc7d058 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -26,7 +26,6 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab from .ports import Port, PortList - logger = logging.getLogger(__name__) @@ -172,8 +171,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return s def __copy__(self) -> 'Pattern': - logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! ' - 'Consider using .deepcopy() if this was not intended.') + logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!') new = Pattern( annotations=copy.deepcopy(self.annotations), ports=copy.deepcopy(self.ports), @@ -200,7 +198,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def __lt__(self, other: 'Pattern') -> bool: self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] - other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) @@ -214,7 +212,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return refs_ours < refs_theirs self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] - other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) @@ -223,21 +221,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(other.shapes[layer])) + shapes_theirs = tuple(sorted(self.shapes[layer])) if shapes_ours != shapes_theirs: return shapes_ours < shapes_theirs self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] - other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) if self_txtlayerkeys != other_txtlayerkeys: return self_txtlayerkeys < other_txtlayerkeys - for _, _, layer in self_txtlayerkeys: + for _, _, layer in self_layerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(other.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) if labels_ours != labels_theirs: return labels_ours < labels_theirs @@ -254,7 +252,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return False self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] - other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) @@ -268,7 +266,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return False self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] - other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) @@ -277,21 +275,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(other.shapes[layer])) + shapes_theirs = tuple(sorted(self.shapes[layer])) if shapes_ours != shapes_theirs: return False self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] - other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) if self_txtlayerkeys != other_txtlayerkeys: return False - for _, _, layer in self_txtlayerkeys: + for _, _, layer in self_layerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(other.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) if labels_ours != labels_theirs: return False @@ -501,61 +499,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ] return polys - def layer_as_polygons( - self, - layer: layer_t, - flatten: bool = True, - library: Mapping[str, 'Pattern'] | None = None, - ) -> list[Polygon]: - """ - Collect all geometry effectively on a given layer as a list of polygons. - - If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`. - `Repetition` objects are expanded, and non-polygon shapes are converted - to `Polygon` approximations. - - Args: - layer: The layer to collect geometry from. - flatten: If `True`, include geometry from referenced patterns. - library: Required if `flatten=True` to resolve references. - - Returns: - A list of `Polygon` objects. - """ - if flatten and self.has_refs() and library is None: - raise PatternError("Must provide a library to layer_as_polygons() when flatten=True") - - polys: list[Polygon] = [] - - # Local shapes - for shape in self.shapes.get(layer, []): - for p in shape.to_polygons(): - # expand repetitions - if p.repetition is not None: - for offset in p.repetition.displacements: - polys.append(p.deepcopy().translate(offset).set_repetition(None)) - else: - polys.append(p.deepcopy()) - - if flatten and self.has_refs(): - assert library is not None - for target, refs in self.refs.items(): - if target is None: - continue - target_pat = library[target] - for ref in refs: - # Get polygons from target pattern on the same layer - ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library) - # Apply ref transformations - for p in ref_polys: - p_pat = ref.as_pattern(Pattern(shapes={layer: [p]})) - # as_pattern expands repetition of the ref itself - # but we need to pull the polygons back out - for p_transformed in p_pat.shapes[layer]: - polys.append(cast('Polygon', p_transformed)) - - return polys - def referenced_patterns(self) -> set[str | None]: """ Get all pattern namers referenced by this pattern. Non-recursive. @@ -692,7 +635,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): """ for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()): cast('Positionable', entry).translate(offset) - self._log_bulk_update(f"translate({offset!r})") return self def scale_elements(self, c: float) -> Self: @@ -746,9 +688,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Extrinsic transformation: Rotate the Pattern around the a location in the - container's coordinate system. This affects all elements' offsets and - their repetition grids. + Rotate the Pattern around the a location. Args: pivot: (x, y) location to rotate around @@ -762,14 +702,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self.rotate_elements(rotation) self.rotate_element_centers(rotation) self.translate_elements(+pivot) - self._log_bulk_update(f"rotate_around({pivot}, {rotation})") return self def rotate_element_centers(self, rotation: float) -> Self: """ - Extrinsic transformation part: Rotate the offsets and repetition grids of all - shapes, labels, refs, and ports around (0, 0) in the container's - coordinate system. + Rotate the offsets of all shapes, labels, refs, and ports around (0, 0) Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -780,15 +717,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): old_offset = cast('Positionable', entry).offset cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) - if isinstance(entry, Repeatable) and entry.repetition is not None: - entry.repetition.rotate(rotation) return self def rotate_elements(self, rotation: float) -> Self: """ - Intrinsic transformation part: Rotate each shape, ref, label, and port around its - origin (offset) in the container's coordinate system. This does NOT - affect their repetition grids. + Rotate each shape, ref, and port around its origin (offset) Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -796,61 +729,54 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - if isinstance(entry, Rotatable): - entry.rotate(rotation) + for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): + cast('Rotatable', entry).rotate(rotation) return self - def mirror_element_centers(self, axis: int = 0) -> Self: + def mirror_element_centers(self, across_axis: int = 0) -> Self: """ - Extrinsic transformation part: Mirror the offsets and repetition grids of all - shapes, labels, refs, and ports relative to the container's origin. + Mirror the offsets of all shapes, labels, and refs across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis) + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Positionable', entry).offset[1 - axis] *= -1 - if isinstance(entry, Repeatable) and entry.repetition is not None: - entry.repetition.mirror(axis) + cast('Positionable', entry).offset[1 - across_axis] *= -1 return self - def mirror_elements(self, axis: int = 0) -> Self: + def mirror_elements(self, across_axis: int = 0) -> Self: """ - Intrinsic transformation part: Mirror each shape, ref, label, and port relative - to its offset. This does NOT affect their repetition grids. + Mirror each shape, ref, and pattern across an axis, relative + to its offset Args: - axis: Axis to mirror across - 0: mirror across x axis (flip y), - 1: mirror across y axis (flip x) + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - if isinstance(entry, Mirrorable): - entry.mirror(axis=axis) - self._log_bulk_update(f"mirror_elements({axis})") + for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): + cast('Mirrorable', entry).mirror(across_axis) return self - def mirror(self, axis: int = 0) -> Self: + def mirror(self, across_axis: int = 0) -> Self: """ - Extrinsic transformation: Mirror the Pattern across an axis through its origin. - This affects all elements' offsets and their internal orientations. + Mirror the Pattern across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ - self.mirror_elements(axis=axis) - self.mirror_element_centers(axis=axis) - self._log_bulk_update(f"mirror({axis})") + self.mirror_elements(across_axis) + self.mirror_element_centers(across_axis) return self def copy(self) -> Self: @@ -861,7 +787,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: A deep copy of the current Pattern. """ - return self.deepcopy() + return copy.deepcopy(self) def deepcopy(self) -> Self: """ @@ -1004,28 +930,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): del self.labels[layer] return self - def resolve_repeated_refs(self) -> Self: - """ - Expand all repeated references into multiple individual references. - Alters the current pattern in-place. - - Returns: - self - """ - new_refs: defaultdict[str | None, list[Ref]] = defaultdict(list) - for target, rseq in self.refs.items(): - for ref in rseq: - if ref.repetition is None: - new_refs[target].append(ref) - else: - for dd in ref.repetition.displacements: - new_ref = ref.deepcopy() - new_ref.offset = ref.offset + dd - new_ref.repetition = None - new_refs[target].append(new_ref) - self.refs = new_refs - return self - def prune_refs(self) -> Self: """ Remove empty ref lists in `self.refs`. @@ -1077,16 +981,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - ports_only = flatten_ports and bool(target_pat.ports) - if target_pat.is_empty() and not ports_only: # avoid some extra allocations + if target_pat.is_empty(): # avoid some extra allocations continue for ref in refs: - if flatten_ports and ref.repetition is not None and target_pat.ports: - raise PatternError( - f'Cannot flatten ports from repeated ref to {target!r}; ' - 'flatten with flatten_ports=False or expand/rename the ports manually first.' - ) p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() @@ -1105,8 +1003,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): line_color: str = 'k', fill_color: str = 'none', overdraw: bool = False, - filename: str | None = None, - ports: bool = False, ) -> None: """ Draw a picture of the Pattern and wait for the user to inspect it @@ -1117,18 +1013,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): klayout or a different GDS viewer! Args: - library: Mapping of {name: Pattern} for resolving references. Required if `self.has_refs()`. - offset: Coordinates to offset by before drawing. - line_color: Outlines are drawn with this color. - fill_color: Interiors are drawn with this color. - overdraw: Whether to create a new figure or draw on a pre-existing one. - filename: If provided, save the figure to this file instead of showing it. - ports: If True, annotate the plot with arrows representing the ports. + offset: Coordinates to offset by before drawing + line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`) + fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`) + overdraw: Whether to create a new figure or draw on a pre-existing one """ # TODO: add text labels to visualize() try: - from matplotlib import pyplot # type: ignore #noqa: PLC0415 - import matplotlib.collections # type: ignore #noqa: PLC0415 + from matplotlib import pyplot # type: ignore + import matplotlib.collections # type: ignore except ImportError: logger.exception('Pattern.visualize() depends on matplotlib!\n' + 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.') @@ -1137,155 +1030,48 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if self.has_refs() and library is None: raise PatternError('Must provide a library when visualizing a pattern with refs') - # Cache for {Pattern object ID: List of local polygon vertex arrays} - # Polygons are stored relative to the pattern's origin (offset included) - poly_cache: dict[int, list[NDArray[numpy.float64]]] = {} + offset = numpy.asarray(offset, dtype=float) - def get_local_polys(pat: 'Pattern') -> list[NDArray[numpy.float64]]: - pid = id(pat) - if pid not in poly_cache: - polys = [] - for shape in chain.from_iterable(pat.shapes.values()): - for ss in shape.to_polygons(): - # Shape.to_polygons() returns Polygons with their own offsets and vertices. - # We need to expand any shape-level repetition here. - v_base = ss.vertices + ss.offset - if ss.repetition is not None: - for disp in ss.repetition.displacements: - polys.append(v_base + disp) - else: - polys.append(v_base) - poly_cache[pid] = polys - return poly_cache[pid] - - all_polygons: list[NDArray[numpy.float64]] = [] - port_info: list[tuple[str, NDArray[numpy.float64], float]] = [] - - def collect_polys_recursive( - pat: 'Pattern', - c_offset: NDArray[numpy.float64], - c_rotation: float, - c_mirrored: bool, - c_scale: float, - ) -> None: - # Current transform: T(c_offset) * R(c_rotation) * M(c_mirrored) * S(c_scale) - - # 1. Transform and collect local polygons - local_polys = get_local_polys(pat) - if local_polys: - rot_mat = rotation_matrix_2d(c_rotation) - for v in local_polys: - vt = v * c_scale - if c_mirrored: - vt = vt.copy() - vt[:, 1] *= -1 - vt = (rot_mat @ vt.T).T + c_offset - all_polygons.append(vt) - - # 2. Collect ports if requested - if ports: - for name, p in pat.ports.items(): - pt_v = p.offset * c_scale - if c_mirrored: - pt_v = pt_v.copy() - pt_v[1] *= -1 - pt_v = rotation_matrix_2d(c_rotation) @ pt_v + c_offset - - if p.rotation is not None: - pt_rot = p.rotation - if c_mirrored: - pt_rot = -pt_rot - pt_rot += c_rotation - port_info.append((name, pt_v, pt_rot)) - - # 3. Recurse into refs - for target, refs in pat.refs.items(): - if target is None: - continue - assert library is not None - target_pat = library[target] - for ref in refs: - # Ref order of operations: mirror, rotate, scale, translate, repeat - - # Combined scale and mirror - r_scale = c_scale * ref.scale - r_mirrored = c_mirrored ^ ref.mirrored - - # Combined rotation: push c_mirrored and c_rotation through ref.rotation - r_rot_relative = -ref.rotation if c_mirrored else ref.rotation - r_rotation = c_rotation + r_rot_relative - - # Offset composition helper - def get_full_offset(rel_offset: NDArray[numpy.float64]) -> NDArray[numpy.float64]: - o = rel_offset * c_scale - if c_mirrored: - o = o.copy() - o[1] *= -1 - return rotation_matrix_2d(c_rotation) @ o + c_offset - - if ref.repetition is not None: - for disp in ref.repetition.displacements: - collect_polys_recursive( - target_pat, - get_full_offset(ref.offset + disp), - r_rotation, - r_mirrored, - r_scale - ) - else: - collect_polys_recursive( - target_pat, - get_full_offset(ref.offset), - r_rotation, - r_mirrored, - r_scale - ) - - # Start recursive collection - collect_polys_recursive(self, numpy.asarray(offset, dtype=float), 0.0, False, 1.0) - - # Plotting if not overdraw: figure = pyplot.figure() + pyplot.axis('equal') else: figure = pyplot.gcf() axes = figure.gca() - if all_polygons: - mpl_poly_collection = matplotlib.collections.PolyCollection( - all_polygons, - facecolors = fill_color, - edgecolors = line_color, - ) - axes.add_collection(mpl_poly_collection) + polygons = [] + for shape in chain.from_iterable(self.shapes.values()): + polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()] - if ports: - for port_name, pt_v, pt_rot in port_info: - p1 = pt_v - angle = pt_rot - size = 1.0 # arrow size - p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) + mpl_poly_collection = matplotlib.collections.PolyCollection( + polygons, + facecolors=fill_color, + edgecolors=line_color, + ) + axes.add_collection(mpl_poly_collection) + pyplot.axis('equal') - axes.annotate( - port_name, - xy = tuple(p1), - xytext = tuple(p2), - arrowprops = dict(arrowstyle="->", color='g', linewidth=1), - color = 'g', - fontsize = 8, + for target, refs in self.refs.items(): + if target is None: + continue + if not refs: + continue + assert library is not None + target_pat = library[target] + for ref in refs: + ref.as_pattern(target_pat).visualize( + library=library, + offset=offset, + overdraw=True, + line_color=line_color, + fill_color=fill_color, ) - axes.autoscale_view() - axes.set_aspect('equal') - if not overdraw: - axes.set_xlabel('x') - axes.set_ylabel('y') - if filename: - figure.savefig(filename) - else: - figure.show() + pyplot.xlabel('x') + pyplot.ylabel('y') + pyplot.show() # @overload # def place( @@ -1328,7 +1114,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map: dict[str, str | None] | None = None, skip_port_check: bool = False, append: bool = False, - skip_geometry: bool = False, ) -> Self: """ Instantiate or append the pattern `other` into the current pattern, adding its @@ -1360,10 +1145,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). - skip_geometry: If `True`, the operation only updates the port list and - skips adding any geometry (shapes, labels, or references). This - allows the pattern assembly to proceed for port-tracking purposes - even when layout generation is suppressed. Returns: self @@ -1395,10 +1176,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): pp.rotate_around(pivot, rotation) pp.translate(offset) self.ports[name] = pp - self._log_port_update(name) - - if skip_geometry: - return self if append: if isinstance(other, Abstract): @@ -1411,9 +1188,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): other_copy.translate_elements(offset) self.append(other_copy) else: - if isinstance(other, Pattern): - raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' - 'Use `append=True` if you intended to append the full geometry.') + assert not isinstance(other, Pattern) ref = Ref(mirrored=mirrored) ref.rotate_around(pivot, rotation) ref.translate(offset) @@ -1459,7 +1234,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): set_rotation: bool | None = None, append: bool = False, ok_connections: Iterable[tuple[str, str]] = (), - skip_geometry: bool = False, ) -> Self: """ Instantiate or append a pattern into the current pattern, connecting @@ -1514,11 +1288,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): any other ptypte. Non-allowed ptype connections will emit a warning. Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. - skip_geometry: If `True`, only ports are updated and geometry is - skipped. If a valid transform cannot be found (e.g. due to - misaligned ports), a 'best-effort' dummy transform is used - to ensure new ports are still added at approximate locations, - allowing downstream routing to continue. Returns: self @@ -1551,42 +1320,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_out = {out_port_name: next(iter(map_in.keys()))} self.check_ports(other.ports.keys(), map_in, map_out) - try: - translation, rotation, pivot = self.find_transform( - other, - map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, - ) - except PortError: - if not skip_geometry: - raise - logger.warning("Port transform failed for dead device. Using dummy transform.") - if map_in: - ki, vi = next(iter(map_in.items())) - s_port = self.ports[ki] - o_port = other.ports[vi].deepcopy() - if mirrored: - o_port.mirror() - o_port.offset[1] *= -1 - translation = s_port.offset - o_port.offset - rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0 - pivot = o_port.offset - else: - translation = numpy.zeros(2) - rotation = 0.0 - pivot = numpy.zeros(2) + translation, rotation, pivot = self.find_transform( + other, + map_in, + mirrored = mirrored, + set_rotation = set_rotation, + ok_connections = ok_connections, + ) # get rid of plugged ports for ki, vi in map_in.items(): del self.ports[ki] - self._log_port_removal(ki) map_out[vi] = None - if isinstance(other, Pattern) and not (append or skip_geometry): - raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' - 'Use `append=True` if you intended to append the full geometry.') + if isinstance(other, Pattern): + assert append, 'Got a name (not an abstract) but was asked to reference (not append)' self.place( other, @@ -1597,7 +1345,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map = map_out, skip_port_check = True, append = append, - skip_geometry = skip_geometry, ) return self diff --git a/masque/ports.py b/masque/ports.py index 3a67003..0211723 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -2,7 +2,6 @@ from typing import overload, Self, NoReturn, Any from collections.abc import Iterable, KeysView, ValuesView, Mapping import logging import functools -import copy from collections import Counter from abc import ABCMeta, abstractmethod from itertools import chain @@ -11,17 +10,16 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable +from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable from .utils import rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace logger = logging.getLogger(__name__) -port_logger = logging.getLogger('masque.ports') @functools.total_ordering -class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): +class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): """ A point at which a `Device` can be snapped to another `Device`. @@ -93,12 +91,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): def copy(self) -> Self: return self.deepcopy() - def __deepcopy__(self, memo: dict | None = None) -> Self: - memo = {} if memo is None else memo - new = copy.copy(self) - new._offset = self._offset.copy() - return new - def get_bounds(self) -> NDArray[numpy.float64]: return numpy.vstack((self.offset, self.offset)) @@ -107,27 +99,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): self.ptype = ptype return self - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - """ - Mirror the object across a line in the container's coordinate system. - - Note this operation is performed relative to the pattern's origin and modifies the port's offset. - - Args: - axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0. - x: Vertical line x=val to mirror across. - y: Horizontal line y=val to mirror across. - - Returns: - self - """ - axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) - self.translate(-pivot) - self.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self - def mirror(self, axis: int = 0) -> Self: if self.rotation is not None: self.rotation *= -1 @@ -143,34 +114,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): self.rotation = rotation return self - def describe(self) -> str: - """ - Returns a human-readable description of the port's state including cardinal directions. - """ - deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None - - cardinal = "" - travel_dir = "" - - if self.rotation is not None: - dirs = {0: "East (+x)", 90: "North (+y)", 180: "West (-x)", 270: "South (-y)"} - # normalize to [0, 360) - deg_norm = deg % 360 - - # Find closest cardinal - closest = min(dirs.keys(), key=lambda x: abs((deg_norm - x + 180) % 360 - 180)) - if numpy.isclose((deg_norm - closest + 180) % 360 - 180, 0, atol=1e-3): - cardinal = f" ({dirs[closest]})" - - # Travel direction (rotation + 180) - t_deg = (deg_norm + 180) % 360 - closest_t = min(dirs.keys(), key=lambda x: abs((t_deg - x + 180) % 360 - 180)) - if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3): - travel_dir = f" (Travel -> {dirs[closest_t]})" - - deg_text = 'any' if deg is None else f'{deg:g}' - return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}" - def __repr__(self) -> str: if self.rotation is None: rot = 'any' @@ -236,19 +179,6 @@ class PortList(metaclass=ABCMeta): def ports(self, value: dict[str, Port]) -> None: pass - def _log_port_update(self, name: str) -> None: - """ Log the current state of the named port """ - port_logger.debug("Port %s: %s", name, self.ports[name].describe()) - - def _log_port_removal(self, name: str) -> None: - """ Log that the named port has been removed """ - port_logger.debug("Port %s: removed", name) - - def _log_bulk_update(self, label: str) -> None: - """ Log all current ports at DEBUG level """ - for name, port in self.ports.items(): - port_logger.debug("%s: Port %s: %s", label, name, port) - @overload def __getitem__(self, key: str) -> Port: pass @@ -302,7 +232,6 @@ class PortList(metaclass=ABCMeta): raise PortError(f'Port {name} already exists.') assert name not in self.ports self.ports[name] = value - self._log_port_update(name) return self def rename_ports( @@ -328,24 +257,12 @@ class PortList(metaclass=ABCMeta): duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values()) if duplicates: raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') - missing = set(mapping) - set(self.ports) - if missing: - raise PortError(f'Ports to rename were not found: {missing}') - - for kk, vv in mapping.items(): - if vv is None or vv != kk: - self._log_port_removal(kk) renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} if None in renamed: del renamed[None] self.ports.update(renamed) # type: ignore - - for vv in mapping.values(): - if vv is not None: - self._log_port_update(vv) - return self def add_port_pair( @@ -368,16 +285,12 @@ class PortList(metaclass=ABCMeta): Returns: self """ - if names[0] == names[1]: - raise PortError(f'Port names must be distinct: {names[0]!r}') new_ports = { names[0]: Port(offset, rotation=rotation, ptype=ptype), names[1]: Port(offset, rotation=rotation + pi, ptype=ptype), } self.check_ports(names) self.ports.update(new_ports) - self._log_port_update(names[0]) - self._log_port_update(names[1]) return self def plugged( @@ -400,14 +313,6 @@ class PortList(metaclass=ABCMeta): Raises: `PortError` if the ports are not properly aligned. """ - if not connections: - raise PortError('Must provide at least one port connection') - missing_a = set(connections) - set(self.ports) - if missing_a: - raise PortError(f'Connection source ports were not found: {missing_a}') - missing_b = set(connections.values()) - set(self.ports) - if missing_b: - raise PortError(f'Connection destination ports were not found: {missing_b}') a_names, b_names = list(zip(*connections.items(), strict=True)) a_ports = [self.ports[pp] for pp in a_names] b_ports = [self.ports[pp] for pp in b_names] @@ -455,7 +360,6 @@ class PortList(metaclass=ABCMeta): for pp in chain(a_names, b_names): del self.ports[pp] - self._log_port_removal(pp) return self def check_ports( @@ -644,7 +548,7 @@ class PortList(metaclass=ABCMeta): rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) if not has_rot.any(): if set_rotation is None: - raise PortError('Must provide set_rotation if rotation is indeterminate') + PortError('Must provide set_rotation if rotation is indeterminate') rotations[:] = set_rotation else: rotations[~has_rot] = rotations[has_rot][0] @@ -669,3 +573,4 @@ class PortList(metaclass=ABCMeta): raise PortError(msg) return translations[0], rotations[0], o_offsets[0] + diff --git a/masque/ref.py b/masque/ref.py index a40776a..b3a684c 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -15,8 +15,7 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, - PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, - FlippableImpl, + Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ) @@ -26,9 +25,8 @@ if TYPE_CHECKING: @functools.total_ordering class Ref( - FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, - PositionableImpl, RotatableImpl, ScalableImpl, - Copyable, + PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, + PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ): """ `Ref` provides basic support for nesting Pattern objects within each other. @@ -44,7 +42,7 @@ class Ref( __slots__ = ( '_mirrored', # inherited - '_offset', '_rotation', '_scale', '_repetition', '_annotations', + '_offset', '_rotation', 'scale', '_repetition', '_annotations', ) _mirrored: bool @@ -92,22 +90,18 @@ class Ref( rotation=self.rotation, scale=self.scale, mirrored=self.mirrored, - repetition=self.repetition, - annotations=self.annotations, + repetition=copy.deepcopy(self.repetition), + annotations=copy.deepcopy(self.annotations), ) return new def __deepcopy__(self, memo: dict | None = None) -> 'Ref': memo = {} if memo is None else memo new = copy.copy(self) - new._offset = self._offset.copy() - new.repetition = copy.deepcopy(self.repetition, memo) - new.annotations = copy.deepcopy(self.annotations, memo) + #new.repetition = copy.deepcopy(self.repetition, memo) + #new.annotations = copy.deepcopy(self.annotations, memo) return new - def copy(self) -> 'Ref': - return self.deepcopy() - def __lt__(self, other: 'Ref') -> bool: if (self.offset != other.offset).any(): return tuple(self.offset) < tuple(other.offset) @@ -166,16 +160,16 @@ class Ref( return pattern def rotate(self, rotation: float) -> Self: - """ - Intrinsic transformation: Rotate the target pattern relative to this Ref's - origin. This does NOT affect the repetition grid. - """ self.rotation += rotation + if self.repetition is not None: + self.repetition.rotate(rotation) return self def mirror(self, axis: int = 0) -> Self: self.mirror_target(axis) self.rotation *= -1 + if self.repetition is not None: + self.repetition.mirror(axis) return self def mirror_target(self, axis: int = 0) -> Self: @@ -193,11 +187,10 @@ class Ref( xys = self.offset[None, :] if self.repetition is not None: xys = xys + self.repetition.displacements - transforms = numpy.empty((xys.shape[0], 5)) + transforms = numpy.empty((xys.shape[0], 4)) transforms[:, :2] = xys transforms[:, 2] = self.rotation transforms[:, 3] = self.mirrored - transforms[:, 4] = self.scale return transforms def get_bounds_single( diff --git a/masque/repetition.py b/masque/repetition.py index a8de94c..5e7a7f0 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -64,7 +64,7 @@ class Grid(Repetition): _a_count: int """ Number of instances along the direction specified by the `a_vector` """ - _b_vector: NDArray[numpy.float64] + _b_vector: NDArray[numpy.float64] | None """ Vector `[x, y]` specifying a second lattice vector for the grid. Specifies center-to-center spacing between adjacent elements. Can be `None` for a 1D array. @@ -199,6 +199,9 @@ class Grid(Repetition): @property def displacements(self) -> NDArray[numpy.float64]: + if self.b_vector is None: + return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :] + aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij') return (aa.flatten()[:, None] * self.a_vector[None, :] + bb.flatten()[:, None] * self.b_vector[None, :]) # noqa @@ -298,8 +301,12 @@ class Grid(Repetition): return self.b_count < other.b_count if not numpy.array_equal(self.a_vector, other.a_vector): return tuple(self.a_vector) < tuple(other.a_vector) + if self.b_vector is None: + return other.b_vector is not None + if other.b_vector is None: + return False if not numpy.array_equal(self.b_vector, other.b_vector): - return tuple(self.b_vector) < tuple(other.b_vector) + return tuple(self.a_vector) < tuple(other.a_vector) return False @@ -343,7 +350,7 @@ class Arbitrary(Repetition): return (f'') def __eq__(self, other: Any) -> bool: - if type(other) is not type(self): + if not type(other) is not type(self): return False return numpy.array_equal(self.displacements, other.displacements) @@ -384,9 +391,7 @@ class Arbitrary(Repetition): Returns: self """ - new_displacements = self.displacements.copy() - new_displacements[:, 1 - axis] *= -1 - self.displacements = new_displacements + self.displacements[1 - axis] *= -1 return self def get_bounds(self) -> NDArray[numpy.float64] | None: @@ -397,8 +402,6 @@ class Arbitrary(Repetition): Returns: `[[x_min, y_min], [x_max, y_max]]` or `None` """ - if self.displacements.size == 0: - return None xy_min = numpy.min(self.displacements, axis=0) xy_max = numpy.max(self.displacements, axis=0) return numpy.array((xy_min, xy_max)) @@ -413,6 +416,6 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements = self.displacements * c + self.displacements *= c return self diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 6f948cb..480835e 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -272,16 +272,13 @@ class Arc(PositionableImpl, Shape): arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) keep = [0] - start = 0 + removable = (numpy.cumsum(arc_lengths) <= max_arclen) + start = 1 while start < arc_lengths.size: - removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen) - if not removable.any(): - next_to_keep = start + 1 - else: - next_to_keep = start + numpy.where(removable)[0][-1] + 1 + next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough? keep.append(next_to_keep) - start = next_to_keep - + removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen) + start = next_to_keep + 1 if keep[-1] != thetas.size - 1: keep.append(thetas.size - 1) @@ -365,20 +362,17 @@ class Arc(PositionableImpl, Shape): yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) # If our arc subtends a coordinate axis, use the extremum along that axis - if abs(a1 - a0) >= 2 * pi: - xn, xp, yn, yp = -xr, xr, -yr, yr - else: - if a0 <= xpt <= a1 or a0 <= xpt + 2 * pi <= a1: - xp = xr + if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1: + xp = xr - if a0 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1: - xn = -xr + if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1: + xn = -xr - if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1: - yp = yr + if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1: + yp = yr - if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1: - yn = -yr + if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1: + yn = -yr mins.append([xn, yn]) maxs.append([xp, yp]) @@ -390,6 +384,7 @@ class Arc(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Arc': + self.offset[axis - 1] *= -1 self.rotation *= -1 self.rotation += axis * pi self.angles *= -1 @@ -469,18 +464,13 @@ class Arc(PositionableImpl, Shape): `[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]` """ aa = [] - d_angle = self.angles[1] - self.angles[0] - if abs(d_angle) >= 2 * pi: - # Full ring - return numpy.tile([0, 2 * pi], (2, 1)).astype(float) - for sgn in (-1, +1): wh = sgn * self.width / 2.0 rx = self.radius_x + wh ry = self.radius_y + wh a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles) - sign = numpy.sign(d_angle) + sign = numpy.sign(self.angles[1] - self.angles[0]) if sign != numpy.sign(a1 - a0): a1 += sign * 2 * pi diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 8dad165..b20a681 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -124,6 +124,7 @@ class Circle(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused) + self.offset[axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Circle': diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 8e3fd49..6029f2f 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -189,6 +189,7 @@ class Ellipse(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> Self: + self.offset[axis - 1] *= -1 self.rotation *= -1 self.rotation += axis * pi return self diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 3aa6f07..7778428 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -24,16 +24,7 @@ class PathCap(Enum): # # defined by path.cap_extensions def __lt__(self, other: Any) -> bool: - if self.__class__ is not other.__class__: - return self.__class__.__name__ < other.__class__.__name__ - # Order: Flush, Square, Circle, SquareCustom - order = { - PathCap.Flush: 0, - PathCap.Square: 1, - PathCap.Circle: 2, - PathCap.SquareCustom: 3, - } - return order[self] < order[other] + return self.value == other.value @functools.total_ordering @@ -88,10 +79,10 @@ class Path(Shape): def cap(self, val: PathCap) -> None: self._cap = PathCap(val) if self.cap != PathCap.SquareCustom: - self._cap_extensions = None - elif self._cap_extensions is None: + self.cap_extensions = None + elif self.cap_extensions is None: # just got set to SquareCustom - self._cap_extensions = numpy.zeros(2) + self.cap_extensions = numpy.zeros(2) # cap_extensions property @property @@ -218,12 +209,9 @@ class Path(Shape): self.vertices = vertices self.repetition = repetition self.annotations = annotations - self._cap = cap - if cap == PathCap.SquareCustom and cap_extensions is None: - self._cap_extensions = numpy.zeros(2) - else: - self.cap_extensions = cap_extensions self.width = width + self.cap = cap + self.cap_extensions = cap_extensions if rotation: self.rotate(rotation) if numpy.any(offset): @@ -265,14 +253,6 @@ class Path(Shape): if self.cap_extensions is None: return True return tuple(self.cap_extensions) < tuple(other.cap_extensions) - if not numpy.array_equal(self.vertices, other.vertices): - min_len = min(self.vertices.shape[0], other.vertices.shape[0]) - eq_mask = self.vertices[:min_len] != other.vertices[:min_len] - eq_lt = self.vertices[:min_len] < other.vertices[:min_len] - eq_lt_masked = eq_lt[eq_mask] - if eq_lt_masked.size > 0: - return eq_lt_masked.flat[0] - return self.vertices.shape[0] < other.vertices.shape[0] if self.repetition != other.repetition: return rep2key(self.repetition) < rep2key(other.repetition) return annotations_lt(self.annotations, other.annotations) @@ -323,30 +303,9 @@ class Path(Shape): ) -> list['Polygon']: extensions = self._calculate_cap_extensions() - v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) + v = remove_colinear_vertices(self.vertices, closed_path=False) dv = numpy.diff(v, axis=0) - norms = numpy.sqrt((dv * dv).sum(axis=1)) - - # Filter out zero-length segments if any remained after remove_colinear_vertices - valid = (norms > 1e-18) - if not numpy.all(valid): - # This shouldn't happen much if remove_colinear_vertices is working - v = v[numpy.append(valid, True)] - dv = numpy.diff(v, axis=0) - norms = norms[valid] - - if dv.shape[0] == 0: - # All vertices were the same. It's a point. - if self.width == 0: - return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate - if self.cap == PathCap.Circle: - return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen) - if self.cap == PathCap.Square: - return [Polygon.square(side_length=self.width, offset=v[0])] - # Flush or CustomSquare - return [Polygon(vertices=numpy.zeros((3, 2)))] - - dvdir = dv / norms[:, None] + dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] if self.width == 0: verts = numpy.vstack((v, v[::-1])) @@ -365,21 +324,11 @@ class Path(Shape): bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] - try: - # Vectorized solve for all intersections - # solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1) - rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0] - rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0] - except numpy.linalg.LinAlgError: - # Fallback to slower lstsq if some segments are parallel (singular matrix) - rp = numpy.zeros(As.shape[0]) - rn = numpy.zeros(As.shape[0]) - for ii in range(As.shape[0]): - rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0] - rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0] + rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0] + rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0] - intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1] - intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1] + 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 @@ -447,14 +396,12 @@ class Path(Shape): return self def mirror(self, axis: int = 0) -> 'Path': - self.vertices[:, 1 - axis] *= -1 + self.vertices[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Path': self.vertices *= c self.width *= c - if self.cap_extensions is not None: - self.cap_extensions *= c return self def normalized_form(self, norm_value: float) -> normalized_shape_tuple: @@ -471,22 +418,21 @@ class Path(Shape): rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v) for v in normed_vertices]) - # Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller - if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat): - reordered_vertices = rotated_vertices[::-1] - else: - reordered_vertices = rotated_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 = cast('Sequence', x_min)[y_min] + reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) width0 = self.width / norm_value - cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions) - return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0), + return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap), (offset, scale / norm_value, rotation, False), lambda: Path( reordered_vertices * norm_value, - width=width0 * norm_value, + width=self.width * norm_value, cap=self.cap, - cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0), )) def clean_vertices(self) -> 'Path': @@ -516,7 +462,7 @@ class Path(Shape): Returns: self """ - self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) + self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) return self def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index 711acc4..6048f24 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -56,11 +56,9 @@ class PolyCollection(Shape): """ Iterator which provides slices which index vertex_lists """ - if self._vertex_offsets.size == 0: - return for ii, ff in zip( self._vertex_offsets, - chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]), + chain(self._vertex_offsets, (self._vertex_lists.shape[0],)), strict=True, ): yield slice(ii, ff) @@ -84,7 +82,7 @@ class PolyCollection(Shape): def set_offset(self, val: ArrayLike) -> Self: if numpy.any(val): - raise PatternError('PolyCollection offset is forced to (0, 0)') + raise PatternError('Path offset is forced to (0, 0)') return self def translate(self, offset: ArrayLike) -> Self: @@ -170,9 +168,7 @@ class PolyCollection(Shape): annotations = copy.deepcopy(self.annotations), ) for vv in self.polygon_vertices] - def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition - if self._vertex_lists.size == 0: - return None + def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition return numpy.vstack((numpy.min(self._vertex_lists, axis=0), numpy.max(self._vertex_lists, axis=0))) @@ -183,7 +179,7 @@ class PolyCollection(Shape): return self def mirror(self, axis: int = 0) -> Self: - self._vertex_lists[:, 1 - axis] *= -1 + self._vertex_lists[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> Self: diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 34a784b..c8c3ddd 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING, Self, Literal +from typing import Any, cast, TYPE_CHECKING, Self import copy import functools @@ -96,11 +96,11 @@ class Polygon(Shape): @offset.setter def offset(self, val: ArrayLike) -> None: if numpy.any(val): - raise PatternError('Polygon offset is forced to (0, 0)') + raise PatternError('Path offset is forced to (0, 0)') def set_offset(self, val: ArrayLike) -> Self: if numpy.any(val): - raise PatternError('Polygon offset is forced to (0, 0)') + raise PatternError('Path offset is forced to (0, 0)') return self def translate(self, offset: ArrayLike) -> Self: @@ -321,7 +321,7 @@ class Polygon(Shape): else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') - poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition) + poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition) return poly @staticmethod @@ -394,7 +394,7 @@ class Polygon(Shape): return self def mirror(self, axis: int = 0) -> 'Polygon': - self.vertices[:, 1 - axis] *= -1 + self.vertices[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Polygon': @@ -417,15 +417,11 @@ class Polygon(Shape): for v in normed_vertices]) # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min_val = rotated_vertices[:, 0].min() - x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0] - if x_min_inds.size > 1: - y_min_val = rotated_vertices[x_min_inds, 1].min() - tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0] - start_ind = x_min_inds[tie_breaker] - else: - start_ind = x_min_inds[0] - reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0) + x_min = rotated_vertices[:, 0].argmin() + if not is_scalar(x_min): + y_min = rotated_vertices[x_min, 1].argmin() + x_min = cast('Sequence', x_min)[y_min] + reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) # TODO: normalize mirroring? @@ -466,23 +462,3 @@ class Polygon(Shape): def __repr__(self) -> str: centroid = self.vertices.mean(axis=0) return f'' - - def boolean( - self, - other: Any, - operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', - scale: float = 1e6, - ) -> list['Polygon']: - """ - Perform a boolean operation using this polygon as the subject. - - Args: - other: Polygon, Iterable[Polygon], or raw vertices acting as the CLIP. - operation: 'union', 'intersection', 'difference', 'xor'. - scale: Scaling factor for integer conversion. - - Returns: - A list of resulting Polygons. - """ - from ..utils.boolean import boolean #noqa: PLC0415 - return boolean([self], other, operation=operation, scale=scale) diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index efc0859..90bca2b 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -6,8 +6,8 @@ import numpy from numpy.typing import NDArray, ArrayLike from ..traits import ( - Copyable, Scalable, FlippableImpl, - PivotableImpl, RepeatableImpl, AnnotatableImpl, + Rotatable, Mirrorable, Copyable, Scalable, + Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,9 +26,8 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, - Copyable, Scalable, - metaclass=ABCMeta): +class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, + PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): """ Class specifying functions common to all shapes. """ @@ -74,7 +73,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, pass @abstractmethod - def normalized_form(self, norm_value: float) -> normalized_shape_tuple: + def normalized_form(self, norm_value: int) -> normalized_shape_tuple: """ Writes the shape in a standardized notation, with offset, scale, and rotation information separated out from the remaining values. @@ -121,7 +120,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon #noqa: PLC0415 + from . import Polygon gx = numpy.unique(grid_x) gy = numpy.unique(grid_y) @@ -139,24 +138,22 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True): dv = v_next - v - # Find x-index bounds for the line + # Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape gxi_range = numpy.digitize([v[0], v_next[0]], gx) - gxi_min = int(numpy.min(gxi_range - 1).clip(0, len(gx) - 1)) - gxi_max = int(numpy.max(gxi_range).clip(0, len(gx))) + gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1) + gxi_max = numpy.max(gxi_range).clip(0, len(gx)) - if gxi_min < len(gx) - 1: - err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min]) - if err_xmin >= 0.5: - gxi_min += 1 + err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min]) + err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1]) - if gxi_max > 0 and gxi_max < len(gx): - err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1]) - if err_xmax >= 0.5: - gxi_max += 1 + if err_xmin >= 0.5: + gxi_min += 1 + if err_xmax >= 0.5: + gxi_max += 1 if abs(dv[0]) < 1e-20: # Vertical line, don't calculate slope - xi = [gxi_min, max(gxi_min, gxi_max - 1)] + xi = [gxi_min, gxi_max - 1] ys = numpy.array([v[1], v_next[1]]) yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1) err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1]) @@ -252,9 +249,9 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon #noqa: PLC0415 - import skimage.measure #noqa: PLC0415 - import float_raster #noqa: PLC0415 + from . import Polygon + import skimage.measure # type: ignore + import float_raster grx = numpy.unique(grid_x) gry = numpy.unique(grid_y) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index dec4c33..78632f6 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -70,7 +70,6 @@ class Text(PositionableImpl, RotatableImpl, Shape): *, offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, - mirrored: bool = False, repetition: Repetition | None = None, annotations: annotations_t = None, raw: bool = False, @@ -81,7 +80,6 @@ class Text(PositionableImpl, RotatableImpl, Shape): self._string = string self._height = height self._rotation = rotation - self._mirrored = mirrored self._repetition = repetition self._annotations = annotations else: @@ -89,7 +87,6 @@ class Text(PositionableImpl, RotatableImpl, Shape): self.string = string self.height = height self.rotation = rotation - self.mirrored = mirrored self.repetition = repetition self.annotations = annotations self.font_path = font_path @@ -149,7 +146,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): if self.mirrored: poly.mirror() poly.scale_by(self.height) - poly.translate(self.offset + [total_advance, 0]) + poly.offset = self.offset + [total_advance, 0] poly.rotate_around(self.offset, self.rotation) all_polygons += [poly] @@ -205,8 +202,8 @@ def get_char_as_polygons( char: str, resolution: float = 48 * 64, ) -> tuple[list[NDArray[numpy.float64]], float]: - from freetype import Face # type: ignore #noqa: PLC0415 - from matplotlib.path import Path # type: ignore #noqa: PLC0415 + from freetype import Face # type: ignore + from matplotlib.path import Path # type: ignore """ Get a list of polygons representing a single character. diff --git a/masque/test/__init__.py b/masque/test/__init__.py deleted file mode 100644 index e02b636..0000000 --- a/masque/test/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests (run with `python3 -m pytest -rxPXs | tee results.txt`) -""" diff --git a/masque/test/conftest.py b/masque/test/conftest.py deleted file mode 100644 index 3116ee2..0000000 --- a/masque/test/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -""" - -Test fixtures - -""" - -# ruff: noqa: ARG001 -from typing import Any -import numpy - - -FixtureRequest = Any -PRNG = numpy.random.RandomState(12345) diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py deleted file mode 100644 index d2f54ed..0000000 --- a/masque/test/test_abstract.py +++ /dev/null @@ -1,85 +0,0 @@ -from numpy.testing import assert_allclose -from numpy import pi - -from ..abstract import Abstract -from ..ports import Port -from ..ref import Ref - - -def test_abstract_init() -> None: - ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)} - abs_obj = Abstract("test", ports) - assert abs_obj.name == "test" - assert len(abs_obj.ports) == 2 - assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied - - -def test_abstract_transform() -> None: - abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) - # Rotate 90 deg around (0,0) - abs_obj.rotate_around((0, 0), pi / 2) - # (10, 0) rot 0 -> (0, 10) rot pi/2 - assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) - - # Mirror across x axis (axis 0): flips y-offset - abs_obj.mirror(0) - # (0, 10) mirrored(0) -> (0, -10) - # rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2 - assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10) - - -def test_abstract_ref_transform() -> None: - abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) - - # Apply ref transform - abs_obj.apply_ref_transform(ref) - # Ref order: mirror, rotate, scale, translate - - # 1. mirror (across x: y -> -y) - # (10, 0) rot 0 -> (10, 0) rot 0 - - # 2. rotate pi/2 around (0,0) - # (10, 0) rot 0 -> (0, 10) rot pi/2 - - # 3. translate (100, 100) - # (0, 10) -> (100, 110) - - assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) - - -def test_abstract_ref_transform_scales_offsets() -> None: - abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2) - - abs_obj.apply_ref_transform(ref) - - assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) - - -def test_abstract_undo_transform() -> None: - abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) - - abs_obj.undo_ref_transform(ref) - assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) - - -def test_abstract_undo_transform_scales_offsets() -> None: - abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2) - - abs_obj.undo_ref_transform(ref) - assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py deleted file mode 100644 index 91d6c3b..0000000 --- a/masque/test/test_advanced_routing.py +++ /dev/null @@ -1,76 +0,0 @@ -import pytest -from numpy.testing import assert_equal -from numpy import pi - -from ..builder import Pather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - - -@pytest.fixture -def advanced_pather() -> tuple[Pather, PathTool, Library]: - lib = Library() - # Simple PathTool: 2um width on layer (1,0) - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False) - return p, tool, lib - - -def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = advanced_pather - # Facing ports - p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device) - # Forward (+pi relative to port) is West (-x). - # Put destination at (-20, 0) pointing East (pi). - p.ports["dst"] = Port((-20, 0), pi, ptype="wire") - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - # Pather._traceL adds a Reference to the generated pattern - assert len(p.pattern.refs) == 1 - - -def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = advanced_pather - # Source at (0,0) rot 0 (facing East). Forward is West (-x). - p.ports["src"] = Port((0, 0), 0, ptype="wire") - # Destination at (-20, -20) rot pi (facing West). Forward is East (+x). - # Wait, src forward is -x. dst is at -20, -20. - # To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South). - # Forward for South is North (+y). - p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire") - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - # Single bend should result in 2 segments (one for x move, one for y move) - assert len(p.pattern.refs) == 2 - - -def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = advanced_pather - # Facing but offset ports - p.ports["src"] = Port((0, 0), 0, ptype="wire") # Forward is West (-x) - p.ports["dst"] = Port((-20, -10), pi, ptype="wire") # Facing East (rot pi) - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - - -def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = advanced_pather - p.ports["src"] = Port((0, 0), 0, ptype="wire") - p.ports["dst"] = Port((-20, 0), pi, ptype="wire") - p.ports["other"] = Port((10, 10), 0) - - p.trace_into("src", "dst", thru="other") - - assert "src" in p.ports - assert_equal(p.ports["src"].offset, [10, 10]) - assert "other" not in p.ports diff --git a/masque/test/test_autotool.py b/masque/test/test_autotool.py deleted file mode 100644 index e03994e..0000000 --- a/masque/test/test_autotool.py +++ /dev/null @@ -1,81 +0,0 @@ -import pytest -from numpy.testing import assert_allclose -from numpy import pi - -from ..builder import Pather -from ..builder.tools import AutoTool -from ..library import Library -from ..pattern import Pattern -from ..ports import Port - - -def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) - pat.ports["in"] = Port((0, 0), 0, ptype=ptype) - pat.ports["out"] = Port((length, 0), pi, ptype=ptype) - return pat - - -@pytest.fixture -def autotool_setup() -> tuple[Pather, AutoTool, Library]: - lib = Library() - - # Define a simple bend - bend_pat = Pattern() - # 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise) - bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire") - bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire") - lib["bend"] = bend_pat - lib.abstract("bend") - - # Define a transition (e.g., via) - via_pat = Pattern() - via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1") - via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2") - lib["via"] = via_pat - via_abs = lib.abstract("via") - - tool_m1 = AutoTool( - straights=[ - AutoTool.Straight(ptype="wire_m1", fn=lambda length: make_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out") - ], - bends=[], - sbends=[], - transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")}, - default_out_ptype="wire_m1", - ) - - p = Pather(lib, tools=tool_m1) - # Start with an m2 port - p.ports["start"] = Port((0, 0), pi, ptype="wire_m2") - - return p, tool_m1, lib - - -def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: - p, _tool, _lib = autotool_setup - - # Route m1 from an m2 port. Should trigger via. - # length 10. Via length is 1. So straight m1 should be 9. - p.straight("start", 10) - - # Start at (0,0) rot pi (facing West). - # Forward (+pi relative to port) is East (+x). - # Via: m2(1,0)pi -> m1(0,0)0. - # Plug via m2 into start(0,0)pi: transformation rot=mod(pi-pi-pi, 2pi)=pi. - # rotate via by pi: m2 at (0,0), m1 at (-1, 0) rot pi. - # Then straight m1 of length 9 from (-1, 0) rot pi -> ends at (8, 0) rot pi. - # Wait, (length, 0) relative to (-1, 0) rot pi: - # transform (9, 0) by pi: (-9, 0). - # (-1, 0) + (-9, 0) = (-10, 0)? No. - # Let's re-calculate. - # start (0,0) rot pi. Direction East. - # via m2 is at (0,0), m1 is at (1,0). - # When via is plugged into start: m2 goes to (0,0). - # since start is pi and m2 is pi, rotation is 0. - # so via m1 is at (1,0) rot 0. - # then straight m1 length 9 from (1,0) rot 0: ends at (10, 0) rot 0. - - assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) - assert p.ports["start"].ptype == "wire_m1" diff --git a/masque/test/test_autotool_refactor.py b/masque/test/test_autotool_refactor.py deleted file mode 100644 index d93f935..0000000 --- a/masque/test/test_autotool_refactor.py +++ /dev/null @@ -1,226 +0,0 @@ -import pytest -from numpy.testing import assert_allclose -from numpy import pi - -from masque.builder.tools import AutoTool -from masque.pattern import Pattern -from masque.ports import Port -from masque.library import Library -from masque.builder.pather import Pather, RenderPather - -def make_straight(length, width=2, ptype="wire"): - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((length, 0), pi, ptype=ptype) - return pat - -def make_bend(R, width=2, ptype="wire", clockwise=True): - pat = Pattern() - # 90 degree arc approximation (just two rects for start and end) - if clockwise: - # (0,0) rot 0 to (R, -R) rot pi/2 - pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) - pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype) - else: - # (0,0) rot 0 to (R, R) rot -pi/2 - pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) - pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype) - return pat - -@pytest.fixture -def multi_bend_tool(): - lib = Library() - - # Bend 1: R=2 - lib["b1"] = make_bend(2, ptype="wire") - b1_abs = lib.abstract("b1") - # Bend 2: R=5 - lib["b2"] = make_bend(5, ptype="wire") - b2_abs = lib.abstract("b2") - - tool = AutoTool( - straights=[ - # Straight 1: only for length < 10 - AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)), - # Straight 2: for length >= 10 - AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8)) - ], - bends=[ - AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True), - AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True) - ], - sbends=[], - transitions={}, - default_out_ptype="wire" - ) - return tool, lib - -def test_autotool_planL_selection(multi_bend_tool) -> None: - tool, _ = multi_bend_tool - - # Small length: should pick straight 1 and bend 1 (R=2) - # L = straight + R. If L=5, straight=3. - p, data = tool.planL(True, 5) - assert data.straight.length_range == (0, 10) - assert data.straight_length == 3 - assert data.bend.abstract.name == "b1" - assert_allclose(p.offset, [5, 2]) - - # Large length: should pick straight 2 and bend 1 (R=2) - # If L=15, straight=13. - p, data = tool.planL(True, 15) - assert data.straight.length_range == (10, 1e8) - assert data.straight_length == 13 - assert_allclose(p.offset, [15, 2]) - -def test_autotool_planU_consistency(multi_bend_tool) -> None: - tool, lib = multi_bend_tool - - # length=10, jog=20. - # U-turn: Straight1 -> Bend1 -> Straight_mid -> Straight3(0) -> Bend2 - # X = L1_total - R2 = length - # Y = R1 + L2_mid + R2 = jog - - p, data = tool.planU(20, length=10) - assert data.ldata0.straight_length == 7 - assert data.ldata0.bend.abstract.name == "b2" - assert data.l2_length == 13 - assert data.ldata1.straight_length == 0 - assert data.ldata1.bend.abstract.name == "b1" - -def test_autotool_planS_double_L(multi_bend_tool) -> None: - tool, lib = multi_bend_tool - - # length=20, jog=10. S-bend (ccw1, cw2) - # X = L1_total + R2 = length - # Y = R1 + L2_mid + R2 = jog - - p, data = tool.planS(20, 10) - assert_allclose(p.offset, [20, 10]) - assert_allclose(p.rotation, pi) - - assert data.ldata0.straight_length == 16 - assert data.ldata1.straight_length == 0 - assert data.l2_length == 6 - - -def test_autotool_planS_pure_sbend_with_transition_dx() -> None: - lib = Library() - - def make_straight(length: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((length, 0), pi, ptype="core") - return pat - - def make_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((10, jog), pi, ptype="core") - return pat - - trans_pat = Pattern() - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core") - lib["xin"] = trans_pat - - tool = AutoTool( - straights=[ - AutoTool.Straight( - ptype="core", - fn=make_straight, - in_port_name="A", - out_port_name="B", - length_range=(1, 1e8), - ) - ], - bends=[], - sbends=[ - AutoTool.SBend( - ptype="core", - fn=make_sbend, - in_port_name="A", - out_port_name="B", - jog_range=(0, 1e8), - ) - ], - transitions={ - ("ext", "core"): AutoTool.Transition(lib.abstract("xin"), "EXT", "CORE"), - }, - default_out_ptype="core", - ) - - p, data = tool.planS(15, 4, in_ptype="ext") - - assert_allclose(p.offset, [15, 4]) - assert_allclose(p.rotation, pi) - assert data.straight_length == 0 - assert data.jog_remaining == 4 - assert data.in_transition is not None - - -def test_renderpather_autotool_double_L(multi_bend_tool) -> None: - tool, lib = multi_bend_tool - rp = RenderPather(lib, tools=tool) - rp.ports["A"] = Port((0,0), 0, ptype="wire") - - # This should trigger double-L fallback in planS - rp.jog("A", 10, length=20) - - # port_rot=0 -> forward is -x. jog=10 (left) is -y. - assert_allclose(rp.ports["A"].offset, [-20, -10]) - assert_allclose(rp.ports["A"].rotation, 0) # jog rot is pi relative to input, input rot is pi relative to port. - # Wait, planS returns out_port at (length, jog) rot pi relative to input (0,0) rot 0. - # Input rot relative to port is pi. - # Rotate (length, jog) rot pi by pi: (-length, -jog) rot 0. Correct. - - rp.render() - assert len(rp.pattern.refs) > 0 - -def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None: - tool, lib = multi_bend_tool - - class BasicTool(AutoTool): - def planU(self, *args, **kwargs): - raise NotImplementedError() - - tool_basic = BasicTool( - straights=tool.straights, - bends=tool.bends, - sbends=tool.sbends, - transitions=tool.transitions, - default_out_ptype=tool.default_out_ptype - ) - - p = Pather(lib, tools=tool_basic) - p.ports["A"] = Port((0,0), 0, ptype="wire") # facing West (Actually East points Inwards, West is Extension) - - # uturn jog=10, length=5. - # R=2. L1 = 5+2=7. L2 = 10-2=8. - p.uturn("A", 10, length=5) - - # port_rot=0 -> forward is -x. jog=10 (left) is -y. - # L1=7 along -x -> (-7, 0). Bend1 (ccw) -> rot -pi/2 (South). - # L2=8 along -y -> (-7, -8). Bend2 (ccw) -> rot 0 (East). - # wait. CCW turn from facing South (-y): turn towards East (+x). - # Wait. - # Input facing -x. CCW turn -> face -y. - # Input facing -y. CCW turn -> face +x. - # So final rotation is 0. - # Bend1 (ccw) relative to -x: global offset is (-7, -2)? - # Let's re-run my manual calculation. - # Port rot 0. Wire input rot pi. Wire output relative to input: - # L1=7, R1=2, CCW=True. Output (7, 2) rot pi/2. - # Rotate wire by pi: output (-7, -2) rot 3pi/2. - # Second turn relative to (-7, -2) rot 3pi/2: - # local output (8, 2) rot pi/2. - # global: (-7, -2) + 8*rot(3pi/2)*x + 2*rot(3pi/2)*y - # = (-7, -2) + 8*(0, -1) + 2*(1, 0) = (-7, -2) + (0, -8) + (2, 0) = (-5, -10). - # YES! ACTUAL result was (-5, -10). - assert_allclose(p.ports["A"].offset, [-5, -10]) - assert_allclose(p.ports["A"].rotation, pi) diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py deleted file mode 100644 index bf5d33d..0000000 --- a/masque/test/test_boolean.py +++ /dev/null @@ -1,120 +0,0 @@ -# ruff: noqa: PLC0415 -import pytest -import numpy -from numpy.testing import assert_allclose -from masque.pattern import Pattern -from masque.shapes.polygon import Polygon -from masque.repetition import Grid -from masque.library import Library - -def test_layer_as_polygons_basic() -> None: - pat = Pattern() - pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]]) - - polys = pat.layer_as_polygons((1, 0), flatten=False) - assert len(polys) == 1 - assert isinstance(polys[0], Polygon) - assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - -def test_layer_as_polygons_repetition() -> None: - pat = Pattern() - rep = Grid(a_vector=(2, 0), a_count=2) - pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep) - - polys = pat.layer_as_polygons((1, 0), flatten=False) - assert len(polys) == 2 - # First polygon at (0,0) - assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - # Second polygon at (2,0) - assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]]) - -def test_layer_as_polygons_flatten() -> None: - lib = Library() - - child = Pattern() - child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]]) - lib['child'] = child - - parent = Pattern() - parent.ref('child', offset=(10, 10), rotation=numpy.pi/2) - - polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib) - assert len(polys) == 1 - # Original child at (0,0) with rot pi/2 is still at (0,0) in its own space? - # No, ref.as_pattern(child) will apply the transform. - # Child (0,0), (1,0), (1,1) rotated pi/2 around (0,0) -> (0,0), (0,1), (-1,1) - # Then offset by (10,10) -> (10,10), (10,11), (9,11) - - # Let's verify the vertices - expected = numpy.array([[10, 10], [10, 11], [9, 11]]) - assert_allclose(polys[0].vertices, expected, atol=1e-10) - -def test_boolean_import_error() -> None: - from masque import boolean - # If pyclipper is not installed, this should raise ImportError - try: - import pyclipper # noqa: F401 - pytest.skip("pyclipper is installed, cannot test ImportError") - except ImportError: - with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): - boolean([], [], operation='union') - -def test_polygon_boolean_shortcut() -> None: - poly = Polygon([[0, 0], [1, 0], [1, 1]]) - # This should also raise ImportError if pyclipper is missing - try: - import pyclipper # noqa: F401 - pytest.skip("pyclipper is installed") - except ImportError: - with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): - poly.boolean(poly) - -def test_bridge_holes() -> None: - from masque.utils.boolean import _bridge_holes - - # Outer: 10x10 square - outer = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) - # Hole: 2x2 square in the middle - hole = numpy.array([[4, 4], [6, 4], [6, 6], [4, 6]]) - - bridged = _bridge_holes(outer, [hole]) - - # We expect more vertices than outer + hole - # Original outer has 4, hole has 4. Bridge adds 2 (to hole) and 2 (back to outer) + 1 to close hole loop? - # Our implementation: - # 1. outer up to bridge edge (best_edge_idx) - # 2. bridge point on outer - # 3. hole reordered starting at max X - # 4. close hole loop (repeat max X) - # 5. bridge point on outer again - # 6. rest of outer - - # max X of hole is 6 at (6,4) or (6,6). argmax will pick first one. - # hole vertices: [4,4], [6,4], [6,6], [4,6]. argmax(x) is index 1: (6,4) - # roll hole to start at (6,4): [6,4], [6,6], [4,6], [4,4] - - # intersection of ray from (6,4) to right: - # edges of outer: (0,0)-(10,0), (10,0)-(10,10), (10,10)-(0,10), (0,10)-(0,0) - # edge (10,0)-(10,10) spans y=4. - # intersection at (10,4). best_edge_idx = 1 (edge from index 1 to 2) - - # vertices added: - # outer[0:2]: (0,0), (10,0) - # bridge pt: (10,4) - # hole: (6,4), (6,6), (4,6), (4,4) - # hole close: (6,4) - # bridge pt back: (10,4) - # outer[2:]: (10,10), (0,10) - - expected_len = 11 - assert len(bridged) == expected_len - - # verify it wraps around the hole and back - # index 2 is bridge_pt - assert_allclose(bridged[2], [10, 4]) - # index 3 is hole reordered max X - assert_allclose(bridged[3], [6, 4]) - # index 7 is hole closed at max X - assert_allclose(bridged[7], [6, 4]) - # index 8 is bridge_pt back - assert_allclose(bridged[8], [10, 4]) diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py deleted file mode 100644 index 0ad6e80..0000000 --- a/masque/test/test_builder.py +++ /dev/null @@ -1,131 +0,0 @@ -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..builder import Builder -from ..library import Library -from ..pattern import Pattern -from ..ports import Port - - -def test_builder_init() -> None: - lib = Library() - b = Builder(lib, name="mypat") - assert b.pattern is lib["mypat"] - assert b.library is lib - - -def test_builder_place() -> None: - lib = Library() - child = Pattern() - child.ports["A"] = Port((0, 0), 0) - lib["child"] = child - - b = Builder(lib) - b.place("child", offset=(10, 20), port_map={"A": "child_A"}) - - assert "child_A" in b.ports - assert_equal(b.ports["child_A"].offset, [10, 20]) - assert "child" in b.pattern.refs - - -def test_builder_plug() -> None: - lib = Library() - - wire = Pattern() - wire.ports["in"] = Port((0, 0), 0) - wire.ports["out"] = Port((10, 0), pi) - lib["wire"] = wire - - b = Builder(lib) - b.ports["start"] = Port((100, 100), 0) - - # Plug wire's "in" port into builder's "start" port - # Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports - # builder start: (100, 100) rotation 0 - # wire in: (0, 0) rotation 0 - # wire out: (10, 0) rotation pi - # Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg) - # so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100) - b.plug("wire", map_in={"start": "in"}) - - assert "start" in b.ports - assert_equal(b.ports["start"].offset, [90, 100]) - assert b.ports["start"].rotation is not None - assert_allclose(b.ports["start"].rotation, 0, atol=1e-10) - - -def test_builder_interface() -> None: - lib = Library() - source = Pattern() - source.ports["P1"] = Port((0, 0), 0) - lib["source"] = source - - b = Builder.interface("source", library=lib, name="iface") - assert "in_P1" in b.ports - assert "P1" in b.ports - assert b.pattern is lib["iface"] - - -def test_builder_set_dead() -> None: - lib = Library() - lib["sub"] = Pattern() - b = Builder(lib) - b.set_dead() - - b.place("sub") - assert not b.pattern.has_refs() - - -def test_builder_dead_ports() -> None: - lib = Library() - pat = Pattern() - pat.ports['A'] = Port((0, 0), 0) - b = Builder(lib, pattern=pat) - b.set_dead() - - # Attempt to plug a device where ports don't line up - # A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference). - other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)}) - - # This should NOT raise PortError because b is dead - b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'}) - - # Port A should be removed, and Port B (renamed from D) should be added - assert 'A' not in b.ports - assert 'B' in b.ports - - # Verify geometry was not added - assert not b.pattern.has_refs() - assert not b.pattern.has_shapes() - - -def test_dead_plug_best_effort() -> None: - lib = Library() - pat = Pattern() - pat.ports['A'] = Port((0, 0), 0) - b = Builder(lib, pattern=pat) - b.set_dead() - - # Device with multiple ports, none of which line up correctly - other = Pattern(ports={ - 'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi) - 'P2': Port((20, 20), pi) # Correct rotation but wrong offset - }) - - # Try to plug. find_transform will fail. - # It should fall back to aligning the first pair ('A' and 'P1'). - b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'}) - - assert 'A' not in b.ports - assert 'B' in b.ports - - # Dummy transform aligns A (0,0) with P1 (10,10) - # A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi - # P2 (20,20) rotation pi: - # 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10) - # 2. Rotate (10,10) by -pi: (-10,-10) - # 3. Translate by s_port.offset (0,0): (-10,-10) - assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10) - # P2 rot pi + transform rot -pi = 0 - assert b.ports['B'].rotation is not None - assert_allclose(b.ports['B'].rotation, 0, atol=1e-10) diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py deleted file mode 100644 index 0c0a1a3..0000000 --- a/masque/test/test_dxf.py +++ /dev/null @@ -1,129 +0,0 @@ -import io -import numpy -import ezdxf -from numpy.testing import assert_allclose -from pathlib import Path - -from ..pattern import Pattern -from ..library import Library -from ..shapes import Path as MPath, Polygon -from ..repetition import Grid -from ..file import dxf - -def test_dxf_roundtrip(tmp_path: Path): - lib = Library() - pat = Pattern() - - # 1. Polygon (closed) - poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) - pat.polygon("1", vertices=poly_verts) - - # 2. Path (open, 3 points) - path_verts = numpy.array([[20, 0], [30, 0], [30, 10]]) - pat.path("2", vertices=path_verts, width=2) - - # 3. Path (open, 2 points) - Testing the fix for 2-point polylines - path2_verts = numpy.array([[40, 0], [50, 10]]) - pat.path("3", vertices=path2_verts, width=0) # width 0 to be sure it's not a polygonized path if we're not careful - - # 4. Ref with Grid repetition (Manhattan) - subpat = Pattern() - subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]]) - lib["sub"] = subpat - - pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3)) - - lib["top"] = pat - - dxf_file = tmp_path / "test.dxf" - dxf.writefile(lib, "top", dxf_file) - - read_lib, _ = dxf.readfile(dxf_file) - - # In DXF read, the top level is usually called "Model" - top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] - - # Verify Polygon - polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)] - assert len(polys) >= 1 - poly_read = polys[0] - # DXF polyline might be shifted or vertices reordered, but here they should be simple - assert_allclose(poly_read.vertices, poly_verts) - - # Verify 3-point Path - paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)] - assert len(paths) >= 1 - path_read = paths[0] - assert_allclose(path_read.vertices, path_verts) - assert path_read.width == 2 - - # Verify 2-point Path - paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)] - assert len(paths2) >= 1 - path2_read = paths2[0] - assert_allclose(path2_read.vertices, path2_verts) - assert path2_read.width == 0 - - # Verify Ref with Grid - # Finding the sub pattern name might be tricky because of how DXF stores blocks - # but "sub" should be in read_lib - assert "sub" in read_lib - - # Check refs in the top pattern - found_grid = False - for target, reflist in top_pat.refs.items(): - # DXF names might be case-insensitive or modified, but ezdxf usually preserves them - if target.upper() == "SUB": - for ref in reflist: - if isinstance(ref.repetition, Grid): - assert ref.repetition.a_count == 2 - assert ref.repetition.b_count == 3 - assert_allclose(ref.repetition.a_vector, (10, 0)) - assert_allclose(ref.repetition.b_vector, (0, 10)) - found_grid = True - assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}" - -def test_dxf_manhattan_precision(tmp_path: Path): - # Test that float precision doesn't break Manhattan grid detection - lib = Library() - sub = Pattern() - sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) - lib["sub"] = sub - - top = Pattern() - # 90 degree rotation: in masque the grid is NOT rotated, so it stays [[10,0],[0,10]] - # In DXF, an array with rotation 90 has basis vectors [[0,10],[-10,0]]. - # So a masque grid [[10,0],[0,10]] with ref rotation 90 matches a DXF array. - angle = numpy.pi / 2 # 90 degrees - top.ref("sub", offset=(0, 0), rotation=angle, - repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2)) - - lib["top"] = top - - dxf_file = tmp_path / "precision.dxf" - dxf.writefile(lib, "top", dxf_file) - - # If the isclose() fix works, this should still be a Grid when read back - read_lib, _ = dxf.readfile(dxf_file) - read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] - - target_name = next(k for k in read_top.refs if k.upper() == "SUB") - ref = read_top.refs[target_name][0] - assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation" - - -def test_dxf_read_legacy_polyline() -> None: - doc = ezdxf.new() - msp = doc.modelspace() - msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True) - - stream = io.StringIO() - doc.write(stream) - stream.seek(0) - - read_lib, _ = dxf.read(stream) - top_pat = read_lib.get("Model") or list(read_lib.values())[0] - - polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)] - assert len(polys) == 1 - assert_allclose(polys[0].vertices, [[0, 0], [10, 0], [10, 10]]) diff --git a/masque/test/test_fdfd.py b/masque/test/test_fdfd.py deleted file mode 100644 index 2b4f3d3..0000000 --- a/masque/test/test_fdfd.py +++ /dev/null @@ -1,24 +0,0 @@ -# ruff: noqa -# ruff: noqa: ARG001 - - -import dataclasses -import pytest # type: ignore -import numpy -from numpy import pi -from numpy.typing import NDArray -# from numpy.testing import assert_allclose, assert_array_equal - -from .. import Pattern, Arc, Circle - - -def test_circle_mirror(): - cc = Circle(radius=4, offset=(10, 20)) - cc.flip_across(axis=0) # flip across y=0 - assert cc.offset[0] == 10 - assert cc.offset[1] == -20 - assert cc.radius == 4 - cc.flip_across(axis=1) # flip across x=0 - assert cc.offset[0] == -10 - assert cc.offset[1] == -20 - assert cc.radius == 4 diff --git a/masque/test/test_file_roundtrip.py b/masque/test/test_file_roundtrip.py deleted file mode 100644 index 2cfb0d1..0000000 --- a/masque/test/test_file_roundtrip.py +++ /dev/null @@ -1,152 +0,0 @@ -from pathlib import Path -from typing import cast -import pytest -from numpy.testing import assert_allclose - -from ..pattern import Pattern -from ..library import Library -from ..shapes import Path as MPath, Circle, Polygon -from ..repetition import Grid, Arbitrary - -def create_test_library(for_gds: bool = False) -> Library: - lib = Library() - - # 1. Polygons - pat_poly = Pattern() - pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]]) - lib["polygons"] = pat_poly - - # 2. Paths with different endcaps - pat_paths = Pattern() - # Flush - pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush) - # Square - pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square) - # Circle (Only for GDS) - if for_gds: - pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle) - # SquareCustom - pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5)) - lib["paths"] = pat_paths - - # 3. Circles (only for OASIS or polygonized for GDS) - pat_circles = Pattern() - if for_gds: - # GDS writer calls to_polygons() for non-supported shapes, - # but we can also pre-polygonize - pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0]) - else: - pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10))) - lib["circles"] = pat_circles - - # 4. Refs with repetitions - pat_refs = Pattern() - # Simple Ref - pat_refs.ref("polygons", offset=(0, 0)) - # Ref with Grid repetition - pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2)) - # Ref with Arbitrary repetition - pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]])) - lib["refs"] = pat_refs - - # 5. Shapes with repetitions (OASIS only, must be wrapped for GDS) - pat_rep_shapes = Pattern() - poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5)) - pat_rep_shapes.shapes[(4, 0)].append(poly_rep) - lib["rep_shapes"] = pat_rep_shapes - - if for_gds: - lib.wrap_repeated_shapes() - - return lib - -def test_gdsii_full_roundtrip(tmp_path: Path) -> None: - from ..file import gdsii - lib = create_test_library(for_gds=True) - gds_file = tmp_path / "full_test.gds" - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, _ = gdsii.readfile(gds_file) - - # Check existence - for name in lib: - assert name in read_lib - - # Check Paths - read_paths = read_lib["paths"] - # Check caps (GDS stores them as path_type) - # Order might be different depending on how they were written, - # but here they should match the order they were added if dict order is preserved. - # Actually, they are grouped by layer. - p_flush = cast("MPath", read_paths.shapes[(2, 0)][0]) - assert p_flush.cap == MPath.Cap.Flush - - p_square = cast("MPath", read_paths.shapes[(2, 1)][0]) - assert p_square.cap == MPath.Cap.Square - - p_circle = cast("MPath", read_paths.shapes[(2, 2)][0]) - assert p_circle.cap == MPath.Cap.Circle - - p_custom = cast("MPath", read_paths.shapes[(2, 3)][0]) - assert p_custom.cap == MPath.Cap.SquareCustom - assert p_custom.cap_extensions is not None - assert_allclose(p_custom.cap_extensions, (1, 5)) - - # Check Refs with repetitions - read_refs = read_lib["refs"] - assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs) - - # AREF check - arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None] - assert len(arefs) == 1 - assert isinstance(arefs[0].repetition, Grid) - assert arefs[0].repetition.a_count == 3 - assert arefs[0].repetition.b_count == 2 - - # Check wrapped shapes - # lib.wrap_repeated_shapes() created new patterns - # Original pattern "rep_shapes" now should have a Ref - assert len(read_lib["rep_shapes"].refs) > 0 - -def test_oasis_full_roundtrip(tmp_path: Path) -> None: - pytest.importorskip("fatamorgana") - from ..file import oasis - lib = create_test_library(for_gds=False) - oas_file = tmp_path / "full_test.oas" - oasis.writefile(lib, oas_file, units_per_micron=1000) - - read_lib, _ = oasis.readfile(oas_file) - - # Check existence - for name in lib: - assert name in read_lib - - # Check Circle - read_circles = read_lib["circles"] - assert isinstance(read_circles.shapes[(3, 0)][0], Circle) - assert read_circles.shapes[(3, 0)][0].radius == 5 - - # Check Path caps - read_paths = read_lib["paths"] - assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush - assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square - # OASIS HalfWidth is Square. masque's Square is also HalfWidth extension. - # Wait, Circle cap in OASIS? - # masque/file/oasis.py: - # path_cap_map = { - # PathExtensionScheme.Flush: Path.Cap.Flush, - # PathExtensionScheme.HalfWidth: Path.Cap.Square, - # PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom, - # } - # It seems Circle cap is NOT supported in OASIS by masque currently. - # Let's verify what happens with Circle cap in OASIS write. - # _shapes_to_elements in oasis.py: - # path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) - # This will raise StopIteration if Circle is not in path_cap_map. - - # Check Shape repetition - read_rep_shapes = read_lib["rep_shapes"] - poly = read_rep_shapes.shapes[(4, 0)][0] - assert poly.repetition is not None - assert isinstance(poly.repetition, Grid) - assert poly.repetition.a_count == 5 diff --git a/masque/test/test_gdsii.py b/masque/test/test_gdsii.py deleted file mode 100644 index 7ce8c88..0000000 --- a/masque/test/test_gdsii.py +++ /dev/null @@ -1,71 +0,0 @@ -from pathlib import Path -from typing import cast -import numpy -from numpy.testing import assert_equal, assert_allclose - -from ..pattern import Pattern -from ..library import Library -from ..file import gdsii -from ..shapes import Path as MPath, Polygon - - -def test_gdsii_roundtrip(tmp_path: Path) -> None: - lib = Library() - - # Simple polygon cell - pat1 = Pattern() - pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) - lib["poly_cell"] = pat1 - - # Path cell - pat2 = Pattern() - pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10) - lib["path_cell"] = pat2 - - # Cell with Ref - pat3 = Pattern() - pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2) - lib["ref_cell"] = pat3 - - gds_file = tmp_path / "test.gds" - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, info = gdsii.readfile(gds_file) - - assert "poly_cell" in read_lib - assert "path_cell" in read_lib - assert "ref_cell" in read_lib - - # Check polygon - read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0]) - # GDSII closes polygons, so it might have an extra vertex or different order - assert len(read_poly.vertices) >= 4 - # Check bounds as a proxy for geometry correctness - assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]]) - - # Check path - read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0]) - assert isinstance(read_path, MPath) - assert read_path.width == 10 - assert_equal(read_path.vertices, [[0, 0], [100, 0]]) - - # Check Ref - read_ref = read_lib["ref_cell"].refs["poly_cell"][0] - assert_equal(read_ref.offset, [50, 50]) - assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5) - - -def test_gdsii_annotations(tmp_path: Path) -> None: - lib = Library() - pat = Pattern() - # GDS only supports integer keys in range [1, 126] for properties - pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]}) - lib["cell"] = pat - - gds_file = tmp_path / "test_ann.gds" - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, _ = gdsii.readfile(gds_file) - read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations - assert read_ann is not None - assert read_ann["1"] == ["hello"] diff --git a/masque/test/test_label.py b/masque/test/test_label.py deleted file mode 100644 index ad8c08b..0000000 --- a/masque/test/test_label.py +++ /dev/null @@ -1,48 +0,0 @@ -import copy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..label import Label -from ..repetition import Grid -from ..utils import annotations_eq - - -def test_label_init() -> None: - lbl = Label("test", offset=(10, 20)) - assert lbl.string == "test" - assert_equal(lbl.offset, [10, 20]) - - -def test_label_transform() -> None: - lbl = Label("test", offset=(10, 0)) - # Rotate 90 deg CCW around (0,0) - lbl.rotate_around((0, 0), pi / 2) - assert_allclose(lbl.offset, [0, 10], atol=1e-10) - - # Translate - lbl.translate((5, 5)) - assert_allclose(lbl.offset, [5, 15], atol=1e-10) - - -def test_label_repetition() -> None: - rep = Grid(a_vector=(10, 0), a_count=3) - lbl = Label("rep", offset=(0, 0), repetition=rep) - assert lbl.repetition is rep - assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]]) - # Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't - # seem to automatically include repetition bounds in label.py itself, - # it's handled during pattern bounding. - - -def test_label_copy() -> None: - l1 = Label("test", offset=(1, 2), annotations={"a": [1]}) - l2 = copy.deepcopy(l1) - - print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}") - print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}") - print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}") - - assert l1 == l2 - assert l1 is not l2 - l2.offset[0] = 100 - assert l1.offset[0] == 1 diff --git a/masque/test/test_library.py b/masque/test/test_library.py deleted file mode 100644 index e58bd10..0000000 --- a/masque/test/test_library.py +++ /dev/null @@ -1,261 +0,0 @@ -import pytest -from typing import cast, TYPE_CHECKING -from numpy.testing import assert_allclose -from ..library import Library, LazyLibrary -from ..pattern import Pattern -from ..error import LibraryError, PatternError -from ..ports import Port -from ..repetition import Grid -from ..shapes import Path -from ..file.utils import preflight - -if TYPE_CHECKING: - from ..shapes import Polygon - - -def test_library_basic() -> None: - lib = Library() - pat = Pattern() - lib["cell1"] = pat - - assert "cell1" in lib - assert lib["cell1"] is pat - assert len(lib) == 1 - - with pytest.raises(LibraryError): - lib["cell1"] = Pattern() # Overwriting not allowed - - -def test_library_tops() -> None: - lib = Library() - lib["child"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("child") - - assert set(lib.tops()) == {"parent"} - assert lib.top() == "parent" - - -def test_library_dangling() -> None: - lib = Library() - lib["parent"] = Pattern() - lib["parent"].ref("missing") - - assert lib.dangling_refs() == {"missing"} - - -def test_library_dangling_graph_modes() -> None: - lib = Library() - lib["parent"] = Pattern() - lib["parent"].ref("missing") - - with pytest.raises(LibraryError, match="Dangling refs found"): - lib.child_graph() - with pytest.raises(LibraryError, match="Dangling refs found"): - lib.parent_graph() - with pytest.raises(LibraryError, match="Dangling refs found"): - lib.child_order() - - assert lib.child_graph(dangling="ignore") == {"parent": set()} - assert lib.parent_graph(dangling="ignore") == {"parent": set()} - assert lib.child_order(dangling="ignore") == ["parent"] - - assert lib.child_graph(dangling="include") == {"parent": {"missing"}, "missing": set()} - assert lib.parent_graph(dangling="include") == {"parent": set(), "missing": {"parent"}} - assert lib.child_order(dangling="include") == ["missing", "parent"] - - -def test_find_refs_with_dangling_modes() -> None: - lib = Library() - lib["target"] = Pattern() - - mid = Pattern() - mid.ref("target", offset=(2, 0)) - lib["mid"] = mid - - top = Pattern() - top.ref("mid", offset=(5, 0)) - top.ref("missing", offset=(9, 0)) - lib["top"] = top - - assert lib.find_refs_local("missing", dangling="ignore") == {} - assert lib.find_refs_global("missing", dangling="ignore") == {} - - local_missing = lib.find_refs_local("missing", dangling="include") - assert set(local_missing) == {"top"} - assert_allclose(local_missing["top"][0], [[9, 0, 0, 0, 1]]) - - global_missing = lib.find_refs_global("missing", dangling="include") - assert_allclose(global_missing[("top", "missing")], [[9, 0, 0, 0, 1]]) - - with pytest.raises(LibraryError, match="missing"): - lib.find_refs_local("missing") - with pytest.raises(LibraryError, match="missing"): - lib.find_refs_global("missing") - - global_target = lib.find_refs_global("target") - assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]]) - - -def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None: - def make_lib() -> Library: - lib = Library() - lib["empty"] = Pattern() - lib["top"] = Pattern() - lib["top"].ref("missing") - return lib - - caplog.set_level("WARNING") - warned = preflight(make_lib(), allow_dangling_refs=None, prune_empty_patterns=True) - assert "empty" not in warned - assert any("Dangling refs found" in record.message for record in caplog.records) - - allowed = preflight(make_lib(), allow_dangling_refs=True, prune_empty_patterns=True) - assert "empty" not in allowed - - with pytest.raises(LibraryError, match="Dangling refs found"): - preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True) - - -def test_library_flatten() -> None: - lib = Library() - child = Pattern() - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - lib["child"] = child - - parent = Pattern() - parent.ref("child", offset=(10, 10)) - lib["parent"] = parent - - flat_lib = lib.flatten("parent") - flat_parent = flat_lib["parent"] - - assert not flat_parent.has_refs() - assert len(flat_parent.shapes[(1, 0)]) == 1 - # Transformations are baked into vertices for Polygon - assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices - assert tuple(assert_vertices[0]) == (10.0, 10.0) - - -def test_library_flatten_preserves_ports_only_child() -> None: - lib = Library() - child = Pattern(ports={"P1": Port((1, 2), 0)}) - lib["child"] = child - - parent = Pattern() - parent.ref("child", offset=(10, 10)) - lib["parent"] = parent - - flat_parent = lib.flatten("parent", flatten_ports=True)["parent"] - - assert set(flat_parent.ports) == {"P1"} - assert cast("Port", flat_parent.ports["P1"]).rotation == 0 - assert tuple(flat_parent.ports["P1"].offset) == (11.0, 12.0) - - -def test_library_flatten_repeated_ref_with_ports_raises() -> None: - lib = Library() - child = Pattern(ports={"P1": Port((1, 2), 0)}) - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - lib["child"] = child - - parent = Pattern() - parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2)) - lib["parent"] = parent - - with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'): - lib.flatten("parent", flatten_ports=True) - - -def test_library_flatten_dangling_ok_nested_preserves_dangling_refs() -> None: - lib = Library() - child = Pattern() - child.ref("missing") - lib["child"] = child - - parent = Pattern() - parent.ref("child") - lib["parent"] = parent - - flat = lib.flatten("parent", dangling_ok=True) - - assert set(flat["child"].refs) == {"missing"} - assert flat["child"].has_refs() - assert set(flat["parent"].refs) == {"missing"} - assert flat["parent"].has_refs() - - -def test_lazy_library() -> None: - lib = LazyLibrary() - called = 0 - - def make_pat() -> Pattern: - nonlocal called - called += 1 - return Pattern() - - lib["lazy"] = make_pat - assert called == 0 - - pat = lib["lazy"] - assert called == 1 - assert isinstance(pat, Pattern) - - # Second access should be cached - pat2 = lib["lazy"] - assert called == 1 - assert pat is pat2 - - -def test_library_rename() -> None: - lib = Library() - lib["old"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("old") - - lib.rename("old", "new", move_references=True) - - assert "old" not in lib - assert "new" in lib - assert "new" in lib["parent"].refs - assert "old" not in lib["parent"].refs - - -def test_library_subtree() -> None: - lib = Library() - lib["a"] = Pattern() - lib["b"] = Pattern() - lib["c"] = Pattern() - lib["a"].ref("b") - - sub = lib.subtree("a") - assert "a" in sub - assert "b" in sub - assert "c" not in sub - - -def test_library_get_name() -> None: - lib = Library() - lib["cell"] = Pattern() - - name1 = lib.get_name("cell") - assert name1 != "cell" - assert name1.startswith("cell") - - name2 = lib.get_name("other") - assert name2 == "other" - - -def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None: - lib = Library() - pat = Pattern() - pat.shapes[(1, 0)] += [ - Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)), - Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)), - ] - lib["top"] = pat - - lib.dedup(norm_value=1, threshold=2) - - assert not lib["top"].refs - assert len(lib["top"].shapes[(1, 0)]) == 2 diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py deleted file mode 100644 index b1129f4..0000000 --- a/masque/test/test_oasis.py +++ /dev/null @@ -1,25 +0,0 @@ -from pathlib import Path -import pytest -from numpy.testing import assert_equal - -from ..pattern import Pattern -from ..library import Library -def test_oasis_roundtrip(tmp_path: Path) -> None: - # Skip if fatamorgana is not installed - pytest.importorskip("fatamorgana") - from ..file import oasis - - lib = Library() - pat1 = Pattern() - pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) - lib["cell1"] = pat1 - - oas_file = tmp_path / "test.oas" - # OASIS needs units_per_micron - oasis.writefile(lib, oas_file, units_per_micron=1000) - - read_lib, info = oasis.readfile(oas_file) - assert "cell1" in read_lib - - # Check bounds - assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]]) diff --git a/masque/test/test_pack2d.py b/masque/test/test_pack2d.py deleted file mode 100644 index 914c23e..0000000 --- a/masque/test/test_pack2d.py +++ /dev/null @@ -1,96 +0,0 @@ -from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns -from ..library import Library -from ..pattern import Pattern - - -def test_maxrects_bssf_simple() -> None: - # Pack two 10x10 squares into one 20x10 container - rects = [[10, 10], [10, 10]] - containers = [[0, 0, 20, 10]] - - locs, rejects = maxrects_bssf(rects, containers) - - assert not rejects - # They should be at (0,0) and (10,0) - assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)} - - -def test_maxrects_bssf_reject() -> None: - # Try to pack a too-large rectangle - rects = [[10, 10], [30, 30]] - containers = [[0, 0, 20, 20]] - - locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True) - assert 1 in rejects # Second rect rejected - assert 0 not in rejects - - -def test_maxrects_bssf_exact_fill_rejects_remaining() -> None: - rects = [[20, 20], [1, 1]] - containers = [[0, 0, 20, 20]] - - locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True) - - assert tuple(locs[0]) == (0.0, 0.0) - assert rejects == {1} - - -def test_maxrects_bssf_presort_reject_mapping() -> None: - rects = [[10, 12], [19, 14], [13, 11]] - containers = [[0, 0, 20, 20]] - - _locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True) - - assert rejects == {0, 2} - - -def test_guillotine_bssf_sas_presort_reject_mapping() -> None: - rects = [[2, 1], [17, 15], [16, 11]] - containers = [[0, 0, 20, 20]] - - _locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True) - - assert rejects == {2} - - -def test_pack_patterns() -> None: - lib = Library() - p1 = Pattern() - p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) - lib["p1"] = p1 - - p2 = Pattern() - p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) - lib["p2"] = p2 - - # Containers: one 20x20 - containers = [[0, 0, 20, 20]] - # 2um spacing - pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2)) - - assert not rejects - assert len(pat.refs) == 2 - assert "p1" in pat.refs - assert "p2" in pat.refs - - # Check that they don't overlap (simple check via bounds) - # p1 size 10x10, effectively 12x12 - # p2 size 5x5, effectively 7x7 - # Both should fit in 20x20 - - -def test_pack_patterns_reject_names_match_original_patterns() -> None: - lib = Library() - for name, (lx, ly) in { - "p0": (10, 12), - "p1": (19, 14), - "p2": (13, 11), - }.items(): - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly) - lib[name] = pat - - pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0)) - - assert set(rejects) == {"p0", "p2"} - assert set(pat.refs) == {"p1"} diff --git a/masque/test/test_path.py b/masque/test/test_path.py deleted file mode 100644 index 1cdd872..0000000 --- a/masque/test/test_path.py +++ /dev/null @@ -1,111 +0,0 @@ -from numpy.testing import assert_equal, assert_allclose - -from ..shapes import Path - - -def test_path_init() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) - assert_equal(p.vertices, [[0, 0], [10, 0]]) - assert p.width == 2 - assert p.cap == Path.Cap.Flush - - -def test_path_to_polygons_flush() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) - polys = p.to_polygons() - assert len(polys) == 1 - # Rectangle from (0, -1) to (10, 1) - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[0, -1], [10, 1]]) - - -def test_path_to_polygons_square() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square) - polys = p.to_polygons() - assert len(polys) == 1 - # Square cap adds width/2 = 1 to each end - # Rectangle from (-1, -1) to (11, 1) - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[-1, -1], [11, 1]]) - - -def test_path_to_polygons_circle() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle) - polys = p.to_polygons(num_vertices=32) - # Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps - assert len(polys) >= 3 - - # Combined bounds should be from (-1, -1) to (11, 1) - # But wait, Path.get_bounds_single() handles this more directly - bounds = p.get_bounds_single() - assert_equal(bounds, [[-1, -1], [11, 1]]) - - -def test_path_custom_cap() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10)) - polys = p.to_polygons() - assert len(polys) == 1 - # Extends 5 units at start, 10 at end - # Starts at -5, ends at 20 - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[-5, -1], [20, 1]]) - - -def test_path_bend() -> None: - # L-shaped path - p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2) - polys = p.to_polygons() - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - # Outer corner at (11, -1) is not right. - # Segments: (0,0)-(10,0) and (10,0)-(10,10) - # Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1) - # Corners of segment 2: (9,0), (9,10), (11,10), (11,0) - # Bounds should be [[-1 (if start is square), -1], [11, 11]]? - # Flush cap start at (0,0) with width 2 means y from -1 to 1. - # Vertical segment end at (10,10) with width 2 means x from 9 to 11. - # So bounds should be x: [0, 11], y: [-1, 10] - assert_equal(bounds, [[0, -1], [11, 10]]) - - -def test_path_mirror() -> None: - p = Path(vertices=[[10, 5], [20, 10]], width=2) - p.mirror(0) # Mirror across x axis (y -> -y) - assert_equal(p.vertices, [[10, -5], [20, -10]]) - - -def test_path_scale() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2) - p.scale_by(2) - assert_equal(p.vertices, [[0, 0], [20, 0]]) - assert p.width == 4 - - -def test_path_scale_custom_cap_extensions() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) - p.scale_by(3) - - assert_equal(p.vertices, [[0, 0], [30, 0]]) - assert p.width == 6 - assert p.cap_extensions is not None - assert_allclose(p.cap_extensions, [3, 6]) - assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]]) - - -def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) - - intrinsic, _extrinsic, ctor = p.normalized_form(5) - q = ctor() - - assert intrinsic[-1] == (0.2, 0.4) - assert q.width == 2 - assert q.cap_extensions is not None - assert_allclose(q.cap_extensions, [1, 2]) - - -def test_path_normalized_form_distinguishes_custom_caps() -> None: - p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) - p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)) - - assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0] diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py deleted file mode 100644 index 47cae29..0000000 --- a/masque/test/test_pather.py +++ /dev/null @@ -1,108 +0,0 @@ -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..builder import Pather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - - -@pytest.fixture -def pather_setup() -> tuple[Pather, PathTool, Library]: - lib = Library() - # Simple PathTool: 2um width on layer (1,0) - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool) - # Add an initial port facing North (pi/2) - # Port rotation points INTO device. So "North" rotation means device is North of port. - # Pathing "forward" moves South. - p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - return p, tool, lib - - -def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # Route 10um "forward" - p.straight("start", 10) - - # port rot pi/2 (North). Travel +pi relative to port -> South. - assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) - - -def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # Start (0,0) rot pi/2 (North). - # Path 10um "forward" (South), then turn Clockwise (ccw=False). - # Facing South, turn Right -> West. - p.cw("start", 10) - - # PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0. - # Transformed by port rot pi/2 (North) + pi (to move "forward" away from device): - # Transformation rot = pi/2 + pi = 3pi/2. - # (10, -1) rotated 3pi/2: (x,y) -> (y, -x) -> (-1, -10). - - assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10) - # North (pi/2) + CW (90 deg) -> West (pi)? - # Actual behavior results in 0 (East) - apparently rotation is flipped. - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) - - -def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # start at (0,0) rot pi/2 (North) - # path "forward" (South) to y=-50 - p.straight("start", y=-50) - assert_equal(p.ports["start"].offset, [0, -50]) - - -def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - p.ports["A"] = Port((0, 0), pi / 2, ptype="wire") - p.ports["B"] = Port((10, 0), pi / 2, ptype="wire") - - # Path both "forward" (South) to y=-20 - p.straight(["A", "B"], ymin=-20) - assert_equal(p.ports["A"].offset, [0, -20]) - assert_equal(p.ports["B"].offset, [10, -20]) - - -def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # Fluent API test - p.at("start").straight(10).ccw(10) - # 10um South -> (0, -10) rot pi/2 - # then 10um South and turn CCW (Facing South, CCW is East) - # PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0 - # Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10) - # (0, -10) + (1, -10) = (1, -20) - assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10) - # pi/2 (North) + CCW (90 deg) -> 0 (East)? - # Actual behavior results in pi (West). - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) - - -def test_pather_dead_ports() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=1) - p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool) - p.set_dead() - - # Path with negative length (impossible for PathTool, would normally raise BuildError) - p.straight("in", -10) - - # Port 'in' should be updated by dummy extension despite tool failure - # port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x. - assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10) - - # Downstream path should work correctly using the dummy port location - p.straight("in", 20) - # 10 + (-20) = -10 - assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10) - - # Verify no geometry - assert not p.pattern.has_shapes() diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py deleted file mode 100644 index c837280..0000000 --- a/masque/test/test_pather_api.py +++ /dev/null @@ -1,272 +0,0 @@ -import pytest -import numpy -from numpy import pi -from masque import Pather, RenderPather, Library, Pattern, Port -from masque.builder.tools import PathTool -from masque.error import BuildError - -def test_pather_trace_basic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - # Port rotation 0 points in +x (INTO device). - # To extend it, we move in -x direction. - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - # Trace single port - p.at('A').trace(None, 5000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) - - # Trace with bend - p.at('A').trace(True, 5000) # CCW bend - # Port was at (-5000, 0) rot 0. - # New wire starts at (-5000, 0) rot 0. - # Output port of wire before rotation: (5000, 500) rot -pi/2 - # Rotate by pi (since dev port rot is 0 and tool port rot is 0): - # (-5000, -500) rot pi - pi/2 = pi/2 - # Add to start: (-10000, -500) rot pi/2 - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2) - -def test_pather_trace_to() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - # Trace to x=-10000 - p.at('A').trace_to(None, x=-10000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) - - # Trace to position=-20000 - p.at('A').trace_to(None, p=-20000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0)) - -def test_pather_bundle_trace() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 2000), rotation=0) - - # Straight bundle - all should align to same x - p.at(['A', 'B']).straight(xmin=-10000) - assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) - assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) - - # Bundle with bend - p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) - # Traveling in -x direction. CCW turn turns towards -y. - # A is at y=0, B is at y=2000. - # Rotation center is at y = -R. - # A is closer to center than B. So A is inner, B is outer. - # xmin is coordinate of innermost bend (A). - assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000) - # B's bend is further out (more negative x) - assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) - -def test_pather_each_bound() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((-1000, 2000), rotation=0) - - # Each should move by 5000 (towards -x) - p.at(['A', 'B']).trace(None, each=5000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) - assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000)) - -def test_selection_management() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 0), rotation=0) - - pp = p.at('A') - assert pp.ports == ['A'] - - pp.select('B') - assert pp.ports == ['A', 'B'] - - pp.deselect('A') - assert pp.ports == ['B'] - - pp.select(['A']) - assert pp.ports == ['B', 'A'] - - pp.drop() - assert 'A' not in p.pattern.ports - assert 'B' not in p.pattern.ports - assert pp.ports == [] - -def test_mark_fork() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((100, 200), rotation=1) - - pp = p.at('A') - pp.mark('B') - assert 'B' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200)) - assert p.pattern.ports['B'].rotation == 1 - assert pp.ports == ['A'] # mark keeps current selection - - pp.fork('C') - assert 'C' in p.pattern.ports - assert pp.ports == ['C'] # fork switches to new name - -def test_rename() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').rename('B') - assert 'A' not in p.pattern.ports - assert 'B' in p.pattern.ports - - p.pattern.ports['C'] = Port((0, 0), rotation=0) - pp = p.at(['B', 'C']) - pp.rename({'B': 'D', 'C': 'E'}) - assert 'B' not in p.pattern.ports - assert 'C' not in p.pattern.ports - assert 'D' in p.pattern.ports - assert 'E' in p.pattern.ports - assert set(pp.ports) == {'D', 'E'} - -def test_renderpather_uturn_fallback() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - rp = RenderPather(lib, tools=tool) - rp.pattern.ports['A'] = Port((0, 0), rotation=0) - - # PathTool doesn't implement planU, so it should fall back to two planL calls - rp.at('A').uturn(offset=10000, length=5000) - - # Two steps should be added - assert len(rp.paths['A']) == 2 - assert rp.paths['A'][0].opcode == 'L' - assert rp.paths['A'][1].opcode == 'L' - - rp.render() - assert rp.pattern.ports['A'].rotation is not None - assert numpy.isclose(rp.pattern.ports['A'].rotation, pi) - -def test_autotool_uturn() -> None: - from masque.builder.tools import AutoTool - lib = Library() - - # Setup AutoTool with a simple straight and a bend - def make_straight(length: float) -> Pattern: - pat = Pattern() - pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000) - pat.ports['in'] = Port((0, 0), 0) - pat.ports['out'] = Port((length, 0), pi) - return pat - - bend_pat = Pattern() - bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)]) - bend_pat.ports['in'] = Port((0, 0), 0) - bend_pat.ports['out'] = Port((500, -500), pi/2) - lib['bend'] = bend_pat - - tool = AutoTool( - straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')], - bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)], - sbends=[], - transitions={}, - default_out_ptype='wire' - ) - - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), 0) - - # CW U-turn (jog < 0) - # R = 500. jog = -2000. length = 1000. - # p0 = planL(length=1000) -> out at (1000, -500) rot pi/2 - # R2 = 500. - # l2_length = abs(-2000) - abs(-500) - 500 = 1000. - p.at('A').uturn(offset=-2000, length=1000) - - # Final port should be at (-1000, 2000) rot pi - # Start: (0,0) rot 0. Wire direction is rot + pi = pi (West, -x). - # Tool planU returns (length, jog) = (1000, -2000) relative to (0,0) rot 0. - # Rotation of pi transforms (1000, -2000) to (-1000, 2000). - # Final rotation: 0 + pi = pi. - assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi) - -def test_pather_trace_into() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - # 1. Straight connector - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((-10000, 0), rotation=pi) - p.at('A').trace_into('B', plug_destination=False) - assert 'B' in p.pattern.ports - assert 'A' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) - - # 2. Single bend - p.pattern.ports['C'] = Port((0, 0), rotation=0) - p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2) - p.at('C').trace_into('D', plug_destination=False) - assert 'D' in p.pattern.ports - assert 'C' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000)) - - # 3. Jog (S-bend) - p.pattern.ports['E'] = Port((0, 0), rotation=0) - p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi) - p.at('E').trace_into('F', plug_destination=False) - assert 'F' in p.pattern.ports - assert 'E' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000)) - - # 4. U-bend (0 deg angle) - p.pattern.ports['G'] = Port((0, 0), rotation=0) - p.pattern.ports['H'] = Port((-10000, 2000), rotation=0) - p.at('G').trace_into('H', plug_destination=False) - assert 'H' in p.pattern.ports - assert 'G' in p.pattern.ports - # A U-bend with length=-travel=10000 and jog=-2000 from (0,0) rot 0 - # ends up at (-10000, 2000) rot pi. - assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000)) - assert p.pattern.ports['G'].rotation is not None - assert numpy.isclose(p.pattern.ports['G'].rotation, pi) - - -def test_pather_jog_failed_fallback_is_atomic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='shorter than required bend'): - p.jog('A', 1.5, length=5) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation == 0 - assert len(p.paths['A']) == 0 - - -def test_pather_uturn_failed_fallback_is_atomic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='shorter than required bend'): - p.uturn('A', 1.5, length=0) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation == 0 - assert len(p.paths['A']) == 0 diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py deleted file mode 100644 index 07e4150..0000000 --- a/masque/test/test_pattern.py +++ /dev/null @@ -1,150 +0,0 @@ -import pytest -from typing import cast -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..error import PatternError -from ..pattern import Pattern -from ..shapes import Polygon -from ..ref import Ref -from ..ports import Port -from ..label import Label -from ..repetition import Grid - - -def test_pattern_init() -> None: - pat = Pattern() - assert pat.is_empty() - assert not pat.has_shapes() - assert not pat.has_refs() - assert not pat.has_labels() - assert not pat.has_ports() - - -def test_pattern_with_elements() -> None: - poly = Polygon.square(10) - label = Label("test", offset=(5, 5)) - ref = Ref(offset=(100, 100)) - port = Port((0, 0), 0) - - pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port}) - - assert pat.has_shapes() - assert pat.has_labels() - assert pat.has_refs() - assert pat.has_ports() - assert not pat.is_empty() - assert pat.shapes[(1, 0)] == [poly] - assert pat.labels[(1, 2)] == [label] - assert pat.refs["sub"] == [ref] - assert pat.ports["P1"] == port - - -def test_pattern_append() -> None: - pat1 = Pattern() - pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) - - pat2 = Pattern() - pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]]) - - pat1.append(pat2) - assert len(pat1.shapes[(1, 0)]) == 1 - assert len(pat1.shapes[(2, 0)]) == 1 - - -def test_pattern_translate() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) - pat.ports["P1"] = Port((5, 5), 0) - - pat.translate_elements((10, 20)) - - # Polygon.translate adds to vertices, and offset is always (0,0) - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20]) - assert_equal(pat.ports["P1"].offset, [15, 25]) - - -def test_pattern_scale() -> None: - pat = Pattern() - # Polygon.rect sets an offset in its constructor which is immediately translated into vertices - pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1) - pat.scale_by(2) - - # Vertices should be scaled - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) - - -def test_pattern_rotate() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]]) - # Rotate 90 degrees CCW around (0,0) - pat.rotate_around((0, 0), pi / 2) - - # [10, 0] rotated 90 deg around (0,0) is [0, 10] - assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10) - - -def test_pattern_mirror() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]]) - # Mirror across X axis (y -> -y) - pat.mirror(0) - - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5]) - - -def test_pattern_get_bounds() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]]) - pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]]) - - bounds = pat.get_bounds() - assert_equal(bounds, [[-5, -5], [10, 10]]) - - -def test_pattern_flatten_preserves_ports_only_child() -> None: - child = Pattern(ports={"P1": Port((1, 2), 0)}) - - parent = Pattern() - parent.ref("child", offset=(10, 10)) - - parent.flatten({"child": child}, flatten_ports=True) - - assert set(parent.ports) == {"P1"} - assert parent.ports["P1"].rotation == 0 - assert tuple(parent.ports["P1"].offset) == (11.0, 12.0) - - -def test_pattern_flatten_repeated_ref_with_ports_raises() -> None: - child = Pattern(ports={"P1": Port((1, 2), 0)}) - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - parent = Pattern() - parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2)) - - with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'): - parent.flatten({"child": child}, flatten_ports=True) - - -def test_pattern_place_requires_abstract_for_reference() -> None: - parent = Pattern() - child = Pattern() - - with pytest.raises(PatternError, match='Must provide an `Abstract`'): - parent.place(child) - - -def test_pattern_interface() -> None: - source = Pattern() - source.ports["A"] = Port((10, 20), 0, ptype="test") - - iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_") - - assert "in_A" in iface.ports - assert "out_A" in iface.ports - assert iface.ports["in_A"].rotation is not None - assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10) - assert iface.ports["out_A"].rotation is not None - assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10) - assert iface.ports["in_A"].ptype == "test" - assert iface.ports["out_A"].ptype == "test" diff --git a/masque/test/test_polygon.py b/masque/test/test_polygon.py deleted file mode 100644 index 5d98ad9..0000000 --- a/masque/test/test_polygon.py +++ /dev/null @@ -1,125 +0,0 @@ -import pytest -import numpy -from numpy.testing import assert_equal - - -from ..shapes import Polygon -from ..utils import R90 -from ..error import PatternError - - -@pytest.fixture -def polygon() -> Polygon: - return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) - - -def test_vertices(polygon: Polygon) -> None: - assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - - -def test_xs(polygon: Polygon) -> None: - assert_equal(polygon.xs, [0, 1, 1, 0]) - - -def test_ys(polygon: Polygon) -> None: - assert_equal(polygon.ys, [0, 0, 1, 1]) - - -def test_offset(polygon: Polygon) -> None: - assert_equal(polygon.offset, [0, 0]) - - -def test_square() -> None: - square = Polygon.square(1) - assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]]) - - -def test_rectangle() -> None: - rectangle = Polygon.rectangle(1, 2) - assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]]) - - -def test_rect() -> None: - rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1) - assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) - - rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2) - assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) - - rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2) - assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]]) - - rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0) - assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]]) - - with pytest.raises(PatternError): - Polygon.rect(xctr=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(xmin=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(xmax=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(lx=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(yctr=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ymin=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ymax=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ly=0, xctr=-2, xmax=0) - - -def test_octagon() -> None: - octagon = Polygon.octagon(side_length=1) # regular=True - assert_equal(octagon.vertices.shape, (8, 2)) - diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0) - side_len = numpy.sqrt((diff * diff).sum(axis=1)) - assert numpy.allclose(side_len, 1) - - -def test_to_polygons(polygon: Polygon) -> None: - assert polygon.to_polygons() == [polygon] - - -def test_get_bounds_single(polygon: Polygon) -> None: - assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]]) - - -def test_rotate(polygon: Polygon) -> None: - rotated_polygon = polygon.rotate(R90) - assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]]) - - -def test_mirror(polygon: Polygon) -> None: - mirrored_by_y = polygon.deepcopy().mirror(1) - assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]]) - print(polygon.vertices) - mirrored_by_x = polygon.deepcopy().mirror(0) - assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]]) - - -def test_scale_by(polygon: Polygon) -> None: - scaled_polygon = polygon.scale_by(2) - assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]]) - - -def test_clean_vertices(polygon: Polygon) -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices() - assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) - - -def test_remove_duplicate_vertices() -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices() - assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]]) - - -def test_remove_colinear_vertices() -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices() - assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) - - -def test_vertices_dtype() -> None: - polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32)) - polygon.scale_by(0.5) - assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]]) diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py deleted file mode 100644 index 0291a1c..0000000 --- a/masque/test/test_ports.py +++ /dev/null @@ -1,189 +0,0 @@ -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..ports import Port, PortList -from ..error import PortError - - -def test_port_init() -> None: - p = Port(offset=(10, 20), rotation=pi / 2, ptype="test") - assert_equal(p.offset, [10, 20]) - assert p.rotation == pi / 2 - assert p.ptype == "test" - - -def test_port_transform() -> None: - p = Port(offset=(10, 0), rotation=0) - p.rotate_around((0, 0), pi / 2) - assert_allclose(p.offset, [0, 10], atol=1e-10) - assert p.rotation is not None - assert_allclose(p.rotation, pi / 2, atol=1e-10) - - p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset - assert_allclose(p.offset, [0, 10], atol=1e-10) - # rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2 - assert p.rotation is not None - assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10) - - -def test_port_flip_across() -> None: - p = Port(offset=(10, 0), rotation=0) - p.flip_across(axis=1) # Mirror across x=0: flips x-offset - assert_equal(p.offset, [-10, 0]) - # rotation was 0, mirrored(1) -> pi - assert p.rotation is not None - assert_allclose(p.rotation, pi, atol=1e-10) - - -def test_port_measure_travel() -> None: - p1 = Port((0, 0), 0) - p2 = Port((10, 5), pi) # Facing each other - - (travel, jog), rotation = p1.measure_travel(p2) - assert travel == 10 - assert jog == 5 - assert rotation == pi - - -def test_port_describe_any_rotation() -> None: - p = Port((0, 0), None) - assert p.describe() == "pos=(0, 0), rot=any" - - -def test_port_list_rename() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((0, 0), 0)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - pl.rename_ports({"A": "B"}) - assert "A" not in pl.ports - assert "B" in pl.ports - - -def test_port_list_rename_missing_port_raises() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((0, 0), 0)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Ports to rename were not found"): - pl.rename_ports({"missing": "B"}) - assert set(pl.ports) == {"A"} - - -def test_port_list_add_port_pair_requires_distinct_names() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports: dict[str, Port] = {} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Port names must be distinct"): - pl.add_port_pair(names=("A", "A")) - assert not pl.ports - - -def test_port_list_plugged() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - pl.plugged({"A": "B"}) - assert not pl.ports # Both should be removed - - -def test_port_list_plugged_empty_raises() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Must provide at least one port connection"): - pl.plugged({}) - assert set(pl.ports) == {"A", "B"} - - -def test_port_list_plugged_missing_port_raises() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Connection source ports were not found"): - pl.plugged({"missing": "B"}) - assert set(pl.ports) == {"A", "B"} - - with pytest.raises(PortError, match="Connection destination ports were not found"): - pl.plugged({"A": "missing"}) - assert set(pl.ports) == {"A", "B"} - - -def test_port_list_plugged_mismatch() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = { - "A": Port((10, 10), 0), - "B": Port((11, 10), pi), # Offset mismatch - } - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError): - pl.plugged({"A": "B"}) diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py deleted file mode 100644 index 72f6870..0000000 --- a/masque/test/test_ports2data.py +++ /dev/null @@ -1,76 +0,0 @@ -import numpy -from numpy.testing import assert_allclose - -from ..utils.ports2data import ports_to_data, data_to_ports -from ..pattern import Pattern -from ..ports import Port -from ..library import Library - - -def test_ports2data_roundtrip() -> None: - pat = Pattern() - pat.ports["P1"] = Port((10, 20), numpy.pi / 2, ptype="test") - - layer = (10, 0) - ports_to_data(pat, layer) - - assert len(pat.labels[layer]) == 1 - assert pat.labels[layer][0].string == "P1:test 90" - assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0) - - # New pattern, read ports back - pat2 = Pattern() - pat2.labels[layer] = pat.labels[layer] - data_to_ports([layer], {}, pat2) - - assert "P1" in pat2.ports - assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10) - assert pat2.ports["P1"].rotation is not None - assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10) - assert pat2.ports["P1"].ptype == "test" - - -def test_data_to_ports_hierarchical() -> None: - lib = Library() - - # Child has port data in labels - child = Pattern() - layer = (10, 0) - child.label(layer=layer, string="A:type1 0", offset=(5, 0)) - lib["child"] = child - - # Parent references child - parent = Pattern() - parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2) - - # Read ports hierarchically (max_depth > 0) - data_to_ports([layer], lib, parent, max_depth=1) - - # child port A (5,0) rot 0 - # transformed by parent ref: rot pi/2, trans (100, 100) - # (5,0) rot pi/2 -> (0, 5) - # (0, 5) + (100, 100) = (100, 105) - # rot 0 + pi/2 = pi/2 - assert "A" in parent.ports - assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) - assert parent.ports["A"].rotation is not None - assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) - - -def test_data_to_ports_hierarchical_scaled_ref() -> None: - lib = Library() - - child = Pattern() - layer = (10, 0) - child.label(layer=layer, string="A:type1 0", offset=(5, 0)) - lib["child"] = child - - parent = Pattern() - parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2, scale=2) - - data_to_ports([layer], lib, parent, max_depth=1) - - assert "A" in parent.ports - assert_allclose(parent.ports["A"].offset, [100, 110], atol=1e-10) - assert parent.ports["A"].rotation is not None - assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py deleted file mode 100644 index c1dbf26..0000000 --- a/masque/test/test_ref.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import cast, TYPE_CHECKING -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..error import MasqueError -from ..pattern import Pattern -from ..ref import Ref -from ..repetition import Grid - -if TYPE_CHECKING: - from ..shapes import Polygon - - -def test_ref_init() -> None: - ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0) - assert_equal(ref.offset, [10, 20]) - assert ref.rotation == pi / 4 - assert ref.mirrored is True - assert ref.scale == 2.0 - - -def test_ref_as_pattern() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - ref = Ref(offset=(10, 10), rotation=pi / 2, scale=2.0) - transformed_pat = ref.as_pattern(sub_pat) - - # Check transformed shape - shape = cast("Polygon", transformed_pat.shapes[(1, 0)][0]) - # ref.as_pattern deepcopies sub_pat then applies transformations: - # 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]] - # 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]] - # 3. pattern.translate_elements((10,10)) -> vertices [[10,10], [10,12], [8,10]] - - assert_allclose(shape.vertices, [[10, 10], [10, 12], [8, 10]], atol=1e-10) - - -def test_ref_with_repetition() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - rep = Grid(a_vector=(10, 0), b_vector=(0, 10), a_count=2, b_count=2) - ref = Ref(repetition=rep) - - repeated_pat = ref.as_pattern(sub_pat) - # Should have 4 shapes - assert len(repeated_pat.shapes[(1, 0)]) == 4 - - first_verts = sorted([tuple(cast("Polygon", s).vertices[0]) for s in repeated_pat.shapes[(1, 0)]]) - assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)] - - -def test_ref_get_bounds() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [5, 0], [0, 5]]) - - ref = Ref(offset=(10, 10), scale=2.0) - bounds = ref.get_bounds_single(sub_pat) - # sub_pat bounds [[0,0], [5,5]] - # scaled [[0,0], [10,10]] - # translated [[10,10], [20,20]] - assert_equal(bounds, [[10, 10], [20, 20]]) - - -def test_ref_copy() -> None: - ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]}) - ref2 = ref1.copy() - assert ref1 == ref2 - assert ref1 is not ref2 - - ref2.offset[0] = 100 - assert ref1.offset[0] == 1 - - -def test_ref_rejects_nonpositive_scale() -> None: - with pytest.raises(MasqueError, match='Scale must be positive'): - Ref(scale=0) - - with pytest.raises(MasqueError, match='Scale must be positive'): - Ref(scale=-1) - - -def test_ref_scale_by_rejects_nonpositive_scale() -> None: - ref = Ref(scale=2.0) - - with pytest.raises(MasqueError, match='Scale must be positive'): - ref.scale_by(-1) diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py deleted file mode 100644 index 3ad0d95..0000000 --- a/masque/test/test_renderpather.py +++ /dev/null @@ -1,132 +0,0 @@ -import pytest -from typing import cast, TYPE_CHECKING -from numpy.testing import assert_allclose -from numpy import pi - -from ..builder import RenderPather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - -if TYPE_CHECKING: - from ..shapes import Path - - -@pytest.fixture -def rpather_setup() -> tuple[RenderPather, PathTool, Library]: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - rp = RenderPather(lib, tools=tool) - rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - return rp, tool, lib - - -def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - # Plan two segments - rp.at("start").straight(10).straight(10) - - # Before rendering, no shapes in pattern - assert not rp.pattern.has_shapes() - assert len(rp.paths["start"]) == 2 - - # Render - rp.render() - assert rp.pattern.has_shapes() - assert len(rp.pattern.shapes[(1, 0)]) == 1 - - # Path vertices should be (0,0), (0,-10), (0,-20) - # transformed by start port (rot pi/2 -> 270 deg transform) - # wait, PathTool.render for opcode L uses rotation_matrix_2d(port_rot + pi) - # start_port rot pi/2. pi/2 + pi = 3pi/2. - # (10, 0) rotated 3pi/2 -> (0, -10) - # So vertices: (0,0), (0,-10), (0,-20) - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert len(path_shape.vertices) == 3 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) - - -def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - # Plan straight then bend - rp.at("start").straight(10).cw(10) - - rp.render() - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - # Path vertices: - # 1. Start (0,0) - # 2. Straight end: (0, -10) - # 3. Bend end: (-1, -20) - # PathTool.planL(ccw=False, length=10) returns data=[10, -1] - # start_port for 2nd segment is at (0, -10) with rotation pi/2 - # dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20). - # and final end_port.offset is (-1, -20). - assert len(path_shape.vertices) == 4 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10) - - -def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: - rp, tool1, lib = rpather_setup - tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") - - rp.at("start").straight(10) - rp.retool(tool2, keys=["start"]) - rp.at("start").straight(10) - - rp.render() - # Different tools should cause different batches/shapes - assert len(rp.pattern.shapes[(1, 0)]) == 1 - assert len(rp.pattern.shapes[(2, 0)]) == 1 - - -def test_renderpather_dead_ports() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=1) - rp = RenderPather(lib, ports={"in": Port((0, 0), 0)}, tools=tool) - rp.set_dead() - - # Impossible path - rp.straight("in", -10) - - # port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x. - assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10) - - # Verify no render steps were added - assert len(rp.paths["in"]) == 0 - - # Verify no geometry - rp.render() - assert not rp.pattern.has_shapes() - - -def test_renderpather_rename_port(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - rp.at("start").straight(10) - # Rename port while path is planned - rp.rename_ports({"start": "new_start"}) - # Continue path on new name - rp.at("new_start").straight(10) - - assert "start" not in rp.paths - assert len(rp.paths["new_start"]) == 2 - - rp.render() - assert rp.pattern.has_shapes() - assert len(rp.pattern.shapes[(1, 0)]) == 1 - # Total length 20. start_port rot pi/2 -> 270 deg transform. - # Vertices (0,0), (0,-10), (0,-20) - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) - assert "new_start" in rp.ports - assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10) - - -def test_pathtool_traceL_bend_geometry_matches_ports() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - - tree = tool.traceL(True, 10) - pat = tree.top_pattern() - path_shape = cast("Path", pat.shapes[(1, 0)][0]) - - assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10) - assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10) diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py deleted file mode 100644 index 5ef2fa9..0000000 --- a/masque/test/test_repetition.py +++ /dev/null @@ -1,51 +0,0 @@ -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..repetition import Grid, Arbitrary - - -def test_grid_displacements() -> None: - # 2x2 grid - grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) - disps = sorted([tuple(d) for d in grid.displacements]) - assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)] - - -def test_grid_1d() -> None: - grid = Grid(a_vector=(10, 0), a_count=3) - disps = sorted([tuple(d) for d in grid.displacements]) - assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)] - - -def test_grid_rotate() -> None: - grid = Grid(a_vector=(10, 0), a_count=2) - grid.rotate(pi / 2) - assert_allclose(grid.a_vector, [0, 10], atol=1e-10) - - -def test_grid_get_bounds() -> None: - grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) - bounds = grid.get_bounds() - assert_equal(bounds, [[0, 0], [10, 5]]) - - -def test_arbitrary_displacements() -> None: - pts = [[0, 0], [10, 20], [-5, 30]] - arb = Arbitrary(pts) - # They should be sorted by displacements.setter - disps = arb.displacements - assert len(disps) == 3 - assert any((disps == [0, 0]).all(axis=1)) - assert any((disps == [10, 20]).all(axis=1)) - assert any((disps == [-5, 30]).all(axis=1)) - - -def test_arbitrary_transform() -> None: - arb = Arbitrary([[10, 0]]) - arb.rotate(pi / 2) - assert_allclose(arb.displacements, [[0, 10]], atol=1e-10) - - arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is: - # self.displacements[:, 1 - axis] *= -1 - # if axis=0, 1-axis=1, so y *= -1 - assert_allclose(arb.displacements, [[0, -10]], atol=1e-10) diff --git a/masque/test/test_rotation_consistency.py b/masque/test/test_rotation_consistency.py deleted file mode 100644 index f574f52..0000000 --- a/masque/test/test_rotation_consistency.py +++ /dev/null @@ -1,133 +0,0 @@ - -from typing import cast -import numpy as np -from numpy.testing import assert_allclose -from ..pattern import Pattern -from ..ref import Ref -from ..label import Label -from ..repetition import Grid - -def test_ref_rotate_intrinsic() -> None: - # Intrinsic rotate() should NOT affect repetition - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep) - - ref.rotate(np.pi/2) - - assert_allclose(ref.rotation, np.pi/2, atol=1e-10) - # Grid vector should still be (10, 0) - assert ref.repetition is not None - assert_allclose(cast('Grid', ref.repetition).a_vector, [10, 0], atol=1e-10) - -def test_ref_rotate_around_extrinsic() -> None: - # Extrinsic rotate_around() SHOULD affect repetition - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep) - - ref.rotate_around((0, 0), np.pi/2) - - assert_allclose(ref.rotation, np.pi/2, atol=1e-10) - # Grid vector should be rotated to (0, 10) - assert ref.repetition is not None - assert_allclose(cast('Grid', ref.repetition).a_vector, [0, 10], atol=1e-10) - -def test_pattern_rotate_around_extrinsic() -> None: - # Pattern.rotate_around() SHOULD affect repetition of its elements - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.rotate_around((0, 0), np.pi/2) - - # Check the ref inside the pattern - ref_in_pat = pat.refs['cell'][0] - assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10) - # Grid vector should be rotated to (0, 10) - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10) - -def test_label_rotate_around_extrinsic() -> None: - # Extrinsic rotate_around() SHOULD affect repetition of labels - rep = Grid(a_vector=(10, 0), a_count=2) - lbl = Label("test", repetition=rep, offset=(5, 0)) - - lbl.rotate_around((0, 0), np.pi/2) - - # Label offset should be (0, 5) - assert_allclose(lbl.offset, [0, 5], atol=1e-10) - # Grid vector should be rotated to (0, 10) - assert lbl.repetition is not None - assert_allclose(cast('Grid', lbl.repetition).a_vector, [0, 10], atol=1e-10) - -def test_pattern_rotate_elements_intrinsic() -> None: - # rotate_elements() should NOT affect repetition - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.rotate_elements(np.pi/2) - - ref_in_pat = pat.refs['cell'][0] - assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10) - # Grid vector should still be (10, 0) - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 0], atol=1e-10) - -def test_pattern_rotate_element_centers_extrinsic() -> None: - # rotate_element_centers() SHOULD affect repetition and offset - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep, offset=(5, 0)) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.rotate_element_centers(np.pi/2) - - ref_in_pat = pat.refs['cell'][0] - # Offset should be (0, 5) - assert_allclose(ref_in_pat.offset, [0, 5], atol=1e-10) - # Grid vector should be rotated to (0, 10) - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10) - # Ref rotation should NOT be changed - assert_allclose(ref_in_pat.rotation, 0, atol=1e-10) - -def test_pattern_mirror_elements_intrinsic() -> None: - # mirror_elements() should NOT affect repetition or offset - rep = Grid(a_vector=(10, 5), a_count=2) - ref = Ref(repetition=rep, offset=(5, 2)) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.mirror_elements(axis=0) # Mirror across x (flip y) - - ref_in_pat = pat.refs['cell'][0] - assert ref_in_pat.mirrored is True - # Repetition and offset should be unchanged - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 5], atol=1e-10) - assert_allclose(ref_in_pat.offset, [5, 2], atol=1e-10) - -def test_pattern_mirror_element_centers_extrinsic() -> None: - # mirror_element_centers() SHOULD affect repetition and offset - rep = Grid(a_vector=(10, 5), a_count=2) - ref = Ref(repetition=rep, offset=(5, 2)) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.mirror_element_centers(axis=0) # Mirror across x (flip y) - - ref_in_pat = pat.refs['cell'][0] - # Offset should be (5, -2) - assert_allclose(ref_in_pat.offset, [5, -2], atol=1e-10) - # Grid vector should be (10, -5) - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, -5], atol=1e-10) - # Ref mirrored state should NOT be changed - assert ref_in_pat.mirrored is False diff --git a/masque/test/test_shape_advanced.py b/masque/test/test_shape_advanced.py deleted file mode 100644 index 4e38e55..0000000 --- a/masque/test/test_shape_advanced.py +++ /dev/null @@ -1,147 +0,0 @@ -from pathlib import Path -import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection -from ..error import PatternError - - -# 1. Text shape tests -def test_text_to_polygons() -> None: - pytest.importorskip("freetype") - font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" - if not Path(font_path).exists(): - pytest.skip("Font file not found") - - t = Text("Hi", height=10, font_path=font_path) - polys = t.to_polygons() - assert len(polys) > 0 - assert all(isinstance(p, Polygon) for p in polys) - - # Check that it advances - # Character 'H' and 'i' should have different vertices - # Each character is a set of polygons. We check the mean x of vertices for each character. - char_x_means = [p.vertices[:, 0].mean() for p in polys] - assert len(set(char_x_means)) >= 2 - - -# 2. Manhattanization tests -def test_manhattanize() -> None: - pytest.importorskip("float_raster") - pytest.importorskip("skimage.measure") - # Diamond shape - poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]]) - grid = numpy.arange(0, 11, 1) - - manhattan_polys = poly.manhattanize(grid, grid) - assert len(manhattan_polys) >= 1 - for mp in manhattan_polys: - # Check that all edges are axis-aligned - dv = numpy.diff(mp.vertices, axis=0) - # For each segment, either dx or dy must be zero - assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0)) - - -# 3. Comparison and Sorting tests -def test_shape_comparisons() -> None: - c1 = Circle(radius=10) - c2 = Circle(radius=20) - assert c1 < c2 - assert not (c2 < c1) - - p1 = Polygon([[0, 0], [10, 0], [10, 10]]) - p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex - assert p1 < p2 - - # Different types - assert c1 < p1 or p1 < c1 - assert (c1 < p1) != (p1 < c1) - - -# 4. Arc/Path Edge Cases -def test_arc_edge_cases() -> None: - # Wrapped arc (> 360 deg) - a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2) - a.to_polygons(num_vertices=64) - # Should basically be a ring - bounds = a.get_bounds_single() - assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10) - - -def test_path_edge_cases() -> None: - # Zero-length segments - p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2) - polys = p.to_polygons() - assert len(polys) == 1 - assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]]) - - -# 5. PolyCollection with holes -def test_poly_collection_holes() -> None: - # Outer square, inner square hole - # PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do? - # wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple - # polygons or using specific winding rules. - # masque.shapes.Polygon doc says "specify an implicitly-closed boundary". - # Pyclipper is used in connectivity.py for holes. - - # Let's test PolyCollection with multiple polygons - verts = [ - [0, 0], - [10, 0], - [10, 10], - [0, 10], # Poly 1 - [2, 2], - [2, 8], - [8, 8], - [8, 2], # Poly 2 - ] - offsets = [0, 4] - pc = PolyCollection(verts, offsets) - polys = pc.to_polygons() - assert len(polys) == 2 - assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]]) - assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]]) - - -def test_poly_collection_constituent_empty() -> None: - # One real triangle, one "empty" polygon (0 vertices), one real square - # Note: Polygon requires 3 vertices, so "empty" here might mean just some junk - # that to_polygons should handle. - # Actually PolyCollection doesn't check vertex count per polygon. - verts = [ - [0, 0], - [1, 0], - [0, 1], # Tri - # Empty space - [10, 10], - [11, 10], - [11, 11], - [10, 11], # Square - ] - offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square? - # No, offsets should be strictly increasing or handle 0-length slices. - # vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)])) - # if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7] - offsets = [0, 3, 3] - pc = PolyCollection(verts, offsets) - # Polygon(vertices=[]) will fail because of the setter check. - # Let's see if pc.to_polygons() handles it. - # It calls Polygon(vertices=vv) for each slice. - # slice [3:3] gives empty vv. - with pytest.raises(PatternError): - pc.to_polygons() - - -def test_poly_collection_valid() -> None: - verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 3] - pc = PolyCollection(verts, offsets) - assert len(pc.to_polygons()) == 2 - shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))] - sorted_shapes = sorted(shapes) - assert len(sorted_shapes) == 4 - # Just verify it doesn't crash and is stable - assert sorted(sorted_shapes) == sorted_shapes diff --git a/masque/test/test_shapes.py b/masque/test/test_shapes.py deleted file mode 100644 index b19d6bc..0000000 --- a/masque/test/test_shapes.py +++ /dev/null @@ -1,142 +0,0 @@ -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection - - -def test_poly_collection_init() -> None: - # Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]] - verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 4] - pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) - assert len(list(pc.polygon_vertices)) == 2 - assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) - - -def test_poly_collection_to_polygons() -> None: - verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 4] - pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) - polys = pc.to_polygons() - assert len(polys) == 2 - assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]]) - - -def test_circle_init() -> None: - c = Circle(radius=10, offset=(5, 5)) - assert c.radius == 10 - assert_equal(c.offset, [5, 5]) - - -def test_circle_to_polygons() -> None: - c = Circle(radius=10) - polys = c.to_polygons(num_vertices=32) - assert len(polys) == 1 - assert isinstance(polys[0], Polygon) - # A circle with 32 vertices should have vertices distributed around (0,0) - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10) - - -def test_ellipse_init() -> None: - e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4) - assert_equal(e.radii, [10, 5]) - assert_equal(e.offset, [1, 2]) - assert e.rotation == pi / 4 - - -def test_ellipse_to_polygons() -> None: - e = Ellipse(radii=(10, 5)) - polys = e.to_polygons(num_vertices=64) - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10) - - -def test_arc_init() -> None: - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0)) - assert_equal(a.radii, [10, 10]) - assert_equal(a.angles, [0, pi / 2]) - assert a.width == 2 - - -def test_arc_to_polygons() -> None: - # Quarter circle arc - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - polys = a.to_polygons(num_vertices=32) - assert len(polys) == 1 - # Outer radius 11, inner radius 9 - # Quarter circle from 0 to 90 deg - bounds = polys[0].get_bounds_single() - # Min x should be 0 (inner edge start/stop or center if width is large) - # But wait, the arc is centered at 0,0. - # Outer edge goes from (11, 0) to (0, 11) - # Inner edge goes from (9, 0) to (0, 9) - # So x ranges from 0 to 11, y ranges from 0 to 11. - assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10) - - -def test_shape_mirror() -> None: - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) - e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset - assert_equal(e.offset, [10, 20]) - # rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi) - assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) - - a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20)) - a.mirror(0) - assert_equal(a.offset, [10, 20]) - # For Arc, mirror(0) negates rotation and angles - assert_allclose(a.angles, [0, -pi / 4], atol=1e-10) - - -def test_shape_flip_across() -> None: - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) - e.flip_across(axis=0) # Mirror across y=0: flips y-offset - assert_equal(e.offset, [10, -20]) - # rotation also flips: -pi/4 == 3pi/4 (mod pi) - assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) - # Mirror across specific y - e = Ellipse(radii=(10, 5), offset=(10, 20)) - e.flip_across(y=10) # Mirror across y=10 - # y=20 mirrored across y=10 -> y=0 - assert_equal(e.offset, [10, 0]) - - -def test_shape_scale() -> None: - e = Ellipse(radii=(10, 5)) - e.scale_by(2) - assert_equal(e.radii, [20, 10]) - - a = Arc(radii=(10, 5), angles=(0, pi), width=2) - a.scale_by(0.5) - assert_equal(a.radii, [5, 2.5]) - assert a.width == 1 - - -def test_shape_arclen() -> None: - # Test that max_arclen correctly limits segment lengths - - # Ellipse - e = Ellipse(radii=(10, 5)) - # Approximate perimeter is ~48.4 - # With max_arclen=5, should have > 10 segments - polys = e.to_polygons(max_arclen=5) - v = polys[0].vertices - dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1)) - assert numpy.all(dist <= 5.000001) - assert len(v) > 10 - - # Arc - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - # Outer perimeter is 11 * pi/2 ~ 17.27 - # Inner perimeter is 9 * pi/2 ~ 14.14 - # With max_arclen=2, should have > 8 segments on outer edge - polys = a.to_polygons(max_arclen=2) - v = polys[0].vertices - # Arc polygons are closed, but contain both inner and outer edges and caps - # Let's just check that all segment lengths are within limit - dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1)) - assert numpy.all(dist <= 2.000001) diff --git a/masque/test/test_svg.py b/masque/test/test_svg.py deleted file mode 100644 index a3261b6..0000000 --- a/masque/test/test_svg.py +++ /dev/null @@ -1,70 +0,0 @@ -from pathlib import Path -import xml.etree.ElementTree as ET - -import numpy -import pytest -from numpy.testing import assert_allclose - -pytest.importorskip("svgwrite") - -from ..library import Library -from ..pattern import Pattern -from ..file import svg - - -SVG_NS = "{http://www.w3.org/2000/svg}" -XLINK_HREF = "{http://www.w3.org/1999/xlink}href" - - -def _child_transform(svg_path: Path) -> tuple[float, ...]: - root = ET.fromstring(svg_path.read_text()) - for use in root.iter(f"{SVG_NS}use"): - if use.attrib.get(XLINK_HREF) == "#child": - raw = use.attrib["transform"] - assert raw.startswith("matrix(") and raw.endswith(")") - return tuple(float(value) for value in raw[7:-1].split()) - raise AssertionError("No child reference found in SVG output") - - -def test_svg_ref_rotation_uses_correct_affine_transform(tmp_path: Path) -> None: - lib = Library() - child = Pattern() - child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) - lib["child"] = child - - top = Pattern() - top.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2) - lib["top"] = top - - svg_path = tmp_path / "rotation.svg" - svg.writefile(lib, "top", str(svg_path)) - - assert_allclose(_child_transform(svg_path), (0, 2, -2, 0, 3, 4), atol=1e-10) - - -def test_svg_ref_mirroring_changes_affine_transform(tmp_path: Path) -> None: - base = Library() - child = Pattern() - child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) - base["child"] = child - - top_plain = Pattern() - top_plain.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=False) - base["plain"] = top_plain - - plain_path = tmp_path / "plain.svg" - svg.writefile(base, "plain", str(plain_path)) - plain_transform = _child_transform(plain_path) - - mirrored = Library() - mirrored["child"] = child.deepcopy() - top_mirrored = Pattern() - top_mirrored.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=True) - mirrored["mirrored"] = top_mirrored - - mirrored_path = tmp_path / "mirrored.svg" - svg.writefile(mirrored, "mirrored", str(mirrored_path)) - mirrored_transform = _child_transform(mirrored_path) - - assert_allclose(plain_transform, (0, 2, -2, 0, 3, 4), atol=1e-10) - assert_allclose(mirrored_transform, (0, 2, 2, 0, 3, 4), atol=1e-10) diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py deleted file mode 100644 index 45e347e..0000000 --- a/masque/test/test_utils.py +++ /dev/null @@ -1,106 +0,0 @@ -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms, DeferredDict - - -def test_remove_duplicate_vertices() -> None: - # Closed path (default) - v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]] - v_clean = remove_duplicate_vertices(v, closed_path=True) - # The last [0,0] is a duplicate of the first [0,0] if closed_path=True - assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]]) - - # Open path - v_clean_open = remove_duplicate_vertices(v, closed_path=False) - assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]]) - - -def test_remove_colinear_vertices() -> None: - v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]] - v_clean = remove_colinear_vertices(v, closed_path=True) - # [1, 0] is between [0, 0] and [2, 0] - # [2, 1] is between [2, 0] and [2, 2] - # [1, 1] is between [2, 2] and [0, 0] - assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]]) - - -def test_remove_colinear_vertices_exhaustive() -> None: - # U-turn - v = [[0, 0], [10, 0], [0, 0]] - v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True) - # Open path should keep ends. [10,0] is between [0,0] and [0,0]? - # They are colinear, but it's a 180 degree turn. - # We preserve 180 degree turns if preserve_uturns is True. - assert len(v_clean) == 3 - - v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False) - # If not preserving u-turns, it should collapse to just the endpoints - assert len(v_collapsed) == 2 - - # 180 degree U-turn in closed path - v = [[0, 0], [10, 0], [5, 0]] - v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False) - assert len(v_clean) == 2 - - -def test_poly_contains_points() -> None: - v = [[0, 0], [10, 0], [10, 10], [0, 10]] - pts = [[5, 5], [-1, -1], [10, 10], [11, 5]] - inside = poly_contains_points(v, pts) - assert_equal(inside, [True, False, True, False]) - - -def test_rotation_matrix_2d() -> None: - m = rotation_matrix_2d(pi / 2) - assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10) - - -def test_rotation_matrix_non_manhattan() -> None: - # 45 degrees - m = rotation_matrix_2d(pi / 4) - s = numpy.sqrt(2) / 2 - assert_allclose(m, [[s, -s], [s, s]], atol=1e-10) - - -def test_apply_transforms() -> None: - # cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)] - t1 = [10, 20, 0, 0] - t2 = [[5, 0, 0, 0], [0, 5, 0, 0]] - combined = apply_transforms(t1, t2) - assert_equal(combined, [[15, 20, 0, 0, 1], [10, 25, 0, 0, 1]]) - - -def test_apply_transforms_advanced() -> None: - # Ox4: (x, y, rot, mir) - # Outer: mirror x (axis 0), then rotate 90 deg CCW - # apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror) - outer = [0, 0, pi / 2, 1] - - # Inner: (10, 0, 0, 0) - inner = [10, 0, 0, 0] - - combined = apply_transforms(outer, inner) - # 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0) - # 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10) - # 3. add outer offset (0, 0) -> (0, 10) - assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10) - - -def test_deferred_dict_accessors_resolve_values_once() -> None: - calls = 0 - - def make_value() -> int: - nonlocal calls - calls += 1 - return 7 - - deferred = DeferredDict[str, int]() - deferred["x"] = make_value - - assert deferred.get("missing", 9) == 9 - assert deferred.get("x") == 7 - assert list(deferred.values()) == [7] - assert list(deferred.items()) == [("x", 7)] - assert calls == 1 diff --git a/masque/test/test_visualize.py b/masque/test/test_visualize.py deleted file mode 100644 index 4dab435..0000000 --- a/masque/test/test_visualize.py +++ /dev/null @@ -1,55 +0,0 @@ -import numpy as np -import pytest -from masque.pattern import Pattern -from masque.ports import Port -from masque.repetition import Grid - -try: - import matplotlib - HAS_MATPLOTLIB = True -except ImportError: - HAS_MATPLOTLIB = False - -@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") -def test_visualize_noninteractive(tmp_path) -> None: - """ - Test that visualize() runs and saves a file without error. - This covers the recursive transformation and collection logic. - """ - # Create a hierarchy - child = Pattern() - child.polygon('L1', [[0, 0], [1, 0], [1, 1], [0, 1]]) - child.ports['P1'] = Port((0.5, 0.5), 0) - - parent = Pattern() - # Add some refs with various transforms - parent.ref('child', offset=(10, 0), rotation=np.pi/4, mirrored=True, scale=2.0) - - # Add a repetition - rep = Grid(a_vector=(5, 5), a_count=2) - parent.ref('child', offset=(0, 10), repetition=rep) - - library = {'child': child} - - output_file = tmp_path / "test_plot.png" - - # Run visualize with filename to avoid showing window - parent.visualize(library=library, filename=str(output_file), ports=True) - - assert output_file.exists() - assert output_file.stat().st_size > 0 - -@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") -def test_visualize_empty() -> None: - """ Test visualizing an empty pattern. """ - pat = Pattern() - # Should not raise - pat.visualize(overdraw=True) - -@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") -def test_visualize_no_refs() -> None: - """ Test visualizing a pattern with only local shapes (no library needed). """ - pat = Pattern() - pat.polygon('L1', [[0, 0], [1, 0], [0, 1]]) - # Should not raise even if library is None - pat.visualize(overdraw=True) diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py index cca38f3..7c7360c 100644 --- a/masque/traits/__init__.py +++ b/masque/traits/__init__.py @@ -26,11 +26,7 @@ from .scalable import ( Scalable as Scalable, ScalableImpl as ScalableImpl, ) -from .mirrorable import ( - Mirrorable as Mirrorable, - Flippable as Flippable, - FlippableImpl as FlippableImpl, - ) +from .mirrorable import Mirrorable as Mirrorable from .copyable import Copyable as Copyable from .annotatable import ( Annotatable as Annotatable, diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 2a3a9fb..6d4ec3c 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,13 +1,6 @@ from typing import Self from abc import ABCMeta, abstractmethod -import numpy -from numpy.typing import NDArray - -from ..error import MasqueError -from .positionable import Positionable -from .repeatable import Repeatable - class Mirrorable(metaclass=ABCMeta): """ @@ -18,17 +11,11 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - Intrinsic transformation: Mirror the entity across an axis through its origin. - This does NOT affect the object's repetition grid. - - This operation is performed relative to the object's internal origin (ignoring - its offset). For objects like `Polygon` and `Path` where the offset is forced - to (0, 0), this is equivalent to mirroring in the container's coordinate system. + Mirror the entity across an axis. Args: - axis: Axis to mirror across: - 0: X-axis (flip y coords), - 1: Y-axis (flip x coords) + axis: Axis to mirror across. + Returns: self """ @@ -36,11 +23,10 @@ class Mirrorable(metaclass=ABCMeta): def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self: """ - Optionally mirror the entity across both axes through its origin. + Optionally mirror the entity across both axes Args: - across_x: Mirror across the horizontal X-axis (flip Y coordinates). - across_y: Mirror across the vertical Y-axis (flip X coordinates). + axes: (mirror_across_x, mirror_across_y) Returns: self @@ -52,61 +38,30 @@ class Mirrorable(metaclass=ABCMeta): return self -class Flippable(Positionable, metaclass=ABCMeta): - """ - Trait class for entities which can be mirrored relative to an external line. - """ - __slots__ = () - - @staticmethod - def _check_flip_args(axis: int | None = None, *, x: float | None = None, y: float | None = None) -> tuple[int, NDArray[numpy.float64]]: - pivot = numpy.zeros(2) - if axis is not None: - if x is not None or y is not None: - raise MasqueError('Cannot specify both axis and x or y') - return axis, pivot - if x is not None: - if y is not None: - raise MasqueError('Cannot specify both x and y') - return 1, pivot + (x, 0) - if y is not None: - return 0, pivot + (0, y) - raise MasqueError('Must specify one of axis, x, or y') - - @abstractmethod - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - """ - Extrinsic transformation: Mirror the object across a line in the container's - coordinate system. This affects both the object's offset and its repetition grid. - - Unlike `mirror()`, this operation is performed relative to the container's origin - (e.g. the `Pattern` origin, in the case of shapes) and takes the object's offset - into account. - - Args: - axis: Axis to mirror across. 0: x-axis (flip y coord), 1: y-axis (flip x coord). - x: Vertical line x=val to mirror across. - y: Horizontal line y=val to mirror across. - - Returns: - self - """ - pass - - -class FlippableImpl(Flippable, Mirrorable, Repeatable, metaclass=ABCMeta): - """ - Implementation of `Flippable` for objects which are `Mirrorable`, `Positionable`, - and `Repeatable`. - """ - __slots__ = () - - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) - self.translate(-pivot) - self.mirror(axis) - if self.repetition is not None: - self.repetition.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self +#class MirrorableImpl(Mirrorable, metaclass=ABCMeta): +# """ +# Simple implementation of `Mirrorable` +# """ +# __slots__ = () +# +# _mirrored: NDArray[numpy.bool] +# """ Whether to mirror the instance across the x and/or y axes. """ +# +# # +# # Properties +# # +# # Mirrored property +# @property +# def mirrored(self) -> NDArray[numpy.bool]: +# """ Whether to mirror across the [x, y] axes, respectively """ +# return self._mirrored +# +# @mirrored.setter +# def mirrored(self, val: Sequence[bool]) -> None: +# if is_scalar(val): +# raise MasqueError('Mirrored must be a 2-element list of booleans') +# self._mirrored = numpy.array(val, dtype=bool) +# +# # +# # Methods +# # diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py index dbf4fad..fbd765f 100644 --- a/masque/traits/repeatable.py +++ b/masque/traits/repeatable.py @@ -76,7 +76,7 @@ class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta): @repetition.setter def repetition(self, repetition: 'Repetition | None') -> None: - from ..repetition import Repetition #noqa: PLC0415 + from ..repetition import Repetition if repetition is not None and not isinstance(repetition, Repetition): raise MasqueError(f'{repetition} is not a valid Repetition object!') self._repetition = repetition diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 436d0a2..2fa86c1 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Self, cast, Any, TYPE_CHECKING from abc import ABCMeta, abstractmethod import numpy @@ -8,7 +8,8 @@ from numpy.typing import ArrayLike from ..error import MasqueError from ..utils import rotation_matrix_2d -from .positionable import Positionable +if TYPE_CHECKING: + from .positionable import Positionable _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass @@ -25,8 +26,7 @@ class Rotatable(metaclass=ABCMeta): @abstractmethod def rotate(self, val: float) -> Self: """ - Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. - This does NOT affect the object's repetition grid. + Rotate the shape around its origin (0, 0), ignoring its offset. Args: val: Angle to rotate by (counterclockwise, radians) @@ -64,10 +64,6 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): # Methods # def rotate(self, rotation: float) -> Self: - """ - Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. - This does NOT affect the object's repetition grid. - """ self.rotation += rotation return self @@ -85,9 +81,9 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): return self -class Pivotable(Positionable, metaclass=ABCMeta): +class Pivotable(metaclass=ABCMeta): """ - Trait class for entities which can be rotated around a point. + Trait class for entites which can be rotated around a point. This requires that they are `Positionable` but not necessarily `Rotatable` themselves. """ __slots__ = () @@ -95,11 +91,7 @@ class Pivotable(Positionable, metaclass=ABCMeta): @abstractmethod def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Extrinsic transformation: Rotate the object around a point in the container's - coordinate system. This affects both the object's offset and its repetition grid. - - For objects that are also `Rotatable`, this also performs an intrinsic - rotation of the object. + Rotate the object around a point. Args: pivot: Point (x, y) to rotate around @@ -111,21 +103,20 @@ class Pivotable(Positionable, metaclass=ABCMeta): pass -class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta): +class PivotableImpl(Pivotable, metaclass=ABCMeta): """ Implementation of `Pivotable` for objects which are `Rotatable` - and `Positionable`. """ __slots__ = () + offset: Any # TODO see if we can get around defining `offset` in PivotableImpl + """ `[x_offset, y_offset]` """ + def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: - from .repeatable import Repeatable #noqa: PLC0415 pivot = numpy.asarray(pivot, dtype=float) - self.translate(-pivot) - self.rotate(rotation) - if isinstance(self, Repeatable) and self.repetition is not None: - self.repetition.rotate(rotation) + cast('Positionable', self).translate(-pivot) + cast('Rotatable', self).rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - self.translate(+pivot) + cast('Positionable', self).translate(+pivot) return self diff --git a/masque/utils/autoslots.py b/masque/utils/autoslots.py index cef8006..e82d3db 100644 --- a/masque/utils/autoslots.py +++ b/masque/utils/autoslots.py @@ -17,12 +17,11 @@ class AutoSlots(ABCMeta): for base in bases: parents |= set(base.mro()) - slots = list(dctn.get('__slots__', ())) + slots = tuple(dctn.get('__slots__', ())) for parent in parents: if not hasattr(parent, '__annotations__'): continue - slots.extend(parent.__annotations__.keys()) + slots += tuple(parent.__annotations__.keys()) - # Deduplicate (dict to preserve order) - dctn['__slots__'] = tuple(dict.fromkeys(slots)) + dctn['__slots__'] = slots return super().__new__(cls, name, bases, dctn) diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py deleted file mode 100644 index 78c24e2..0000000 --- a/masque/utils/boolean.py +++ /dev/null @@ -1,182 +0,0 @@ -from typing import Any, Literal -from collections.abc import Iterable -import logging - -import numpy -from numpy.typing import NDArray - -from ..shapes.polygon import Polygon -from ..error import PatternError - - -logger = logging.getLogger(__name__) - - -def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]: - """ - Bridge multiple holes into an outer boundary using zero-width slits. - """ - current_outer = outer_path - - # Sort holes by max X to potentially minimize bridge lengths or complexity - # (though not strictly necessary for correctness) - holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True) - - for hole in holes: - # Find max X vertex of hole - max_idx = numpy.argmax(hole[:, 0]) - m = hole[max_idx] - - # Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges - best_t = numpy.inf - best_pt = None - best_edge_idx = -1 - - n = len(current_outer) - for i in range(n): - p1 = current_outer[i] - p2 = current_outer[(i + 1) % n] - - # Check if edge (p1, p2) spans m.y - if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]): - # Intersection x: - # x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y) - t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0] - if 0 <= t < best_t: - best_t = t - best_pt = numpy.array([m[0] + t, m[1]]) - best_edge_idx = i - - if best_edge_idx == -1: - # Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole) - dists = numpy.linalg.norm(current_outer - m, axis=1) - best_edge_idx = int(numpy.argmin(dists)) - best_pt = current_outer[best_edge_idx] - # Adjust best_edge_idx to insert AFTER this vertex - # (treating it as a degenerate edge) - - assert best_pt is not None - - # Reorder hole vertices to start at m - hole_reordered = numpy.roll(hole, -max_idx, axis=0) - - # Construct new outer: - # 1. Start of outer up to best_edge_idx - # 2. Intersection point - # 3. Hole vertices (starting and ending at m) - # 4. Intersection point (to close slit) - # 5. Rest of outer - - new_outer: list[NDArray[numpy.float64]] = [] - new_outer.extend(current_outer[:best_edge_idx + 1]) - new_outer.append(best_pt) - new_outer.extend(hole_reordered) - new_outer.append(hole_reordered[0]) # close hole loop at m - new_outer.append(best_pt) # back to outer - new_outer.extend(current_outer[best_edge_idx + 1:]) - - current_outer = numpy.array(new_outer) - - return current_outer - -def boolean( - subjects: Iterable[Any], - clips: Iterable[Any] | None = None, - operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', - scale: float = 1e6, - ) -> list[Polygon]: - """ - Perform a boolean operation on two sets of polygons. - - Args: - subjects: List of subjects (Polygons or vertex arrays). - clips: List of clips (Polygons or vertex arrays). - operation: The boolean operation to perform. - scale: Scaling factor for integer conversion (pyclipper uses integers). - - Returns: - A list of result Polygons. - """ - try: - import pyclipper #noqa: PLC0415 - except ImportError: - raise ImportError( - "Boolean operations require 'pyclipper'. " - "Install it with 'pip install pyclipper' or 'pip install masque[boolean]'." - ) from None - - op_map = { - 'union': pyclipper.PT_UNION, - 'intersection': pyclipper.PT_INTERSECTION, - 'difference': pyclipper.PT_DIFFERENCE, - 'xor': pyclipper.PT_XOR, - } - - def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]: - if objs is None: - return [] - verts = [] - for obj in objs: - if hasattr(obj, 'to_polygons'): - for p in obj.to_polygons(): - verts.append(p.vertices) - elif isinstance(obj, numpy.ndarray): - verts.append(obj) - elif isinstance(obj, Polygon): - verts.append(obj.vertices) - else: - # Try to iterate if it's an iterable of shapes - try: - for sub in obj: - if hasattr(sub, 'to_polygons'): - for p in sub.to_polygons(): - verts.append(p.vertices) - elif isinstance(sub, Polygon): - verts.append(sub.vertices) - except TypeError: - raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None - return verts - - subject_verts = to_vertices(subjects) - clip_verts = to_vertices(clips) - - pc = pyclipper.Pyclipper() - pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) - if clip_verts: - pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True) - - # Use GetPolyTree to distinguish between outers and holes - polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) - - result_polygons = [] - - def process_node(node: Any) -> None: - if not node.IsHole: - # This is an outer boundary - outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale)) - - # Find immediate holes - holes = [] - for child in node.Childs: - if child.IsHole: - holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale))) - - if holes: - combined_vertices = _bridge_holes(outer_path, holes) - result_polygons.append(Polygon(combined_vertices)) - else: - result_polygons.append(Polygon(outer_path)) - - # Recursively process children of holes (which are nested outers) - for child in node.Childs: - if child.IsHole: - for grandchild in child.Childs: - process_node(grandchild) - else: - # Holes are processed as children of outers - pass - - for top_node in polytree.Childs: - process_node(top_node) - - return result_polygons diff --git a/masque/utils/comparisons.py b/masque/utils/comparisons.py index ffb7206..63981c9 100644 --- a/masque/utils/comparisons.py +++ b/masque/utils/comparisons.py @@ -47,7 +47,7 @@ def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool: keys_a = tuple(sorted(aa.keys())) keys_b = tuple(sorted(bb.keys())) if keys_a != keys_b: - return False + return keys_a < keys_b for key in keys_a: va = aa[key] diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 2348678..8b3fcc4 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -69,25 +69,14 @@ def euler_bend( num_points_arc = num_points - 2 * num_points_spiral def gen_spiral(ll_max: float) -> NDArray[numpy.float64]: - if ll_max == 0: - return numpy.zeros((num_points_spiral, 2)) - - resolution = 100000 - qq = numpy.linspace(0, ll_max, resolution) - dx = numpy.cos(qq * qq / 2) - dy = -numpy.sin(qq * qq / 2) - - dq = ll_max / (resolution - 1) - ix = numpy.zeros(resolution) - iy = numpy.zeros(resolution) - ix[1:] = numpy.cumsum((dx[:-1] + dx[1:]) / 2) * dq - iy[1:] = numpy.cumsum((dy[:-1] + dy[1:]) / 2) * dq - - ll_target = numpy.linspace(0, ll_max, num_points_spiral) - x_target = numpy.interp(ll_target, qq, ix) - y_target = numpy.interp(ll_target, qq, iy) - - return numpy.stack((x_target, y_target), axis=1) + xx = [] + yy = [] + for ll in numpy.linspace(0, ll_max, num_points_spiral): + qq = numpy.linspace(0, ll, 1000) # integrate to current arclength + xx.append(trapezoid( numpy.cos(qq * qq / 2), qq)) + yy.append(trapezoid(-numpy.sin(qq * qq / 2), qq)) + xy_part = numpy.stack((xx, yy), axis=1) + return xy_part xy_spiral = gen_spiral(ll_max) xy_parts = [xy_spiral] diff --git a/masque/utils/deferreddict.py b/masque/utils/deferreddict.py index def9b10..aff3bcc 100644 --- a/masque/utils/deferreddict.py +++ b/masque/utils/deferreddict.py @@ -1,5 +1,5 @@ from typing import TypeVar, Generic -from collections.abc import Callable, Iterator +from collections.abc import Callable from functools import lru_cache @@ -25,45 +25,18 @@ class DeferredDict(dict, Generic[Key, Value]): """ def __init__(self, *args, **kwargs) -> None: dict.__init__(self) - if args or kwargs: - self.update(*args, **kwargs) + self.update(*args, **kwargs) def __setitem__(self, key: Key, value: Callable[[], Value]) -> None: - """ - Set a value, which must be a callable that returns the actual value. - The result of the callable is cached after the first access. - """ - if not callable(value): - raise TypeError(f"DeferredDict value must be callable, got {type(value)}") cached_fn = lru_cache(maxsize=1)(value) dict.__setitem__(self, key, cached_fn) def __getitem__(self, key: Key) -> Value: return dict.__getitem__(self, key)() - def get(self, key: Key, default: Value | None = None) -> Value | None: - if key not in self: - return default - return self[key] - - def items(self) -> Iterator[tuple[Key, Value]]: - for key in self.keys(): - yield key, self[key] - - def values(self) -> Iterator[Value]: - for key in self.keys(): - yield self[key] - def update(self, *args, **kwargs) -> None: - """ - Update the DeferredDict. If a value is callable, it is used as a generator. - Otherwise, it is wrapped as a constant. - """ for k, v in dict(*args, **kwargs).items(): - if callable(v): - self[k] = v - else: - self.set_const(k, v) + self[k] = v def __repr__(self) -> str: return '' @@ -73,4 +46,4 @@ class DeferredDict(dict, Generic[Key, Value]): Convenience function to avoid having to manually wrap constant values into callables. """ - self[key] = lambda v=value: v + self[key] = lambda: value diff --git a/masque/utils/pack2d.py b/masque/utils/pack2d.py index 248f408..ce6b006 100644 --- a/masque/utils/pack2d.py +++ b/masque/utils/pack2d.py @@ -60,12 +60,6 @@ def maxrects_bssf( degenerate = (min_more & max_less).any(axis=0) regions = regions[~degenerate] - if regions.shape[0] == 0: - if allow_rejects: - rejected_inds.add(rect_ind) - continue - raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}') - ''' Place the rect ''' # Best short-side fit (bssf) to pick a region region_sizes = regions[:, 2:] - regions[:, :2] @@ -108,7 +102,7 @@ def maxrects_bssf( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} + rejected_inds = set(unsort_order[list(rejected_inds)]) return rect_locs, rejected_inds @@ -193,7 +187,7 @@ def guillotine_bssf_sas( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} + rejected_inds = set(unsort_order[list(rejected_inds)]) return rect_locs, rejected_inds @@ -242,9 +236,7 @@ def pack_patterns( locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects) pat = Pattern() - for ii, (pp, oo, loc) in enumerate(zip(patterns, offsets, locations, strict=True)): - if ii in reject_inds: - continue + for pp, oo, loc in zip(patterns, offsets, locations, strict=True): pat.ref(pp, offset=oo + loc) rejects = [patterns[ii] for ii in reject_inds] diff --git a/masque/utils/ports2data.py b/masque/utils/ports2data.py index c7f42e1..b67fa0a 100644 --- a/masque/utils/ports2data.py +++ b/masque/utils/ports2data.py @@ -57,9 +57,11 @@ def data_to_ports( name: str | None = None, # Note: name optional, but arg order different from read(postprocess=) max_depth: int = 0, skip_subcells: bool = True, - visited: set[int] | None = None, + # TODO missing ok? ) -> Pattern: """ + # TODO fixup documentation in ports2data + # TODO move to utils.file? Examine `pattern` for labels specifying port info, and use that info to fill out its `ports` attribute. @@ -68,30 +70,18 @@ def data_to_ports( Args: layers: Search for labels on all the given layers. - library: Mapping from pattern names to patterns. pattern: Pattern object to scan for labels. - name: Name of the pattern object. - max_depth: Maximum hierarcy depth to search. Default 0. + max_depth: Maximum hierarcy depth to search. Default 999_999. Reduce this to 0 to avoid ever searching subcells. skip_subcells: If port labels are found at a given hierarcy level, do not continue searching at deeper levels. This allows subcells to contain their own port info without interfering with supercells' port data. Default True. - visited: Set of object IDs which have already been processed. Returns: The updated `pattern`. Port labels are not removed. """ - if visited is None: - visited = set() - - # Note: visited uses id(pattern) to detect cycles and avoid redundant processing. - # This may not catch identical patterns if they are loaded as separate object instances. - if id(pattern) in visited: - return pattern - visited.add(id(pattern)) - if pattern.ports: logger.warning(f'Pattern {name if name else pattern} already had ports, skipping data_to_ports') return pattern @@ -109,13 +99,12 @@ def data_to_ports( if target is None: continue pp = data_to_ports( - layers = layers, - library = library, - pattern = library[target], - name = target, - max_depth = max_depth - 1, - skip_subcells = skip_subcells, - visited = visited, + layers=layers, + library=library, + pattern=library[target], + name=target, + max_depth=max_depth - 1, + skip_subcells=skip_subcells, ) found_ports |= bool(pp.ports) @@ -171,17 +160,13 @@ def data_to_ports_flat( local_ports = {} for label in labels: - if ':' not in label.string: - logger.warning(f'Invalid port label "{label.string}" in pattern "{pstr}" (missing ":")') - continue - - name, property_string = label.string.split(':', 1) - properties = property_string.split() - ptype = properties[0] if len(properties) > 0 else 'unk' - angle_deg = float(properties[1]) if len(properties) > 1 else numpy.inf + name, property_string = label.string.split(':') + properties = property_string.split(' ') + ptype = properties[0] + angle_deg = float(properties[1]) if len(ptype) else 0 xy = label.offset - angle = numpy.deg2rad(angle_deg) if numpy.isfinite(angle_deg) else None + angle = numpy.deg2rad(angle_deg) if name in local_ports: logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"') diff --git a/masque/utils/transform.py b/masque/utils/transform.py index ed0453b..dfb6492 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -28,9 +28,8 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)], [numpy.sin(theta), +numpy.cos(theta)]]) - # If this was a manhattan rotation, round to remove some inaccuracies in sin & cos - # cos(4*theta) is 1 for any multiple of pi/2. - if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12): + # If this was a manhattan rotation, round to remove some inacuraccies in sin & cos + if numpy.isclose(theta % (pi / 2), 0): arr = numpy.round(arr) arr.flags.writeable = False @@ -87,50 +86,37 @@ def apply_transforms( Apply a set of transforms (`outer`) to a second set (`inner`). This is used to find the "absolute" transform for nested `Ref`s. - The two transforms should be of shape Ox5 and Ix5. - Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x, scale)`. - The output will be of the form (O*I)x5 (if `tensor=False`) or OxIx5 (`tensor=True`). + The two transforms should be of shape Ox4 and Ix4. + Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. + The output will be of the form (O*I)x4 (if `tensor=False`) or OxIx4 (`tensor=True`). Args: - outer: Transforms for the container refs. Shape Ox5. - inner: Transforms for the contained refs. Shape Ix5. - tensor: If `True`, an OxIx5 array is returned, with `result[oo, ii, :]` corresponding + outer: Transforms for the container refs. Shape Ox4. + inner: Transforms for the contained refs. Shape Ix4. + tensor: If `True`, an OxIx4 array is returned, with `result[oo, ii, :]` corresponding to the `oo`th `outer` transform applied to the `ii`th inner transform. - If `False` (default), this is concatenated into `(O*I)x5` to allow simple + If `False` (default), this is concatenated into `(O*I)x4` to allow simple chaining into additional `apply_transforms()` calls. Returns: - OxIx5 or (O*I)x5 array. Final dimension is - `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x, total_scale)`. + OxIx4 or (O*I)x4 array. Final dimension is + `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`. """ outer = numpy.atleast_2d(outer).astype(float, copy=False) inner = numpy.atleast_2d(inner).astype(float, copy=False) - if outer.shape[1] == 4: - outer = numpy.pad(outer, ((0, 0), (0, 1)), constant_values=1.0) - if inner.shape[1] == 4: - inner = numpy.pad(inner, ((0, 0), (0, 1)), constant_values=1.0) - # If mirrored, flip y's xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm xy_mir[outer[:, 3].astype(bool), :, 1] *= -1 - # Apply outer scale to inner offset - xy_mir *= outer[:, None, 4, None] - rot_mats = [rotation_matrix_2d(angle) for angle in outer[:, 2]] xy = numpy.einsum('ort,oit->oir', rot_mats, xy_mir) - tot = numpy.empty((outer.shape[0], inner.shape[0], 5)) + tot = numpy.empty((outer.shape[0], inner.shape[0], 4)) tot[:, :, :2] = outer[:, None, :2] + xy - - # If mirrored, flip inner rotation - mirrored_outer = outer[:, None, 3].astype(bool) - rotations = outer[:, None, 2] + numpy.where(mirrored_outer, -inner[None, :, 2], inner[None, :, 2]) - - tot[:, :, 2] = rotations % (2 * pi) - tot[:, :, 3] = (outer[:, None, 3] + inner[None, :, 3]) % 2 # net mirrored - tot[:, :, 4] = outer[:, None, 4] * inner[None, :, 4] # net scale + tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored + tot[:, :, 2] %= 2 * pi # clamp rot + tot[:, :, 3] %= 2 # clamp mirrored if tensor: return tot diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 5a5df9f..5fddd52 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -18,23 +18,13 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> `vertices` with no consecutive duplicates. This may be a view into the original array. """ vertices = numpy.asarray(vertices) - if vertices.shape[0] <= 1: - return vertices duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1) if not closed_path: duplicates[-1] = False - - result = vertices[~duplicates] - if result.shape[0] == 0 and vertices.shape[0] > 0: - return vertices[:1] - return result + return vertices[~duplicates] -def remove_colinear_vertices( - vertices: ArrayLike, - closed_path: bool = True, - preserve_uturns: bool = False, - ) -> NDArray[numpy.float64]: +def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any superflous vertices (i.e. those which lie along the line formed by their neighbors) @@ -43,40 +33,24 @@ def remove_colinear_vertices( vertices: Nx2 ndarray of vertices closed_path: If `True`, the vertices are assumed to represent an implicitly closed path. If `False`, the path is assumed to be open. Default `True`. - preserve_uturns: If `True`, colinear vertices that correspond to a 180 degree - turn (a "spike") are preserved. Default `False`. Returns: `vertices` with colinear (superflous) vertices removed. May be a view into the original array. """ - vertices = remove_duplicate_vertices(vertices, closed_path=closed_path) + vertices = remove_duplicate_vertices(vertices) # Check for dx0/dy0 == dx1/dy1 - dv = numpy.roll(vertices, -1, axis=0) - vertices - if not closed_path: - dv[-1] = 0 - # dxdy[i] is based on dv[i] and dv[i-1] - # slopes_equal[i] refers to vertex i - dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] + dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]] dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 slopes_equal = (dxdy_diff / err_mult) < 1e-15 - - if preserve_uturns: - # Only merge if segments are in the same direction (avoid collapsing u-turns) - dot_prod = (dv * numpy.roll(dv, 1, axis=0)).sum(axis=1) - slopes_equal &= (dot_prod > 0) - if not closed_path: slopes_equal[[0, -1]] = False - if slopes_equal.all() and vertices.shape[0] > 0: - # All colinear, keep the first and last - return vertices[[0, vertices.shape[0] - 1]] - return vertices[~slopes_equal] @@ -84,7 +58,7 @@ def poly_contains_points( vertices: ArrayLike, points: ArrayLike, include_boundary: bool = True, - ) -> NDArray[numpy.bool_]: + ) -> NDArray[numpy.int_]: """ Tests whether the provided points are inside the implicitly closed polygon described by the provided list of vertices. @@ -103,13 +77,13 @@ def poly_contains_points( vertices = numpy.asarray(vertices, dtype=float) if points.size == 0: - return numpy.zeros(0, dtype=bool) + return numpy.zeros(0, dtype=numpy.int8) min_bounds = numpy.min(vertices, axis=0)[None, :] max_bounds = numpy.max(vertices, axis=0)[None, :] trivially_outside = ((points < min_bounds).any(axis=1) - | (points > max_bounds).any(axis=1)) + | (points > max_bounds).any(axis=1)) # noqa: E128 nontrivial = ~trivially_outside if trivially_outside.all(): @@ -127,10 +101,10 @@ def poly_contains_points( dv = numpy.roll(verts, -1, axis=0) - verts is_left = (dv[:, 0] * (ntpts[..., 1] - verts[:, 1]) # >0 if left of dv, <0 if right, 0 if on the line - - dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) + - dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) # noqa: E128 winding_number = ((upward & (is_left > 0)).sum(axis=0) - - (downward & (is_left < 0)).sum(axis=0)) + - (downward & (is_left < 0)).sum(axis=0)) # noqa: E128 nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number if include_boundary: diff --git a/pyproject.toml b/pyproject.toml index af8802c..9a29065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "masque" description = "Lithography mask library" @@ -42,38 +46,17 @@ dependencies = [ "klamath~=1.4", ] -[dependency-groups] -dev = [ - "pytest", - "masque[oasis]", - "masque[dxf]", - "masque[svg]", - "masque[visualize]", - "masque[text]", - "masque[manhattanize]", - "masque[manhattanize_slow]", - "matplotlib>=3.10.8", - "pytest>=9.0.2", - "ruff>=0.15.5", - "mypy>=1.19.1", - ] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" [tool.hatch.version] path = "masque/__init__.py" [project.optional-dependencies] oasis = ["fatamorgana~=0.11"] -dxf = ["ezdxf~=1.4"] +dxf = ["ezdxf~=1.0.2"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] -manhattanize = ["scikit-image"] -manhattanize_slow = ["float_raster"] -boolean = ["pyclipper"] +manhatanize_slow = ["float_raster"] [tool.ruff] @@ -104,21 +87,10 @@ lint.ignore = [ "PLR09", # Too many xxx "PLR2004", # magic number "PLC0414", # import x as x -# "PLC0415", # non-top-level import - "PLW1641", # missing __hash__ with total_ordering "TRY003", # Long exception message ] [tool.pytest.ini_options] addopts = "-rsXx" testpaths = ["masque"] -filterwarnings = [ - "ignore::DeprecationWarning:ezdxf.*", -] - -[tool.mypy] -mypy_path = "stubs" -python_version = "3.11" -strict = false -check_untyped_defs = true diff --git a/stubs/ezdxf/__init__.pyi b/stubs/ezdxf/__init__.pyi deleted file mode 100644 index f25475f..0000000 --- a/stubs/ezdxf/__init__.pyi +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Any, TextIO -from collections.abc import Iterable -from .layouts import Modelspace, BlockRecords - -class Drawing: - blocks: BlockRecords - @property - def layers(self) -> Iterable[Any]: ... - def modelspace(self) -> Modelspace: ... - def write(self, stream: TextIO) -> None: ... - -def new(version: str = ..., setup: bool = ...) -> Drawing: ... -def read(stream: TextIO) -> Drawing: ... diff --git a/stubs/ezdxf/entities.pyi b/stubs/ezdxf/entities.pyi deleted file mode 100644 index 2c6efa9..0000000 --- a/stubs/ezdxf/entities.pyi +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Any -from collections.abc import Iterable, Sequence - -class DXFEntity: - def dxfattribs(self) -> dict[str, Any]: ... - def dxftype(self) -> str: ... - -class LWPolyline(DXFEntity): - def get_points(self) -> Iterable[tuple[float, ...]]: ... - -class Polyline(DXFEntity): - def points(self) -> Iterable[Any]: ... # has .xyz - -class Text(DXFEntity): - def get_placement(self) -> tuple[int, tuple[float, float, float]]: ... - def set_placement(self, p: Sequence[float], align: int = ...) -> Text: ... - -class Insert(DXFEntity): ... diff --git a/stubs/ezdxf/enums.pyi b/stubs/ezdxf/enums.pyi deleted file mode 100644 index 0dcf600..0000000 --- a/stubs/ezdxf/enums.pyi +++ /dev/null @@ -1,4 +0,0 @@ -from enum import IntEnum - -class TextEntityAlignment(IntEnum): - BOTTOM_LEFT = ... diff --git a/stubs/ezdxf/layouts.pyi b/stubs/ezdxf/layouts.pyi deleted file mode 100644 index c9d12ad..0000000 --- a/stubs/ezdxf/layouts.pyi +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Any -from collections.abc import Iterator, Sequence, Iterable -from .entities import DXFEntity - -class BaseLayout: - def __iter__(self) -> Iterator[DXFEntity]: ... - def add_lwpolyline(self, points: Iterable[Sequence[float]], dxfattribs: dict[str, Any] = ...) -> Any: ... - def add_text(self, text: str, dxfattribs: dict[str, Any] = ...) -> Any: ... - def add_blockref(self, name: str, insert: Any, dxfattribs: dict[str, Any] = ...) -> Any: ... - -class Modelspace(BaseLayout): - @property - def name(self) -> str: ... - -class BlockLayout(BaseLayout): - @property - def name(self) -> str: ... - -class BlockRecords: - def new(self, name: str) -> BlockLayout: ... - def __iter__(self) -> Iterator[BlockLayout]: ... diff --git a/stubs/pyclipper/__init__.pyi b/stubs/pyclipper/__init__.pyi deleted file mode 100644 index 08d77c8..0000000 --- a/stubs/pyclipper/__init__.pyi +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any -from collections.abc import Iterable, Sequence -import numpy -from numpy.typing import NDArray - - -# Basic types for Clipper integer coordinates -Path = Sequence[tuple[int, int]] -Paths = Sequence[Path] - -# Types for input/output floating point coordinates -FloatPoint = tuple[float, float] | NDArray[numpy.floating] -FloatPath = Sequence[FloatPoint] | NDArray[numpy.floating] -FloatPaths = Iterable[FloatPath] - -# Constants -PT_SUBJECT: int -PT_CLIP: int - -PT_UNION: int -PT_INTERSECTION: int -PT_DIFFERENCE: int -PT_XOR: int - -PFT_EVENODD: int -PFT_NONZERO: int -PFT_POSITIVE: int -PFT_NEGATIVE: int - -# Scaling functions -def scale_to_clipper(paths: FloatPaths, scale: float = ...) -> Paths: ... -def scale_from_clipper(paths: Path | Paths, scale: float = ...) -> Any: ... - -class PolyNode: - Contour: Path - Childs: list[PolyNode] - Parent: PolyNode - IsHole: bool - -class Pyclipper: - def __init__(self) -> None: ... - def AddPath(self, path: Path, poly_type: int, closed: bool) -> None: ... - def AddPaths(self, paths: Paths, poly_type: int, closed: bool) -> None: ... - def Execute(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> Paths: ... - def Execute2(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> PolyNode: ... - def Clear(self) -> None: ...