From 37418d2137e6d97c693e1c2f7f19f5d315fcade8 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 16:07:19 -0800 Subject: [PATCH 001/157] [examples] fixup examples and add port_pather example --- examples/tutorial/pather.py | 112 ++++++++++++++++++------------ examples/tutorial/pcgen.py | 16 ++--- examples/tutorial/port_pather.py | 86 +++++++++++++++++++++++ examples/tutorial/renderpather.py | 29 ++++---- 4 files changed, 176 insertions(+), 67 deletions(-) create mode 100644 examples/tutorial/port_pather.py diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index 101fbb5..d4831bb 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -1,10 +1,10 @@ """ -Manual wire routing tutorial: Pather and BasicTool +Manual wire routing tutorial: Pather and AutoTool """ from collections.abc import Callable from numpy import pi from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers -from masque.builder.tools import BasicTool, PathTool +from masque.builder.tools import AutoTool, PathTool from masque.file.gdsii import writefile from basic_shapes import GDS_OPTS @@ -110,28 +110,24 @@ def map_layer(layer: layer_t) -> layer_t: return layer_mapping.get(layer, layer) -# -# Now we can start building up our library (collection of static cells) and pathing tools. -# -# If any of the operations below are confusing, you can cross-reference against the `RenderPather` -# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others -# (e.g. geometry definition). -# -def main() -> None: +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', + 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', ) # @@ -140,53 +136,79 @@ def main() -> None: # 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` + # 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 = 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 - ), + 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': ( # For example, when we're attaching to a port with type 'm2wire' + ('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 = BasicTool( - straight = ( + M2_tool = AutoTool( + straights = [ # 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', - ), + 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': ( + ('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 + + +# +# Now we can start building up our library (collection of static cells) and pathing tools. +# +# If any of the operations below are confusing, you can cross-reference against the `RenderPather` +# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others +# (e.g. geometry definition). +# +def main() -> None: + library, M1_tool, M2_tool = prepare_tools() # # Create a new pather which writes to `library` and uses `M2_tool` as its default tool. @@ -272,7 +294,7 @@ def main() -> None: pather.path_to('GND', None, -50_000) # Save the pather's pattern into our library - library['Pather_and_BasicTool'] = pather.pattern + library['Pather_and_AutoTool'] = 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 023079c..14d9994 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 collection.abc import Sequence +from collections.abc import Sequence import numpy from numpy.typing import ArrayLike, NDArray @@ -198,11 +198,11 @@ def ln_defect( """ if defect_length % 2 != 1: raise Exception('defect_length must be odd!') - p = triangular_lattice([2 * d + 1 for d in mirror_dims]) + pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims]) half_length = numpy.floor(defect_length / 2) hole_nums = numpy.arange(-half_length, half_length + 1) - holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True) - return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ] + holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True) + return pp[numpy.logical_or(holes_to_keep, pp[:, 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 row in xyr: - if numpy.fabs(row) in perturbed_holes: - row[2] = perturbed_radius + for xy in perturbed_holes: + which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1) + xyr[which, 2] = perturbed_radius return xyr diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py new file mode 100644 index 0000000..f6f2a76 --- /dev/null +++ b/examples/tutorial/port_pather.py @@ -0,0 +1,86 @@ +""" +PortPather tutorial: Using .at() syntax for fluent port manipulation +""" +from numpy import pi +from masque import Pather, Library, Pattern, Port +from masque.builder.tools import AutoTool +from masque.file.gdsii import writefile + +from basic_shapes import GDS_OPTS +# Reuse helper functions and constants from the basic pather tutorial +from pather import ( + M1_WIDTH, V1_WIDTH, M2_WIDTH, + make_pad, make_via, make_bend, make_straight_wire, map_layer + +) + +def main() -> None: + # Reuse the same patterns (pads, bends, vias) and tools as in pather.py + library, M1_tool, M2_tool = prepare_tools() + 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', + ) + + # 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() + # + # The .at(port_name) method returns a PortPather object which wraps the Pather + # and remembers the selected port(s). This allows method chaining. + + # Then we can route just like in the other pather tutorials: + (rpather.at('VCC') + .path(ccw=False, length=6_000) + .path_to(ccw=None, x=0) + ) + + rpather.at('GND').path(0, 5_000).path_to(None, x=rpather['VCC'].x) + + + # We're using AutoTool so we could retool directly to M1_ptool like in the Pather + # tutorial, but let's manually plug to demonstrate what it looks like: + (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: + (rpather.at(['GND', 'VCC']) + .mpath(ccw=True, xmax=-10_000, spacing=5_000) + .retool(M1_tool) # this retools both ports + .mpath(ccw=True, emax=50_000, spacing=1_200) # ...causing an automatic via on VCC here + .mpath(ccw=False, emin=1_000, spacing=1_200) + .mpath(ccw=False, emin=2_000, spacing=4_500) + ) + + # Now we can finish up the equivalent to the other tutorials.. + rpather.at('VCC').retool(M2_tool) + rpather.at(['GND', 'VCC']).mpath(None, xmin=-28_000) + + rpather.at('VCC').path_to(None, x=-50_000, out_ptype='m1wire') + with pather.toolctx(M2_tool, keys=['GND']): + pather.at('GND').path_to(None, x=-40_000) + pather.at('GND').path_to(None, x=-50_000) + + # + # Save result + # + library['PortPather_Tutorial'] = pather.pattern + library.map_layers(map_layer) + writefile(library, 'port_pather.gds', **GDS_OPTS) + + +if __name__ == '__main__': + main() diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py index cb002f3..3707fc6 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -25,14 +25,14 @@ 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 `BasicTool`. It only generates one type of shape @@ -42,7 +42,7 @@ def main() -> None: M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire') rpather = RenderPather(tools=M2_ptool, library=library) - # As in the pather tutorial, we make soem pads and labels... + # As in the pather tutorial, we make some 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)) @@ -52,7 +52,7 @@ def main() -> None: 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]) + rpather.path_to('GND', None, x=rpather['VCC'].x) # `PathTool` doesn't know how to transition betwen metal layers, so we have to # `plug` the via into the GND wire ourselves. @@ -76,13 +76,14 @@ def main() -> None: # 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. - via_size = abs( - library['v1_via'].ports['top'].offset[0] - - library['v1_via'].ports['bottom'].offset[0] - ) + 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.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 From dfa0259997aaef8723caa0668c5ffc932be170cc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 16:57:11 -0800 Subject: [PATCH 002/157] [examples] clean up imports --- examples/tutorial/basic_shapes.py | 4 +--- examples/tutorial/devices.py | 4 ++-- examples/tutorial/library.py | 8 ++------ examples/tutorial/pather.py | 5 ++--- examples/tutorial/renderpather.py | 3 +-- 5 files changed, 8 insertions(+), 16 deletions(-) diff --git a/examples/tutorial/basic_shapes.py b/examples/tutorial/basic_shapes.py index 87baaf0..5b5aab7 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -1,11 +1,9 @@ -from collections.abc import Sequence import numpy from numpy import pi from masque import ( - layer_t, Pattern, Label, Port, - Circle, Arc, Polygon, + layer_t, Pattern, Circle, Arc, Polygon, ) import masque.file.gdsii diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index 6b9cfa2..79d318a 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, Label, Builder, Port, Polygon, - Library, ILibraryView, + layer_t, Pattern, Ref, Builder, Port, Polygon, + Library, ) 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 eab8a12..abfbbf1 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,17 +1,13 @@ from typing import Any -from collections.abc import Sequence, Callable from pprint import pformat -import numpy -from numpy import pi -from masque import Pattern, Builder, LazyLibrary +from masque import Builder, LazyLibrary from masque.file.gdsii import writefile, load_libraryfile -import pcgen import basic_shapes import devices -from devices import ports_to_data, data_to_ports +from devices import data_to_ports from basic_shapes import GDS_OPTS diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index d4831bb..c212bc5 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -1,10 +1,9 @@ """ Manual wire routing tutorial: Pather and AutoTool """ -from collections.abc import Callable from numpy import pi -from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers -from masque.builder.tools import AutoTool, PathTool +from masque import Pather, Library, Pattern, Port, layer_t +from masque.builder.tools import AutoTool, Tool from masque.file.gdsii import writefile from basic_shapes import GDS_OPTS diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py index 3707fc6..87e58f2 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -1,8 +1,7 @@ """ Manual wire routing tutorial: RenderPather an PathTool """ -from collections.abc import Callable -from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers +from masque import RenderPather, Library from masque.builder.tools import PathTool from masque.file.gdsii import writefile From 43ccd8de2fafcc713f61263739d2326351915bdb Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 16:57:34 -0800 Subject: [PATCH 003/157] [examples] type annotations --- examples/ellip_grating.py | 2 +- examples/test_rep.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py index a51a27e..57b170c 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(): +def main() -> None: pat = Pattern() layer = (0, 0) pat.shapes[layer].extend([ diff --git a/examples/test_rep.py b/examples/test_rep.py index f82575d..d25fb55 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis -def main(): +def main() -> None: lib = Library() cell_name = 'ellip_grating' From 395244ee8315d7ac5f8a873f47e5ef1cbe9a1db4 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 16:58:24 -0800 Subject: [PATCH 004/157] [examples] some cleanup --- examples/nested_poly_test.py | 8 +++----- examples/tutorial/pcgen.py | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/examples/nested_poly_test.py b/examples/nested_poly_test.py index de51d6a..60e0a3e 100644 --- a/examples/nested_poly_test.py +++ b/examples/nested_poly_test.py @@ -1,7 +1,5 @@ -import numpy from pyclipper import ( - Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO, - scale_to_clipper, scale_from_clipper, + Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO, ) p = Pyclipper() p.AddPaths([ @@ -12,8 +10,8 @@ p.AddPaths([ ], PT_SUBJECT, closed=True) #p.Execute2? #p.Execute? -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.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) p = Pyclipper() diff --git a/examples/tutorial/pcgen.py b/examples/tutorial/pcgen.py index 14d9994..5c5c31b 100644 --- a/examples/tutorial/pcgen.py +++ b/examples/tutorial/pcgen.py @@ -50,7 +50,7 @@ def triangular_lattice( elif origin == 'corner': pass else: - raise Exception(f'Invalid value for `origin`: {origin}') + raise ValueError(f'Invalid value for `origin`: {origin}') return xy[xy[:, 0].argsort(), :] @@ -197,7 +197,7 @@ def ln_defect( `[[x0, y0], [x1, y1], ...]` for all the holes """ if defect_length % 2 != 1: - raise Exception('defect_length must be odd!') + raise ValueError('defect_length must be odd!') pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims]) half_length = numpy.floor(defect_length / 2) hole_nums = numpy.arange(-half_length, half_length + 1) From 737d41d592a392ac8f967096bea47062c3082421 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 17:06:29 -0800 Subject: [PATCH 005/157] [examples] expand port_pather tutorial --- examples/tutorial/port_pather.py | 161 +++++++++++++++++++++++-------- 1 file changed, 123 insertions(+), 38 deletions(-) diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py index f6f2a76..3fad6e7 100644 --- a/examples/tutorial/port_pather.py +++ b/examples/tutorial/port_pather.py @@ -1,30 +1,16 @@ """ -PortPather tutorial: Using .at() syntax for fluent port manipulation +PortPather tutorial: Using .at() syntax """ -from numpy import pi -from masque import Pather, Library, Pattern, Port -from masque.builder.tools import AutoTool +from masque import RenderPather, Pattern, Port, R90 from masque.file.gdsii import writefile from basic_shapes import GDS_OPTS -# Reuse helper functions and constants from the basic pather tutorial -from pather import ( - M1_WIDTH, V1_WIDTH, M2_WIDTH, - make_pad, make_via, make_bend, make_straight_wire, map_layer +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() - 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', - ) # Create a RenderPather and place some initial pads (same as Pather tutorial) rpather = RenderPather(library, tools=M2_tool) @@ -35,51 +21,150 @@ def main() -> None: rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) # - # Routing with .at() + # 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. - # Then we can route just like in the other pather tutorials: + # 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) - .path_to(ccw=None, x=0) + .path(ccw=False, length=6_000) # Move South, turn West (Clockwise) + .path_to(ccw=None, x=0) # Continue West to x=0 ) - rpather.at('GND').path(0, 5_000).path_to(None, x=rpather['VCC'].x) + # 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) - # We're using AutoTool so we could retool directly to M1_ptool like in the Pather - # tutorial, but let's manually plug to demonstrate what it looks like: + # + # 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: + # 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) - .retool(M1_tool) # this retools both ports - .mpath(ccw=True, emax=50_000, spacing=1_200) # ...causing an automatic via on VCC here - .mpath(ccw=False, emin=1_000, spacing=1_200) - .mpath(ccw=False, emin=2_000, spacing=4_500) + .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 ) - # Now we can finish up the equivalent to the other tutorials.. + # Retool VCC back to M2 and move both to x=-28k rpather.at('VCC').retool(M2_tool) - rpather.at(['GND', 'VCC']).mpath(None, xmin=-28_000) + 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) - rpather.at('VCC').path_to(None, x=-50_000, out_ptype='m1wire') - with pather.toolctx(M2_tool, keys=['GND']): - pather.at('GND').path_to(None, x=-40_000) - pather.at('GND').path_to(None, x=-50_000) # - # Save result + # Branching with save_copy and into_copy # - library['PortPather_Tutorial'] = pather.pattern + # .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__': From add0600bac7241f1b9cc73b198647ecb6126f9e4 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 17:13:22 -0800 Subject: [PATCH 006/157] [RenderPather] warn about unrendered paths on deletion --- masque/builder/renderpather.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 7f18e77..29a8173 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -72,6 +72,10 @@ class RenderPather(PatherMixin): def ports(self, value: dict[str, Port]) -> None: self.pattern.ports = value + def __del__(self) -> None: + if any(pp for pp in self.paths): + logger.warning('RenderPather had unrendered paths', stack_info=True) + def __init__( self, library: ILibrary, From 54f3b273bcf74e7e63edf393614bf48474238832 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 18:53:23 -0800 Subject: [PATCH 007/157] [Label] don't drop annotations when copying --- masque/label.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/masque/label.py b/masque/label.py index 711ef35..89f0384 100644 --- a/masque/label.py +++ b/masque/label.py @@ -58,12 +58,14 @@ 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: From f64b080b150d3818845a3d0ad58e66927219720e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 19:10:01 -0800 Subject: [PATCH 008/157] [repetition.Arbitrary] fix mirroring --- masque/repetition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/repetition.py b/masque/repetition.py index 5e7a7f0..a774f7e 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -391,7 +391,7 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements[1 - axis] *= -1 + self.displacements[:, 1 - axis] *= -1 return self def get_bounds(self) -> NDArray[numpy.float64] | None: From 05098c0c13bc2622f48b4b0f1b01d53d0cc11949 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 19:15:54 -0800 Subject: [PATCH 009/157] [remove_colinear_vertices] keep two vertices if all were colinear --- masque/utils/vertices.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 5fddd52..36d2e59 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -51,6 +51,10 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N 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] From accad3db9fbefe54d3211e1c3467eabb34de88e2 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 14 Feb 2026 19:20:50 -0800 Subject: [PATCH 010/157] Prefer [1 - axis] for clarity --- masque/shapes/arc.py | 2 +- masque/shapes/ellipse.py | 2 +- masque/shapes/path.py | 2 +- masque/shapes/poly_collection.py | 2 +- masque/shapes/polygon.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 480835e..79411d6 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -384,7 +384,7 @@ class Arc(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Arc': - self.offset[axis - 1] *= -1 + self.offset[1 - axis] *= -1 self.rotation *= -1 self.rotation += axis * pi self.angles *= -1 diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 6029f2f..6699e53 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -189,7 +189,7 @@ class Ellipse(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> Self: - self.offset[axis - 1] *= -1 + self.offset[1 - axis] *= -1 self.rotation *= -1 self.rotation += axis * pi return self diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 7778428..654cfaa 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -396,7 +396,7 @@ class Path(Shape): return self def mirror(self, axis: int = 0) -> 'Path': - self.vertices[:, axis - 1] *= -1 + self.vertices[:, 1 - axis] *= -1 return self def scale_by(self, c: float) -> 'Path': diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index 6048f24..afa9c99 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -179,7 +179,7 @@ class PolyCollection(Shape): return self def mirror(self, axis: int = 0) -> Self: - self._vertex_lists[:, axis - 1] *= -1 + self._vertex_lists[:, 1 - axis] *= -1 return self def scale_by(self, c: float) -> Self: diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index c8c3ddd..6440144 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -394,7 +394,7 @@ class Polygon(Shape): return self def mirror(self, axis: int = 0) -> 'Polygon': - self.vertices[:, axis - 1] *= -1 + self.vertices[:, 1 - axis] *= -1 return self def scale_by(self, c: float) -> 'Polygon': From 44986bac67e927ca9b931a2d08f396f226b89080 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 00:05:53 -0800 Subject: [PATCH 011/157] [Mirrorable / Flippable] Bifurcate mirror into flip (relative to line) vs mirror (relative to own offset/origin) --- masque/abstract.py | 47 +++--------------- masque/label.py | 20 +++++++- masque/pattern.py | 49 ++++++------------ masque/ports.py | 4 +- masque/ref.py | 3 +- masque/shapes/arc.py | 1 - masque/shapes/ellipse.py | 1 - masque/shapes/shape.py | 9 ++-- masque/shapes/text.py | 3 ++ masque/traits/__init__.py | 6 ++- masque/traits/mirrorable.py | 99 +++++++++++++++++++++++++------------ 11 files changed, 123 insertions(+), 119 deletions(-) diff --git a/masque/abstract.py b/masque/abstract.py index 7135eba..1266ae6 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -8,16 +8,13 @@ from numpy.typing import ArrayLike from .ref import Ref from .ports import PortList, Port from .utils import rotation_matrix_2d - -#if TYPE_CHECKING: -# from .builder import Builder, Tool -# from .library import ILibrary +from .traits import Flippable logger = logging.getLogger(__name__) -class Abstract(PortList): +class Abstract(PortList, Flippable): """ An `Abstract` is a container for a name and associated ports. @@ -131,50 +128,18 @@ class Abstract(PortList): port.rotate(rotation) return self - def mirror_port_offsets(self, across_axis: int = 0) -> Self: + def mirror(self, axis: int = 0) -> Self: """ - Mirror the offsets of all shapes, labels, and refs across an axis + Mirror the Abstract across an axis through its origin. Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) + axis: Axis to mirror across (0: mirror across x axis, 1: mirror across y axis) Returns: self """ for port in self.ports.values(): - 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) + port.flip_across(axis=axis) return self def apply_ref_transform(self, ref: Ref) -> Self: diff --git a/masque/label.py b/masque/label.py index 89f0384..3f4f80e 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 +from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable from .traits import AnnotatableImpl @functools.total_ordering -class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): +class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable): """ A text annotation with a position (but no size; it is not drawn) """ @@ -102,6 +102,22 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl self.translate(+pivot) 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. + + Args: + axis: Axis to mirror across. 0 mirrors across x=0. 1 mirrors across y=0. + x: Vertical line x=val to mirror across. + y: Horizontal line y=val to mirror across. + + Returns: + self + """ + if self.repetition is not None: + self.repetition.flip_across(axis=axis, x=x, y=y) + return self + def get_bounds_single(self) -> NDArray[numpy.float64]: """ Return the bounds of the label. diff --git a/masque/pattern.py b/masque/pattern.py index dc7d058..42f9ab4 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -733,50 +733,31 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): cast('Rotatable', entry).rotate(rotation) return self - def mirror_element_centers(self, across_axis: int = 0) -> Self: + def mirror_elements(self, across_axis: int = 0) -> Self: """ - Mirror the offsets of all shapes, labels, and refs across an axis + Mirror each shape, ref, and port relative to (0,0). Args: across_axis: Axis to mirror across (0: mirror across x axis, 1: mirror across y axis) + Returns: + self + """ + return self.flip_across(axis=across_axis) + + def mirror(self, axis: int = 0) -> Self: + """ + Mirror the Pattern across an axis through its origin. + + Args: + axis: Axis to mirror across (0: x-axis, 1: y-axis). + Returns: self """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Positionable', entry).offset[1 - across_axis] *= -1 - return self - - def mirror_elements(self, across_axis: int = 0) -> Self: - """ - Mirror each shape, ref, and pattern 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 entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): - cast('Mirrorable', entry).mirror(across_axis) - return self - - def mirror(self, across_axis: int = 0) -> Self: - """ - Mirror the Pattern across an axis - - Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) - - Returns: - self - """ - self.mirror_elements(across_axis) - self.mirror_element_centers(across_axis) + cast('Flippable', entry).flip_across(axis=axis) return self def copy(self) -> Self: diff --git a/masque/ports.py b/masque/ports.py index 0211723..c0f7ddc 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -10,7 +10,7 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable +from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, Flippable, FlippableImpl from .utils import rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) @functools.total_ordering -class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): +class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, FlippableImpl): """ A point at which a `Device` can be snapped to another `Device`. diff --git a/masque/ref.py b/masque/ref.py index b3a684c..75a27a8 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -16,6 +16,7 @@ from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, + Flippable, FlippableImpl, ) @@ -25,7 +26,7 @@ if TYPE_CHECKING: @functools.total_ordering class Ref( - PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, + FlippableImpl, PositionableImpl, RotatableImpl, ScalableImpl, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ): """ diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 79411d6..9ae06ce 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -384,7 +384,6 @@ class Arc(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Arc': - self.offset[1 - axis] *= -1 self.rotation *= -1 self.rotation += axis * pi self.angles *= -1 diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 6699e53..8e3fd49 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -189,7 +189,6 @@ class Ellipse(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> Self: - self.offset[1 - axis] *= -1 self.rotation *= -1 self.rotation += axis * pi return self diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 90bca2b..1cf2846 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 ( - Rotatable, Mirrorable, Copyable, Scalable, - Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl, + Rotatable, Mirrorable, Copyable, Scalable, FlippableImpl, + Positionable, Pivotable, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,8 +26,9 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, - PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): +class Shape(Positionable, Rotatable, FlippableImpl, Copyable, Scalable, + AnnotatableImpl, RepeatableImpl, PivotableImpl, Pivotable, + metaclass=ABCMeta): """ Class specifying functions common to all shapes. """ diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 78632f6..87293fc 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -70,6 +70,7 @@ 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, @@ -80,6 +81,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): self._string = string self._height = height self._rotation = rotation + self._mirrored = mirrored self._repetition = repetition self._annotations = annotations else: @@ -87,6 +89,7 @@ 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 diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py index 7c7360c..cca38f3 100644 --- a/masque/traits/__init__.py +++ b/masque/traits/__init__.py @@ -26,7 +26,11 @@ from .scalable import ( Scalable as Scalable, ScalableImpl as ScalableImpl, ) -from .mirrorable import Mirrorable as Mirrorable +from .mirrorable import ( + Mirrorable as Mirrorable, + Flippable as Flippable, + FlippableImpl as FlippableImpl, + ) 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 6d4ec3c..fffa9c8 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,6 +1,14 @@ -from typing import Self +from typing import Self, Any, TYPE_CHECKING, cast from abc import ABCMeta, abstractmethod +import numpy +from numpy.typing import ArrayLike, NDArray + +from ..error import MasqueError + +if TYPE_CHECKING: + from .positionable import Positionable + class Mirrorable(metaclass=ABCMeta): """ @@ -11,10 +19,10 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - Mirror the entity across an axis. + Mirror the entity across an axis through its origin, ignoring its offset. Args: - axis: Axis to mirror across. + axis: Axis to mirror across (0: x-axis, 1: y-axis). Returns: self @@ -23,10 +31,11 @@ class Mirrorable(metaclass=ABCMeta): def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self: """ - Optionally mirror the entity across both axes + Optionally mirror the entity across both axes through its origin. Args: - axes: (mirror_across_x, mirror_across_y) + across_x: Mirror across x axis (flip y) + across_y: Mirror across y axis (flip x) Returns: self @@ -38,30 +47,56 @@ class Mirrorable(metaclass=ABCMeta): 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 -# # +class Flippable(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, float]: + 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 0, pivot + (x, 0) + if y is not None: + return 1, 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: + """ + Mirror the object across a line. + + Args: + axis: Axis to mirror across. 0 mirrors across x=0. 1 mirrors across y=0. + x: Vertical line x=val to mirror across. + y: Horizontal line y=val to mirror across. + + Returns: + self + """ + pass + + +class FlippableImpl(Flippable, metaclass=ABCMeta): + """ + Implementation of `Flippable` for objects which are `Mirrorable` and `Positionable`. + """ + __slots__ = () + + offset: NDArray[numpy.float64] + """ `[x_offset, y_offset]` """ + + 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) + cast('Positionable', self).translate(-pivot) + cast('Mirrorable', self).mirror(axis) + self.offset[1 - axis] *= -1 + cast('Positionable', self).translate(+pivot) + return self From 19fac463e47d3fa2f0c9a3d746e98df0ba160cf2 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 00:07:27 -0800 Subject: [PATCH 012/157] [Shape] fix annotation --- masque/shapes/shape.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 1cf2846..43aa222 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -74,7 +74,7 @@ class Shape(Positionable, Rotatable, FlippableImpl, Copyable, Scalable, pass @abstractmethod - def normalized_form(self, norm_value: int) -> normalized_shape_tuple: + def normalized_form(self, norm_value: float) -> normalized_shape_tuple: """ Writes the shape in a standardized notation, with offset, scale, and rotation information separated out from the remaining values. From 51ced2fe83e4a952eae541f0e7d55b94621e28c6 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 00:07:43 -0800 Subject: [PATCH 013/157] [Text] use translate instead of offset --- masque/shapes/text.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 87293fc..65a9213 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -149,7 +149,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): if self.mirrored: poly.mirror() poly.scale_by(self.height) - poly.offset = self.offset + [total_advance, 0] + poly.translate(self.offset + [total_advance, 0]) poly.rotate_around(self.offset, self.rotation) all_polygons += [poly] From 2d63e7280279b7a0d5bfcae6e554c55ac6e09a26 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 00:49:34 -0800 Subject: [PATCH 014/157] fixup! [Mirrorable / Flippable] Bifurcate mirror into flip (relative to line) vs mirror (relative to own offset/origin) --- masque/abstract.py | 6 +++--- masque/label.py | 6 +++++- masque/ports.py | 23 +++++++++++++++++++++-- masque/ref.py | 5 ++--- masque/shapes/circle.py | 1 - masque/traits/mirrorable.py | 17 ++++++++++------- 6 files changed, 41 insertions(+), 17 deletions(-) diff --git a/masque/abstract.py b/masque/abstract.py index 1266ae6..501e394 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -8,13 +8,13 @@ from numpy.typing import ArrayLike from .ref import Ref from .ports import PortList, Port from .utils import rotation_matrix_2d -from .traits import Flippable +from .traits import Mirrorable logger = logging.getLogger(__name__) -class Abstract(PortList, Flippable): +class Abstract(PortList, Mirrorable): """ An `Abstract` is a container for a name and associated ports. @@ -133,7 +133,7 @@ class Abstract(PortList, Flippable): Mirror the Abstract across an axis through its origin. Args: - axis: Axis to mirror across (0: mirror across x axis, 1: mirror across y axis) + axis: Axis to mirror across (0: x-axis, 1: y-axis). Returns: self diff --git a/masque/label.py b/masque/label.py index 3f4f80e..4bd2c4a 100644 --- a/masque/label.py +++ b/masque/label.py @@ -114,8 +114,12 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl 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.flip_across(axis=axis, x=x, y=y) + self.repetition.mirror(axis) + self.offset[1 - axis] *= -1 + self.translate(+pivot) return self def get_bounds_single(self) -> NDArray[numpy.float64]: diff --git a/masque/ports.py b/masque/ports.py index c0f7ddc..3fc556a 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -10,7 +10,7 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, Flippable, FlippableImpl +from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, Flippable from .utils import rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) @functools.total_ordering -class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, FlippableImpl): +class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Flippable): """ A point at which a `Device` can be snapped to another `Device`. @@ -99,6 +99,25 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, FlippableImpl): 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. + + 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 diff --git a/masque/ref.py b/masque/ref.py index 75a27a8..70e52db 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -26,8 +26,9 @@ if TYPE_CHECKING: @functools.total_ordering class Ref( - FlippableImpl, PositionableImpl, RotatableImpl, ScalableImpl, + PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, + FlippableImpl, Flippable, ): """ `Ref` provides basic support for nesting Pattern objects within each other. @@ -169,8 +170,6 @@ class Ref( 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: diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index b20a681..8dad165 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -124,7 +124,6 @@ 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/traits/mirrorable.py b/masque/traits/mirrorable.py index fffa9c8..2ff486a 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -6,8 +6,8 @@ from numpy.typing import ArrayLike, NDArray from ..error import MasqueError -if TYPE_CHECKING: - from .positionable import Positionable +from .positionable import Positionable +from .repeatable import Repeatable class Mirrorable(metaclass=ABCMeta): @@ -54,7 +54,7 @@ class Flippable(metaclass=ABCMeta): __slots__ = () @staticmethod - def _check_flip_args(axis: int | None = None, *, x: float | None = None, y: float | None = None) -> tuple[int, float]: + 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: @@ -63,9 +63,9 @@ class Flippable(metaclass=ABCMeta): if x is not None: if y is not None: raise MasqueError('Cannot specify both x and y') - return 0, pivot + (x, 0) + return 1, pivot + (x, 0) if y is not None: - return 1, pivot + (0, y) + return 0, pivot + (0, y) raise MasqueError('Must specify one of axis, x, or y') @abstractmethod @@ -84,9 +84,10 @@ class Flippable(metaclass=ABCMeta): pass -class FlippableImpl(Flippable, metaclass=ABCMeta): +class FlippableImpl(Flippable, Repeatable, metaclass=ABCMeta): """ - Implementation of `Flippable` for objects which are `Mirrorable` and `Positionable`. + Implementation of `Flippable` for objects which are `Mirrorable`, `Positionable`, + and `Repeatable`. """ __slots__ = () @@ -97,6 +98,8 @@ class FlippableImpl(Flippable, metaclass=ABCMeta): axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) cast('Positionable', self).translate(-pivot) cast('Mirrorable', self).mirror(axis) + if self.repetition is not None: + self.repetition.mirror(axis) self.offset[1 - axis] *= -1 cast('Positionable', self).translate(+pivot) return self From 2b7ad00204a8b911135d621f49bffe8352f55472 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 00:57:47 -0800 Subject: [PATCH 015/157] [Port] add custom __deepcopy__ --- masque/ports.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/masque/ports.py b/masque/ports.py index 3fc556a..740636e 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -2,6 +2,7 @@ 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 @@ -91,6 +92,12 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Flippable): 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)) From 66d6fae2bda9f9cd365a45496af5c569bedbee2e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:15:07 -0800 Subject: [PATCH 016/157] [AutoTool] Fix error handling for ccw=None --- masque/builder/tools.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 6bd7547..4162be3 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -590,6 +590,13 @@ class AutoTool(Tool, metaclass=ABCMeta): ) -> tuple[Port, LData]: success = False + + # Initialize these to avoid UnboundLocalError in the error message + bend_dxy, bend_angle = numpy.zeros(2), pi + itrans_dxy = numpy.zeros(2) + otrans_dxy = numpy.zeros(2) + btrans_dxy = numpy.zeros(2) + for straight in self.straights: for bend in self.bends: bend_dxy, bend_angle = self._bend2dxy(bend, ccw) From 72f462d07784aac9c7e3b2ca425a8149d587b8b1 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:18:21 -0800 Subject: [PATCH 017/157] [AutoTool] Enable running AutoTool without any bends in the list --- masque/builder/tools.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 4162be3..148af4d 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -543,9 +543,10 @@ class AutoTool(Tool, metaclass=ABCMeta): return self @staticmethod - def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: + def _bend2dxy(bend: Bend | None, 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): @@ -590,6 +591,11 @@ class AutoTool(Tool, metaclass=ABCMeta): ) -> tuple[Port, LData]: success = False + # If ccw is None, we don't need a bend, but we still loop to reuse the logic. + # We'll use a dummy loop if bends is empty and ccw is None. + bends = cast(list[AutoTool.Bend | None], self.bends) + if ccw is None and not bends: + bends += [None] # Initialize these to avoid UnboundLocalError in the error message bend_dxy, bend_angle = numpy.zeros(2), pi @@ -598,7 +604,7 @@ class AutoTool(Tool, metaclass=ABCMeta): btrans_dxy = numpy.zeros(2) for straight in self.straights: - for bend in self.bends: + 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) @@ -607,14 +613,16 @@ class AutoTool(Tool, metaclass=ABCMeta): out_ptype_pair = ( 'unk' if out_ptype is None else out_ptype, - straight.ptype if ccw is None else bend.out_port.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 and bend.in_port.ptype != straight.ptype: - b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), 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) straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] From 278f0783dadf8cdb74bb7fe4b28cf5933e9a3f2b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:26:06 -0800 Subject: [PATCH 018/157] [PolyCollection] gracefully handle empty PolyCollections --- masque/shapes/poly_collection.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index afa9c99..73aca30 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -56,6 +56,8 @@ 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, (self._vertex_lists.shape[0],)), @@ -168,7 +170,9 @@ class PolyCollection(Shape): annotations = copy.deepcopy(self.annotations), ) for vv in self.polygon_vertices] - def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition + 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 return numpy.vstack((numpy.min(self._vertex_lists, axis=0), numpy.max(self._vertex_lists, axis=0))) From 36fed84249733bb08d492d61816ea5e8fe4b981b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:31:15 -0800 Subject: [PATCH 019/157] [PolyCollection] fix slicing --- masque/shapes/poly_collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index 73aca30..c714ed5 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -60,7 +60,7 @@ class PolyCollection(Shape): return for ii, ff in zip( self._vertex_offsets, - chain(self._vertex_offsets, (self._vertex_lists.shape[0],)), + chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]), strict=True, ): yield slice(ii, ff) From fe70d0574be92c6f5a0506e4108f477fbcfe0a22 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:34:56 -0800 Subject: [PATCH 020/157] [Arc] Improve handling of full rings --- masque/shapes/arc.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 9ae06ce..f3c9f79 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -463,13 +463,18 @@ 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(self.angles[1] - self.angles[0]) + sign = numpy.sign(d_angle) if sign != numpy.sign(a1 - a0): a1 += sign * 2 * pi From ad49276345e4cc7f0baaa2b0963b44c63d1e02b3 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:35:43 -0800 Subject: [PATCH 021/157] [Arc] improve bounding box edge cases --- masque/shapes/arc.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index f3c9f79..f95c4f6 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -362,17 +362,20 @@ 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 a0 < xpt < a1 or a0 < xpt + 2 * pi < a1: - xp = xr + 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 < 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]) From 9bb0d5190df0687d87c691024b5c45f973039c60 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:37:53 -0800 Subject: [PATCH 022/157] [Arc] improve some edge cases when calculating arclengths --- masque/shapes/arc.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index f95c4f6..6f948cb 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -272,13 +272,16 @@ class Arc(PositionableImpl, Shape): arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) keep = [0] - removable = (numpy.cumsum(arc_lengths) <= max_arclen) - start = 1 + start = 0 while start < arc_lengths.size: - next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough? + 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 keep.append(next_to_keep) - removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen) - start = next_to_keep + 1 + start = next_to_keep + if keep[-1] != thetas.size - 1: keep.append(thetas.size - 1) From 1de76bff47ecdf2a718bd7632bb6368d8093c8dc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:41:31 -0800 Subject: [PATCH 023/157] [tests] Add machine-generated test suite --- masque/test/__init__.py | 3 + masque/test/conftest.py | 16 ++++ masque/test/test_abstract.py | 58 ++++++++++++ masque/test/test_advanced_routing.py | 82 ++++++++++++++++ masque/test/test_autotool.py | 82 ++++++++++++++++ masque/test/test_builder.py | 73 +++++++++++++++ masque/test/test_fdfd.py | 25 +++++ masque/test/test_gdsii.py | 70 ++++++++++++++ masque/test/test_label.py | 48 ++++++++++ masque/test/test_library.py | 109 ++++++++++++++++++++++ masque/test/test_oasis.py | 28 ++++++ masque/test/test_pack2d.py | 53 +++++++++++ masque/test/test_path.py | 77 +++++++++++++++ masque/test/test_pather.py | 80 ++++++++++++++++ masque/test/test_pattern.py | 111 ++++++++++++++++++++++ masque/test/test_polygon.py | 110 ++++++++++++++++++++++ masque/test/test_ports.py | 87 +++++++++++++++++ masque/test/test_ports2data.py | 55 +++++++++++ masque/test/test_ref.py | 66 +++++++++++++ masque/test/test_renderpather.py | 73 +++++++++++++++ masque/test/test_repetition.py | 48 ++++++++++ masque/test/test_shape_advanced.py | 134 +++++++++++++++++++++++++++ masque/test/test_shapes.py | 132 ++++++++++++++++++++++++++ masque/test/test_utils.py | 83 +++++++++++++++++ 24 files changed, 1703 insertions(+) create mode 100644 masque/test/__init__.py create mode 100644 masque/test/conftest.py create mode 100644 masque/test/test_abstract.py create mode 100644 masque/test/test_advanced_routing.py create mode 100644 masque/test/test_autotool.py create mode 100644 masque/test/test_builder.py create mode 100644 masque/test/test_fdfd.py create mode 100644 masque/test/test_gdsii.py create mode 100644 masque/test/test_label.py create mode 100644 masque/test/test_library.py create mode 100644 masque/test/test_oasis.py create mode 100644 masque/test/test_pack2d.py create mode 100644 masque/test/test_path.py create mode 100644 masque/test/test_pather.py create mode 100644 masque/test/test_pattern.py create mode 100644 masque/test/test_polygon.py create mode 100644 masque/test/test_ports.py create mode 100644 masque/test/test_ports2data.py create mode 100644 masque/test/test_ref.py create mode 100644 masque/test/test_renderpather.py create mode 100644 masque/test/test_repetition.py create mode 100644 masque/test/test_shape_advanced.py create mode 100644 masque/test/test_shapes.py create mode 100644 masque/test/test_utils.py diff --git a/masque/test/__init__.py b/masque/test/__init__.py new file mode 100644 index 0000000..e02b636 --- /dev/null +++ b/masque/test/__init__.py @@ -0,0 +1,3 @@ +""" +Tests (run with `python3 -m pytest -rxPXs | tee results.txt`) +""" diff --git a/masque/test/conftest.py b/masque/test/conftest.py new file mode 100644 index 0000000..62db4c5 --- /dev/null +++ b/masque/test/conftest.py @@ -0,0 +1,16 @@ +""" + +Test fixtures + +""" +# ruff: noqa: ARG001 +from typing import Any +import numpy +from numpy.typing import NDArray + +import pytest # type: ignore + + +FixtureRequest = Any +PRNG = numpy.random.RandomState(12345) + diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py new file mode 100644 index 0000000..c9e2926 --- /dev/null +++ b/masque/test/test_abstract.py @@ -0,0 +1,58 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..abstract import Abstract +from ..ports import Port +from ..ref import Ref + +def test_abstract_init(): + 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(): + 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_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_allclose(abs_obj.ports["A"].rotation, 3*pi/2, atol=1e-10) + +def test_abstract_ref_transform(): + 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_allclose(abs_obj.ports["A"].rotation, pi/2, atol=1e-10) + +def test_abstract_undo_transform(): + 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_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 new file mode 100644 index 0000000..439073d --- /dev/null +++ b/masque/test/test_advanced_routing.py @@ -0,0 +1,82 @@ +import pytest +import numpy +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 advanced_pather(): + lib = Library() + # Simple PathTool: 2um width on layer (1,0) + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool) + return p, tool, lib + +def test_path_into_straight(advanced_pather): + 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.path_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + # Pather.path adds a Reference to the generated pattern + assert len(p.pattern.refs) == 1 + +def test_path_into_bend(advanced_pather): + 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.path_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): + 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.path_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + +def test_path_from(advanced_pather): + p, tool, lib = advanced_pather + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, 0), pi, ptype="wire") + + p.at("dst").path_from("src") + + assert "src" not in p.ports + assert "dst" not in p.ports + +def test_path_into_thru(advanced_pather): + 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.path_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 new file mode 100644 index 0000000..cf730ae --- /dev/null +++ b/masque/test/test_autotool.py @@ -0,0 +1,82 @@ +import pytest +import numpy +from numpy.testing import assert_equal, 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 +from ..abstract import Abstract + +def make_straight(length, width=2, ptype="wire"): + 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(): + 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 + bend_abs = 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 l: make_straight(l, 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): + 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.path("start", ccw=None, length=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_builder.py b/masque/test/test_builder.py new file mode 100644 index 0000000..0884c3f --- /dev/null +++ b/masque/test/test_builder.py @@ -0,0 +1,73 @@ +import pytest +import numpy +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(): + lib = Library() + b = Builder(lib, name="mypat") + assert b.pattern is lib["mypat"] + assert b.library is lib + +def test_builder_place(): + 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(): + 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_allclose(b.ports["start"].rotation, 0, atol=1e-10) + +def test_builder_interface(): + 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(): + lib = Library() + lib["sub"] = Pattern() + b = Builder(lib) + b.set_dead() + + b.place("sub") + assert not b.pattern.has_refs() + diff --git a/masque/test/test_fdfd.py b/masque/test/test_fdfd.py new file mode 100644 index 0000000..32466c1 --- /dev/null +++ b/masque/test/test_fdfd.py @@ -0,0 +1,25 @@ +# 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_gdsii.py b/masque/test/test_gdsii.py new file mode 100644 index 0000000..7a80329 --- /dev/null +++ b/masque/test/test_gdsii.py @@ -0,0 +1,70 @@ +import pytest +import os +import numpy +from numpy.testing import assert_equal, assert_allclose +from pathlib import Path + +from ..pattern import Pattern +from ..library import Library +from ..file import gdsii +from ..shapes import Polygon, Path as MPath + +def test_gdsii_roundtrip(tmp_path): + 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 = 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 = 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): + 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["1"] == ["hello"] + diff --git a/masque/test/test_label.py b/masque/test/test_label.py new file mode 100644 index 0000000..8753be3 --- /dev/null +++ b/masque/test/test_label.py @@ -0,0 +1,48 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..label import Label +from ..repetition import Grid + +def test_label_init(): + l = Label("test", offset=(10, 20)) + assert l.string == "test" + assert_equal(l.offset, [10, 20]) + +def test_label_transform(): + l = Label("test", offset=(10, 0)) + # Rotate 90 deg CCW around (0,0) + l.rotate_around((0, 0), pi/2) + assert_allclose(l.offset, [0, 10], atol=1e-10) + + # Translate + l.translate((5, 5)) + assert_allclose(l.offset, [5, 15], atol=1e-10) + +def test_label_repetition(): + rep = Grid(a_vector=(10, 0), a_count=3) + l = Label("rep", offset=(0, 0), repetition=rep) + assert l.repetition is rep + assert_equal(l.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(): + 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}") + + from ..utils import annotations_eq + 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 + +import copy diff --git a/masque/test/test_library.py b/masque/test/test_library.py new file mode 100644 index 0000000..2c411d4 --- /dev/null +++ b/masque/test/test_library.py @@ -0,0 +1,109 @@ +import pytest +from ..library import Library, LazyLibrary, LibraryView +from ..pattern import Pattern +from ..ref import Ref +from ..error import LibraryError + +def test_library_basic(): + 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(): + 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(): + lib = Library() + lib["parent"] = Pattern() + lib["parent"].ref("missing") + + assert lib.dangling_refs() == {"missing"} + +def test_library_flatten(): + 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 = flat_parent.shapes[(1, 0)][0].vertices + assert tuple(assert_vertices[0]) == (10.0, 10.0) + +def test_lazy_library(): + lib = LazyLibrary() + called = 0 + def make_pat(): + 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(): + 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(): + 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(): + 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" + diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py new file mode 100644 index 0000000..5f60fd8 --- /dev/null +++ b/masque/test/test_oasis.py @@ -0,0 +1,28 @@ +import pytest +import numpy +from numpy.testing import assert_equal +from pathlib import Path + +from ..pattern import Pattern +from ..library import Library +from ..file import oasis + +def test_oasis_roundtrip(tmp_path): + # Skip if fatamorgana is not installed + pytest.importorskip("fatamorgana") + + 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 new file mode 100644 index 0000000..31d19e0 --- /dev/null +++ b/masque/test/test_pack2d.py @@ -0,0 +1,53 @@ +import pytest +import numpy +from numpy.testing import assert_equal + +from ..utils.pack2d import maxrects_bssf, pack_patterns +from ..library import Library +from ..pattern import Pattern + +def test_maxrects_bssf_simple(): + # 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 set([tuple(l) for l in locs]) == {(0.0, 0.0), (10.0, 0.0)} + +def test_maxrects_bssf_reject(): + # 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_pack_patterns(): + 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 + diff --git a/masque/test/test_path.py b/masque/test/test_path.py new file mode 100644 index 0000000..5d63565 --- /dev/null +++ b/masque/test/test_path.py @@ -0,0 +1,77 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..shapes import Path + +def test_path_init(): + 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(): + 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(): + 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(): + 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(): + 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(): + # 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(): + 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(): + 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 + diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py new file mode 100644 index 0000000..814c583 --- /dev/null +++ b/masque/test/test_pather.py @@ -0,0 +1,80 @@ +import pytest +import numpy +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 ..pattern import Pattern +from ..ports import Port + +@pytest.fixture +def pather_setup(): + 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): + p, tool, lib = pather_setup + # Route 10um "forward" + p.path("start", ccw=None, length=10) + + # port rot pi/2 (North). Travel +pi relative to port -> South. + assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) + assert_allclose(p.ports["start"].rotation, pi/2, atol=1e-10) + +def test_pather_bend(pather_setup): + 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.path("start", ccw=False, length=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_allclose(p.ports["start"].rotation, 0, atol=1e-10) + +def test_pather_path_to(pather_setup): + p, tool, lib = pather_setup + # start at (0,0) rot pi/2 (North) + # path "forward" (South) to y=-50 + p.path_to("start", ccw=None, y=-50) + assert_equal(p.ports["start"].offset, [0, -50]) + +def test_pather_mpath(pather_setup): + 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.mpath(["A", "B"], ccw=None, 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): + p, tool, lib = pather_setup + # Fluent API test + p.at("start").path(ccw=None, length=10).path(ccw=True, length=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_allclose(p.ports["start"].rotation, pi, atol=1e-10) + diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py new file mode 100644 index 0000000..f18913c --- /dev/null +++ b/masque/test/test_pattern.py @@ -0,0 +1,111 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..pattern import Pattern +from ..shapes import Polygon, Circle +from ..ref import Ref +from ..ports import Port +from ..label import Label + +def test_pattern_init(): + 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(): + 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(): + 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(): + 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(pat.shapes[(1, 0)][0].vertices[0], [10, 20]) + assert_equal(pat.ports["P1"].offset, [15, 25]) + +def test_pattern_scale(): + 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(pat.shapes[(1, 0)][0].vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) + +def test_pattern_rotate(): + 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(pat.shapes[(1, 0)][0].vertices[0], [0, 10], atol=1e-10) + +def test_pattern_mirror(): + pat = Pattern() + pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]]) + # Mirror across X axis (y -> -y) + pat.mirror(0) + + assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, -5]) + +def test_pattern_get_bounds(): + 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_interface(): + 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_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10) + 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 new file mode 100644 index 0000000..1ae1ff1 --- /dev/null +++ b/masque/test/test_polygon.py @@ -0,0 +1,110 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose + + +from ..shapes import Polygon +from ..utils import R90 +from ..error import PatternError + + +@pytest.fixture +def polygon(): + return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) + +def test_vertices(polygon) -> None: + assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + +def test_xs(polygon) -> None: + assert_equal(polygon.xs, [0, 1, 1, 0]) + +def test_ys(polygon) -> None: + assert_equal(polygon.ys, [0, 0, 1, 1]) + +def test_offset(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) -> None: + assert polygon.to_polygons() == [polygon] + +def test_get_bounds_single(polygon) -> None: + assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]]) + +def test_rotate(polygon) -> None: + rotated_polygon = polygon.rotate(R90) + assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]]) + +def test_mirror(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) -> 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) -> 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(): + 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 new file mode 100644 index 0000000..b6031b2 --- /dev/null +++ b/masque/test/test_ports.py @@ -0,0 +1,87 @@ +import pytest +import numpy +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(): + 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(): + p = Port(offset=(10, 0), rotation=0) + p.rotate_around((0, 0), pi/2) + assert_allclose(p.offset, [0, 10], atol=1e-10) + 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_allclose(p.rotation, 3*pi/2, atol=1e-10) + +def test_port_flip_across(): + 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_allclose(p.rotation, pi, atol=1e-10) + +def test_port_measure_travel(): + 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_list_rename(): + class MyPorts(PortList): + def __init__(self): + self._ports = {"A": Port((0, 0), 0)} + @property + def ports(self): return self._ports + @ports.setter + def ports(self, val): 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_plugged(): + class MyPorts(PortList): + def __init__(self): + self._ports = { + "A": Port((10, 10), 0), + "B": Port((10, 10), pi) + } + @property + def ports(self): return self._ports + @ports.setter + def ports(self, val): self._ports = val + + pl = MyPorts() + pl.plugged({"A": "B"}) + assert not pl.ports # Both should be removed + +def test_port_list_plugged_mismatch(): + class MyPorts(PortList): + def __init__(self): + self._ports = { + "A": Port((10, 10), 0), + "B": Port((11, 10), pi) # Offset mismatch + } + @property + def ports(self): return self._ports + @ports.setter + def ports(self, val): 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 new file mode 100644 index 0000000..bddc0d8 --- /dev/null +++ b/masque/test/test_ports2data.py @@ -0,0 +1,55 @@ +import pytest +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(): + 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_allclose(pat2.ports["P1"].rotation, numpy.pi/2, atol=1e-10) + assert pat2.ports["P1"].ptype == "test" + +def test_data_to_ports_hierarchical(): + 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_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 new file mode 100644 index 0000000..4820874 --- /dev/null +++ b/masque/test/test_ref.py @@ -0,0 +1,66 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..pattern import Pattern +from ..ref import Ref +from ..repetition import Grid + +def test_ref_init(): + 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(): + 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 = 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(): + 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(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(): + 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(): + 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 + diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py new file mode 100644 index 0000000..e0479f9 --- /dev/null +++ b/masque/test/test_renderpather.py @@ -0,0 +1,73 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..builder import RenderPather +from ..builder.tools import PathTool +from ..library import Library +from ..ports import Port + +@pytest.fixture +def rpather_setup(): + 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): + rp, tool, lib = rpather_setup + # Plan two segments + rp.at("start").path(ccw=None, length=10).path(ccw=None, length=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 = 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): + rp, tool, lib = rpather_setup + # Plan straight then bend + rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10) + + rp.render() + path_shape = 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): + rp, tool1, lib = rpather_setup + tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") + + rp.at("start").path(ccw=None, length=10) + rp.retool(tool2, keys=["start"]) + rp.at("start").path(ccw=None, length=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 + diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py new file mode 100644 index 0000000..6b151e8 --- /dev/null +++ b/masque/test/test_repetition.py @@ -0,0 +1,48 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..repetition import Grid, Arbitrary + +def test_grid_displacements(): + # 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(): + 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(): + 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(): + 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(): + 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(): + 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_shape_advanced.py b/masque/test/test_shape_advanced.py new file mode 100644 index 0000000..ef73ea0 --- /dev/null +++ b/masque/test/test_shape_advanced.py @@ -0,0 +1,134 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi +import os + +from ..shapes import Arc, Ellipse, Circle, Polygon, Path, Text, PolyCollection +from ..error import PatternError + +# 1. Text shape tests +def test_text_to_polygons(): + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" + if not os.path.exists(font_path): + 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(): + # 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(): + 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(): + # Wrapped arc (> 360 deg) + a = Arc(radii=(10, 10), angles=(0, 3*pi), width=2) + polys = 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(): + # Zero-length segments + p = Path(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(): + # 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(): + # 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(): + 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 new file mode 100644 index 0000000..940c67a --- /dev/null +++ b/masque/test/test_shapes.py @@ -0,0 +1,132 @@ +import pytest +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(): + # 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(): + 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(): + c = Circle(radius=10, offset=(5, 5)) + assert c.radius == 10 + assert_equal(c.offset, [5, 5]) + +def test_circle_to_polygons(): + 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(): + 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(): + 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(): + 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(): + # 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(): + 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(): + 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(): + 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(): + # 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_utils.py b/masque/test/test_utils.py new file mode 100644 index 0000000..1badf64 --- /dev/null +++ b/masque/test/test_utils.py @@ -0,0 +1,83 @@ +import pytest +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 +) + +def test_remove_duplicate_vertices(): + # 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(): + 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(): + # U-turn + v = [[0, 0], [10, 0], [0, 0]] + v_clean = remove_colinear_vertices(v, closed_path=False) + # Open path should keep ends. [10,0] is between [0,0] and [0,0]? + # Yes, they are all on the same line. + assert len(v_clean) == 2 + + # 180 degree U-turn in closed path + v = [[0, 0], [10, 0], [5, 0]] + v_clean = remove_colinear_vertices(v, closed_path=True) + assert len(v_clean) == 2 + +def test_poly_contains_points(): + 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(): + m = rotation_matrix_2d(pi/2) + assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10) + +def test_rotation_matrix_non_manhattan(): + # 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(): + # 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], [10, 25, 0, 0]]) + +def test_apply_transforms_advanced(): + # 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], atol=1e-10) + From d9adb4e1b9766ae07810f88a51c27fb439504e3e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 12:35:58 -0800 Subject: [PATCH 024/157] [Tools] fixup imports --- masque/builder/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 148af4d..0e08674 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir # TODO document all tools """ -from typing import Literal, Any, Self +from typing import Literal, Any, Self, cast, TYPE_CHECKING 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 From 1cce6c1f707b39816f833816fee5accda3569e57 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 12:36:13 -0800 Subject: [PATCH 025/157] [Tests] cleanup --- masque/test/conftest.py | 5 +- masque/test/test_abstract.py | 48 +++++++-------- masque/test/test_advanced_routing.py | 49 +++++++++------- masque/test/test_autotool.py | 51 ++++++++-------- masque/test/test_builder.py | 34 ++++++----- masque/test/test_fdfd.py | 9 ++- masque/test/test_gdsii.py | 39 ++++++------ masque/test/test_label.py | 54 +++++++++-------- masque/test/test_library.py | 65 +++++++++++--------- masque/test/test_oasis.py | 17 +++--- masque/test/test_pack2d.py | 32 +++++----- masque/test/test_path.py | 34 ++++++----- masque/test/test_pather.py | 39 ++++++------ masque/test/test_pattern.py | 65 ++++++++++---------- masque/test/test_polygon.py | 47 ++++++++++----- masque/test/test_ports.py | 88 ++++++++++++++++------------ masque/test/test_ports2data.py | 32 +++++----- masque/test/test_ref.py | 40 +++++++------ masque/test/test_renderpather.py | 32 +++++----- masque/test/test_repetition.py | 29 +++++---- masque/test/test_shape_advanced.py | 72 +++++++++++++---------- masque/test/test_shapes.py | 76 +++++++++++++----------- masque/test/test_utils.py | 58 +++++++++--------- 23 files changed, 544 insertions(+), 471 deletions(-) diff --git a/masque/test/conftest.py b/masque/test/conftest.py index 62db4c5..3116ee2 100644 --- a/masque/test/conftest.py +++ b/masque/test/conftest.py @@ -3,14 +3,11 @@ Test fixtures """ + # ruff: noqa: ARG001 from typing import Any import numpy -from numpy.typing import NDArray - -import pytest # type: ignore FixtureRequest = Any PRNG = numpy.random.RandomState(12345) - diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py index c9e2926..907cedc 100644 --- a/masque/test/test_abstract.py +++ b/masque/test/test_abstract.py @@ -1,58 +1,60 @@ -import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose +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(): + +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 + assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied -def test_abstract_transform(): + +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) + 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_allclose(abs_obj.ports["A"].rotation, pi/2, atol=1e-10) - + 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_allclose(abs_obj.ports["A"].rotation, 3*pi/2, atol=1e-10) + assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10) -def test_abstract_ref_transform(): + +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) - + 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_allclose(abs_obj.ports["A"].rotation, pi/2, atol=1e-10) -def test_abstract_undo_transform(): - abs_obj = Abstract("test", {"A": Port((100, 110), pi/2)}) - ref = Ref(offset=(100, 100), rotation=pi/2, mirrored=True) - + assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10) + 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_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 index 439073d..5afcc21 100644 --- a/masque/test/test_advanced_routing.py +++ b/masque/test/test_advanced_routing.py @@ -1,6 +1,5 @@ import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_equal from numpy import pi from ..builder import Pather @@ -8,30 +7,33 @@ from ..builder.tools import PathTool from ..library import Library from ..ports import Port + @pytest.fixture -def advanced_pather(): +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) return p, tool, lib -def test_path_into_straight(advanced_pather): + +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) + 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.path_into("src", "dst") - + assert "src" not in p.ports assert "dst" not in p.ports # Pather.path adds a Reference to the generated pattern assert len(p.pattern.refs) == 1 -def test_path_into_bend(advanced_pather): + +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") @@ -39,44 +41,47 @@ def test_path_into_bend(advanced_pather): # 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.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire") + p.path_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): + +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.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.path_into("src", "dst") - + assert "src" not in p.ports assert "dst" not in p.ports -def test_path_from(advanced_pather): + +def test_path_from(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.at("dst").path_from("src") - + assert "src" not in p.ports assert "dst" not in p.ports -def test_path_into_thru(advanced_pather): + +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.path_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 index cf730ae..5686193 100644 --- a/masque/test/test_autotool.py +++ b/masque/test/test_autotool.py @@ -1,6 +1,5 @@ import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_allclose from numpy import pi from ..builder import Pather @@ -8,75 +7,75 @@ from ..builder.tools import AutoTool from ..library import Library from ..pattern import Pattern from ..ports import Port -from ..abstract import Abstract -def make_straight(length, width=2, ptype="wire"): + +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(): +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") + bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire") lib["bend"] = bend_pat - bend_abs = lib.abstract("bend") - + 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 l: make_straight(l, ptype="wire_m1"), - in_port_name="in", out_port_name="out")], + 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" + 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): + +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.path("start", ccw=None, length=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: + # 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). + # 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_builder.py b/masque/test/test_builder.py index 0884c3f..1b67c65 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -1,5 +1,3 @@ -import pytest -import numpy from numpy.testing import assert_equal, assert_allclose from numpy import pi @@ -8,36 +6,39 @@ from ..library import Library from ..pattern import Pattern from ..ports import Port -def test_builder_init(): + +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(): + +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(): + +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 @@ -46,28 +47,29 @@ def test_builder_plug(): # 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_allclose(b.ports["start"].rotation, 0, atol=1e-10) -def test_builder_interface(): + +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(): + +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() - diff --git a/masque/test/test_fdfd.py b/masque/test/test_fdfd.py index 32466c1..2b4f3d3 100644 --- a/masque/test/test_fdfd.py +++ b/masque/test/test_fdfd.py @@ -3,23 +3,22 @@ import dataclasses -import pytest # type: ignore +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 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 + 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 + 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_gdsii.py b/masque/test/test_gdsii.py index 7a80329..86e4bbc 100644 --- a/masque/test/test_gdsii.py +++ b/masque/test/test_gdsii.py @@ -1,70 +1,69 @@ -import pytest -import os +from pathlib import Path import numpy from numpy.testing import assert_equal, assert_allclose -from pathlib import Path from ..pattern import Pattern from ..library import Library from ..file import gdsii -from ..shapes import Polygon, Path as MPath +from ..shapes import Path as MPath -def test_gdsii_roundtrip(tmp_path): + +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) + 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 = 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 = 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) + assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5) -def test_gdsii_annotations(tmp_path): + +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["1"] == ["hello"] - diff --git a/masque/test/test_label.py b/masque/test/test_label.py index 8753be3..ed40614 100644 --- a/masque/test/test_label.py +++ b/masque/test/test_label.py @@ -1,48 +1,50 @@ -import pytest -import numpy +import copy from numpy.testing import assert_equal, assert_allclose from numpy import pi from ..label import Label from ..repetition import Grid -def test_label_init(): - l = Label("test", offset=(10, 20)) - assert l.string == "test" - assert_equal(l.offset, [10, 20]) -def test_label_transform(): - l = Label("test", offset=(10, 0)) +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) - l.rotate_around((0, 0), pi/2) - assert_allclose(l.offset, [0, 10], atol=1e-10) - - # Translate - l.translate((5, 5)) - assert_allclose(l.offset, [5, 15], atol=1e-10) + lbl.rotate_around((0, 0), pi / 2) + assert_allclose(lbl.offset, [0, 10], atol=1e-10) -def test_label_repetition(): + # 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) - l = Label("rep", offset=(0, 0), repetition=rep) - assert l.repetition is rep - assert_equal(l.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, + 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(): + +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}") - + from ..utils import annotations_eq + 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 - -import copy diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 2c411d4..0012219 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -1,109 +1,116 @@ import pytest -from ..library import Library, LazyLibrary, LibraryView +from ..library import Library, LazyLibrary from ..pattern import Pattern -from ..ref import Ref from ..error import LibraryError -def test_library_basic(): + +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(): + 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(): + +def test_library_dangling() -> None: lib = Library() lib["parent"] = Pattern() lib["parent"].ref("missing") - + assert lib.dangling_refs() == {"missing"} -def test_library_flatten(): + +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 = flat_parent.shapes[(1, 0)][0].vertices assert tuple(assert_vertices[0]) == (10.0, 10.0) -def test_lazy_library(): + +def test_lazy_library() -> None: lib = LazyLibrary() called = 0 - def make_pat(): + + 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(): + +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(): + +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(): + +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" - diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py index 5f60fd8..faffa58 100644 --- a/masque/test/test_oasis.py +++ b/masque/test/test_oasis.py @@ -1,28 +1,27 @@ -import pytest -import numpy -from numpy.testing import assert_equal from pathlib import Path +import pytest +from numpy.testing import assert_equal from ..pattern import Pattern from ..library import Library from ..file import oasis -def test_oasis_roundtrip(tmp_path): + +def test_oasis_roundtrip(tmp_path: Path) -> None: # Skip if fatamorgana is not installed pytest.importorskip("fatamorgana") - + 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 index 31d19e0..5390a4c 100644 --- a/masque/test/test_pack2d.py +++ b/masque/test/test_pack2d.py @@ -1,53 +1,51 @@ -import pytest -import numpy -from numpy.testing import assert_equal - from ..utils.pack2d import maxrects_bssf, pack_patterns from ..library import Library from ..pattern import Pattern -def test_maxrects_bssf_simple(): + +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 set([tuple(l) for l in locs]) == {(0.0, 0.0), (10.0, 0.0)} + assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)} -def test_maxrects_bssf_reject(): + +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 1 in rejects # Second rect rejected assert 0 not in rejects -def test_pack_patterns(): + +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 - diff --git a/masque/test/test_path.py b/masque/test/test_path.py index 5d63565..766798f 100644 --- a/masque/test/test_path.py +++ b/masque/test/test_path.py @@ -1,17 +1,16 @@ -import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi +from numpy.testing import assert_equal from ..shapes import Path -def test_path_init(): + +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(): + +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 @@ -19,7 +18,8 @@ def test_path_to_polygons_flush(): bounds = polys[0].get_bounds_single() assert_equal(bounds, [[0, -1], [10, 1]]) -def test_path_to_polygons_square(): + +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 @@ -28,18 +28,20 @@ def test_path_to_polygons_square(): bounds = polys[0].get_bounds_single() assert_equal(bounds, [[-1, -1], [11, 1]]) -def test_path_to_polygons_circle(): + +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(): + +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 @@ -48,7 +50,8 @@ def test_path_custom_cap(): bounds = polys[0].get_bounds_single() assert_equal(bounds, [[-5, -1], [20, 1]]) -def test_path_bend(): + +def test_path_bend() -> None: # L-shaped path p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2) polys = p.to_polygons() @@ -64,14 +67,15 @@ def test_path_bend(): # So bounds should be x: [0, 11], y: [-1, 10] assert_equal(bounds, [[0, -1], [11, 10]]) -def test_path_mirror(): + +def test_path_mirror() -> None: p = Path(vertices=[[10, 5], [20, 10]], width=2) - p.mirror(0) # Mirror across x axis (y -> -y) + p.mirror(0) # Mirror across x axis (y -> -y) assert_equal(p.vertices, [[10, -5], [20, -10]]) -def test_path_scale(): + +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 - diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py index 814c583..e1d28d8 100644 --- a/masque/test/test_pather.py +++ b/masque/test/test_pather.py @@ -1,16 +1,15 @@ import pytest -import numpy 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 ..pattern import Pattern from ..ports import Port + @pytest.fixture -def pather_setup(): +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") @@ -18,53 +17,58 @@ def pather_setup(): # 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") + p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") return p, tool, lib -def test_pather_straight(pather_setup): + +def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: p, tool, lib = pather_setup # Route 10um "forward" p.path("start", ccw=None, length=10) - + # port rot pi/2 (North). Travel +pi relative to port -> South. assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) - assert_allclose(p.ports["start"].rotation, pi/2, atol=1e-10) + assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) -def test_pather_bend(pather_setup): + +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.path("start", ccw=False, length=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)? + # North (pi/2) + CW (90 deg) -> West (pi)? # Actual behavior results in 0 (East) - apparently rotation is flipped. assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) -def test_pather_path_to(pather_setup): + +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.path_to("start", ccw=None, y=-50) assert_equal(p.ports["start"].offset, [0, -50]) -def test_pather_mpath(pather_setup): + +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") - + 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.mpath(["A", "B"], ccw=None, 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): + +def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None: p, tool, lib = pather_setup # Fluent API test p.at("start").path(ccw=None, length=10).path(ccw=True, length=10) @@ -77,4 +81,3 @@ def test_pather_at_chaining(pather_setup): # pi/2 (North) + CCW (90 deg) -> 0 (East)? # Actual behavior results in pi (West). assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) - diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py index f18913c..e66e9d5 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -1,15 +1,14 @@ -import pytest -import numpy from numpy.testing import assert_equal, assert_allclose from numpy import pi from ..pattern import Pattern -from ..shapes import Polygon, Circle +from ..shapes import Polygon from ..ref import Ref from ..ports import Port from ..label import Label -def test_pattern_init(): + +def test_pattern_init() -> None: pat = Pattern() assert pat.is_empty() assert not pat.has_shapes() @@ -17,19 +16,15 @@ def test_pattern_init(): assert not pat.has_labels() assert not pat.has_ports() -def test_pattern_with_elements(): + +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} - ) - + + 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() @@ -40,72 +35,78 @@ def test_pattern_with_elements(): assert pat.refs["sub"] == [ref] assert pat.ports["P1"] == port -def test_pattern_append(): + +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(): + +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(pat.shapes[(1, 0)][0].vertices[0], [10, 20]) assert_equal(pat.ports["P1"].offset, [15, 25]) -def test_pattern_scale(): + +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(pat.shapes[(1, 0)][0].vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) -def test_pattern_rotate(): + +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) - + pat.rotate_around((0, 0), pi / 2) + # [10, 0] rotated 90 deg around (0,0) is [0, 10] assert_allclose(pat.shapes[(1, 0)][0].vertices[0], [0, 10], atol=1e-10) -def test_pattern_mirror(): + +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(pat.shapes[(1, 0)][0].vertices[0], [10, -5]) -def test_pattern_get_bounds(): + +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_interface(): + +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_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10) 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 index 1ae1ff1..5d98ad9 100644 --- a/masque/test/test_polygon.py +++ b/masque/test/test_polygon.py @@ -1,6 +1,6 @@ import pytest import numpy -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_equal from ..shapes import Polygon @@ -9,29 +9,36 @@ from ..error import PatternError @pytest.fixture -def polygon(): +def polygon() -> Polygon: return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) -def test_vertices(polygon) -> None: + +def test_vertices(polygon: Polygon) -> None: assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) -def test_xs(polygon) -> None: + +def test_xs(polygon: Polygon) -> None: assert_equal(polygon.xs, [0, 1, 1, 0]) -def test_ys(polygon) -> None: + +def test_ys(polygon: Polygon) -> None: assert_equal(polygon.ys, [0, 0, 1, 1]) -def test_offset(polygon) -> None: + +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]]) @@ -64,47 +71,55 @@ def test_rect() -> None: def test_octagon() -> None: - octagon = Polygon.octagon(side_length=1) # regular=True + 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) -> None: + +def test_to_polygons(polygon: Polygon) -> None: assert polygon.to_polygons() == [polygon] -def test_get_bounds_single(polygon) -> None: + +def test_get_bounds_single(polygon: Polygon) -> None: assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]]) -def test_rotate(polygon) -> None: + +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) -> None: + +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]]) + assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]]) -def test_scale_by(polygon) -> None: + +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) -> None: + +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(): + +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 index b6031b2..4354bff 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -1,87 +1,101 @@ import pytest -import numpy 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(): - p = Port(offset=(10, 20), rotation=pi/2, ptype="test") + +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.rotation == pi / 2 assert p.ptype == "test" -def test_port_transform(): + +def test_port_transform() -> None: p = Port(offset=(10, 0), rotation=0) - p.rotate_around((0, 0), pi/2) + p.rotate_around((0, 0), pi / 2) assert_allclose(p.offset, [0, 10], atol=1e-10) - 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.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_allclose(p.rotation, 3*pi/2, atol=1e-10) + assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10) -def test_port_flip_across(): + +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 + 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_allclose(p.rotation, pi, atol=1e-10) -def test_port_measure_travel(): + +def test_port_measure_travel() -> None: p1 = Port((0, 0), 0) - p2 = Port((10, 5), pi) # Facing each other - + 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_list_rename(): + +def test_port_list_rename() -> None: class MyPorts(PortList): - def __init__(self): + def __init__(self) -> None: self._ports = {"A": Port((0, 0), 0)} + @property - def ports(self): return self._ports + def ports(self) -> dict[str, Port]: + return self._ports + @ports.setter - def ports(self, val): self._ports = val - + 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_plugged(): + +def test_port_list_plugged() -> None: class MyPorts(PortList): - def __init__(self): - self._ports = { - "A": Port((10, 10), 0), - "B": Port((10, 10), pi) - } + def __init__(self) -> None: + self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} + @property - def ports(self): return self._ports + def ports(self) -> dict[str, Port]: + return self._ports + @ports.setter - def ports(self, val): self._ports = val - + 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 + assert not pl.ports # Both should be removed -def test_port_list_plugged_mismatch(): + +def test_port_list_plugged_mismatch() -> None: class MyPorts(PortList): - def __init__(self): + def __init__(self) -> None: self._ports = { "A": Port((10, 10), 0), - "B": Port((11, 10), pi) # Offset mismatch + "B": Port((11, 10), pi), # Offset mismatch } + @property - def ports(self): return self._ports + def ports(self) -> dict[str, Port]: + return self._ports + @ports.setter - def ports(self, val): self._ports = val - + 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 index bddc0d8..32bc367 100644 --- a/masque/test/test_ports2data.py +++ b/masque/test/test_ports2data.py @@ -1,4 +1,3 @@ -import pytest import numpy from numpy.testing import assert_allclose @@ -7,43 +6,45 @@ from ..pattern import Pattern from ..ports import Port from ..library import Library -def test_ports2data_roundtrip(): + +def test_ports2data_roundtrip() -> None: pat = Pattern() - pat.ports["P1"] = Port((10, 20), numpy.pi/2, ptype="test") - + 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_allclose(pat2.ports["P1"].rotation, numpy.pi/2, atol=1e-10) + assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10) assert pat2.ports["P1"].ptype == "test" -def test_data_to_ports_hierarchical(): + +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) - + 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) @@ -51,5 +52,4 @@ def test_data_to_ports_hierarchical(): # rot 0 + pi/2 = pi/2 assert "A" in parent.ports assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) - assert_allclose(parent.ports["A"].rotation, numpy.pi/2, atol=1e-10) - + 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 index 4820874..8872699 100644 --- a/masque/test/test_ref.py +++ b/masque/test/test_ref.py @@ -1,5 +1,3 @@ -import pytest -import numpy from numpy.testing import assert_equal, assert_allclose from numpy import pi @@ -7,47 +5,51 @@ from ..pattern import Pattern from ..ref import Ref from ..repetition import Grid -def test_ref_init(): - ref = Ref(offset=(10, 20), rotation=pi/4, mirrored=True, scale=2.0) + +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.rotation == pi / 4 assert ref.mirrored is True assert ref.scale == 2.0 -def test_ref_as_pattern(): + +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) + + ref = Ref(offset=(10, 10), rotation=pi / 2, scale=2.0) transformed_pat = ref.as_pattern(sub_pat) - + # Check transformed shape shape = 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(): + +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(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(): + +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]] @@ -55,12 +57,12 @@ def test_ref_get_bounds(): # translated [[10,10], [20,20]] assert_equal(bounds, [[10, 10], [20, 20]]) -def test_ref_copy(): + +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 - diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index e0479f9..b843066 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -1,6 +1,5 @@ import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_allclose from numpy import pi from ..builder import RenderPather @@ -8,28 +7,30 @@ from ..builder.tools import PathTool from ..library import Library from ..ports import Port + @pytest.fixture -def rpather_setup(): +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") + rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire") return rp, tool, lib -def test_renderpather_basic(rpather_setup): + +def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: rp, tool, lib = rpather_setup # Plan two segments rp.at("start").path(ccw=None, length=10).path(ccw=None, length=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) @@ -40,14 +41,15 @@ def test_renderpather_basic(rpather_setup): 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): + +def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: rp, tool, lib = rpather_setup # Plan straight then bend rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10) - + rp.render() path_shape = rp.pattern.shapes[(1, 0)][0] - # Path vertices: + # Path vertices: # 1. Start (0,0) # 2. Straight end: (0, -10) # 3. Bend end: (-1, -20) @@ -58,16 +60,16 @@ def test_renderpather_bend(rpather_setup): 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): + +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").path(ccw=None, length=10) rp.retool(tool2, keys=["start"]) rp.at("start").path(ccw=None, length=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 - diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py index 6b151e8..5ef2fa9 100644 --- a/masque/test/test_repetition.py +++ b/masque/test/test_repetition.py @@ -1,32 +1,35 @@ -import pytest -import numpy from numpy.testing import assert_equal, assert_allclose from numpy import pi from ..repetition import Grid, Arbitrary -def test_grid_displacements(): + +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(): + +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(): + +def test_grid_rotate() -> None: grid = Grid(a_vector=(10, 0), a_count=2) - grid.rotate(pi/2) + grid.rotate(pi / 2) assert_allclose(grid.a_vector, [0, 10], atol=1e-10) -def test_grid_get_bounds(): + +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(): + +def test_arbitrary_displacements() -> None: pts = [[0, 0], [10, 20], [-5, 30]] arb = Arbitrary(pts) # They should be sorted by displacements.setter @@ -36,13 +39,13 @@ def test_arbitrary_displacements(): assert any((disps == [10, 20]).all(axis=1)) assert any((disps == [-5, 30]).all(axis=1)) -def test_arbitrary_transform(): + +def test_arbitrary_transform() -> None: arb = Arbitrary([[10, 0]]) - arb.rotate(pi/2) + 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: + + 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_shape_advanced.py b/masque/test/test_shape_advanced.py index ef73ea0..f6ba69d 100644 --- a/masque/test/test_shape_advanced.py +++ b/masque/test/test_shape_advanced.py @@ -1,16 +1,17 @@ +from pathlib import Path import pytest import numpy from numpy.testing import assert_equal, assert_allclose from numpy import pi -import os -from ..shapes import Arc, Ellipse, Circle, Polygon, Path, Text, PolyCollection +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(): +def test_text_to_polygons() -> None: font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" - if not os.path.exists(font_path): + if not Path(font_path).exists(): pytest.skip("Font file not found") t = Text("Hi", height=10, font_path=font_path) @@ -24,8 +25,9 @@ def test_text_to_polygons(): char_x_means = [p.vertices[:, 0].mean() for p in polys] assert len(set(char_x_means)) >= 2 + # 2. Manhattanization tests -def test_manhattanize(): +def test_manhattanize() -> None: # Diamond shape poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]]) grid = numpy.arange(0, 11, 1) @@ -38,39 +40,43 @@ def test_manhattanize(): # 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(): +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 + 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(): +def test_arc_edge_cases() -> None: # Wrapped arc (> 360 deg) - a = Arc(radii=(10, 10), angles=(0, 3*pi), width=2) - polys = a.to_polygons(num_vertices=64) + 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(): + +def test_path_edge_cases() -> None: # Zero-length segments - p = Path(vertices=[[0, 0], [0, 0], [10, 0]], width=2) + 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(): +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 @@ -80,8 +86,14 @@ def test_poly_collection_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 + [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) @@ -90,17 +102,23 @@ def test_poly_collection_holes(): 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(): + +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 + [0, 0], + [1, 0], + [0, 1], # Tri # Empty space - [10, 10], [11, 10], [11, 11], [10, 11] # Square + [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? + 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] @@ -113,22 +131,14 @@ def test_poly_collection_constituent_empty(): with pytest.raises(PatternError): pc.to_polygons() -def test_poly_collection_valid(): - verts = [ - [0, 0], [1, 0], [0, 1], - [10, 10], [11, 10], [11, 11], [10, 11] - ] + +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)) - ] + 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 index 940c67a..b19d6bc 100644 --- a/masque/test/test_shapes.py +++ b/masque/test/test_shapes.py @@ -1,11 +1,11 @@ -import pytest 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(): + +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] @@ -13,7 +13,8 @@ def test_poly_collection_init(): assert len(list(pc.polygon_vertices)) == 2 assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) -def test_poly_collection_to_polygons(): + +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) @@ -22,12 +23,14 @@ def test_poly_collection_to_polygons(): 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(): + +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(): + +def test_circle_to_polygons() -> None: c = Circle(radius=10) polys = c.to_polygons(num_vertices=32) assert len(polys) == 1 @@ -36,28 +39,32 @@ def test_circle_to_polygons(): bounds = polys[0].get_bounds_single() assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10) -def test_ellipse_init(): - e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi/4) + +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 + assert e.rotation == pi / 4 -def test_ellipse_to_polygons(): + +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(): - a = Arc(radii=(10, 10), angles=(0, pi/2), width=2, offset=(0, 0)) + +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_equal(a.angles, [0, pi / 2]) assert a.width == 2 -def test_arc_to_polygons(): + +def test_arc_to_polygons() -> None: # Quarter circle arc - a = Arc(radii=(10, 10), angles=(0, pi/2), width=2) + 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 @@ -70,32 +77,35 @@ def test_arc_to_polygons(): # 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(): - 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 + +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)) + 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) + assert_allclose(a.angles, [0, -pi / 4], atol=1e-10) -def test_shape_flip_across(): - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi/4) - e.flip_across(axis=0) # Mirror across y=0: flips y-offset + +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) + 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 + 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(): + +def test_shape_scale() -> None: e = Ellipse(radii=(10, 5)) e.scale_by(2) assert_equal(e.radii, [20, 10]) @@ -105,21 +115,22 @@ def test_shape_scale(): assert_equal(a.radii, [5, 2.5]) assert a.width == 1 -def test_shape_arclen(): + +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)) + 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) + 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 @@ -127,6 +138,5 @@ def test_shape_arclen(): 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)) + 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_utils.py b/masque/test/test_utils.py index 1badf64..882b5bd 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -1,28 +1,23 @@ -import pytest 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 -) +from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms -def test_remove_duplicate_vertices(): + +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(): + +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] @@ -30,54 +25,59 @@ def test_remove_colinear_vertices(): # [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(): + +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) - # Open path should keep ends. [10,0] is between [0,0] and [0,0]? + # Open path should keep ends. [10,0] is between [0,0] and [0,0]? # Yes, they are all on the same line. - assert len(v_clean) == 2 - + assert len(v_clean) == 2 + # 180 degree U-turn in closed path v = [[0, 0], [10, 0], [5, 0]] v_clean = remove_colinear_vertices(v, closed_path=True) - assert len(v_clean) == 2 + assert len(v_clean) == 2 -def test_poly_contains_points(): + +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(): - m = rotation_matrix_2d(pi/2) + +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(): + +def test_rotation_matrix_non_manhattan() -> None: # 45 degrees - m = rotation_matrix_2d(pi/4) - s = numpy.sqrt(2)/2 + 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(): + +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], [10, 25, 0, 0]]) -def test_apply_transforms_advanced(): + +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] - + 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], atol=1e-10) - + assert_allclose(combined[0], [0, 10, pi / 2, 1], atol=1e-10) From 8a5667988496d8006ad5d4921879948979a6b7b0 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 12:40:47 -0800 Subject: [PATCH 026/157] Clean up types/imports --- masque/builder/tools.py | 6 +++--- masque/pattern.py | 5 ++++- masque/ports.py | 2 +- masque/shapes/shape.py | 2 +- masque/traits/mirrorable.py | 9 +++++---- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 0e08674..1ffaa4d 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir # TODO document all tools """ -from typing import Literal, Any, Self, cast, TYPE_CHECKING +from typing import Literal, Any, Self, cast 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 @@ -593,7 +593,7 @@ class AutoTool(Tool, metaclass=ABCMeta): success = False # If ccw is None, we don't need a bend, but we still loop to reuse the logic. # We'll use a dummy loop if bends is empty and ccw is None. - bends = cast(list[AutoTool.Bend | None], self.bends) + bends = cast('list[AutoTool.Bend | None]', self.bends) if ccw is None and not bends: bends += [None] @@ -613,7 +613,7 @@ class AutoTool(Tool, metaclass=ABCMeta): 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 + 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) diff --git a/masque/pattern.py b/masque/pattern.py index 42f9ab4..94555cc 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -2,7 +2,7 @@ Object representing a one multi-layer lithographic layout. A single level of hierarchical references is included. """ -from typing import cast, Self, Any, TypeVar +from typing import cast, Self, Any, TypeVar, TYPE_CHECKING from collections.abc import Sequence, Mapping, MutableMapping, Iterable, Callable import copy import logging @@ -25,6 +25,9 @@ from .error import PatternError, PortError from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable, Bounded from .ports import Port, PortList +if TYPE_CHECKING: + from .traits import Flippable + logger = logging.getLogger(__name__) diff --git a/masque/ports.py b/masque/ports.py index 740636e..f2651de 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -11,7 +11,7 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, Flippable +from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Flippable from .utils import rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 43aa222..269c460 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -6,7 +6,7 @@ import numpy from numpy.typing import NDArray, ArrayLike from ..traits import ( - Rotatable, Mirrorable, Copyable, Scalable, FlippableImpl, + Rotatable, Copyable, Scalable, FlippableImpl, Positionable, Pivotable, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 2ff486a..9b4072b 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,14 +1,15 @@ -from typing import Self, Any, TYPE_CHECKING, cast +from typing import Self, cast, TYPE_CHECKING from abc import ABCMeta, abstractmethod import numpy -from numpy.typing import ArrayLike, NDArray +from numpy.typing import NDArray from ..error import MasqueError - -from .positionable import Positionable from .repeatable import Repeatable +if TYPE_CHECKING: + from .positionable import Positionable + class Mirrorable(metaclass=ABCMeta): """ From 48f7569c1fb0817ce6a13c16e307a15fd2ec4acd Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 14:34:10 -0800 Subject: [PATCH 027/157] [traits] Formalize Flippable and Pivotable depending on Positionable --- masque/ports.py | 4 ++-- masque/ref.py | 10 +++++----- masque/shapes/shape.py | 8 ++++---- masque/traits/mirrorable.py | 19 +++++++------------ masque/traits/rotatable.py | 19 ++++++++----------- 5 files changed, 26 insertions(+), 34 deletions(-) diff --git a/masque/ports.py b/masque/ports.py index f2651de..c40cf55 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -11,7 +11,7 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Flippable +from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable from .utils import rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) @functools.total_ordering -class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Flippable): +class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): """ A point at which a `Device` can be snapped to another `Device`. diff --git a/masque/ref.py b/masque/ref.py index 70e52db..3a64dce 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -15,8 +15,8 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, - Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, - Flippable, FlippableImpl, + PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, + FlippableImpl, ) @@ -26,9 +26,9 @@ if TYPE_CHECKING: @functools.total_ordering class Ref( - PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, - PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, - FlippableImpl, Flippable, + FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, + PositionableImpl, RotatableImpl, ScalableImpl, + Copyable, ): """ `Ref` provides basic support for nesting Pattern objects within each other. diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 269c460..13d2e1e 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 ( - Rotatable, Copyable, Scalable, FlippableImpl, - Positionable, Pivotable, PivotableImpl, RepeatableImpl, AnnotatableImpl, + Copyable, Scalable, FlippableImpl, + PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,8 +26,8 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(Positionable, Rotatable, FlippableImpl, Copyable, Scalable, - AnnotatableImpl, RepeatableImpl, PivotableImpl, Pivotable, +class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, + Copyable, Scalable, metaclass=ABCMeta): """ Class specifying functions common to all shapes. diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 9b4072b..644db61 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,15 +1,13 @@ -from typing import Self, cast, TYPE_CHECKING +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 -if TYPE_CHECKING: - from .positionable import Positionable - class Mirrorable(metaclass=ABCMeta): """ @@ -48,7 +46,7 @@ class Mirrorable(metaclass=ABCMeta): return self -class Flippable(metaclass=ABCMeta): +class Flippable(Positionable, metaclass=ABCMeta): """ Trait class for entities which can be mirrored relative to an external line. """ @@ -85,22 +83,19 @@ class Flippable(metaclass=ABCMeta): pass -class FlippableImpl(Flippable, Repeatable, metaclass=ABCMeta): +class FlippableImpl(Flippable, Mirrorable, Repeatable, metaclass=ABCMeta): """ Implementation of `Flippable` for objects which are `Mirrorable`, `Positionable`, and `Repeatable`. """ __slots__ = () - offset: NDArray[numpy.float64] - """ `[x_offset, y_offset]` """ - 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) - cast('Positionable', self).translate(-pivot) - cast('Mirrorable', self).mirror(axis) + self.translate(-pivot) + self.mirror(axis) if self.repetition is not None: self.repetition.mirror(axis) self.offset[1 - axis] *= -1 - cast('Positionable', self).translate(+pivot) + self.translate(+pivot) return self diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 2fa86c1..2517e2e 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -1,4 +1,4 @@ -from typing import Self, cast, Any, TYPE_CHECKING +from typing import Self from abc import ABCMeta, abstractmethod import numpy @@ -8,8 +8,7 @@ from numpy.typing import ArrayLike from ..error import MasqueError from ..utils import rotation_matrix_2d -if TYPE_CHECKING: - from .positionable import Positionable +from .positionable import Positionable _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass @@ -81,7 +80,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): return self -class Pivotable(metaclass=ABCMeta): +class Pivotable(Positionable, metaclass=ABCMeta): """ Trait class for entites which can be rotated around a point. This requires that they are `Positionable` but not necessarily `Rotatable` themselves. @@ -103,20 +102,18 @@ class Pivotable(metaclass=ABCMeta): pass -class PivotableImpl(Pivotable, metaclass=ABCMeta): +class PivotableImpl(Pivotable, Rotatable, 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: pivot = numpy.asarray(pivot, dtype=float) - cast('Positionable', self).translate(-pivot) - cast('Rotatable', self).rotate(rotation) + self.translate(-pivot) + self.rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - cast('Positionable', self).translate(+pivot) + self.translate(+pivot) return self From c18e5b8d3e0f0ad6b0140525b063be11db64698b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 16:43:46 -0800 Subject: [PATCH 028/157] [OASIS] cleanup --- masque/file/oasis.py | 143 +++++++++++++++++++++++-------------------- 1 file changed, 75 insertions(+), 68 deletions(-) diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 672af25..0a11b24 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)] @@ -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,10 +622,12 @@ 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, @@ -647,14 +649,19 @@ def repetition_masq2fata( frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None if isinstance(rep, Grid): a_vector = rint_cast(rep.a_vector) - 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 + 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 + frep = fatamorgana.GridRepetition( - 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), + a_vector = a_vector, + b_vector = b_vector, + a_count = a_count, + b_count = b_count, ) offset = (0, 0) elif isinstance(rep, Arbitrary): From 5e085794986b42f3115bf316457ea4f90e53ff2e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 16:44:17 -0800 Subject: [PATCH 029/157] [tests] add round-trip file tests --- masque/test/test_file_roundtrip.py | 149 +++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 masque/test/test_file_roundtrip.py diff --git a/masque/test/test_file_roundtrip.py b/masque/test/test_file_roundtrip.py new file mode 100644 index 0000000..fbadd7b --- /dev/null +++ b/masque/test/test_file_roundtrip.py @@ -0,0 +1,149 @@ +from pathlib import Path +import pytest +from numpy.testing import assert_allclose + +from ..pattern import Pattern +from ..library import Library +from ..file import gdsii, oasis +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: + 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 = read_paths.shapes[(2, 0)][0] + assert p_flush.cap == MPath.Cap.Flush + + p_square = read_paths.shapes[(2, 1)][0] + assert p_square.cap == MPath.Cap.Square + + p_circle = read_paths.shapes[(2, 2)][0] + assert p_circle.cap == MPath.Cap.Circle + + p_custom = read_paths.shapes[(2, 3)][0] + assert p_custom.cap == MPath.Cap.SquareCustom + 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") + 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 read_paths.shapes[(2, 0)][0].cap == MPath.Cap.Flush + assert 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 From d40bdb1cb2e7f5f49a4648a9ab8f9ec0e737fc74 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 15 Feb 2026 19:23:02 -0800 Subject: [PATCH 030/157] add 'dev' dependency group and 'manhattanize' optional dep --- pyproject.toml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9a29065..d6605fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +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]", +] [tool.hatch.version] path = "masque/__init__.py" @@ -56,7 +67,8 @@ dxf = ["ezdxf~=1.0.2"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] -manhatanize_slow = ["float_raster"] +manhattanize = ["scikit-image"] +manhattanize_slow = ["float_raster"] [tool.ruff] From abf236a046ec37cc3845104be552aea707b57c99 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 15 Feb 2026 19:46:47 -0800 Subject: [PATCH 031/157] [mirror / flip_across] improve documentation --- masque/label.py | 6 ++++-- masque/ports.py | 4 +++- masque/traits/mirrorable.py | 14 +++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/masque/label.py b/masque/label.py index 4bd2c4a..8b67c65 100644 --- a/masque/label.py +++ b/masque/label.py @@ -104,10 +104,12 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: """ - Mirror the object across a line. + Flip the label across a line in the pattern's coordinate system. + + This operation mirrors the label's offset relative to the pattern's origin. Args: - axis: Axis to mirror across. 0 mirrors across x=0. 1 mirrors across y=0. + 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. diff --git a/masque/ports.py b/masque/ports.py index c40cf55..45aedb5 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -108,7 +108,9 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: """ - Mirror the object across a line. + 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. diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 644db61..deddddd 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -18,7 +18,11 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - Mirror the entity across an axis through its origin, ignoring its offset. + Mirror the entity across an axis through its origin. + + 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. Args: axis: Axis to mirror across (0: x-axis, 1: y-axis). @@ -70,10 +74,14 @@ class Flippable(Positionable, metaclass=ABCMeta): @abstractmethod def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: """ - Mirror the object across a line. + Mirror the object across a line in the container's coordinate system. + + 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 mirrors across x=0. 1 mirrors across y=0. + 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. From 59e996e680ec2092f63f4de445c14431197ff9e0 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 15 Feb 2026 20:05:38 -0800 Subject: [PATCH 032/157] [tutorial] include a repetition and update docs --- examples/tutorial/README.md | 9 ++++-- examples/tutorial/basic_shapes.py | 52 +++++++++++++++++++++++++++---- examples/tutorial/pather.py | 2 +- examples/tutorial/renderpather.py | 5 +-- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 7210a93..6e5730b 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -18,11 +18,14 @@ 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 `BasicTool` to generate paths - * Use `BasicTool` to automatically transition between path types -- [renderpather](rendpather.py) + * Use `AutoTool` to generate paths + * Use `AutoTool` to automatically transition between path types +- [renderpather](renderpather.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 5b5aab7..8664b4d 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -2,9 +2,8 @@ import numpy from numpy import pi -from masque import ( - layer_t, Pattern, Circle, Arc, Polygon, - ) +from masque import layer_t, Pattern, Circle, Arc, Polygon, Ref +from masque.repetition import Grid import masque.file.gdsii @@ -37,6 +36,45 @@ 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), @@ -58,9 +96,7 @@ def triangle( ]) * radius pat = Pattern() - pat.shapes[layer].extend([ - Polygon(offset=(0, 0), vertices=vertices), - ]) + pat.polygon(layer, vertices=vertices) return pat @@ -109,9 +145,13 @@ 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/pather.py b/examples/tutorial/pather.py index c212bc5..a9d9af9 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -239,7 +239,7 @@ def main() -> None: 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 BasicPather, we don't need to place one ourselves. + # Since we have defined an M2-to-M1 transition for Pather, 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 diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py index 87e58f2..ecc8bc8 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -12,7 +12,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 `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) + # of `AutoTool`. `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. # @@ -34,7 +34,7 @@ def main() -> None: ptype_top = 'm2wire', ) - # `PathTool` is more limited than `BasicTool`. It only generates one type of shape + # `PathTool` is more limited than `AutoTool`. 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') @@ -77,6 +77,7 @@ def main() -> None: # 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.path_to('VCC', None, -50_000 + via_size) From cf822c7dcfefcac784defe07a7187ce7b7942b0a Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 12:23:40 -0800 Subject: [PATCH 033/157] [Port] add more logging to aid in debug --- masque/builder/renderpather.py | 2 ++ masque/pattern.py | 5 +++++ masque/ports.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 29a8173..4bc3b5f 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -430,6 +430,7 @@ class RenderPather(PatherMixin): self.paths[portspec].append(step) self.pattern.ports[portspec] = out_port.copy() + self._log_port_update(portspec) if plug_into is not None: self.plugged({portspec: plug_into}) @@ -506,6 +507,7 @@ class RenderPather(PatherMixin): step = RenderStep('S', tool, port.copy(), out_port.copy(), data) self.paths[portspec].append(step) self.pattern.ports[portspec] = out_port.copy() + self._log_port_update(portspec) if plug_into is not None: self.plugged({portspec: plug_into}) diff --git a/masque/pattern.py b/masque/pattern.py index 94555cc..05a8962 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -638,6 +638,7 @@ 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})") return self def scale_elements(self, c: float) -> Self: @@ -705,6 +706,7 @@ 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: @@ -761,6 +763,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): cast('Flippable', entry).flip_across(axis=axis) + self._log_bulk_update(f"mirror({axis})") return self def copy(self) -> Self: @@ -1160,6 +1163,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): pp.rotate_around(pivot, rotation) pp.translate(offset) self.ports[name] = pp + self._log_port_update(name) if append: if isinstance(other, Abstract): @@ -1315,6 +1319,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # 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): diff --git a/masque/ports.py b/masque/ports.py index 45aedb5..5260b19 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -17,6 +17,7 @@ from .error import PortError, format_stacktrace logger = logging.getLogger(__name__) +port_logger = logging.getLogger('masque.ports') @functools.total_ordering @@ -207,6 +208,19 @@ 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.info("Port %s: %s", name, self.ports[name]) + + def _log_port_removal(self, name: str) -> None: + """ Log that the named port has been removed """ + port_logger.info("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 @@ -260,6 +274,7 @@ 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( @@ -286,11 +301,22 @@ class PortList(metaclass=ABCMeta): if duplicates: raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') + for kk, vv in mapping.items(): + if vv is None: + self._log_port_removal(kk) + elif 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( @@ -319,6 +345,8 @@ class PortList(metaclass=ABCMeta): } 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( @@ -388,6 +416,7 @@ 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( From f42e720c68510762a51c4162fc2c1ebccde48412 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 13:43:54 -0800 Subject: [PATCH 034/157] [set_dead / skip_geometry] Improve dead pathers so more "broken" layouts can be successfully executed --- masque/builder/builder.py | 8 +- masque/builder/pather.py | 74 +++++++++++++++---- masque/builder/renderpather.py | 121 +++++++++++++++++++------------ masque/pattern.py | 43 +++++++++-- masque/test/test_builder.py | 54 ++++++++++++++ masque/test/test_pather.py | 22 ++++++ masque/test/test_renderpather.py | 20 +++++ 7 files changed, 271 insertions(+), 71 deletions(-) diff --git a/masque/builder/builder.py b/masque/builder/builder.py index 1b534b5..3c39710 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -284,8 +284,7 @@ class Builder(PortList): do not line up) """ if self._dead: - logger.error('Skipping plug() since device is dead') - return self + logger.warning('Skipping geometry for plug() since device is dead') if not isinstance(other, str | Abstract | Pattern): # We got a Tree; add it into self.library and grab an Abstract for it @@ -305,6 +304,7 @@ class Builder(PortList): set_rotation = set_rotation, append = append, ok_connections = ok_connections, + skip_geometry = self._dead, ) return self @@ -357,8 +357,7 @@ class Builder(PortList): are applied. """ if self._dead: - logger.error('Skipping place() since device is dead') - return self + logger.warning('Skipping geometry for place() since device is dead') if not isinstance(other, str | Abstract | Pattern): # We got a Tree; add it into self.library and grab an Abstract for it @@ -378,6 +377,7 @@ class Builder(PortList): port_map = port_map, skip_port_check = skip_port_check, append = append, + skip_geometry = self._dead, ) return self diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 9af473d..d58b282 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -7,6 +7,8 @@ import copy import logging from pprint import pformat +from numpy import pi + from ..pattern import Pattern from ..library import ILibrary from ..error import BuildError @@ -288,14 +290,36 @@ class Pather(Builder, PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.error('Skipping path() since device is dead') - return self + logger.warning('Skipping geometry for path() since device is dead') 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) + try: + tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + except (BuildError, NotImplementedError): + if not self._dead: + raise + logger.warning("Tool path failed for dead pather. Using dummy extension.") + # Fallback for dead pather: manually update the port instead of plugging + port = self.pattern[portspec] + port_rot = port.rotation + if ccw is None: + out_rot = pi + elif bool(ccw): + out_rot = -pi / 2 + else: + out_rot = pi / 2 + out_port = Port((length, 0), 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 + self._log_port_update(portspec) + if plug_into is not None: + self.plugged({portspec: plug_into}) + return self + tname = self.library << tree if plug_into is not None: output = {plug_into: tool_port_names[1]} @@ -340,8 +364,7 @@ class Pather(Builder, PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.error('Skipping pathS() since device is dead') - return self + logger.warning('Skipping geometry for pathS() since device is dead') tool_port_names = ('A', 'B') @@ -353,16 +376,39 @@ class Pather(Builder, PatherMixin): # 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]]) + try: + 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]]) - 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) + 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 + except (BuildError, NotImplementedError): + if not self._dead: + raise + # Fall through to dummy extension below + except BuildError: + if not self._dead: + raise + # Fall through to dummy extension below + + if self._dead: + logger.warning("Tool pathS failed for dead pather. Using dummy extension.") + # Fallback for dead pather: manually update the port instead of plugging + port = self.pattern[portspec] + port_rot = port.rotation + out_port = Port((length, jog), rotation=pi, ptype=in_ptype) + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + self.pattern.ports[portspec] = out_port + self._log_port_update(portspec) + if plug_into is not None: + self.plugged({portspec: plug_into}) return self tname = self.library << tree diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 4bc3b5f..863f3f1 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -253,8 +253,7 @@ class RenderPather(PatherMixin): do not line up) """ if self._dead: - logger.error('Skipping plug() since device is dead') - return self + logger.warning('Skipping geometry for plug() since device is dead') other_tgt: Pattern | Abstract if isinstance(other, str): @@ -262,18 +261,19 @@ class RenderPather(PatherMixin): 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)) + if not self._dead: + # 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)) + 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, @@ -284,6 +284,7 @@ class RenderPather(PatherMixin): set_rotation = set_rotation, append = append, ok_connections = ok_connections, + skip_geometry = self._dead, ) return self @@ -334,8 +335,7 @@ class RenderPather(PatherMixin): are applied. """ if self._dead: - logger.error('Skipping place() since device is dead') - return self + logger.warning('Skipping geometry for place() since device is dead') other_tgt: Pattern | Abstract if isinstance(other, str): @@ -343,10 +343,11 @@ class RenderPather(PatherMixin): 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)) + if not self._dead: + 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, @@ -357,6 +358,7 @@ class RenderPather(PatherMixin): port_map = port_map, skip_port_check = skip_port_check, append = append, + skip_geometry = self._dead, ) return self @@ -365,11 +367,12 @@ class RenderPather(PatherMixin): 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)) + if not self._dead: + 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 @@ -410,8 +413,7 @@ class RenderPather(PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.error('Skipping path() since device is dead') - return self + logger.warning('Skipping geometry for path() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -420,14 +422,28 @@ class RenderPather(PatherMixin): 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) + try: + out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) + except (BuildError, NotImplementedError): + if not self._dead: + raise + logger.warning("Tool planning failed for dead pather. Using dummy extension.") + if ccw is None: + out_rot = pi + elif bool(ccw): + out_rot = -pi / 2 + else: + out_rot = pi / 2 + out_port = Port((length, 0), rotation=out_rot, ptype=in_ptype) + data = None # 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) + if not self._dead: + step = RenderStep('L', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) self.pattern.ports[portspec] = out_port.copy() self._log_port_update(portspec) @@ -475,8 +491,7 @@ class RenderPather(PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.error('Skipping pathS() since device is dead') - return self + logger.warning('Skipping geometry for pathS() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -492,22 +507,38 @@ class RenderPather(PatherMixin): # 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] + try: + 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 + 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 + except (BuildError, NotImplementedError): + if not self._dead: + raise + # Fall through to dummy extension below + except BuildError: + if not self._dead: + raise + # Fall through to dummy extension below - 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() - self._log_port_update(portspec) + if self._dead: + logger.warning("Tool planning failed for dead pather. Using dummy extension.") + out_port = Port((length, jog), rotation=pi, ptype=in_ptype) + data = None + + if out_port is not None: + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + if not self._dead: + step = RenderStep('S', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) + self.pattern.ports[portspec] = out_port.copy() + self._log_port_update(portspec) if plug_into is not None: self.plugged({portspec: plug_into}) diff --git a/masque/pattern.py b/masque/pattern.py index 05a8962..04780ec 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1101,6 +1101,7 @@ 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 @@ -1132,6 +1133,7 @@ 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`, only ports are added; geometry is skipped. Returns: self @@ -1165,6 +1167,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self.ports[name] = pp self._log_port_update(name) + if skip_geometry: + return self + if append: if isinstance(other, Abstract): raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') @@ -1222,6 +1227,7 @@ 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 @@ -1276,6 +1282,7 @@ 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; geometry is skipped. Returns: self @@ -1308,13 +1315,32 @@ 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) - translation, rotation, pivot = self.find_transform( - other, - map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, - ) + 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) # get rid of plugged ports for ki, vi in map_in.items(): @@ -1323,7 +1349,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_out[vi] = None if isinstance(other, Pattern): - assert append, 'Got a name (not an abstract) but was asked to reference (not append)' + assert append or skip_geometry, 'Got a name (not an abstract) but was asked to reference (not append)' self.place( other, @@ -1334,6 +1360,7 @@ 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/test/test_builder.py b/masque/test/test_builder.py index 1b67c65..bfbd1df 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -73,3 +73,57 @@ def test_builder_set_dead() -> None: 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_allclose(b.ports['B'].rotation, 0, atol=1e-10) diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py index e1d28d8..336458f 100644 --- a/masque/test/test_pather.py +++ b/masque/test/test_pather.py @@ -81,3 +81,25 @@ def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> N # pi/2 (North) + CCW (90 deg) -> 0 (East)? # Actual behavior results in pi (West). 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.path("in", None, -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.path("in", None, 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_renderpather.py b/masque/test/test_renderpather.py index b843066..3948214 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -73,3 +73,23 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar # 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.path("in", None, -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() From 5d040061f41b77d8149ef0d37acde0a4be716059 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 13:57:16 -0800 Subject: [PATCH 035/157] [set_dead] improve docs --- masque/builder/builder.py | 19 ++++++++++++++++--- masque/builder/pather.py | 12 ++++++++++++ masque/builder/renderpather.py | 12 ++++++++++++ masque/pattern.py | 11 +++++++++-- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/masque/builder/builder.py b/masque/builder/builder.py index 3c39710..40ea109 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -275,6 +275,10 @@ class Builder(PortList): Returns: self + Note: + If the builder is 'dead' (see `set_dead()`), geometry generation is + skipped but ports are still updated. + Raises: `PortError` if any ports specified in `map_in` or `map_out` do not exist in `self.ports` or `other_names`. @@ -350,6 +354,10 @@ class Builder(PortList): Returns: self + Note: + If the builder is 'dead' (see `set_dead()`), geometry generation is + skipped but ports are still updated. + Raises: `PortError` if any ports specified in `map_in` or `map_out` do not exist in `self.ports` or `other.ports`. @@ -425,13 +433,18 @@ class Builder(PortList): def set_dead(self) -> Self: """ - Disallows further changes through `plug()` or `place()`. + Suppresses geometry generation for subsequent `plug()` and `place()` + operations. Unlike a complete skip, the port state is still tracked + and updated, using 'best-effort' fallbacks for impossible transforms. + This allows a layout script to execute through problematic sections + while maintaining valid port references for downstream code. + 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.plug(b, ...) # usually raises an error, but now uses fallback port update + dev.plug(c, ...) # also updated via fallback dev.pattern.visualize() # shows the device as of the set_dead() call ``` diff --git a/masque/builder/pather.py b/masque/builder/pather.py index d58b282..c23e240 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -285,6 +285,12 @@ class Pather(Builder, PatherMixin): Returns: self + Note: + If the builder is 'dead', this operation will still attempt to update + the target port's location. If the pathing tool fails (e.g. due to an + impossible length), a dummy linear extension is used to maintain port + consistency for downstream operations. + 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. @@ -359,6 +365,12 @@ class Pather(Builder, PatherMixin): Returns: self + Note: + If the builder is 'dead', this operation will still attempt to update + the target port's location. If the pathing tool fails (e.g. due to an + impossible length), a dummy linear extension is used to maintain port + consistency for downstream operations. + 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. diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 863f3f1..ca8cf8a 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -408,6 +408,12 @@ class RenderPather(PatherMixin): Returns: self + Note: + If the builder is 'dead', this operation will still attempt to update + the target port's location. If the pathing tool fails (e.g. due to an + impossible length), a dummy linear extension is used to maintain port + consistency for downstream operations. + 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. @@ -486,6 +492,12 @@ class RenderPather(PatherMixin): Returns: self + Note: + If the builder is 'dead', this operation will still attempt to update + the target port's location. If the pathing tool fails (e.g. due to an + impossible length), a dummy linear extension is used to maintain port + consistency for downstream operations. + 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. diff --git a/masque/pattern.py b/masque/pattern.py index 04780ec..d7bbc01 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1133,7 +1133,10 @@ 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`, only ports are added; geometry is skipped. + 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 @@ -1282,7 +1285,11 @@ 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; geometry is skipped. + 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 From 7ad59d6b891b2c51dbc5461968475162051c8978 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 17:41:58 -0800 Subject: [PATCH 036/157] [boolean] Add basic boolean functionality (boolean() and Polygon.boolean()) --- masque/__init__.py | 1 + masque/pattern.py | 55 +++++++++++ masque/ports.py | 4 +- masque/shapes/polygon.py | 22 ++++- masque/test/test_boolean.py | 119 +++++++++++++++++++++++ masque/utils/boolean.py | 180 +++++++++++++++++++++++++++++++++++ pyproject.toml | 7 ++ stubs/pyclipper/__init__.pyi | 46 +++++++++ 8 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 masque/test/test_boolean.py create mode 100644 masque/utils/boolean.py create mode 100644 stubs/pyclipper/__init__.pyi diff --git a/masque/__init__.py b/masque/__init__.py index 4ad7e69..e435fac 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -55,6 +55,7 @@ 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/pattern.py b/masque/pattern.py index d7bbc01..acebf62 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -502,6 +502,61 @@ 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. diff --git a/masque/ports.py b/masque/ports.py index 5260b19..04ab061 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -302,9 +302,7 @@ class PortList(metaclass=ABCMeta): raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') for kk, vv in mapping.items(): - if vv is None: - self._log_port_removal(kk) - elif vv != kk: + if vv is None or vv != kk: self._log_port_removal(kk) renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 6440144..a243901 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING, Self +from typing import Any, cast, TYPE_CHECKING, Self, Literal import copy import functools @@ -462,3 +462,23 @@ 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 + return boolean([self], other, operation=operation, scale=scale) diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py new file mode 100644 index 0000000..c1a2d7b --- /dev/null +++ b/masque/test/test_boolean.py @@ -0,0 +1,119 @@ +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/utils/boolean.py b/masque/utils/boolean.py new file mode 100644 index 0000000..9b9514e --- /dev/null +++ b/masque/utils/boolean.py @@ -0,0 +1,180 @@ +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 + 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/pyproject.toml b/pyproject.toml index d6605fa..764eee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] manhattanize = ["scikit-image"] manhattanize_slow = ["float_raster"] +boolean = ["pyclipper"] [tool.ruff] @@ -106,3 +107,9 @@ lint.ignore = [ addopts = "-rsXx" testpaths = ["masque"] +[tool.mypy] +mypy_path = "stubs" +python_version = "3.11" +strict = false +check_untyped_defs = true + diff --git a/stubs/pyclipper/__init__.pyi b/stubs/pyclipper/__init__.pyi new file mode 100644 index 0000000..08d77c8 --- /dev/null +++ b/stubs/pyclipper/__init__.pyi @@ -0,0 +1,46 @@ +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: ... From ebfe1b559cef412259bdda51f638c51a5c8aae94 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 17:58:34 -0800 Subject: [PATCH 037/157] misc cleanup (mostly type-related) --- examples/tutorial/basic_shapes.py | 2 +- masque/builder/pather.py | 2 ++ masque/builder/renderpather.py | 2 +- masque/builder/tools.py | 1 + masque/pattern.py | 2 +- masque/test/test_abstract.py | 4 ++++ masque/test/test_builder.py | 2 ++ masque/test/test_file_roundtrip.py | 14 ++++++++------ masque/test/test_gdsii.py | 8 +++++--- masque/test/test_library.py | 6 +++++- masque/test/test_pather.py | 3 +++ masque/test/test_pattern.py | 11 +++++++---- masque/test/test_ports.py | 3 +++ masque/test/test_ports2data.py | 2 ++ masque/test/test_ref.py | 8 ++++++-- masque/test/test_renderpather.py | 8 ++++++-- 16 files changed, 57 insertions(+), 21 deletions(-) diff --git a/examples/tutorial/basic_shapes.py b/examples/tutorial/basic_shapes.py index 8664b4d..d8f7e1e 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -2,7 +2,7 @@ import numpy from numpy import pi -from masque import layer_t, Pattern, Circle, Arc, Polygon, Ref +from masque import layer_t, Pattern, Circle, Arc, Ref from masque.repetition import Grid import masque.file.gdsii diff --git a/masque/builder/pather.py b/masque/builder/pather.py index c23e240..387b0d8 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -311,6 +311,7 @@ class Pather(Builder, PatherMixin): # Fallback for dead pather: manually update the port instead of plugging port = self.pattern[portspec] port_rot = port.rotation + assert port_rot is not None if ccw is None: out_rot = pi elif bool(ccw): @@ -414,6 +415,7 @@ class Pather(Builder, PatherMixin): # Fallback for dead pather: manually update the port instead of plugging port = self.pattern[portspec] port_rot = port.rotation + assert port_rot is not None out_port = Port((length, jog), rotation=pi, ptype=in_ptype) out_port.rotate_around((0, 0), pi + port_rot) out_port.translate(port.offset) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index ca8cf8a..c47232f 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -520,7 +520,7 @@ class RenderPather(PatherMixin): ccw0 = jog > 0 kwargs_no_out = (kwargs | {'out_ptype': None}) try: - t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes + t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail w/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] diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 1ffaa4d..27bc27e 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -643,6 +643,7 @@ class AutoTool(Tool, metaclass=ABCMeta): 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 elif not numpy.isclose(straight_length, 0): out_ptype_actual = straight.ptype diff --git a/masque/pattern.py b/masque/pattern.py index acebf62..f4fb649 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -693,7 +693,7 @@ 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})") + self._log_bulk_update(f"translate({offset!r})") return self def scale_elements(self, c: float) -> Self: diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py index 907cedc..7c2dbbb 100644 --- a/masque/test/test_abstract.py +++ b/masque/test/test_abstract.py @@ -20,6 +20,7 @@ def test_abstract_transform() -> None: 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 @@ -27,6 +28,7 @@ def test_abstract_transform() -> None: # (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) @@ -48,6 +50,7 @@ def test_abstract_ref_transform() -> None: # (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) @@ -57,4 +60,5 @@ def test_abstract_undo_transform() -> None: 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_builder.py b/masque/test/test_builder.py index bfbd1df..0ad6e80 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -50,6 +50,7 @@ def test_builder_plug() -> None: 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) @@ -126,4 +127,5 @@ def test_dead_plug_best_effort() -> None: # 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_file_roundtrip.py b/masque/test/test_file_roundtrip.py index fbadd7b..c7536a5 100644 --- a/masque/test/test_file_roundtrip.py +++ b/masque/test/test_file_roundtrip.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import cast import pytest from numpy.testing import assert_allclose @@ -77,17 +78,18 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None: # 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 = read_paths.shapes[(2, 0)][0] + p_flush = cast("MPath", read_paths.shapes[(2, 0)][0]) assert p_flush.cap == MPath.Cap.Flush - p_square = read_paths.shapes[(2, 1)][0] + p_square = cast("MPath", read_paths.shapes[(2, 1)][0]) assert p_square.cap == MPath.Cap.Square - p_circle = read_paths.shapes[(2, 2)][0] + p_circle = cast("MPath", read_paths.shapes[(2, 2)][0]) assert p_circle.cap == MPath.Cap.Circle - p_custom = read_paths.shapes[(2, 3)][0] + 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 @@ -125,8 +127,8 @@ def test_oasis_full_roundtrip(tmp_path: Path) -> None: # Check Path caps read_paths = read_lib["paths"] - assert read_paths.shapes[(2, 0)][0].cap == MPath.Cap.Flush - assert read_paths.shapes[(2, 1)][0].cap == MPath.Cap.Square + 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: diff --git a/masque/test/test_gdsii.py b/masque/test/test_gdsii.py index 86e4bbc..7ce8c88 100644 --- a/masque/test/test_gdsii.py +++ b/masque/test/test_gdsii.py @@ -1,11 +1,12 @@ 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 +from ..shapes import Path as MPath, Polygon def test_gdsii_roundtrip(tmp_path: Path) -> None: @@ -36,14 +37,14 @@ def test_gdsii_roundtrip(tmp_path: Path) -> None: assert "ref_cell" in read_lib # Check polygon - read_poly = read_lib["poly_cell"].shapes[(1, 0)][0] + 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 = read_lib["path_cell"].shapes[(2, 5)][0] + 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]]) @@ -66,4 +67,5 @@ def test_gdsii_annotations(tmp_path: Path) -> None: 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_library.py b/masque/test/test_library.py index 0012219..22ad42a 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -1,8 +1,12 @@ import pytest +from typing import cast, TYPE_CHECKING from ..library import Library, LazyLibrary from ..pattern import Pattern from ..error import LibraryError +if TYPE_CHECKING: + from ..shapes import Polygon + def test_library_basic() -> None: lib = Library() @@ -51,7 +55,7 @@ def test_library_flatten() -> None: assert not flat_parent.has_refs() assert len(flat_parent.shapes[(1, 0)]) == 1 # Transformations are baked into vertices for Polygon - assert_vertices = flat_parent.shapes[(1, 0)][0].vertices + assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices assert tuple(assert_vertices[0]) == (10.0, 10.0) diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py index 336458f..35e9f53 100644 --- a/masque/test/test_pather.py +++ b/masque/test/test_pather.py @@ -28,6 +28,7 @@ def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None # 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) @@ -46,6 +47,7 @@ def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None: 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) @@ -80,6 +82,7 @@ def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> N 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) diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py index e66e9d5..f5da195 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -1,3 +1,4 @@ +from typing import cast from numpy.testing import assert_equal, assert_allclose from numpy import pi @@ -56,7 +57,7 @@ def test_pattern_translate() -> None: pat.translate_elements((10, 20)) # Polygon.translate adds to vertices, and offset is always (0,0) - assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, 20]) + assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20]) assert_equal(pat.ports["P1"].offset, [15, 25]) @@ -67,7 +68,7 @@ def test_pattern_scale() -> None: pat.scale_by(2) # Vertices should be scaled - assert_equal(pat.shapes[(1, 0)][0].vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) + assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) def test_pattern_rotate() -> None: @@ -77,7 +78,7 @@ def test_pattern_rotate() -> None: pat.rotate_around((0, 0), pi / 2) # [10, 0] rotated 90 deg around (0,0) is [0, 10] - assert_allclose(pat.shapes[(1, 0)][0].vertices[0], [0, 10], atol=1e-10) + assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10) def test_pattern_mirror() -> None: @@ -86,7 +87,7 @@ def test_pattern_mirror() -> None: # Mirror across X axis (y -> -y) pat.mirror(0) - assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, -5]) + assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5]) def test_pattern_get_bounds() -> None: @@ -106,7 +107,9 @@ def test_pattern_interface() -> None: 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_ports.py b/masque/test/test_ports.py index 4354bff..e1dab87 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -17,11 +17,13 @@ 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) @@ -30,6 +32,7 @@ def test_port_flip_across() -> None: 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) diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py index 32bc367..f461cb8 100644 --- a/masque/test/test_ports2data.py +++ b/masque/test/test_ports2data.py @@ -25,6 +25,7 @@ def test_ports2data_roundtrip() -> None: 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" @@ -52,4 +53,5 @@ def test_data_to_ports_hierarchical() -> None: # 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) diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py index 8872699..e2d266b 100644 --- a/masque/test/test_ref.py +++ b/masque/test/test_ref.py @@ -1,3 +1,4 @@ +from typing import cast, TYPE_CHECKING from numpy.testing import assert_equal, assert_allclose from numpy import pi @@ -5,6 +6,9 @@ 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) @@ -22,7 +26,7 @@ def test_ref_as_pattern() -> None: transformed_pat = ref.as_pattern(sub_pat) # Check transformed shape - shape = transformed_pat.shapes[(1, 0)][0] + 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]] @@ -42,7 +46,7 @@ def test_ref_with_repetition() -> None: # Should have 4 shapes assert len(repeated_pat.shapes[(1, 0)]) == 4 - first_verts = sorted([tuple(s.vertices[0]) for s in repeated_pat.shapes[(1, 0)]]) + 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)] diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index 3948214..5d2c8c3 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -1,4 +1,5 @@ import pytest +from typing import cast, TYPE_CHECKING from numpy.testing import assert_allclose from numpy import pi @@ -7,6 +8,9 @@ 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]: @@ -37,7 +41,7 @@ def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library # 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 = rp.pattern.shapes[(1, 0)][0] + 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) @@ -48,7 +52,7 @@ def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library] rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10) rp.render() - path_shape = rp.pattern.shapes[(1, 0)][0] + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) # Path vertices: # 1. Start (0,0) # 2. Straight end: (0, -10) From 0f49924aa694503de9ac2361fcd1b77a0f9f9da5 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 18:04:16 -0800 Subject: [PATCH 038/157] Add ezdxf stubs --- stubs/ezdxf/__init__.pyi | 12 ++++++++++++ stubs/ezdxf/entities.pyi | 17 +++++++++++++++++ stubs/ezdxf/enums.pyi | 4 ++++ stubs/ezdxf/layouts.pyi | 20 ++++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 stubs/ezdxf/__init__.pyi create mode 100644 stubs/ezdxf/entities.pyi create mode 100644 stubs/ezdxf/enums.pyi create mode 100644 stubs/ezdxf/layouts.pyi diff --git a/stubs/ezdxf/__init__.pyi b/stubs/ezdxf/__init__.pyi new file mode 100644 index 0000000..0198407 --- /dev/null +++ b/stubs/ezdxf/__init__.pyi @@ -0,0 +1,12 @@ +from typing import Any, TextIO, 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 new file mode 100644 index 0000000..c8e6a4b --- /dev/null +++ b/stubs/ezdxf/entities.pyi @@ -0,0 +1,17 @@ +from typing import Any, Iterable, Tuple, 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 new file mode 100644 index 0000000..0dcf600 --- /dev/null +++ b/stubs/ezdxf/enums.pyi @@ -0,0 +1,4 @@ +from enum import IntEnum + +class TextEntityAlignment(IntEnum): + BOTTOM_LEFT = ... diff --git a/stubs/ezdxf/layouts.pyi b/stubs/ezdxf/layouts.pyi new file mode 100644 index 0000000..4e713e6 --- /dev/null +++ b/stubs/ezdxf/layouts.pyi @@ -0,0 +1,20 @@ +from typing import Any, Iterator, Sequence, Union, 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]: ... From 504f89796c772e96397a6e590d8a47d74ab7b756 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 18:08:40 -0800 Subject: [PATCH 039/157] Add ruff and mypy to dev deps --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 764eee9..15ba402 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,8 @@ dev = [ "masque[text]", "masque[manhattanize]", "masque[manhattanize_slow]", + "ruff>=0.15.1", + "mypy>=1.19.1", ] [tool.hatch.version] From 07a25ec290a2d2054527d4fd53b64ed0687f1dcf Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 18:53:31 -0800 Subject: [PATCH 040/157] [Mirrorable / Flippable] clarify docs --- masque/traits/mirrorable.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index deddddd..ac00147 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -25,8 +25,9 @@ class Mirrorable(metaclass=ABCMeta): to (0, 0), this is equivalent to mirroring in the container's coordinate system. Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). - + axis: Axis to mirror across: + 0: X-axis (flip y coords), + 1: Y-axis (flip x coords) Returns: self """ @@ -37,8 +38,8 @@ class Mirrorable(metaclass=ABCMeta): Optionally mirror the entity across both axes through its origin. Args: - across_x: Mirror across x axis (flip y) - across_y: Mirror across y axis (flip x) + across_x: Mirror across the horizontal X-axis (flip Y coordinates). + across_y: Mirror across the vertical Y-axis (flip X coordinates). Returns: self @@ -81,7 +82,7 @@ class Flippable(Positionable, metaclass=ABCMeta): into account. Args: - axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0. + 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. From ed021e3d815387b119c74d6bebc49b77cd523871 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 19:21:52 -0800 Subject: [PATCH 041/157] [Pattern] fix mirror_elements and change arg name to `axis` --- masque/pattern.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index f4fb649..6b2de99 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -793,18 +793,22 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): cast('Rotatable', entry).rotate(rotation) return self - def mirror_elements(self, across_axis: int = 0) -> Self: + def mirror_elements(self, axis: int = 0) -> Self: """ - Mirror each shape, ref, and port relative to (0,0). + Mirror each shape, ref, and port relative to its offset. Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) + axis: Axis to mirror across + 0: mirror across x axis (flip y), + 1: mirror across y axis (flip x) Returns: self """ - return self.flip_across(axis=across_axis) + for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): + cast('Mirrorable', entry).mirror(axis=axis) + self._log_bulk_update(f"mirror_elements({axis})") + return self def mirror(self, axis: int = 0) -> Self: """ From ff8ca929633ea999b344414dac47de0535638c2f Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 20:48:15 -0800 Subject: [PATCH 042/157] cleanup --- masque/builder/pather.py | 3 ++- masque/builder/renderpather.py | 3 ++- masque/library.py | 10 +++++----- masque/pattern.py | 4 ++-- masque/shapes/polygon.py | 2 +- masque/shapes/shape.py | 8 ++++---- masque/shapes/text.py | 4 ++-- masque/test/test_boolean.py | 1 + masque/test/test_label.py | 4 +--- masque/traits/repeatable.py | 2 +- masque/utils/boolean.py | 4 +++- masque/utils/vertices.py | 6 +++--- pyproject.toml | 2 ++ 13 files changed, 29 insertions(+), 24 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 387b0d8..a3c4dc5 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -400,11 +400,12 @@ class Pather(Builder, PatherMixin): 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 except (BuildError, NotImplementedError): if not self._dead: raise # Fall through to dummy extension below + else: + return self except BuildError: if not self._dead: raise diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index c47232f..fae975a 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -528,11 +528,12 @@ class RenderPather(PatherMixin): 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 except (BuildError, NotImplementedError): if not self._dead: raise # Fall through to dummy extension below + else: + return self except BuildError: if not self._dead: raise diff --git a/masque/library.py b/masque/library.py index 0ed5271..3e1c65c 100644 --- a/masque/library.py +++ b/masque/library.py @@ -763,7 +763,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: (name, pattern) tuple """ - from .pattern import Pattern + from .pattern import Pattern #noqa: PLC0415 pat = Pattern() self[name] = pat return name, pat @@ -803,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 + from .pattern import map_targets #noqa: PLC0415 duplicates = set(self.keys()) & set(other.keys()) if not duplicates: @@ -909,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 + from .pattern import Pattern #noqa: PLC0415 if exclude_types is None: exclude_types = () @@ -1002,7 +1002,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: self """ - from .pattern import Pattern + from .pattern import Pattern #noqa: PLC0415 if name_func is None: def name_func(_pat: Pattern, _shape: Shape | Label) -> str: @@ -1205,7 +1205,7 @@ class Library(ILibrary): Returns: The newly created `Library` and the newly created `Pattern` """ - from .pattern import Pattern + from .pattern import Pattern #noqa: PLC0415 tree = cls() pat = Pattern() tree[name] = pat diff --git a/masque/pattern.py b/masque/pattern.py index 6b2de99..4c72fdf 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1066,8 +1066,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): """ # TODO: add text labels to visualize() try: - from matplotlib import pyplot # type: ignore - import matplotlib.collections # type: ignore + from matplotlib import pyplot # type: ignore #noqa: PLC0415 + import matplotlib.collections # type: ignore #noqa: PLC0415 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.') diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index a243901..1fd6350 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -480,5 +480,5 @@ class Polygon(Shape): Returns: A list of resulting Polygons. """ - from ..utils.boolean import boolean + 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 13d2e1e..ee6f19a 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -121,7 +121,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon + from . import Polygon #noqa: PLC0415 gx = numpy.unique(grid_x) gy = numpy.unique(grid_y) @@ -250,9 +250,9 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon - import skimage.measure # type: ignore - import float_raster + from . import Polygon #noqa: PLC0415 + import skimage.measure #noqa: PLC0415 + import float_raster #noqa: PLC0415 grx = numpy.unique(grid_x) gry = numpy.unique(grid_y) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 65a9213..dec4c33 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -205,8 +205,8 @@ def get_char_as_polygons( char: str, resolution: float = 48 * 64, ) -> tuple[list[NDArray[numpy.float64]], float]: - from freetype import Face # type: ignore - from matplotlib.path import Path # type: ignore + from freetype import Face # type: ignore #noqa: PLC0415 + from matplotlib.path import Path # type: ignore #noqa: PLC0415 """ Get a list of polygons representing a single character. diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py index c1a2d7b..bf5d33d 100644 --- a/masque/test/test_boolean.py +++ b/masque/test/test_boolean.py @@ -1,3 +1,4 @@ +# ruff: noqa: PLC0415 import pytest import numpy from numpy.testing import assert_allclose diff --git a/masque/test/test_label.py b/masque/test/test_label.py index ed40614..ad8c08b 100644 --- a/masque/test/test_label.py +++ b/masque/test/test_label.py @@ -4,6 +4,7 @@ from numpy import pi from ..label import Label from ..repetition import Grid +from ..utils import annotations_eq def test_label_init() -> None: @@ -39,9 +40,6 @@ def test_label_copy() -> None: 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}") - - from ..utils import annotations_eq - print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}") assert l1 == l2 diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py index fbd765f..dbf4fad 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 + from ..repetition import Repetition #noqa: PLC0415 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/utils/boolean.py b/masque/utils/boolean.py index 9b9514e..78c24e2 100644 --- a/masque/utils/boolean.py +++ b/masque/utils/boolean.py @@ -8,8 +8,10 @@ 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. @@ -96,7 +98,7 @@ def boolean( A list of result Polygons. """ try: - import pyclipper + import pyclipper #noqa: PLC0415 except ImportError: raise ImportError( "Boolean operations require 'pyclipper'. " diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 36d2e59..7d3791c 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -87,7 +87,7 @@ def poly_contains_points( max_bounds = numpy.max(vertices, axis=0)[None, :] trivially_outside = ((points < min_bounds).any(axis=1) - | (points > max_bounds).any(axis=1)) # noqa: E128 + | (points > max_bounds).any(axis=1)) nontrivial = ~trivially_outside if trivially_outside.all(): @@ -105,10 +105,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])) # noqa: E128 + - dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) winding_number = ((upward & (is_left > 0)).sum(axis=0) - - (downward & (is_left < 0)).sum(axis=0)) # noqa: E128 + - (downward & (is_left < 0)).sum(axis=0)) nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number if include_boundary: diff --git a/pyproject.toml b/pyproject.toml index 15ba402..ba7a240 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,8 @@ 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 ] From 4332cf14c05b03e789e00a5081fe4ae3acc85bd4 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 20:48:26 -0800 Subject: [PATCH 043/157] [ezdxf] add stubs --- stubs/ezdxf/__init__.pyi | 3 ++- stubs/ezdxf/entities.pyi | 7 ++++--- stubs/ezdxf/layouts.pyi | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/stubs/ezdxf/__init__.pyi b/stubs/ezdxf/__init__.pyi index 0198407..f25475f 100644 --- a/stubs/ezdxf/__init__.pyi +++ b/stubs/ezdxf/__init__.pyi @@ -1,4 +1,5 @@ -from typing import Any, TextIO, Iterable +from typing import Any, TextIO +from collections.abc import Iterable from .layouts import Modelspace, BlockRecords class Drawing: diff --git a/stubs/ezdxf/entities.pyi b/stubs/ezdxf/entities.pyi index c8e6a4b..2c6efa9 100644 --- a/stubs/ezdxf/entities.pyi +++ b/stubs/ezdxf/entities.pyi @@ -1,17 +1,18 @@ -from typing import Any, Iterable, Tuple, Sequence +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, ...]]: ... + 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 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/layouts.pyi b/stubs/ezdxf/layouts.pyi index 4e713e6..c9d12ad 100644 --- a/stubs/ezdxf/layouts.pyi +++ b/stubs/ezdxf/layouts.pyi @@ -1,4 +1,5 @@ -from typing import Any, Iterator, Sequence, Union, Iterable +from typing import Any +from collections.abc import Iterator, Sequence, Iterable from .entities import DXFEntity class BaseLayout: From 16875e9cd6fedfbd9ef2dbecd2dbad81c35975fb Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 6 Mar 2026 13:07:06 -0800 Subject: [PATCH 044/157] [RenderPather / PathTool] Improve support for port transformations So that moving a port while in the middle of planning a path doesn't break everything --- masque/builder/renderpather.py | 72 ++++++++++++++++++++++++++++------ masque/builder/tools.py | 69 +++++++++++++++++++++++++++----- 2 files changed, 121 insertions(+), 20 deletions(-) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index fae975a..8104a50 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -9,8 +9,9 @@ from collections import defaultdict from functools import wraps from pprint import pformat +import numpy from numpy import pi -from numpy.typing import ArrayLike +from numpy.typing import ArrayLike, NDArray from ..pattern import Pattern from ..library import ILibrary, TreeView @@ -579,22 +580,54 @@ class RenderPather(PatherMixin): 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() + # Tools render in local space (first port at 0,0, rotation 0). + tree = batch[0].tool.render(batch, port_names=tool_port_names) + + actual_in, actual_out = tool_port_names + name = lib << tree + + # To plug the segment at its intended location, we create a + # 'stationary' port in our temporary pattern that matches + # the batch's planned start. + if portspec in pat.ports: + del pat.ports[portspec] + + stationary_port = batch[0].start_port.copy() + pat.ports[portspec] = stationary_port + 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 + # pat.plug() translates and rotates the tool's local output to the start port. + pat.plug(lib[name], {portspec: actual_in}, append=append) + del lib[name] else: - pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append) + pat.plug(lib.abstract(name), {portspec: actual_in}, append=append) + + # Rename output back to portspec for the next batch. + if portspec not in pat.ports and actual_out in pat.ports: + pat.rename_ports({actual_out: portspec}, overwrite=True) for portspec, steps in self.paths.items(): + if not steps: + continue + batch: list[RenderStep] = [] + # Initialize continuity check with the start of the entire path. + prev_end = steps[0].start_port + for step in steps: appendable_op = step.opcode in ('L', 'S', 'U') same_tool = batch and step.tool == batch[0].tool + # Check continuity with tolerance + offsets_match = numpy.allclose(step.start_port.offset, prev_end.offset) + rotations_match = (step.start_port.rotation is None and prev_end.rotation is None) or ( + step.start_port.rotation is not None and prev_end.rotation is not None and + numpy.isclose(step.start_port.rotation, prev_end.rotation) + ) + continuous = offsets_match and rotations_match + # If we can't continue a batch, render it - if batch and (not appendable_op or not same_tool): + if batch and (not appendable_op or not same_tool or not continuous): render_batch(portspec, batch, append) batch = [] @@ -603,8 +636,14 @@ class RenderPather(PatherMixin): 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 not appendable_op: + if portspec in pat.ports: + del pat.ports[portspec] + # Plugged ports should be tracked + if step.opcode == 'P' and portspec in pat.ports: + del pat.ports[portspec] + + prev_end = step.end_port #If the last batch didn't end yet if batch: @@ -626,7 +665,11 @@ class RenderPather(PatherMixin): Returns: self """ - self.pattern.translate_elements(offset) + offset_arr: NDArray[numpy.float64] = 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 def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: @@ -640,7 +683,11 @@ class RenderPather(PatherMixin): Returns: self """ - self.pattern.rotate_around(pivot, angle) + pivot_arr: NDArray[numpy.float64] = 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 def mirror(self, axis: int) -> Self: @@ -654,6 +701,9 @@ class RenderPather(PatherMixin): self """ self.pattern.mirror(axis) + for steps in self.paths.values(): + for i, step in enumerate(steps): + steps[i] = step.mirrored(axis) return self def set_dead(self) -> Self: diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 27bc27e..3e6616a 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -47,6 +47,43 @@ class RenderStep: if self.opcode != 'P' and self.tool is None: raise BuildError('Got tool=None but the opcode is not "P"') + 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, + ) + class Tool: """ @@ -991,29 +1028,43 @@ class PathTool(Tool, metaclass=ABCMeta): **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: - path_vertices = [batch[0].start_port.offset] - for step in batch: + # 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: assert step.tool == self port_rot = step.start_port.rotation - assert port_rot is not None + # 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. if step.opcode == 'L': - length, bend_run = step.data + + length, _ = 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}"') - if (path_vertices[-1] != batch[-1].end_port.offset).any(): + # 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 the path ends in a bend, we need to add the final vertex - path_vertices.append(batch[-1].end_port.offset) + path_vertices.append(local_batch[-1].end_port.offset) tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.path(layer=self.layer, width=self.width, vertices=path_vertices) pat.ports = { - port_names[0]: batch[0].start_port.copy().rotate(pi), - port_names[1]: batch[-1].end_port.copy().rotate(pi), + port_names[0]: local_batch[0].start_port.copy().rotate(pi), + port_names[1]: local_batch[-1].end_port.copy().rotate(pi), } return tree From babbe78daad2a093c0cb6932b1cfd728e3ac6ad2 Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 6 Mar 2026 22:58:03 -0800 Subject: [PATCH 045/157] [Pather/RenderPather/PortPather] Rework pathing verbs *BREAKING CHANGE* --- masque/builder/pather.py | 30 +- masque/builder/pather_mixin.py | 848 ++++++++++++++++--------------- masque/builder/renderpather.py | 20 +- masque/test/test_pather.py | 14 +- masque/test/test_renderpather.py | 10 +- 5 files changed, 487 insertions(+), 435 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index a3c4dc5..68dfcd9 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -255,7 +255,7 @@ class Pather(Builder, PatherMixin): return s - def path( + def _path( self, portspec: str, ccw: SupportsBool | None, @@ -296,7 +296,7 @@ class Pather(Builder, PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.warning('Skipping geometry for path() since device is dead') + logger.warning('Skipping geometry for _path() since device is dead') tool_port_names = ('A', 'B') @@ -335,7 +335,7 @@ class Pather(Builder, PatherMixin): self.plug(tname, {portspec: tool_port_names[0], **output}) return self - def pathS( + def _pathS( self, portspec: str, length: float, @@ -346,20 +346,17 @@ class Pather(Builder, PatherMixin): ) -> 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). + of traveling exactly `length` distance. - 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. + The wire will travel `length` distance along the port's axis, and exactly `jog` + distance in the perpendicular direction. The output port will have an orientation + identical to the input port. 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.) + length: The total distance from input to output, along the input's axis only. + jog: Total distance perpendicular to the direction of travel. Positive values + are to the left of the direction of travel. plug_into: If not None, attempts to plug the wire's output port into the provided port on `self`. @@ -377,7 +374,7 @@ class Pather(Builder, PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.warning('Skipping geometry for pathS() since device is dead') + logger.warning('Skipping geometry for _pathS() since device is dead') tool_port_names = ('A', 'B') @@ -398,8 +395,8 @@ class Pather(Builder, PatherMixin): (_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[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) + self._path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self._path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) except (BuildError, NotImplementedError): if not self._dead: raise @@ -433,4 +430,3 @@ class Pather(Builder, PatherMixin): output = {} self.plug(tname, {portspec: tool_port_names[0], **output}) return self - diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 1655329..0f5fa92 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -1,5 +1,5 @@ from typing import Self, overload -from collections.abc import Sequence, Iterator, Iterable +from collections.abc import Sequence, Iterator, Iterable, Mapping import logging from contextlib import contextmanager from abc import abstractmethod, ABCMeta @@ -37,8 +37,323 @@ class PatherMixin(PortList, metaclass=ABCMeta): (e.g wires or waveguides) to be plugged into this device. """ + def trace( + self, + portspec: str | Sequence[str], + ccw: SupportsBool | None, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + **bounds, + ) -> Self: + """ + Create a "wire"/"waveguide" extending from the port(s) `portspec`. + + Args: + portspec: The name(s) of the port(s) into which the wire(s) 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. + Length is only allowed with a single port. + spacing: Center-to-center distance between output ports along the input port's axis. + Only used when routing multiple ports with a bend. + bounds: Boundary constraints for the trace. + - each: results in each port being extended by `each` distance. + - emin, emax, pmin, pmax, xmin, xmax, ymin, ymax: bundle routing via `ell()`. + - set_rotation: explicit rotation for ports without one. + + Returns: + self + """ + if isinstance(portspec, str): + portspec = [portspec] + + if length is not None: + if len(portspec) > 1: + raise BuildError('length is only allowed with a single port in trace()') + if bounds: + raise BuildError('length and bounds are mutually exclusive in trace()') + return self._path(portspec[0], ccw, length) + + if 'each' in bounds: + each = bounds.pop('each') + if bounds: + raise BuildError('each and other bounds are mutually exclusive in trace()') + for port in portspec: + self._path(port, ccw, each) + return self + + # Bundle routing (formerly mpath logic) + bound_types = set() + if 'bound_type' in bounds: + bound_types.add(bounds.pop('bound_type')) + bound = bounds.pop('bound') + for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): + if bt in bounds: + bound_types.add(bt) + bound = bounds.pop(bt) + + if not bound_types: + raise BuildError('No bound type specified for trace()') + if len(bound_types) > 1: + raise BuildError(f'Too many bound types specified: {bound_types}') + bound_type = tuple(bound_types)[0] + + ports = self.pattern[tuple(portspec)] + set_rotation = bounds.pop('set_rotation', None) + + extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) + + for port_name, ext_len in extensions.items(): + self._path(port_name, ccw, ext_len, **bounds) + return self + + def trace_to( + self, + portspec: str | Sequence[str], + ccw: SupportsBool | None, + *, + spacing: float | ArrayLike | None = None, + **bounds, + ) -> Self: + """ + Create a "wire"/"waveguide" extending from the port(s) `portspec` to a target position. + + Args: + portspec: The name(s) of the port(s) into which the wire(s) 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. + spacing: Center-to-center distance between output ports along the input port's axis. + Only used when routing multiple ports with a bend. + bounds: Boundary constraints for the target position. + - p, x, y, pos, position: Coordinate of the target position. Error if used with multiple ports. + - pmin, pmax, xmin, xmax, ymin, ymax, emin, emax: bundle routing via `ell()`. + + Returns: + self + """ + if isinstance(portspec, str): + portspec = [portspec] + + pos_bounds = {kk: bounds[kk] for kk in ('p', 'x', 'y', 'pos', 'position') if kk in bounds} + if pos_bounds: + if len(portspec) > 1: + raise BuildError(f'{tuple(pos_bounds.keys())} bounds are only allowed with a single port in trace_to()') + if len(pos_bounds) > 1: + raise BuildError(f'Too many position bounds: {tuple(pos_bounds.keys())}') + + k, v = next(iter(pos_bounds.items())) + k = 'position' if k in ('p', 'pos') else k + + # Logic hoisted from path_to() + port_name = portspec[0] + port = self.pattern[port_name] + if port.rotation is None: + raise PortError(f'Port {port_name} has no rotation and cannot be used for trace_to()') + + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise BuildError('trace_to was asked to route from non-manhattan port') + + is_horizontal = numpy.isclose(port.rotation % pi, 0) + if is_horizontal: + if k == 'y': + raise BuildError('Asked to trace to y-coordinate, but port is horizontal') + target = v + else: + if k == 'x': + raise BuildError('Asked to trace to x-coordinate, but port is vertical') + target = v + + x0, y0 = port.offset + if is_horizontal: + if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(target - x0): + raise BuildError(f'trace_to routing to behind source port: x0={x0:g} to {target:g}') + length = numpy.abs(target - x0) + else: + if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(target - y0): + raise BuildError(f'trace_to routing to behind source port: y0={y0:g} to {target:g}') + length = numpy.abs(target - y0) + + other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in pos_bounds and bk != 'length'} + if 'length' in bounds and bounds['length'] is not None: + raise BuildError('Cannot specify both relative length and absolute position in trace_to()') + + return self._path(port_name, ccw, length, **other_bounds) + + # Bundle routing (delegate to trace which handles ell) + return self.trace(portspec, ccw, spacing=spacing, **bounds) + + def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: + """ Straight extension. Replaces `path(ccw=None)` and `path_to(ccw=None)` """ + return self.trace_to(portspec, None, length=length, **bounds) + + def bend(self, portspec: str | Sequence[str], ccw: SupportsBool, length: float | None = None, **bounds) -> Self: + """ Bend extension. Replaces `path(ccw=True/False)` and `path_to(ccw=True/False)` """ + return self.trace_to(portspec, ccw, length=length, **bounds) + + def ccw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: + """ Counter-clockwise bend extension. """ + return self.bend(portspec, True, length, **bounds) + + def cw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: + """ Clockwise bend extension. """ + return self.bend(portspec, False, length, **bounds) + + def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds) -> Self: + """ Jog extension. Replaces `pathS`. """ + if isinstance(portspec, str): + portspec = [portspec] + + for port in portspec: + l_actual = length + if l_actual is None: + # TODO: use bounds to determine length? + raise BuildError('jog() currently requires a length') + self._pathS(port, l_actual, offset, **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, + ) -> 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). + + 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 renamed 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 an invalid port config is encountered: + - Non-manhattan ports + - U-bend + - Destination too close to (or behind) source + """ + if self._dead: + logger.error('Skipping trace_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 trace_into()') + if port_dst.rotation is None: + raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for trace_into()') + + if not numpy.isclose(port_src.rotation % (pi / 2), 0): + raise BuildError('trace_into was asked to route from non-manhattan port') + if not numpy.isclose(port_dst.rotation % (pi / 2), 0): + raise BuildError('trace_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.trace_to(portspec_src, angle > pi, x=xd, **src_args) + self.trace_to(portspec_src, None, y=yd, **dst_args) + elif dst_is_horizontal and not src_is_horizontal: + # single bend should suffice + self.trace_to(portspec_src, angle > pi, y=yd, **src_args) + self.trace_to(portspec_src, None, x=xd, **dst_args) + elif numpy.isclose(angle, pi): + if src_is_horizontal and ys == yd: + # straight connector + self.trace_to(portspec_src, None, x=xd, **dst_args) + elif not src_is_horizontal and xs == xd: + # straight connector + self.trace_to(portspec_src, None, y=yd, **dst_args) + else: + # S-bend + (travel, jog), _ = port_src.measure_travel(port_dst) + self.jog(portspec_src, -jog, -travel, **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 _uturn_fallback( + self, + tool: Tool, + portspec: str, + jog: float, + length: float, + in_ptype: str | None, + plug_into: str | None, + **kwargs, + ) -> bool: + """ + Attempt to perform a U-turn using two L-bends. + Returns True if successful, False if planL failed. + """ + # Fall back to drawing two L-bends + ccw = jog > 0 + kwargs_no_out = kwargs | {'out_ptype': None} + try: + # First, find R by planning a minimal L-bend. + # Use a large length to ensure we don't hit tool-specific minimum length constraints. + dummy_port, _ = tool.planL(ccw, 1e9, in_ptype=in_ptype, **kwargs_no_out) + R = abs(dummy_port.y) + + L1 = length + R + L2 = abs(jog) - R + + kwargs_plug = kwargs | {'plug_into': plug_into} + self._path(portspec, ccw, L1, **kwargs_no_out) + self._path(portspec, ccw, L2, **kwargs_plug) + except (BuildError, NotImplementedError): + return False + else: + return True + @abstractmethod - def path( + def _path( self, portspec: str, ccw: SupportsBool | None, @@ -50,7 +365,7 @@ class PatherMixin(PortList, metaclass=ABCMeta): pass @abstractmethod - def pathS( + def _pathS( self, portspec: str, length: float, @@ -61,6 +376,16 @@ class PatherMixin(PortList, metaclass=ABCMeta): ) -> Self: pass + def path(self, *args, **kwargs) -> Self: + import warnings + warnings.warn("path() is deprecated; use trace(), straight(), or bend() instead", DeprecationWarning, stacklevel=2) + return self._path(*args, **kwargs) + + def pathS(self, *args, **kwargs) -> Self: + import warnings + warnings.warn("pathS() is deprecated; use jog() instead", DeprecationWarning, stacklevel=2) + return self._pathS(*args, **kwargs) + @abstractmethod def plug( self, @@ -76,6 +401,11 @@ class PatherMixin(PortList, metaclass=ABCMeta): ) -> Self: pass + @abstractmethod + def plugged(self, connections: dict[str, str]) -> Self: + """ Manual connection acknowledgment. """ + pass + def retool( self, tool: Tool, @@ -143,88 +473,13 @@ class PatherMixin(PortList, metaclass=ABCMeta): **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. + [DEPRECATED] use trace_to() instead. """ - if self._dead: - logger.error('Skipping path_to() since device is dead') - return self + import warnings + warnings.warn("path_to() is deprecated; use trace_to() instead", DeprecationWarning, stacklevel=2) - 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, - ) + bounds = {kk: vv for kk, vv in (('position', position), ('x', x), ('y', y)) if vv is not None} + return self.trace_to(portspec, ccw, plug_into=plug_into, **bounds, **kwargs) def path_into( self, @@ -237,100 +492,19 @@ class PatherMixin(PortList, metaclass=ABCMeta): **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 + [DEPRECATED] use trace_into() instead. """ - if self._dead: - logger.error('Skipping path_into() since device is dead') - return self + import warnings + warnings.warn("path_into() is deprecated; use trace_into() instead", DeprecationWarning, stacklevel=2) - 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 + return self.trace_into( + portspec_src, + portspec_dst, + out_ptype = out_ptype, + plug_destination = plug_destination, + thru = thru, + **kwargs, + ) def mpath( self, @@ -342,109 +516,12 @@ class PatherMixin(PortList, metaclass=ABCMeta): **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. + [DEPRECATED] use trace() or trace_to() instead. """ - if self._dead: - logger.error('Skipping mpath() since device is dead') - return self + import warnings + warnings.warn("mpath() is deprecated; use trace() or trace_to() instead", DeprecationWarning, stacklevel=2) - 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 + return self.trace(portspec, ccw, spacing=spacing, set_rotation=set_rotation, **kwargs) # TODO def bus_join()? @@ -488,61 +565,42 @@ class PortPather: with self.pather.toolctx(tool, keys=self.ports): yield self - def path(self, *args, **kwargs) -> Self: + def trace(self, ccw: SupportsBool | None, length: float | None = None, **kwargs) -> Self: + self.pather.trace(self.ports, ccw, length, **kwargs) + return self + + def trace_to(self, ccw: SupportsBool | None, **kwargs) -> Self: + self.pather.trace_to(self.ports, ccw, **kwargs) + return self + + def straight(self, length: float | None = None, **kwargs) -> Self: + self.pather.straight(self.ports, length, **kwargs) + return self + + def bend(self, ccw: SupportsBool, length: float | None = None, **kwargs) -> Self: + self.pather.bend(self.ports, ccw, length, **kwargs) + return self + + def ccw(self, length: float | None = None, **kwargs) -> Self: + self.pather.ccw(self.ports, length, **kwargs) + return self + + def cw(self, length: float | None = None, **kwargs) -> Self: + self.pather.cw(self.ports, length, **kwargs) + return self + + def jog(self, offset: float, length: float | None = None, **kwargs) -> Self: + self.pather.jog(self.ports, offset, length, **kwargs) + return self + + def uturn(self, offset: float, length: float | None = None, **kwargs) -> Self: + self.pather.uturn(self.ports, offset, length, **kwargs) + return self + + def trace_into(self, target_port: str, **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) + 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( @@ -558,10 +616,13 @@ class PortPather: 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: + 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.') - self.pather.plugged({self.ports[0]: other_port}) + else: + self.pather.plugged({self.ports[0]: other_port}) return self # @@ -569,95 +630,91 @@ class PortPather: # def set_ptype(self, ptype: str) -> Self: for port in self.ports: - self.pather[port].set_ptype(ptype) + self.pather.pattern[port].set_ptype(ptype) return self def translate(self, *args, **kwargs) -> Self: for port in self.ports: - self.pather[port].translate(*args, **kwargs) + self.pather.pattern[port].translate(*args, **kwargs) return self def mirror(self, *args, **kwargs) -> Self: for port in self.ports: - self.pather[port].mirror(*args, **kwargs) + self.pather.pattern[port].mirror(*args, **kwargs) return self def rotate(self, rotation: float) -> Self: for port in self.ports: - self.pather[port].rotate(rotation) + self.pather.pattern[port].rotate(rotation) return self def set_rotation(self, rotation: float | None) -> Self: for port in self.ports: - self.pather[port].set_rotation(rotation) + self.pather.pattern[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: + def rename(self, name: str | Mapping[str, str | None]) -> Self: + """ Rename active ports. Replaces `rename_to`. """ + 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 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 + def select(self, ports: str | Iterable[str]) -> Self: + """ Add ports to the selection. Replaces `add_ports`. """ + if isinstance(ports, str): + ports = [ports] + for port in ports: + if port not in self.ports: + self.ports.append(port) 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) + def deselect(self, ports: str | Iterable[str]) -> Self: + """ Remove ports from the selection. Replaces `drop_port`. """ + 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). Replaces `save_copy`. """ + name_map: Mapping[str, str] + if isinstance(name, str): + if len(self.ports) > 1: + raise BuildError('Use a mapping to mark >1 port') + name_map = {self.ports[0]: name} else: - self.ports.append(port) + name_map = name + for src, dst in name_map.items(): + self.pather.pattern.ports[dst] = self.pather.pattern[src].copy() 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] + def fork(self, name: str | Mapping[str, str]) -> Self: + """ Split and follow new name. Replaces `into_copy`. """ + name_map: Mapping[str, str] + if isinstance(name, str): + if len(self.ports) > 1: + raise BuildError('Use a mapping to fork >1 port') + name_map = {self.ports[0]: name} + else: + name_map = name + 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 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() + def drop(self) -> Self: + """ Remove selected ports from the pattern and the PortPather. Replaces `delete(None)`. """ + for pp in self.ports: + del self.pather.pattern.ports[pp] + self.ports = [] return self @overload @@ -668,10 +725,9 @@ class PortPather: def delete(self, name: str | None = None) -> Self | None: if name is None: - for pp in self.ports: - del self.pather.ports[pp] + self.drop() return None - del self.pather.ports[name] + del self.pather.pattern.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 index 8104a50..93fd2e3 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) class RenderPather(PatherMixin): """ - `RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath` + `RenderPather` is an alternative to `Pather` which uses the `trace`/`trace_to` 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 @@ -97,7 +97,7 @@ class RenderPather(PatherMixin): 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` + to generate waveguide or wire segments when `trace`/`trace_to` are called. Relies on `Tool.planL` and `Tool.render` implementations. name: If specified, `library[name]` is set to `self.pattern`. """ @@ -150,7 +150,7 @@ class RenderPather(PatherMixin): 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`). + or waveguides (via `trace`/`trace_to`). 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 @@ -377,7 +377,7 @@ class RenderPather(PatherMixin): PortList.plugged(self, connections) return self - def path( + def _path( self, portspec: str, ccw: SupportsBool | None, @@ -420,7 +420,7 @@ class RenderPather(PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.warning('Skipping geometry for path() since device is dead') + logger.warning('Skipping geometry for _path() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -460,7 +460,7 @@ class RenderPather(PatherMixin): return self - def pathS( + def _pathS( self, portspec: str, length: float, @@ -504,7 +504,7 @@ class RenderPather(PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.warning('Skipping geometry for pathS() since device is dead') + logger.warning('Skipping geometry for _pathS() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -527,8 +527,8 @@ class RenderPather(PatherMixin): 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) + self._path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self._path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) except (BuildError, NotImplementedError): if not self._dead: raise @@ -564,7 +564,7 @@ class RenderPather(PatherMixin): append: bool = True, ) -> Self: """ - Generate the geometry which has been planned out with `path`/`path_to`/etc. + Generate the geometry which has been planned out with `trace`/`trace_to`/etc. Args: append: If `True`, the rendered geometry will be directly appended to diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py index 35e9f53..47cae29 100644 --- a/masque/test/test_pather.py +++ b/masque/test/test_pather.py @@ -24,7 +24,7 @@ def pather_setup() -> tuple[Pather, PathTool, Library]: def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: p, tool, lib = pather_setup # Route 10um "forward" - p.path("start", ccw=None, length=10) + 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) @@ -37,7 +37,7 @@ def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None: # Start (0,0) rot pi/2 (North). # Path 10um "forward" (South), then turn Clockwise (ccw=False). # Facing South, turn Right -> West. - p.path("start", ccw=False, length=10) + 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): @@ -55,7 +55,7 @@ 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.path_to("start", ccw=None, y=-50) + p.straight("start", y=-50) assert_equal(p.ports["start"].offset, [0, -50]) @@ -65,7 +65,7 @@ def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None: p.ports["B"] = Port((10, 0), pi / 2, ptype="wire") # Path both "forward" (South) to y=-20 - p.mpath(["A", "B"], ccw=None, ymin=-20) + p.straight(["A", "B"], ymin=-20) assert_equal(p.ports["A"].offset, [0, -20]) assert_equal(p.ports["B"].offset, [10, -20]) @@ -73,7 +73,7 @@ def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None: def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None: p, tool, lib = pather_setup # Fluent API test - p.at("start").path(ccw=None, length=10).path(ccw=True, length=10) + 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 @@ -93,14 +93,14 @@ def test_pather_dead_ports() -> None: p.set_dead() # Path with negative length (impossible for PathTool, would normally raise BuildError) - p.path("in", None, -10) + 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.path("in", None, 20) + p.straight("in", 20) # 10 + (-20) = -10 assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10) diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index 5d2c8c3..cbeef3a 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -24,7 +24,7 @@ def rpather_setup() -> tuple[RenderPather, PathTool, Library]: def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: rp, tool, lib = rpather_setup # Plan two segments - rp.at("start").path(ccw=None, length=10).path(ccw=None, length=10) + rp.at("start").straight(10).straight(10) # Before rendering, no shapes in pattern assert not rp.pattern.has_shapes() @@ -49,7 +49,7 @@ def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: rp, tool, lib = rpather_setup # Plan straight then bend - rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10) + rp.at("start").straight(10).cw(10) rp.render() path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) @@ -69,9 +69,9 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar rp, tool1, lib = rpather_setup tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") - rp.at("start").path(ccw=None, length=10) + rp.at("start").straight(10) rp.retool(tool2, keys=["start"]) - rp.at("start").path(ccw=None, length=10) + rp.at("start").straight(10) rp.render() # Different tools should cause different batches/shapes @@ -86,7 +86,7 @@ def test_renderpather_dead_ports() -> None: rp.set_dead() # Impossible path - rp.path("in", None, -10) + 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) From 69ac25078c5dee71138b8f14393601fe4e110247 Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 6 Mar 2026 22:58:32 -0800 Subject: [PATCH 046/157] [Pather/RenderPather/Tool/PortPather] Add U-bends --- masque/builder/pather.py | 66 ++++++++++++++++++++++++++++++++++ masque/builder/pather_mixin.py | 30 ++++++++++++++++ masque/builder/renderpather.py | 60 +++++++++++++++++++++++++++++++ masque/builder/tools.py | 39 ++++++++++++++++++++ 4 files changed, 195 insertions(+) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 68dfcd9..e34aa00 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -255,6 +255,72 @@ class Pather(Builder, PatherMixin): return s + def _pathU( + self, + portspec: str, + jog: float, + *, + length: float = 0, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Create a U-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of traveling exactly `length` distance along the axis of `portspec` and returning to + the same orientation with an offset `jog`. + + 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: Extra distance to travel along the port's axis. Default 0. + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + """ + if self._dead: + logger.warning('Skipping geometry for _pathU() since device is dead') + + tool_port_names = ('A', 'B') + + tool = self.tools.get(portspec, self.tools[None]) + in_ptype = self.pattern[portspec].ptype + try: + tree = tool.pathU(jog, length=length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + except (BuildError, NotImplementedError): + if self._uturn_fallback(tool, portspec, jog, length, in_ptype, plug_into, **kwargs): + return self + + if not self._dead: + raise + logger.warning("Tool pathU failed for dead pather. Using dummy extension.") + # Fallback for dead pather: manually update the port instead of plugging + port = self.pattern[portspec] + port_rot = port.rotation + assert port_rot is not None + out_port = Port((length, jog), rotation=0, ptype=in_ptype) + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + self.pattern.ports[portspec] = out_port + self._log_port_update(portspec) + if plug_into is not None: + self.plugged({portspec: plug_into}) + return self + + tname = self.library << tree + if plug_into is not None: + output = {plug_into: tool_port_names[1]} + else: + output = {} + self.plug(tname, {portspec: tool_port_names[0], **output}) + return self + + def plugged(self, connections: dict[str, str]) -> Self: + PortList.plugged(self, connections) + return self + def _path( self, portspec: str, diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 0f5fa92..cb957b1 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -214,6 +214,19 @@ class PatherMixin(PortList, metaclass=ABCMeta): self._pathS(port, l_actual, offset, **bounds) return self + def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds) -> Self: + """ 180-degree turn extension. """ + if isinstance(portspec, str): + portspec = [portspec] + + for port in portspec: + l_actual = length + if l_actual is None: + # TODO: use bounds to determine length? + l_actual = 0 + self._pathU(port, offset, length=l_actual, **bounds) + return self + def trace_into( self, portspec_src: str, @@ -376,6 +389,18 @@ class PatherMixin(PortList, metaclass=ABCMeta): ) -> Self: pass + @abstractmethod + def _pathU( + self, + portspec: str, + jog: float, + *, + length: float = 0, + plug_into: str | None = None, + **kwargs, + ) -> Self: + pass + def path(self, *args, **kwargs) -> Self: import warnings warnings.warn("path() is deprecated; use trace(), straight(), or bend() instead", DeprecationWarning, stacklevel=2) @@ -386,6 +411,11 @@ class PatherMixin(PortList, metaclass=ABCMeta): warnings.warn("pathS() is deprecated; use jog() instead", DeprecationWarning, stacklevel=2) return self._pathS(*args, **kwargs) + def pathU(self, *args, **kwargs) -> Self: + import warnings + warnings.warn("pathU() is deprecated; use uturn() instead", DeprecationWarning, stacklevel=2) + return self._pathU(*args, **kwargs) + @abstractmethod def plug( self, diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 93fd2e3..2f78f8e 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -377,6 +377,66 @@ class RenderPather(PatherMixin): PortList.plugged(self, connections) return self + def _pathU( + self, + portspec: str, + jog: float, + *, + length: float = 0, + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Plan a U-shaped "wire"/"waveguide" extending from the port `portspec`, with the aim + of traveling exactly `length` distance and returning to the same orientation + with an offset `jog`. + + 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: Extra distance to travel along the port's axis. Default 0. + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + """ + if self._dead: + logger.warning('Skipping geometry for _pathU() since device is dead') + + port = self.pattern[portspec] + in_ptype = port.ptype + port_rot = port.rotation + assert port_rot is not None + + tool = self.tools.get(portspec, self.tools[None]) + + try: + out_port, data = tool.planU(jog, length=length, in_ptype=in_ptype, **kwargs) + except (BuildError, NotImplementedError): + if self._uturn_fallback(tool, portspec, jog, length, in_ptype, plug_into, **kwargs): + return self + + if not self._dead: + raise + logger.warning("Tool planning failed for dead pather. Using dummy extension.") + out_port = Port((length, jog), rotation=0, ptype=in_ptype) + data = None + + if out_port is not None: + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + if not self._dead: + step = RenderStep('U', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) + self.pattern.ports[portspec] = out_port.copy() + self._log_port_update(portspec) + + if plug_into is not None: + self.plugged({portspec: plug_into}) + return self + def _path( self, portspec: str, diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 3e6616a..8617541 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -260,6 +260,45 @@ class Tool: """ raise NotImplementedError(f'planS() not implemented for {type(self)}') + def pathU( + self, + jog: float, + *, + 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'pathU() not implemented for {type(self)}') + def planU( self, jog: float, From 9d6fb985d8fbc131e3ccbcd7d3522cccb916deac Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 6 Mar 2026 23:09:59 -0800 Subject: [PATCH 047/157] [Pather/RenderPather/PathTool] Add updated pather tests --- masque/test/test_pather_api.py | 183 +++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 masque/test/test_pather_api.py diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py new file mode 100644 index 0000000..0803b77 --- /dev/null +++ b/masque/test/test_pather_api.py @@ -0,0 +1,183 @@ +import numpy +from numpy import pi +from masque import Pather, RenderPather, Library, Port +from masque.builder.tools import PathTool + +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 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 numpy.isclose(rp.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)) From 8a45c6d8d6e3650eae1de0afae362a9b8df60bf1 Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 6 Mar 2026 23:31:44 -0800 Subject: [PATCH 048/157] [tutorial] update pather and renderpather tutorials to new syntax --- examples/tutorial/pather.py | 45 +++++++++++++++---------------- examples/tutorial/renderpather.py | 22 +++++++-------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index a9d9af9..f7bbdb2 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -106,7 +106,9 @@ def map_layer(layer: layer_t) -> layer_t: 'M2': (20, 0), 'V1': (30, 0), } - return layer_mapping.get(layer, layer) + if isinstance(layer, str): + return layer_mapping.get(layer, layer) + return layer def prepare_tools() -> tuple[Library, Tool, Tool]: @@ -224,19 +226,17 @@ 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.path('VCC', ccw=False, length=6_000) + pather.cw('VCC', 6_000) - # Now path VCC to x=0. This time, don't include any bend (ccw=None). + # Now path VCC to x=0. This time, don't include any bend. # Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction. - pather.path_to('VCC', ccw=None, x=0) + pather.straight('VCC', x=0) # Path GND forward by 5um, turning clockwise 90 degrees. - # 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) + pather.cw('GND', 5_000) # This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend. - pather.path_to('GND', None, x=pather['VCC'].offset[0]) + pather.straight('GND', 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. @@ -244,7 +244,7 @@ def main() -> None: # 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 +252,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.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000) + pather.ccw(['GND', 'VCC'], 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,35 +262,34 @@ 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.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200) + pather.ccw(['GND', 'VCC'], 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 mpath() call - # that applies to VCC, and for teh second call, that applies to GND; the relative lengths of the + # 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 # segments depend on their starting positions and their ordering within the bundle. - pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) - pather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500) + pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200) + pather.cw(['GND', 'VCC'], 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. We could `del pather.tools['GND']` to force it to use the default. + # explicitly assigned a tool. pather.retool(M2_tool) # Now path both ports to x=-28_000. - # 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 + # With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is # equivalent. - pather.mpath(['GND', 'VCC'], None, xmin=-28_000) + pather.straight(['GND', 'VCC'], 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.path_to('VCC', None, -50_000, out_ptype='m1wire') + pather.straight('VCC', x=-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.path_to('GND', None, -40_000) - pather.path_to('GND', None, -50_000) + with pather.toolctx(M2_tool, keys='GND'): + pather.straight('GND', x=-40_000) + pather.straight('GND', x=-50_000) # Save the pather's pattern into our library library['Pather_and_AutoTool'] = pather.pattern diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py index ecc8bc8..7b75f5d 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -48,28 +48,28 @@ def main() -> None: rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) # ...and start routing the signals. - 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'].x) + rpather.cw('VCC', 6_000) + rpather.straight('VCC', x=0) + rpather.cw('GND', 5_000) + rpather.straight('GND', x=rpather.pattern['VCC'].x) # `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.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000) + rpather.retool(M1_ptool, keys='GND') + rpather.ccw(['GND', 'VCC'], 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.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) + 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) # And again when VCC goes back up to M2. rpather.plug('v1_via', {'VCC': 'bottom'}) rpather.retool(M2_ptool) - rpather.mpath(['GND', 'VCC'], None, xmin=-28_000) + rpather.straight(['GND', 'VCC'], 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. @@ -80,7 +80,7 @@ def main() -> None: # 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.path_to('VCC', None, -50_000 + via_size) + rpather.straight('VCC', x=-50_000 + via_size) rpather.plug('v1_via', {'VCC': 'top'}) # Render the path we defined From 10708157300b6d73b82097d3aa3c07a759d47e32 Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 6 Mar 2026 23:51:56 -0800 Subject: [PATCH 049/157] [AutoTool] add U-bend --- masque/builder/tools.py | 119 +++++++++++++++++++++++++++++++++ masque/test/test_pather_api.py | 49 +++++++++++++- 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 8617541..c964fe7 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -264,6 +264,7 @@ class Tool: self, jog: float, *, + length: float = 0, in_ptype: str | None = None, out_ptype: str | None = None, port_names: tuple[str, str] = ('A', 'B'), @@ -597,6 +598,14 @@ 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 """ + ldata0: 'AutoTool.LData' + ldata1: 'AutoTool.LData' + straight2: 'AutoTool.Straight' + l2_length: float + straights: list[Straight] """ List of straight-generators to choose from, in order of priority """ @@ -942,6 +951,113 @@ class AutoTool(Tool, metaclass=ABCMeta): 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 + kwargs_no_out = kwargs | {'out_ptype': None} + + # Use loops to find a combination of straights and bends that fits + success = False + for _straight1 in self.straights: + for _bend1 in self.bends: + for straight2 in self.straights: + for _bend2 in self.bends: + try: + # We need to know R1 and R2 to calculate the lengths. + # Use large dummy lengths to probe the bends. + p_probe1, _ = self.planL(ccw, 1e9, in_ptype=in_ptype, **kwargs_no_out) + R1 = abs(Port((0, 0), 0).measure_travel(p_probe1)[0][1]) + p_probe2, _ = self.planL(ccw, 1e9, in_ptype=p_probe1.ptype, out_ptype=out_ptype, **kwargs) + R2 = abs(Port((0, 0), 0).measure_travel(p_probe2)[0][1]) + + # Final x will be: x = l1_straight + R1 - R2 + # We want final x = length. So: l1_straight = length - R1 + R2 + # Total length for planL(0) is l1 = l1_straight + R1 = length + R2 + l1 = length + R2 + + # Final y will be: y = R1 + l2_straight + R2 = abs(jog) + # So: l2_straight = abs(jog) - R1 - R2 + l2_length = abs(jog) - R1 - R2 + + if l2_length >= straight2.length_range[0] and l2_length < straight2.length_range[1]: + p0, ldata0 = self.planL(ccw, l1, in_ptype=in_ptype, **kwargs_no_out) + # For the second bend, we want straight length = 0. + # Total length for planL(1) is l2 = 0 + R2 = R2. + p1, ldata1 = self.planL(ccw, R2, in_ptype=p0.ptype, out_ptype=out_ptype, **kwargs) + + success = True + break + except BuildError: + continue + if success: + break + if success: + break + if success: + break + + if not success: + raise BuildError(f"AutoTool failed to plan U-turn with {jog=}, {length=}") + + data = self.UData(ldata0, ldata1, straight2, l2_length) + # Final port is at (length, jog) rot pi relative to input + out_port = Port((length, jog), rotation=pi, ptype=p1.ptype) + return out_port, data + + 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 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 pathU( + 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 + 'pathU') + 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) + return tree + def render( self, batch: Sequence[RenderStep], @@ -959,6 +1075,8 @@ class AutoTool(Tool, metaclass=ABCMeta): self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) elif step.opcode == 'S': 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) return tree @@ -1086,6 +1204,7 @@ class PathTool(Tool, metaclass=ABCMeta): 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': diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index 0803b77..5937ea1 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -1,6 +1,6 @@ import numpy from numpy import pi -from masque import Pather, RenderPather, Library, Port +from masque import Pather, RenderPather, Library, Pattern, Port from masque.builder.tools import PathTool def test_pather_trace_basic() -> None: @@ -25,6 +25,7 @@ def test_pather_trace_basic() -> None: # (-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: @@ -151,8 +152,54 @@ def test_renderpather_uturn_fallback() -> None: 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) From 0189756df4b6bc444419d06d2cb3ce8892ea9f1c Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 00:03:07 -0800 Subject: [PATCH 050/157] [Pather/RenderPather] Add U-bend to trace_into --- masque/builder/pather_mixin.py | 4 +++- masque/test/test_pather_api.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index cb957b1..239f5a9 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -322,7 +322,9 @@ class PatherMixin(PortList, metaclass=ABCMeta): (travel, jog), _ = port_src.measure_travel(port_dst) self.jog(portspec_src, -jog, -travel, **dst_args) elif numpy.isclose(angle, 0): - raise BuildError("Don't know how to route a U-bend yet (TODO)!") + # U-bend + (travel, jog), _ = port_src.measure_travel(port_dst) + self.uturn(portspec_src, -jog, length=-travel, **dst_args) else: raise BuildError(f"Don't know how to route ports with relative angle {angle}") diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index 5937ea1..9ac1b78 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -228,3 +228,15 @@ def test_pather_trace_into() -> None: 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) From 84f37195ad1abe6cece44cf710e443fe7c057e65 Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 00:33:18 -0800 Subject: [PATCH 051/157] [Pather / RenderPather / Tool] Rename path->trace in more locations --- masque/builder/pather.py | 64 ++++++++++++++++------------------ masque/builder/pather_mixin.py | 29 ++++++++------- masque/builder/renderpather.py | 17 +++++---- masque/builder/tools.py | 38 ++++++++++---------- 4 files changed, 71 insertions(+), 77 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index e34aa00..df00cc0 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -28,7 +28,7 @@ class Pather(Builder, PatherMixin): single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns. `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. + out to `Tool.traceL` 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. @@ -63,7 +63,10 @@ class Pather(Builder, PatherMixin): Examples: Adding to a pattern ============================= - - `pather.path('my_port', ccw=True, distance)` creates a "wire" for which the output + - `pather.straight('my_port', distance)` creates a straight wire with a length + of `distance` and `plug`s it into `'my_port'`. + + - `pather.bend('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. @@ -72,22 +75,15 @@ class Pather(Builder, PatherMixin): 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 + - `pather.trace_to('my_port', ccw=False, x=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. + output is not specified, so `position` takes the form of a single coordinate. - - `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.trace(['A', 'B', 'C'], ccw=True, spacing=spacing, xmax=position)` acts + 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. - `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`, @@ -141,8 +137,8 @@ class Pather(Builder, PatherMixin): 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. + to generate waveguide or wire segments when `trace`/`trace_to`/etc. + are called. Relies on `Tool.traceL` implementations. name: If specified, `library[name]` is set to `self.pattern`. """ self._dead = False @@ -213,7 +209,7 @@ class Pather(Builder, PatherMixin): 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`). + or waveguides (via `trace`/`trace_to`). 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 @@ -255,7 +251,7 @@ class Pather(Builder, PatherMixin): return s - def _pathU( + def _traceU( self, portspec: str, jog: float, @@ -281,21 +277,21 @@ class Pather(Builder, PatherMixin): self """ if self._dead: - logger.warning('Skipping geometry for _pathU() since device is dead') + logger.warning('Skipping geometry for _traceU() since device is dead') tool_port_names = ('A', 'B') tool = self.tools.get(portspec, self.tools[None]) in_ptype = self.pattern[portspec].ptype try: - tree = tool.pathU(jog, length=length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + tree = tool.traceU(jog, length=length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) except (BuildError, NotImplementedError): if self._uturn_fallback(tool, portspec, jog, length, in_ptype, plug_into, **kwargs): return self if not self._dead: raise - logger.warning("Tool pathU failed for dead pather. Using dummy extension.") + logger.warning("Tool traceU failed for dead pather. Using dummy extension.") # Fallback for dead pather: manually update the port instead of plugging port = self.pattern[portspec] port_rot = port.rotation @@ -321,7 +317,7 @@ class Pather(Builder, PatherMixin): PortList.plugged(self, connections) return self - def _path( + def _traceL( self, portspec: str, ccw: SupportsBool | None, @@ -362,18 +358,18 @@ class Pather(Builder, PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.warning('Skipping geometry for _path() since device is dead') + logger.warning('Skipping geometry for _traceL() since device is dead') tool_port_names = ('A', 'B') tool = self.tools.get(portspec, self.tools[None]) in_ptype = self.pattern[portspec].ptype try: - tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + tree = tool.traceL(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) except (BuildError, NotImplementedError): if not self._dead: raise - logger.warning("Tool path failed for dead pather. Using dummy extension.") + logger.warning("Tool traceL failed for dead pather. Using dummy extension.") # Fallback for dead pather: manually update the port instead of plugging port = self.pattern[portspec] port_rot = port.rotation @@ -401,7 +397,7 @@ class Pather(Builder, PatherMixin): self.plug(tname, {portspec: tool_port_names[0], **output}) return self - def _pathS( + def _traceS( self, portspec: str, length: float, @@ -440,29 +436,29 @@ class Pather(Builder, PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.warning('Skipping geometry for _pathS() since device is dead') + logger.warning('Skipping geometry for _traceS() since device is dead') tool_port_names = ('A', 'B') tool = self.tools.get(portspec, self.tools[None]) in_ptype = self.pattern[portspec].ptype try: - tree = tool.pathS(length, jog, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + tree = tool.traceS(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} try: - t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) + t_tree0 = tool.traceL( 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_tree1 = tool.traceL(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]]) 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) + self._traceL(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self._traceL(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) except (BuildError, NotImplementedError): if not self._dead: raise @@ -475,7 +471,7 @@ class Pather(Builder, PatherMixin): # Fall through to dummy extension below if self._dead: - logger.warning("Tool pathS failed for dead pather. Using dummy extension.") + logger.warning("Tool traceS failed for dead pather. Using dummy extension.") # Fallback for dead pather: manually update the port instead of plugging port = self.pattern[portspec] port_rot = port.rotation diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index 239f5a9..eeae7ad 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -74,14 +74,14 @@ class PatherMixin(PortList, metaclass=ABCMeta): raise BuildError('length is only allowed with a single port in trace()') if bounds: raise BuildError('length and bounds are mutually exclusive in trace()') - return self._path(portspec[0], ccw, length) + return self._traceL(portspec[0], ccw, length) if 'each' in bounds: each = bounds.pop('each') if bounds: raise BuildError('each and other bounds are mutually exclusive in trace()') for port in portspec: - self._path(port, ccw, each) + self._traceL(port, ccw, each) return self # Bundle routing (formerly mpath logic) @@ -106,7 +106,7 @@ class PatherMixin(PortList, metaclass=ABCMeta): extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) for port_name, ext_len in extensions.items(): - self._path(port_name, ccw, ext_len, **bounds) + self._traceL(port_name, ccw, ext_len, **bounds) return self def trace_to( @@ -180,7 +180,7 @@ class PatherMixin(PortList, metaclass=ABCMeta): if 'length' in bounds and bounds['length'] is not None: raise BuildError('Cannot specify both relative length and absolute position in trace_to()') - return self._path(port_name, ccw, length, **other_bounds) + return self._traceL(port_name, ccw, length, **other_bounds) # Bundle routing (delegate to trace which handles ell) return self.trace(portspec, ccw, spacing=spacing, **bounds) @@ -211,7 +211,7 @@ class PatherMixin(PortList, metaclass=ABCMeta): if l_actual is None: # TODO: use bounds to determine length? raise BuildError('jog() currently requires a length') - self._pathS(port, l_actual, offset, **bounds) + self._traceS(port, l_actual, offset, **bounds) return self def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds) -> Self: @@ -224,7 +224,7 @@ class PatherMixin(PortList, metaclass=ABCMeta): if l_actual is None: # TODO: use bounds to determine length? l_actual = 0 - self._pathU(port, offset, length=l_actual, **bounds) + self._traceU(port, offset, length=l_actual, **bounds) return self def trace_into( @@ -360,15 +360,15 @@ class PatherMixin(PortList, metaclass=ABCMeta): L2 = abs(jog) - R kwargs_plug = kwargs | {'plug_into': plug_into} - self._path(portspec, ccw, L1, **kwargs_no_out) - self._path(portspec, ccw, L2, **kwargs_plug) + self._traceL(portspec, ccw, L1, **kwargs_no_out) + self._traceL(portspec, ccw, L2, **kwargs_plug) except (BuildError, NotImplementedError): return False else: return True @abstractmethod - def _path( + def _traceL( self, portspec: str, ccw: SupportsBool | None, @@ -380,7 +380,7 @@ class PatherMixin(PortList, metaclass=ABCMeta): pass @abstractmethod - def _pathS( + def _traceS( self, portspec: str, length: float, @@ -392,7 +392,7 @@ class PatherMixin(PortList, metaclass=ABCMeta): pass @abstractmethod - def _pathU( + def _traceU( self, portspec: str, jog: float, @@ -406,17 +406,17 @@ class PatherMixin(PortList, metaclass=ABCMeta): def path(self, *args, **kwargs) -> Self: import warnings warnings.warn("path() is deprecated; use trace(), straight(), or bend() instead", DeprecationWarning, stacklevel=2) - return self._path(*args, **kwargs) + return self._traceL(*args, **kwargs) def pathS(self, *args, **kwargs) -> Self: import warnings warnings.warn("pathS() is deprecated; use jog() instead", DeprecationWarning, stacklevel=2) - return self._pathS(*args, **kwargs) + return self._traceS(*args, **kwargs) def pathU(self, *args, **kwargs) -> Self: import warnings warnings.warn("pathU() is deprecated; use uturn() instead", DeprecationWarning, stacklevel=2) - return self._pathU(*args, **kwargs) + return self._traceU(*args, **kwargs) @abstractmethod def plug( @@ -762,4 +762,3 @@ class PortPather: del self.pather.pattern.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 index 2f78f8e..0e16c7b 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -377,7 +377,7 @@ class RenderPather(PatherMixin): PortList.plugged(self, connections) return self - def _pathU( + def _traceU( self, portspec: str, jog: float, @@ -403,7 +403,7 @@ class RenderPather(PatherMixin): self """ if self._dead: - logger.warning('Skipping geometry for _pathU() since device is dead') + logger.warning('Skipping geometry for _traceU() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -437,7 +437,7 @@ class RenderPather(PatherMixin): self.plugged({portspec: plug_into}) return self - def _path( + def _traceL( self, portspec: str, ccw: SupportsBool | None, @@ -480,7 +480,7 @@ class RenderPather(PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.warning('Skipping geometry for _path() since device is dead') + logger.warning('Skipping geometry for _traceL() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -520,7 +520,7 @@ class RenderPather(PatherMixin): return self - def _pathS( + def _traceS( self, portspec: str, length: float, @@ -564,7 +564,7 @@ class RenderPather(PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.warning('Skipping geometry for _pathS() since device is dead') + logger.warning('Skipping geometry for _traceS() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -587,8 +587,8 @@ class RenderPather(PatherMixin): 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) + self._traceL(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self._traceL(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) except (BuildError, NotImplementedError): if not self._dead: raise @@ -803,4 +803,3 @@ class RenderPather(PatherMixin): 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 c964fe7..f8a72fb 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -93,7 +93,7 @@ class Tool: unimplemented (e.g. in cases where they don't make sense or the required components are impractical or unavailable). """ - def path( + def traceL( self, ccw: SupportsBool | None, length: float, @@ -136,9 +136,9 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - raise NotImplementedError(f'path() not implemented for {type(self)}') + raise NotImplementedError(f'traceL() not implemented for {type(self)}') - def pathS( + def traceS( self, length: float, jog: float, @@ -178,7 +178,7 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - raise NotImplementedError(f'path() not implemented for {type(self)}') + raise NotImplementedError(f'traceS() not implemented for {type(self)}') def planL( self, @@ -260,7 +260,7 @@ class Tool: """ raise NotImplementedError(f'planS() not implemented for {type(self)}') - def pathU( + def traceU( self, jog: float, *, @@ -298,7 +298,7 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - raise NotImplementedError(f'pathU() not implemented for {type(self)}') + raise NotImplementedError(f'traceU() not implemented for {type(self)}') def planU( self, @@ -467,7 +467,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored) return tree - def path( + def traceL( self, ccw: SupportsBool | None, length: float, @@ -484,7 +484,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): out_ptype = out_ptype, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') 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 @@ -497,7 +497,7 @@ class SimpleTool(Tool, metaclass=ABCMeta): **kwargs, ) -> ILibrary: - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') pat.add_port_pair(names=(port_names[0], port_names[1])) for step in batch: @@ -774,7 +774,7 @@ class AutoTool(Tool, metaclass=ABCMeta): pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) return tree - def path( + def traceL( self, ccw: SupportsBool | None, length: float, @@ -791,7 +791,7 @@ class AutoTool(Tool, metaclass=ABCMeta): out_ptype = out_ptype, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') 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 @@ -930,7 +930,7 @@ class AutoTool(Tool, metaclass=ABCMeta): pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) return tree - def pathS( + def traceS( self, length: float, jog: float, @@ -946,7 +946,7 @@ class AutoTool(Tool, metaclass=ABCMeta): in_ptype = in_ptype, out_ptype = out_ptype, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) return tree @@ -1036,7 +1036,7 @@ class AutoTool(Tool, metaclass=ABCMeta): self._renderL(data.ldata1, tree, port_names, gen_kwargs) return tree - def pathU( + def traceU( self, jog: float, *, @@ -1053,7 +1053,7 @@ class AutoTool(Tool, metaclass=ABCMeta): out_ptype = out_ptype, **kwargs, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathU') + 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) return tree @@ -1066,7 +1066,7 @@ class AutoTool(Tool, metaclass=ABCMeta): **kwargs, ) -> ILibrary: - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') pat.add_port_pair(names=(port_names[0], port_names[1])) for step in batch: @@ -1107,7 +1107,7 @@ class PathTool(Tool, metaclass=ABCMeta): # self.width = width # self.ptype: str - def path( + def traceL( self, ccw: SupportsBool | None, length: float, @@ -1124,7 +1124,7 @@ class PathTool(Tool, metaclass=ABCMeta): out_ptype=out_ptype, ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)]) if ccw is None: @@ -1219,7 +1219,7 @@ class PathTool(Tool, metaclass=ABCMeta): # If the path ends in a bend, we need to add the final vertex path_vertices.append(local_batch[-1].end_port.offset) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') pat.path(layer=self.layer, width=self.width, vertices=path_vertices) pat.ports = { port_names[0]: local_batch[0].start_port.copy().rotate(pi), From 32681edb477850f6a2dfccd1bc04bbf3ed0f7ab7 Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 00:48:22 -0800 Subject: [PATCH 052/157] [tests] fixup tests related to pather api changes --- masque/test/test_advanced_routing.py | 29 +++++++++------------------- masque/test/test_autotool.py | 4 ++-- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py index 5afcc21..7033159 100644 --- a/masque/test/test_advanced_routing.py +++ b/masque/test/test_advanced_routing.py @@ -18,23 +18,23 @@ def advanced_pather() -> tuple[Pather, PathTool, Library]: def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = advanced_pather + 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.path_into("src", "dst") + p.trace_into("src", "dst") assert "src" not in p.ports assert "dst" not in p.ports - # Pather.path adds a Reference to the generated pattern + # 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 + 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). @@ -43,7 +43,7 @@ def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> No # Forward for South is North (+y). p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire") - p.path_into("src", "dst") + p.trace_into("src", "dst") assert "src" not in p.ports assert "dst" not in p.ports @@ -52,35 +52,24 @@ def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> No def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = advanced_pather + 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.path_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - - -def test_path_from(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.at("dst").path_from("src") + 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, _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.path_into("src", "dst", thru="other") + p.trace_into("src", "dst", thru="other") assert "src" in p.ports assert_equal(p.ports["src"].offset, [10, 10]) diff --git a/masque/test/test_autotool.py b/masque/test/test_autotool.py index 5686193..e03994e 100644 --- a/masque/test/test_autotool.py +++ b/masque/test/test_autotool.py @@ -54,11 +54,11 @@ def autotool_setup() -> tuple[Pather, AutoTool, Library]: def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: - p, tool, lib = autotool_setup + 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.path("start", ccw=None, length=10) + p.straight("start", 10) # Start at (0,0) rot pi (facing West). # Forward (+pi relative to port) is East (+x). From 26e6a44559ed6c4394d88783657055811aecdc8e Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 00:48:50 -0800 Subject: [PATCH 053/157] [readme] clean up todos --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 62b13bb..6ebc5ab 100644 --- a/README.md +++ b/README.md @@ -277,12 +277,6 @@ 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 From 46555dbd4dd750e0933d8045cc4022fdfba0faa9 Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 10:22:54 -0800 Subject: [PATCH 054/157] [pattern.visualize] add options for file output and port visualization --- masque/pattern.py | 55 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index 4c72fdf..6d73c4b 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1049,6 +1049,8 @@ 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 @@ -1063,6 +1065,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): 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 + filename: If provided, save the figure to this file instead of showing it. + ports: If True, annotate the plot with arrows representing the ports """ # TODO: add text labels to visualize() try: @@ -1080,7 +1084,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if not overdraw: figure = pyplot.figure() - pyplot.axis('equal') else: figure = pyplot.gcf() @@ -1088,15 +1091,34 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): polygons = [] for shape in chain.from_iterable(self.shapes.values()): - polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()] + for ss in shape.to_polygons(): + polygons.append(offset + ss.offset + ss.vertices) mpl_poly_collection = matplotlib.collections.PolyCollection( polygons, - facecolors=fill_color, - edgecolors=line_color, + facecolors = fill_color, + edgecolors = line_color, ) axes.add_collection(mpl_poly_collection) - pyplot.axis('equal') + + if ports: + for port_name, port in self.ports.items(): + if port.rotation is not None: + p1 = offset + port.offset + angle = port.rotation + size = 1.0 # Arrow size based on bounds or fixed + dx = size * numpy.cos(angle) + dy = size * numpy.sin(angle) + p2 = p1 + numpy.array([dx, dy]) + + 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: @@ -1107,17 +1129,24 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): 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, + library = library, + offset = offset, + overdraw = True, + line_color = line_color, + fill_color = fill_color, + filename = filename, ) + axes.autoscale_view() + axes.set_aspect('equal') + if not overdraw: - pyplot.xlabel('x') - pyplot.ylabel('y') - pyplot.show() + axes.set_xlabel('x') + axes.set_ylabel('y') + if filename: + figure.savefig(filename) + else: + figure.show() # @overload # def place( From ad4e9af59d17bcb222be75ce3a24990662dc8913 Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 10:32:22 -0800 Subject: [PATCH 055/157] [svg] add annotate_ports arg --- masque/file/svg.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/masque/file/svg.py b/masque/file/svg.py index 859c074..7c77fd9 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -20,6 +20,7 @@ def writefile( 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 @@ -44,6 +45,8 @@ 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] @@ -79,6 +82,27 @@ 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 From 2513c7f8fd3e336e6676c93fd6b28d25ab70455e Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 10:32:41 -0800 Subject: [PATCH 056/157] [pattern.visualize] cleanup --- masque/pattern.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index 6d73c4b..12cae7f 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1106,10 +1106,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if port.rotation is not None: p1 = offset + port.offset angle = port.rotation - size = 1.0 # Arrow size based on bounds or fixed - dx = size * numpy.cos(angle) - dy = size * numpy.sin(angle) - p2 = p1 + numpy.array([dx, dy]) + size = 1.0 # arrow size + p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) axes.annotate( port_name, From bb7f4906af0612c7d84aee58e59d44f62ab01d9d Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 23:35:47 -0800 Subject: [PATCH 057/157] [ILibrary] add .resolve() --- masque/library.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/masque/library.py b/masque/library.py index 3e1c65c..1240b48 100644 --- a/masque/library.py +++ b/masque/library.py @@ -682,6 +682,33 @@ 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, From a89f07c441588d5e6003e539811e72e63a958c04 Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 23:36:14 -0800 Subject: [PATCH 058/157] [Port] add describe() for logging --- masque/ports.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/masque/ports.py b/masque/ports.py index 04ab061..4be208d 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -143,6 +143,33 @@ 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 "any" + + 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]})" + + return f"pos=({self.x:g}, {self.y:g}), rot={deg:g}{cardinal}{travel_dir}" + def __repr__(self) -> str: if self.rotation is None: rot = 'any' @@ -210,11 +237,11 @@ class PortList(metaclass=ABCMeta): def _log_port_update(self, name: str) -> None: """ Log the current state of the named port """ - port_logger.info("Port %s: %s", name, self.ports[name]) + 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.info("Port %s: removed", name) + port_logger.debug("Port %s: removed", name) def _log_bulk_update(self, label: str) -> None: """ Log all current ports at DEBUG level """ From 338c123fb18fc7adccbfa4d758b294aad8adff12 Mon Sep 17 00:00:00 2001 From: jan Date: Sat, 7 Mar 2026 23:57:12 -0800 Subject: [PATCH 059/157] [pattern] speed up visualize() --- masque/pattern.py | 178 +++++++++++++++++++++++++--------- masque/test/test_visualize.py | 55 +++++++++++ 2 files changed, 186 insertions(+), 47 deletions(-) create mode 100644 masque/test/test_visualize.py diff --git a/masque/pattern.py b/masque/pattern.py index 12cae7f..9e15910 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1061,12 +1061,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): klayout or a different GDS viewer! Args: - 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 + 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 + ports: If True, annotate the plot with arrows representing the ports. """ # TODO: add text labels to visualize() try: @@ -1080,8 +1081,113 @@ 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') - offset = numpy.asarray(offset, dtype=float) + # 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]]] = {} + 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 + 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() else: @@ -1089,50 +1195,28 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): axes = figure.gca() - polygons = [] - for shape in chain.from_iterable(self.shapes.values()): - for ss in shape.to_polygons(): - polygons.append(offset + ss.offset + ss.vertices) - - mpl_poly_collection = matplotlib.collections.PolyCollection( - polygons, - facecolors = fill_color, - edgecolors = line_color, - ) - axes.add_collection(mpl_poly_collection) + if all_polygons: + mpl_poly_collection = matplotlib.collections.PolyCollection( + all_polygons, + facecolors = fill_color, + edgecolors = line_color, + ) + axes.add_collection(mpl_poly_collection) if ports: - for port_name, port in self.ports.items(): - if port.rotation is not None: - p1 = offset + port.offset - angle = port.rotation - size = 1.0 # arrow size - p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) + 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)]) - 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, - filename = filename, + axes.annotate( + port_name, + xy = tuple(p1), + xytext = tuple(p2), + arrowprops = dict(arrowstyle="->", color='g', linewidth=1), + color = 'g', + fontsize = 8, ) axes.autoscale_view() diff --git a/masque/test/test_visualize.py b/masque/test/test_visualize.py new file mode 100644 index 0000000..4dab435 --- /dev/null +++ b/masque/test/test_visualize.py @@ -0,0 +1,55 @@ +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) From c3581243c8ae5d37679589f3a0f249ad5e9df725 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 00:18:47 -0800 Subject: [PATCH 060/157] [Pather] Major pathing rework / Consolidate RenderPather, Pather, and Builder --- masque/builder/__init__.py | 13 +- masque/builder/builder.py | 461 ---------- masque/builder/logging.py | 120 +++ masque/builder/pather.py | 1214 +++++++++++++++++-------- masque/builder/pather_mixin.py | 764 ---------------- masque/builder/renderpather.py | 805 ---------------- masque/builder/tools.py | 417 ++++++--- masque/test/test_autotool_refactor.py | 167 ++++ masque/test/test_renderpather.py | 22 + 9 files changed, 1409 insertions(+), 2574 deletions(-) delete mode 100644 masque/builder/builder.py create mode 100644 masque/builder/logging.py delete mode 100644 masque/builder/pather_mixin.py delete mode 100644 masque/builder/renderpather.py create mode 100644 masque/test/test_autotool_refactor.py diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 2fd00a4..65958c1 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,7 +1,9 @@ -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 .pather import ( + Pather as Pather, + PortPather as PortPather, + Builder as Builder, + RenderPather as RenderPather, +) from .utils import ell as ell from .tools import ( Tool as Tool, @@ -9,4 +11,5 @@ 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 deleted file mode 100644 index 40ea109..0000000 --- a/masque/builder/builder.py +++ /dev/null @@ -1,461 +0,0 @@ -""" -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 - - Note: - If the builder is 'dead' (see `set_dead()`), geometry generation is - skipped but ports are still updated. - - 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.warning('Skipping geometry for plug() since device is dead') - - 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, - skip_geometry = self._dead, - ) - 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 - - Note: - If the builder is 'dead' (see `set_dead()`), geometry generation is - skipped but ports are still updated. - - 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.warning('Skipping geometry for place() since device is dead') - - 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, - skip_geometry = self._dead, - ) - 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: - """ - Suppresses geometry generation for subsequent `plug()` and `place()` - operations. Unlike a complete skip, the port state is still tracked - and updated, using 'best-effort' fallbacks for impossible transforms. - This allows a layout script to execute through problematic sections - while maintaining valid port references for downstream code. - - This is meant for debugging: - ``` - dev.plug(a, ...) - dev.set_dead() # added for debug purposes - dev.plug(b, ...) # usually raises an error, but now uses fallback port update - dev.plug(c, ...) # also updated via fallback - 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 new file mode 100644 index 0000000..78a566e --- /dev/null +++ b/masque/builder/logging.py @@ -0,0 +1,120 @@ +""" +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 df00cc0..df9a3f9 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -1,122 +1,106 @@ """ -Manual wire/waveguide routing (`Pather`) +Unified Pattern assembly and routing (`Pather`) """ -from typing import Self -from collections.abc import Sequence, Mapping, MutableMapping +from typing import Self, Literal, Any, overload +from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence 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 -from ..error import BuildError +from ..library import ILibrary, TreeView +from ..error import BuildError, PortError from ..ports import PortList, Port +from ..abstract import Abstract from ..utils import SupportsBool -from .tools import Tool -from .pather_mixin import PatherMixin -from .builder import Builder +from .tools import Tool, RenderStep +from .utils import ell +from .logging import logged_op, PatherLogger logger = logging.getLogger(__name__) -class Pather(Builder, PatherMixin): +class Pather(PortList): """ - 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. + 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. - `Pather` is mostly concerned with calculating how long each wire should be. It calls - out to `Tool.traceL` 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. + The `Pather` holds context in the form of a `Library`, its underlying + pattern, and a set of `Tool`s for generating routing segments. + 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 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, tools=my_tool)` makes an empty pattern with no ports. + The default routing tool for all ports is set to `my_tool`. + - `Pather(library, name='mypat')` makes an empty pattern and adds it to + `library` under the name `'mypat'`. Examples: Adding to a pattern ============================= - - `pather.straight('my_port', distance)` creates a straight wire with a length - of `distance` and `plug`s it into `'my_port'`. + - `pather.plug(subdevice, {'A': 'C'})` instantiates `subdevice` and + connects port 'A' of the current pattern to port 'C' of `subdevice`. - - `pather.bend('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. - - 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.trace_to('my_port', ccw=False, x=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. - - - `pather.trace(['A', 'B', 'C'], ccw=True, spacing=spacing, xmax=position)` acts - 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. - - - `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. + - `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. """ - __slots__ = ('tools',) + __slots__ = ( + 'pattern', 'library', 'tools', 'paths', + '_dead', '_logger', '_auto_render' + ) + + pattern: Pattern + """ Layout of this device """ library: ILibrary - """ - Library from which existing patterns should be referenced, and to which - new ones should be added - """ + """ Library from which patterns should be referenced """ tools: dict[str | None, 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`. + Tool objects used to dynamically generate new routing segments. + 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, @@ -125,40 +109,34 @@ class Pather(Builder, PatherMixin): 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, ) -> 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 `trace`/`trace_to`/etc. - are called. Relies on `Tool.traceL` implementations. + 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. 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. """ self._dead = False + self._logger = PatherLogger(debug=debug) + self._auto_render = auto_render self.library = library - if pattern is not None: - self.pattern = pattern - else: - self.pattern = Pattern() + self.pattern = pattern if pattern is not None else Pattern() + self.paths = defaultdict(list) 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): @@ -166,29 +144,516 @@ class Pather(Builder, PatherMixin): else: self.tools = dict(tools) - @classmethod - def from_builder( - cls: type['Pather'], - builder: Builder, + 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() + + 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, *, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - ) -> 'Pather': - """ - Construct a `Pather` by adding tools to a `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}) - Args: - builder: Builder to turn into a Pather - tools: Tools for the `Pather` + @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 - Returns: - A new Pather object, using `builder.library` and `builder.pattern`. - """ - new = Pather(library=builder.library, tools=tools, pattern=builder.pattern) - return new + @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 + 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 + + self._traceL(portspec, ccw0, L1, **(kwargs | {'out_ptype': None})) + self._traceL(portspec, not ccw0, L2, **(kwargs | {'plug_into': 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 + self._traceL(portspec, ccw, L1, **(kwargs | {'out_ptype': None})) + self._traceL(portspec, ccw, L2, **(kwargs | {'plug_into': plug_into})) + 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: + 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: type['Pather'], + cls, source: PortList | Mapping[str, Port] | str, *, library: ILibrary | None = None, @@ -197,298 +662,259 @@ class Pather(Builder, PatherMixin): out_prefix: str = '', port_map: dict[str, str] | Sequence[str] | None = None, name: str | None = None, - ) -> '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 `trace`/`trace_to`). - 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. - """ + **kwargs: Any, + ) -> Self: 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`') - + raise BuildError('No library provided') 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 = Pather(library=library, pattern=pat, name=name, tools=tools) - return new + return cls(library=library, pattern=pat, name=name, tools=tools, **kwargs) - def __repr__(self) -> str: - s = f'' - return s - - - def _traceU( - self, - portspec: str, - jog: float, - *, - length: float = 0, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Create a U-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance along the axis of `portspec` and returning to - the same orientation with an offset `jog`. - - 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: Extra distance to travel along the port's axis. Default 0. - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - """ - if self._dead: - logger.warning('Skipping geometry for _traceU() since device is dead') - - tool_port_names = ('A', 'B') - - tool = self.tools.get(portspec, self.tools[None]) - in_ptype = self.pattern[portspec].ptype - try: - tree = tool.traceU(jog, length=length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - except (BuildError, NotImplementedError): - if self._uturn_fallback(tool, portspec, jog, length, in_ptype, plug_into, **kwargs): - return self - - if not self._dead: - raise - logger.warning("Tool traceU failed for dead pather. Using dummy extension.") - # Fallback for dead pather: manually update the port instead of plugging - port = self.pattern[portspec] - port_rot = port.rotation - assert port_rot is not None - out_port = Port((length, jog), rotation=0, ptype=in_ptype) - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - self.pattern.ports[portspec] = out_port - self._log_port_update(portspec) - if plug_into is not None: - self.plugged({portspec: plug_into}) - return self - - tname = self.library << tree - if plug_into is not None: - output = {plug_into: tool_port_names[1]} + 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 else: - output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) + for k in keys: + self.tools[k] = tool return self - def plugged(self, connections: dict[str, str]) -> Self: - PortList.plugged(self, connections) - return self - - def _traceL( - 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 - - Note: - If the builder is 'dead', this operation will still attempt to update - the target port's location. If the pathing tool fails (e.g. due to an - impossible length), a dummy linear extension is used to maintain port - consistency for downstream operations. - - 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.warning('Skipping geometry for _traceL() since device is dead') - - tool_port_names = ('A', 'B') - - tool = self.tools.get(portspec, self.tools[None]) - in_ptype = self.pattern[portspec].ptype + @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} try: - tree = tool.traceL(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - except (BuildError, NotImplementedError): - if not self._dead: - raise - logger.warning("Tool traceL failed for dead pather. Using dummy extension.") - # Fallback for dead pather: manually update the port instead of plugging - port = self.pattern[portspec] - port_rot = port.rotation - assert port_rot is not None - if ccw is None: - out_rot = pi - elif bool(ccw): - out_rot = -pi / 2 - else: - out_rot = pi / 2 - out_port = Port((length, 0), 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 - self._log_port_update(portspec) - if plug_into is not None: - self.plugged({portspec: plug_into}) - return self + 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 - tname = self.library << tree - if plug_into is not None: - output = {plug_into: tool_port_names[1]} - else: - output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) + def flatten(self) -> Self: + self.pattern.flatten(self.library) return self - def _traceS( + 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.') + else: + 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.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, - portspec: str, - length: float, - jog: float, + library: ILibrary, *, - 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. + 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, + ) - The wire will travel `length` distance along the port's axis, and exactly `jog` - distance in the perpendicular direction. The output port will have an orientation - identical to the input port. - Args: - portspec: The name of the port into which the wire will be plugged. - length: The total distance from input to output, along the input's axis only. - jog: Total distance perpendicular to the direction of travel. Positive values - are to the left of the direction of travel. - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Note: - If the builder is 'dead', this operation will still attempt to update - the target port's location. If the pathing tool fails (e.g. due to an - impossible length), a dummy linear extension is used to maintain port - consistency for downstream operations. - - 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.warning('Skipping geometry for _traceS() since device is dead') - - tool_port_names = ('A', 'B') - - tool = self.tools.get(portspec, self.tools[None]) - in_ptype = self.pattern[portspec].ptype - try: - tree = tool.traceS(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} - try: - t_tree0 = tool.traceL( 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.traceL(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]]) - - kwargs_plug = kwargs | {'plug_into': plug_into} - self._traceL(portspec, ccw0, length - abs(jog1), **kwargs_no_out) - self._traceL(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) - except (BuildError, NotImplementedError): - if not self._dead: - raise - # Fall through to dummy extension below - else: - return self - except BuildError: - if not self._dead: - raise - # Fall through to dummy extension below - - if self._dead: - logger.warning("Tool traceS failed for dead pather. Using dummy extension.") - # Fallback for dead pather: manually update the port instead of plugging - port = self.pattern[portspec] - port_rot = port.rotation - assert port_rot is not None - out_port = Port((length, jog), rotation=pi, ptype=in_ptype) - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - self.pattern.ports[portspec] = out_port - self._log_port_update(portspec) - if plug_into is not None: - self.plugged({portspec: plug_into}) - return self - - tname = self.library << tree - if plug_into is not None: - output = {plug_into: tool_port_names[1]} - else: - output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) - return self +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 deleted file mode 100644 index eeae7ad..0000000 --- a/masque/builder/pather_mixin.py +++ /dev/null @@ -1,764 +0,0 @@ -from typing import Self, overload -from collections.abc import Sequence, Iterator, Iterable, Mapping -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. - """ - - def trace( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - **bounds, - ) -> Self: - """ - Create a "wire"/"waveguide" extending from the port(s) `portspec`. - - Args: - portspec: The name(s) of the port(s) into which the wire(s) 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. - Length is only allowed with a single port. - spacing: Center-to-center distance between output ports along the input port's axis. - Only used when routing multiple ports with a bend. - bounds: Boundary constraints for the trace. - - each: results in each port being extended by `each` distance. - - emin, emax, pmin, pmax, xmin, xmax, ymin, ymax: bundle routing via `ell()`. - - set_rotation: explicit rotation for ports without one. - - Returns: - self - """ - if isinstance(portspec, str): - portspec = [portspec] - - if length is not None: - if len(portspec) > 1: - raise BuildError('length is only allowed with a single port in trace()') - if bounds: - raise BuildError('length and bounds are mutually exclusive in trace()') - return self._traceL(portspec[0], ccw, length) - - if 'each' in bounds: - each = bounds.pop('each') - if bounds: - raise BuildError('each and other bounds are mutually exclusive in trace()') - for port in portspec: - self._traceL(port, ccw, each) - return self - - # Bundle routing (formerly mpath logic) - bound_types = set() - if 'bound_type' in bounds: - bound_types.add(bounds.pop('bound_type')) - bound = bounds.pop('bound') - for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): - if bt in bounds: - bound_types.add(bt) - bound = bounds.pop(bt) - - if not bound_types: - raise BuildError('No bound type specified for trace()') - if len(bound_types) > 1: - raise BuildError(f'Too many bound types specified: {bound_types}') - bound_type = tuple(bound_types)[0] - - ports = self.pattern[tuple(portspec)] - set_rotation = bounds.pop('set_rotation', None) - - extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) - - for port_name, ext_len in extensions.items(): - self._traceL(port_name, ccw, ext_len, **bounds) - return self - - def trace_to( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - **bounds, - ) -> Self: - """ - Create a "wire"/"waveguide" extending from the port(s) `portspec` to a target position. - - Args: - portspec: The name(s) of the port(s) into which the wire(s) 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. - spacing: Center-to-center distance between output ports along the input port's axis. - Only used when routing multiple ports with a bend. - bounds: Boundary constraints for the target position. - - p, x, y, pos, position: Coordinate of the target position. Error if used with multiple ports. - - pmin, pmax, xmin, xmax, ymin, ymax, emin, emax: bundle routing via `ell()`. - - Returns: - self - """ - if isinstance(portspec, str): - portspec = [portspec] - - pos_bounds = {kk: bounds[kk] for kk in ('p', 'x', 'y', 'pos', 'position') if kk in bounds} - if pos_bounds: - if len(portspec) > 1: - raise BuildError(f'{tuple(pos_bounds.keys())} bounds are only allowed with a single port in trace_to()') - if len(pos_bounds) > 1: - raise BuildError(f'Too many position bounds: {tuple(pos_bounds.keys())}') - - k, v = next(iter(pos_bounds.items())) - k = 'position' if k in ('p', 'pos') else k - - # Logic hoisted from path_to() - port_name = portspec[0] - port = self.pattern[port_name] - if port.rotation is None: - raise PortError(f'Port {port_name} has no rotation and cannot be used for trace_to()') - - if not numpy.isclose(port.rotation % (pi / 2), 0): - raise BuildError('trace_to was asked to route from non-manhattan port') - - is_horizontal = numpy.isclose(port.rotation % pi, 0) - if is_horizontal: - if k == 'y': - raise BuildError('Asked to trace to y-coordinate, but port is horizontal') - target = v - else: - if k == 'x': - raise BuildError('Asked to trace to x-coordinate, but port is vertical') - target = v - - x0, y0 = port.offset - if is_horizontal: - if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(target - x0): - raise BuildError(f'trace_to routing to behind source port: x0={x0:g} to {target:g}') - length = numpy.abs(target - x0) - else: - if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(target - y0): - raise BuildError(f'trace_to routing to behind source port: y0={y0:g} to {target:g}') - length = numpy.abs(target - y0) - - other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in pos_bounds and bk != 'length'} - if 'length' in bounds and bounds['length'] is not None: - raise BuildError('Cannot specify both relative length and absolute position in trace_to()') - - return self._traceL(port_name, ccw, length, **other_bounds) - - # Bundle routing (delegate to trace which handles ell) - return self.trace(portspec, ccw, spacing=spacing, **bounds) - - def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - """ Straight extension. Replaces `path(ccw=None)` and `path_to(ccw=None)` """ - return self.trace_to(portspec, None, length=length, **bounds) - - def bend(self, portspec: str | Sequence[str], ccw: SupportsBool, length: float | None = None, **bounds) -> Self: - """ Bend extension. Replaces `path(ccw=True/False)` and `path_to(ccw=True/False)` """ - return self.trace_to(portspec, ccw, length=length, **bounds) - - def ccw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - """ Counter-clockwise bend extension. """ - return self.bend(portspec, True, length, **bounds) - - def cw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - """ Clockwise bend extension. """ - return self.bend(portspec, False, length, **bounds) - - def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds) -> Self: - """ Jog extension. Replaces `pathS`. """ - if isinstance(portspec, str): - portspec = [portspec] - - for port in portspec: - l_actual = length - if l_actual is None: - # TODO: use bounds to determine length? - raise BuildError('jog() currently requires a length') - self._traceS(port, l_actual, offset, **bounds) - return self - - def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds) -> Self: - """ 180-degree turn extension. """ - if isinstance(portspec, str): - portspec = [portspec] - - for port in portspec: - l_actual = length - if l_actual is None: - # TODO: use bounds to determine length? - l_actual = 0 - self._traceU(port, offset, length=l_actual, **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, - ) -> 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). - - 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 renamed 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 an invalid port config is encountered: - - Non-manhattan ports - - U-bend - - Destination too close to (or behind) source - """ - if self._dead: - logger.error('Skipping trace_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 trace_into()') - if port_dst.rotation is None: - raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for trace_into()') - - if not numpy.isclose(port_src.rotation % (pi / 2), 0): - raise BuildError('trace_into was asked to route from non-manhattan port') - if not numpy.isclose(port_dst.rotation % (pi / 2), 0): - raise BuildError('trace_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.trace_to(portspec_src, angle > pi, x=xd, **src_args) - self.trace_to(portspec_src, None, y=yd, **dst_args) - elif dst_is_horizontal and not src_is_horizontal: - # single bend should suffice - self.trace_to(portspec_src, angle > pi, y=yd, **src_args) - self.trace_to(portspec_src, None, x=xd, **dst_args) - elif numpy.isclose(angle, pi): - if src_is_horizontal and ys == yd: - # straight connector - self.trace_to(portspec_src, None, x=xd, **dst_args) - elif not src_is_horizontal and xs == xd: - # straight connector - self.trace_to(portspec_src, None, y=yd, **dst_args) - else: - # S-bend - (travel, jog), _ = port_src.measure_travel(port_dst) - self.jog(portspec_src, -jog, -travel, **dst_args) - elif numpy.isclose(angle, 0): - # U-bend - (travel, jog), _ = port_src.measure_travel(port_dst) - self.uturn(portspec_src, -jog, length=-travel, **dst_args) - 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 _uturn_fallback( - self, - tool: Tool, - portspec: str, - jog: float, - length: float, - in_ptype: str | None, - plug_into: str | None, - **kwargs, - ) -> bool: - """ - Attempt to perform a U-turn using two L-bends. - Returns True if successful, False if planL failed. - """ - # Fall back to drawing two L-bends - ccw = jog > 0 - kwargs_no_out = kwargs | {'out_ptype': None} - try: - # First, find R by planning a minimal L-bend. - # Use a large length to ensure we don't hit tool-specific minimum length constraints. - dummy_port, _ = tool.planL(ccw, 1e9, in_ptype=in_ptype, **kwargs_no_out) - R = abs(dummy_port.y) - - L1 = length + R - L2 = abs(jog) - R - - kwargs_plug = kwargs | {'plug_into': plug_into} - self._traceL(portspec, ccw, L1, **kwargs_no_out) - self._traceL(portspec, ccw, L2, **kwargs_plug) - except (BuildError, NotImplementedError): - return False - else: - return True - - @abstractmethod - def _traceL( - self, - portspec: str, - ccw: SupportsBool | None, - length: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - @abstractmethod - def _traceS( - self, - portspec: str, - length: float, - jog: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - @abstractmethod - def _traceU( - self, - portspec: str, - jog: float, - *, - length: float = 0, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - def path(self, *args, **kwargs) -> Self: - import warnings - warnings.warn("path() is deprecated; use trace(), straight(), or bend() instead", DeprecationWarning, stacklevel=2) - return self._traceL(*args, **kwargs) - - def pathS(self, *args, **kwargs) -> Self: - import warnings - warnings.warn("pathS() is deprecated; use jog() instead", DeprecationWarning, stacklevel=2) - return self._traceS(*args, **kwargs) - - def pathU(self, *args, **kwargs) -> Self: - import warnings - warnings.warn("pathU() is deprecated; use uturn() instead", DeprecationWarning, stacklevel=2) - return self._traceU(*args, **kwargs) - - @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 - - @abstractmethod - def plugged(self, connections: dict[str, str]) -> Self: - """ Manual connection acknowledgment. """ - 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: - """ - [DEPRECATED] use trace_to() instead. - """ - import warnings - warnings.warn("path_to() is deprecated; use trace_to() instead", DeprecationWarning, stacklevel=2) - - bounds = {kk: vv for kk, vv in (('position', position), ('x', x), ('y', y)) if vv is not None} - return self.trace_to(portspec, ccw, plug_into=plug_into, **bounds, **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: - """ - [DEPRECATED] use trace_into() instead. - """ - import warnings - warnings.warn("path_into() is deprecated; use trace_into() instead", DeprecationWarning, stacklevel=2) - - return self.trace_into( - portspec_src, - portspec_dst, - out_ptype = out_ptype, - plug_destination = plug_destination, - thru = thru, - **kwargs, - ) - - def mpath( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - set_rotation: float | None = None, - **kwargs, - ) -> Self: - """ - [DEPRECATED] use trace() or trace_to() instead. - """ - import warnings - warnings.warn("mpath() is deprecated; use trace() or trace_to() instead", DeprecationWarning, stacklevel=2) - - return self.trace(portspec, ccw, spacing=spacing, set_rotation=set_rotation, **kwargs) - - # 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 trace(self, ccw: SupportsBool | None, length: float | None = None, **kwargs) -> Self: - self.pather.trace(self.ports, ccw, length, **kwargs) - return self - - def trace_to(self, ccw: SupportsBool | None, **kwargs) -> Self: - self.pather.trace_to(self.ports, ccw, **kwargs) - return self - - def straight(self, length: float | None = None, **kwargs) -> Self: - self.pather.straight(self.ports, length, **kwargs) - return self - - def bend(self, ccw: SupportsBool, length: float | None = None, **kwargs) -> Self: - self.pather.bend(self.ports, ccw, length, **kwargs) - return self - - def ccw(self, length: float | None = None, **kwargs) -> Self: - self.pather.ccw(self.ports, length, **kwargs) - return self - - def cw(self, length: float | None = None, **kwargs) -> Self: - self.pather.cw(self.ports, length, **kwargs) - return self - - def jog(self, offset: float, length: float | None = None, **kwargs) -> Self: - self.pather.jog(self.ports, offset, length, **kwargs) - return self - - def uturn(self, offset: float, length: float | None = None, **kwargs) -> Self: - self.pather.uturn(self.ports, offset, length, **kwargs) - 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, - *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 | 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.') - else: - 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.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. Replaces `rename_to`. """ - 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. Replaces `add_ports`. """ - 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. Replaces `drop_port`. """ - 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). Replaces `save_copy`. """ - name_map: Mapping[str, str] - if isinstance(name, str): - if len(self.ports) > 1: - raise BuildError('Use a mapping to mark >1 port') - name_map = {self.ports[0]: name} - else: - name_map = name - 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. Replaces `into_copy`. """ - name_map: Mapping[str, str] - if isinstance(name, str): - if len(self.ports) > 1: - raise BuildError('Use a mapping to fork >1 port') - name_map = {self.ports[0]: name} - else: - name_map = name - 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. Replaces `delete(None)`. """ - 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 diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py deleted file mode 100644 index 0e16c7b..0000000 --- a/masque/builder/renderpather.py +++ /dev/null @@ -1,805 +0,0 @@ -""" -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 - -import numpy -from numpy import pi -from numpy.typing import ArrayLike, NDArray - -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 `trace`/`trace_to` - 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 __del__(self) -> None: - if any(pp for pp in self.paths): - logger.warning('RenderPather had unrendered paths', stack_info=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, - ) -> 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 `trace`/`trace_to` - 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 `trace`/`trace_to`). - 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.warning('Skipping geometry for plug() since device is dead') - - 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] - - if not self._dead: - # 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, - skip_geometry = self._dead, - ) - - 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.warning('Skipping geometry for place() since device is dead') - - 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] - - if not self._dead: - 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, - skip_geometry = self._dead, - ) - - return self - - def plugged( - self, - connections: dict[str, str], - ) -> Self: - if not self._dead: - 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 _traceU( - self, - portspec: str, - jog: float, - *, - length: float = 0, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Plan a U-shaped "wire"/"waveguide" extending from the port `portspec`, with the aim - of traveling exactly `length` distance and returning to the same orientation - with an offset `jog`. - - 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: Extra distance to travel along the port's axis. Default 0. - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - """ - if self._dead: - logger.warning('Skipping geometry for _traceU() since device is dead') - - port = self.pattern[portspec] - in_ptype = port.ptype - port_rot = port.rotation - assert port_rot is not None - - tool = self.tools.get(portspec, self.tools[None]) - - try: - out_port, data = tool.planU(jog, length=length, in_ptype=in_ptype, **kwargs) - except (BuildError, NotImplementedError): - if self._uturn_fallback(tool, portspec, jog, length, in_ptype, plug_into, **kwargs): - return self - - if not self._dead: - raise - logger.warning("Tool planning failed for dead pather. Using dummy extension.") - out_port = Port((length, jog), rotation=0, ptype=in_ptype) - data = None - - if out_port is not None: - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - if not self._dead: - step = RenderStep('U', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - self.pattern.ports[portspec] = out_port.copy() - self._log_port_update(portspec) - - if plug_into is not None: - self.plugged({portspec: plug_into}) - return self - - def _traceL( - 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 - - Note: - If the builder is 'dead', this operation will still attempt to update - the target port's location. If the pathing tool fails (e.g. due to an - impossible length), a dummy linear extension is used to maintain port - consistency for downstream operations. - - 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.warning('Skipping geometry for _traceL() since device is dead') - - 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 - try: - out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) - except (BuildError, NotImplementedError): - if not self._dead: - raise - logger.warning("Tool planning failed for dead pather. Using dummy extension.") - if ccw is None: - out_rot = pi - elif bool(ccw): - out_rot = -pi / 2 - else: - out_rot = pi / 2 - out_port = Port((length, 0), rotation=out_rot, ptype=in_ptype) - data = None - - # Update port - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - - if not self._dead: - step = RenderStep('L', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - - self.pattern.ports[portspec] = out_port.copy() - self._log_port_update(portspec) - - if plug_into is not None: - self.plugged({portspec: plug_into}) - - return self - - def _traceS( - 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 - - Note: - If the builder is 'dead', this operation will still attempt to update - the target port's location. If the pathing tool fails (e.g. due to an - impossible length), a dummy linear extension is used to maintain port - consistency for downstream operations. - - 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.warning('Skipping geometry for _traceS() since device is dead') - - 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}) - try: - t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail w/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._traceL(portspec, ccw0, length - abs(jog1), **kwargs_no_out) - self._traceL(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) - except (BuildError, NotImplementedError): - if not self._dead: - raise - # Fall through to dummy extension below - else: - return self - except BuildError: - if not self._dead: - raise - # Fall through to dummy extension below - - if self._dead: - logger.warning("Tool planning failed for dead pather. Using dummy extension.") - out_port = Port((length, jog), rotation=pi, ptype=in_ptype) - data = None - - if out_port is not None: - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - if not self._dead: - step = RenderStep('S', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - self.pattern.ports[portspec] = out_port.copy() - self._log_port_update(portspec) - - 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 `trace`/`trace_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 - # Tools render in local space (first port at 0,0, rotation 0). - tree = batch[0].tool.render(batch, port_names=tool_port_names) - - actual_in, actual_out = tool_port_names - name = lib << tree - - # To plug the segment at its intended location, we create a - # 'stationary' port in our temporary pattern that matches - # the batch's planned start. - if portspec in pat.ports: - del pat.ports[portspec] - - stationary_port = batch[0].start_port.copy() - pat.ports[portspec] = stationary_port - - if append: - # pat.plug() translates and rotates the tool's local output to the start port. - pat.plug(lib[name], {portspec: actual_in}, append=append) - del lib[name] - else: - pat.plug(lib.abstract(name), {portspec: actual_in}, append=append) - - # Rename output back to portspec for the next batch. - if portspec not in pat.ports and actual_out in pat.ports: - pat.rename_ports({actual_out: portspec}, overwrite=True) - - for portspec, steps in self.paths.items(): - if not steps: - continue - - batch: list[RenderStep] = [] - # Initialize continuity check with the start of the entire path. - prev_end = steps[0].start_port - - for step in steps: - appendable_op = step.opcode in ('L', 'S', 'U') - same_tool = batch and step.tool == batch[0].tool - - # Check continuity with tolerance - offsets_match = numpy.allclose(step.start_port.offset, prev_end.offset) - rotations_match = (step.start_port.rotation is None and prev_end.rotation is None) or ( - step.start_port.rotation is not None and prev_end.rotation is not None and - numpy.isclose(step.start_port.rotation, prev_end.rotation) - ) - continuous = offsets_match and rotations_match - - # If we can't continue a batch, render it - if batch and (not appendable_op or not same_tool or not continuous): - 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: - if portspec in pat.ports: - del pat.ports[portspec] - # Plugged ports should be tracked - if step.opcode == 'P' and portspec in pat.ports: - del pat.ports[portspec] - - prev_end = step.end_port - - #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 - """ - offset_arr: NDArray[numpy.float64] = 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 - - 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 - """ - pivot_arr: NDArray[numpy.float64] = 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 - - 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) - for steps in self.paths.values(): - for i, step in enumerate(steps): - steps[i] = step.mirrored(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 f8a72fb..4a82b34 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -4,7 +4,7 @@ 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 +from collections.abc import Sequence, Callable, Iterator from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? from dataclasses import dataclass @@ -47,6 +47,18 @@ 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. @@ -85,13 +97,20 @@ class RenderStep: ) +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( self, @@ -220,7 +239,17 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - raise NotImplementedError(f'planL() not implemented for {type(self)}') + # 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) def planS( self, @@ -258,7 +287,17 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - raise NotImplementedError(f'planS() not implemented for {type(self)}') + # 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, @@ -323,7 +362,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. @@ -336,14 +375,26 @@ class Tool: Raises: BuildError if an impossible or unsupported geometry is requested. """ - raise NotImplementedError(f'planU() not implemented for {type(self)}') + # 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) def render( self, batch: Sequence[RenderStep], *, - port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused) - **kwargs, # noqa: ARG002 (unused) + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, ) -> ILibrary: """ Render the provided `batch` of `RenderStep`s into geometry, returning a tree @@ -357,7 +408,48 @@ class Tool: kwargs: Custom tool-specific parameters. """ assert not batch or batch[0].tool == self - raise NotImplementedError(f'render() not implemented for {type(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 abstract_tuple_t = tuple[Abstract, str, str] @@ -574,6 +666,19 @@ 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 """ @@ -586,6 +691,65 @@ 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 """ @@ -600,11 +764,77 @@ class AutoTool(Tool, metaclass=ABCMeta): @dataclass(frozen=True, slots=True) class UData: - """ Data for planU """ + """ 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 """ @@ -675,69 +905,23 @@ class AutoTool(Tool, metaclass=ABCMeta): **kwargs, ) -> tuple[Port, LData]: - success = False - # If ccw is None, we don't need a bend, but we still loop to reuse the logic. - # We'll use a dummy loop if bends is empty and ccw is None. - bends = cast('list[AutoTool.Bend | None]', self.bends) - if ccw is None and not bends: - bends += [None] - - # Initialize these to avoid UnboundLocalError in the error message - bend_dxy, bend_angle = numpy.zeros(2), pi - itrans_dxy = numpy.zeros(2) - otrans_dxy = numpy.zeros(2) - btrans_dxy = numpy.zeros(2) - - 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 + 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_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, bend_angle) + out_port = Port((length, plan.overhead_y), rotation=plan.bend_angle, ptype=plan.out_ptype) + return out_port, data - 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) - - 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: - assert bend 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 + raise BuildError(f'Failed to find a valid L-path configuration for {length=:,g}, {ccw=}, {in_ptype=}, {out_ptype=}') def _renderL( self, @@ -856,26 +1040,8 @@ class AutoTool(Tool, metaclass=ABCMeta): break if not success: - 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 + ccw0 = jog > 0 + return self._solve_double_l(length, jog, ccw0, not ccw0, in_ptype, out_ptype, **kwargs) if out_transition is not None: out_ptype_actual = out_transition.their_port.ptype @@ -948,7 +1114,10 @@ class AutoTool(Tool, metaclass=ABCMeta): ) tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS') pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + 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( @@ -961,55 +1130,7 @@ class AutoTool(Tool, metaclass=ABCMeta): **kwargs, ) -> tuple[Port, UData]: ccw = jog > 0 - kwargs_no_out = kwargs | {'out_ptype': None} - - # Use loops to find a combination of straights and bends that fits - success = False - for _straight1 in self.straights: - for _bend1 in self.bends: - for straight2 in self.straights: - for _bend2 in self.bends: - try: - # We need to know R1 and R2 to calculate the lengths. - # Use large dummy lengths to probe the bends. - p_probe1, _ = self.planL(ccw, 1e9, in_ptype=in_ptype, **kwargs_no_out) - R1 = abs(Port((0, 0), 0).measure_travel(p_probe1)[0][1]) - p_probe2, _ = self.planL(ccw, 1e9, in_ptype=p_probe1.ptype, out_ptype=out_ptype, **kwargs) - R2 = abs(Port((0, 0), 0).measure_travel(p_probe2)[0][1]) - - # Final x will be: x = l1_straight + R1 - R2 - # We want final x = length. So: l1_straight = length - R1 + R2 - # Total length for planL(0) is l1 = l1_straight + R1 = length + R2 - l1 = length + R2 - - # Final y will be: y = R1 + l2_straight + R2 = abs(jog) - # So: l2_straight = abs(jog) - R1 - R2 - l2_length = abs(jog) - R1 - R2 - - if l2_length >= straight2.length_range[0] and l2_length < straight2.length_range[1]: - p0, ldata0 = self.planL(ccw, l1, in_ptype=in_ptype, **kwargs_no_out) - # For the second bend, we want straight length = 0. - # Total length for planL(1) is l2 = 0 + R2 = R2. - p1, ldata1 = self.planL(ccw, R2, in_ptype=p0.ptype, out_ptype=out_ptype, **kwargs) - - success = True - break - except BuildError: - continue - if success: - break - if success: - break - if success: - break - - if not success: - raise BuildError(f"AutoTool failed to plan U-turn with {jog=}, {length=}") - - data = self.UData(ldata0, ldata1, straight2, l2_length) - # Final port is at (length, jog) rot pi relative to input - out_port = Port((length, jog), rotation=pi, ptype=p1.ptype) - return out_port, data + return self._solve_double_l(length, jog, ccw, ccw, in_ptype, out_ptype, **kwargs) def _renderU( self, @@ -1022,6 +1143,8 @@ class AutoTool(Tool, metaclass=ABCMeta): # 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} @@ -1053,6 +1176,7 @@ class AutoTool(Tool, metaclass=ABCMeta): 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) @@ -1074,7 +1198,10 @@ 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': - self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + 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) return tree diff --git a/masque/test/test_autotool_refactor.py b/masque/test/test_autotool_refactor.py new file mode 100644 index 0000000..c95c082 --- /dev/null +++ b/masque/test/test_autotool_refactor.py @@ -0,0 +1,167 @@ +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 +from masque.builder.renderpather import 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 + if clockwise: + # (0,0) rot 0 to (R, -R) rot pi/2 + 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.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_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_renderpather.py b/masque/test/test_renderpather.py index cbeef3a..ee04671 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -97,3 +97,25 @@ def test_renderpather_dead_ports() -> None: # 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) From 74fa3774503b3881f1c0e02d38351f061c8f6e4c Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 09:47:50 -0700 Subject: [PATCH 061/157] [repetition.Arbitrary] fix equality check --- masque/repetition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/repetition.py b/masque/repetition.py index a774f7e..20ec0a3 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -350,7 +350,7 @@ class Arbitrary(Repetition): return (f'') def __eq__(self, other: Any) -> bool: - if not type(other) is not type(self): + if type(other) is not type(self): return False return numpy.array_equal(self.displacements, other.displacements) From 3bf7efc4045d817cf9574d71a74c76be8ff6efd9 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 09:48:03 -0700 Subject: [PATCH 062/157] [Polygon] fix offset error messages --- masque/shapes/polygon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 1fd6350..dc5afa1 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -96,11 +96,11 @@ class Polygon(Shape): @offset.setter def offset(self, val: ArrayLike) -> None: if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') + raise PatternError('Polygon offset is forced to (0, 0)') def set_offset(self, val: ArrayLike) -> Self: if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') + raise PatternError('Polygon offset is forced to (0, 0)') return self def translate(self, offset: ArrayLike) -> Self: From 049864ddc7e2055733d34a618efe9b4691ce137f Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 10:10:46 -0700 Subject: [PATCH 063/157] [manhattanize_fast] Improve handling of grids smaller than the shape --- masque/shapes/shape.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index ee6f19a..efc0859 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -139,22 +139,24 @@ 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 # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape + # Find x-index bounds for the line gxi_range = numpy.digitize([v[0], v_next[0]], gx) - gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1) - gxi_max = numpy.max(gxi_range).clip(0, len(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))) - 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_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 - if err_xmin >= 0.5: - gxi_min += 1 - if err_xmax >= 0.5: - 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 abs(dv[0]) < 1e-20: # Vertical line, don't calculate slope - xi = [gxi_min, gxi_max - 1] + xi = [gxi_min, max(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]) From ef5c8c715eb92ee4f1873af637f04b2e13851fd2 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 10:12:43 -0700 Subject: [PATCH 064/157] [Pather] add auto_render_append arg --- masque/builder/pather.py | 8 ++++++-- masque/test/test_advanced_routing.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index df9a3f9..3478c32 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -63,7 +63,7 @@ class Pather(PortList): """ __slots__ = ( 'pattern', 'library', 'tools', 'paths', - '_dead', '_logger', '_auto_render' + '_dead', '_logger', '_auto_render', '_auto_render_append' ) pattern: Pattern @@ -111,6 +111,7 @@ class Pather(PortList): name: str | None = None, debug: bool = False, auto_render: bool = False, + auto_render_append: bool = True, ) -> None: """ Args: @@ -122,10 +123,13 @@ class Pather(PortList): 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) @@ -321,7 +325,7 @@ class Pather(PortList): self.plugged({portspec: plug_into}) if self._auto_render: - self.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. """ diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py index 7033159..91d6c3b 100644 --- a/masque/test/test_advanced_routing.py +++ b/masque/test/test_advanced_routing.py @@ -13,7 +13,7 @@ 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) + p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False) return p, tool, lib From d0b48e6bfcf59fcff985d18693c746b52fc3fa87 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 10:15:09 -0700 Subject: [PATCH 065/157] [tests] fix some tests --- masque/test/test_autotool_refactor.py | 9 ++++++--- masque/test/test_shape_advanced.py | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/masque/test/test_autotool_refactor.py b/masque/test/test_autotool_refactor.py index c95c082..677ddd6 100644 --- a/masque/test/test_autotool_refactor.py +++ b/masque/test/test_autotool_refactor.py @@ -6,8 +6,7 @@ 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 -from masque.builder.renderpather import RenderPather +from masque.builder.pather import Pather, RenderPather def make_straight(length, width=2, ptype="wire"): pat = Pattern() @@ -18,13 +17,17 @@ def make_straight(length, width=2, ptype="wire"): def make_bend(R, width=2, ptype="wire", clockwise=True): pat = Pattern() - # 90 degree arc + # 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 diff --git a/masque/test/test_shape_advanced.py b/masque/test/test_shape_advanced.py index f6ba69d..4e38e55 100644 --- a/masque/test/test_shape_advanced.py +++ b/masque/test/test_shape_advanced.py @@ -10,6 +10,7 @@ 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") @@ -28,6 +29,8 @@ def test_text_to_polygons() -> None: # 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) From 5cb608734d45f971ec3c59a6ba92f4275844bb18 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 10:17:52 -0700 Subject: [PATCH 066/157] [poly_contains_points] consistently return boolean arrays --- masque/utils/vertices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 7d3791c..c4a7cb6 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -62,7 +62,7 @@ def poly_contains_points( vertices: ArrayLike, points: ArrayLike, include_boundary: bool = True, - ) -> NDArray[numpy.int_]: + ) -> NDArray[numpy.bool_]: """ Tests whether the provided points are inside the implicitly closed polygon described by the provided list of vertices. @@ -81,7 +81,7 @@ def poly_contains_points( vertices = numpy.asarray(vertices, dtype=float) if points.size == 0: - return numpy.zeros(0, dtype=numpy.int8) + return numpy.zeros(0, dtype=bool) min_bounds = numpy.min(vertices, axis=0)[None, :] max_bounds = numpy.max(vertices, axis=0)[None, :] From 19dafad15779c85fd6c96f0a4ae0aa00b07b73cc Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 10:24:25 -0700 Subject: [PATCH 067/157] [remove_duplicate_vertices] improve handling of degenerate shapes --- masque/utils/vertices.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index c4a7cb6..176f0f5 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -18,10 +18,16 @@ 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 - return vertices[~duplicates] + + result = vertices[~duplicates] + if result.shape[0] == 0 and vertices.shape[0] > 0: + return vertices[:1] + return result def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: From d32a5ee762597304e5efcda48cd4aac5bb0f1a7b Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 14:51:51 -0700 Subject: [PATCH 068/157] [dxf] fix typos --- masque/file/dxf.py | 2 +- masque/file/gdsii.py | 2 +- masque/shapes/poly_collection.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0f6dd32..e8c7486 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -55,7 +55,7 @@ def write( tuple: (1, 2) -> '1.2' str: '1.2' -> '1.2' (no change) - DXF does not support shape repetition (only block repeptition). Please call + DXF does not support shape repetition (only block repetition). Please call library.wrap_repeated_shapes() before writing to file. Other functions you may want to call: diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index 6972cfa..d78f591 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 repeptition). Please call + GDS does not support shape repetition (only cell repetition). Please call `library.wrap_repeated_shapes()` before writing to file. Other functions you may want to call: diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index c714ed5..711acc4 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -84,7 +84,7 @@ class PolyCollection(Shape): def set_offset(self, val: ArrayLike) -> Self: if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') + raise PatternError('PolyCollection offset is forced to (0, 0)') return self def translate(self, offset: ArrayLike) -> Self: From a0d7d0ed265e6bd51102248d1dcf918159b9fb24 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 14:56:13 -0700 Subject: [PATCH 069/157] [annotations] fix annotations_eq -e --- masque/utils/comparisons.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/utils/comparisons.py b/masque/utils/comparisons.py index 63981c9..ffb7206 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 keys_a < keys_b + return False for key in keys_a: va = aa[key] From 0f63acbad05bd86479cad9916741722f20c54c23 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 15:01:27 -0700 Subject: [PATCH 070/157] [AutoSlots] deduplicate slots entries --- masque/utils/autoslots.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/masque/utils/autoslots.py b/masque/utils/autoslots.py index e82d3db..cef8006 100644 --- a/masque/utils/autoslots.py +++ b/masque/utils/autoslots.py @@ -17,11 +17,12 @@ class AutoSlots(ABCMeta): for base in bases: parents |= set(base.mro()) - slots = tuple(dctn.get('__slots__', ())) + slots = list(dctn.get('__slots__', ())) for parent in parents: if not hasattr(parent, '__annotations__'): continue - slots += tuple(parent.__annotations__.keys()) + slots.extend(parent.__annotations__.keys()) - dctn['__slots__'] = slots + # Deduplicate (dict to preserve order) + dctn['__slots__'] = tuple(dict.fromkeys(slots)) return super().__new__(cls, name, bases, dctn) From 042941c838542a023d6ba2dde477444c04207490 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 15:05:08 -0700 Subject: [PATCH 071/157] [DeferredDict] improve handling of constants --- masque/utils/deferreddict.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/masque/utils/deferreddict.py b/masque/utils/deferreddict.py index aff3bcc..02c1a22 100644 --- a/masque/utils/deferreddict.py +++ b/masque/utils/deferreddict.py @@ -25,9 +25,16 @@ class DeferredDict(dict, Generic[Key, Value]): """ def __init__(self, *args, **kwargs) -> None: dict.__init__(self) - self.update(*args, **kwargs) + if args or 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) @@ -35,8 +42,15 @@ class DeferredDict(dict, Generic[Key, Value]): return dict.__getitem__(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(): - self[k] = v + if callable(v): + self[k] = v + else: + self.set_const(k, v) def __repr__(self) -> str: return '' From e5a6aab940240c1648e0c364a6ff380439a3b457 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 15:15:28 -0700 Subject: [PATCH 072/157] [dxf] improve repetition handling --- masque/file/dxf.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index e8c7486..da35531 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -55,8 +55,7 @@ def write( tuple: (1, 2) -> '1.2' str: '1.2' -> '1.2' (no change) - DXF does not support shape repetition (only block repetition). Please call - library.wrap_repeated_shapes() before writing to file. + Shape repetitions are expanded into individual DXF entities. Other functions you may want to call: - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names @@ -344,16 +343,15 @@ 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: - raise PatternError( - 'Shape repetitions are not supported by DXF.' - ' Please call library.wrap_repeated_shapes() before writing to file.' - ) + displacements = shape.repetition.displacements - 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) + for dd in displacements: + for polygon in shape.to_polygons(): + xy_open = polygon.vertices + dd + xy_closed = numpy.vstack((xy_open, xy_open[0, :])) + block.add_lwpolyline(xy_closed, dxfattribs=attribs) def _labels_to_texts( @@ -363,11 +361,17 @@ def _labels_to_texts( for layer, lseq in labels.items(): attribs = dict(layer=_mlayer2dxf(layer)) for label in lseq: - xy = label.offset - block.add_text( - label.string, - dxfattribs=attribs - ).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT) + 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) def _mlayer2dxf(layer: layer_t) -> str: From 963103b859494b60b87f2771c94c109d86c61699 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 15:15:53 -0700 Subject: [PATCH 073/157] [Pattern / Library] add resolve_repeated_refs --- masque/library.py | 19 +++++++++++++++++++ masque/pattern.py | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/masque/library.py b/masque/library.py index 1240b48..b730fba 100644 --- a/masque/library.py +++ b/masque/library.py @@ -1063,6 +1063,25 @@ 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], diff --git a/masque/pattern.py b/masque/pattern.py index 9e15910..ea8fdd5 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -976,6 +976,28 @@ 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`. From 3ceeba23b802bd2e80c0a7189cf38b0e3c5f4bfc Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 19:00:20 -0700 Subject: [PATCH 074/157] [tests] move imports into functions --- masque/test/test_file_roundtrip.py | 6 +++++- masque/test/test_oasis.py | 4 +--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/masque/test/test_file_roundtrip.py b/masque/test/test_file_roundtrip.py index c7536a5..6933d61 100644 --- a/masque/test/test_file_roundtrip.py +++ b/masque/test/test_file_roundtrip.py @@ -5,11 +5,13 @@ from numpy.testing import assert_allclose from ..pattern import Pattern from ..library import Library -from ..file import gdsii, oasis from ..shapes import Path as MPath, Circle, Polygon from ..repetition import Grid, Arbitrary def create_test_library(for_gds: bool = False) -> Library: + from ..file import gdsii + if not for_gds: + from ..file import oasis lib = Library() # 1. Polygons @@ -62,6 +64,7 @@ def create_test_library(for_gds: bool = False) -> Library: 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) @@ -110,6 +113,7 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None: 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) diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py index faffa58..b1129f4 100644 --- a/masque/test/test_oasis.py +++ b/masque/test/test_oasis.py @@ -4,12 +4,10 @@ from numpy.testing import assert_equal from ..pattern import Pattern from ..library import Library -from ..file import oasis - - 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() From 4eb1d8d4861fe227eaec8ca2f7759f17cab91eca Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 19:57:49 -0700 Subject: [PATCH 075/157] [gdsii] fix missing paren in message --- masque/file/gdsii.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index d78f591..fa49eb3 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -648,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_chars)) + logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths)) if bad_chars or bad_lengths: raise LibraryError('Library contains invalid names, see log above') From 3bedab2301eacb331809557d9774fbbb69b1647a Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 19:58:56 -0700 Subject: [PATCH 076/157] [ports2data] Make port label parsing more robust --- masque/utils/ports2data.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/masque/utils/ports2data.py b/masque/utils/ports2data.py index b67fa0a..b97b41e 100644 --- a/masque/utils/ports2data.py +++ b/masque/utils/ports2data.py @@ -160,13 +160,17 @@ def data_to_ports_flat( local_ports = {} for label in labels: - name, property_string = label.string.split(':') - properties = property_string.split(' ') - ptype = properties[0] - angle_deg = float(properties[1]) if len(ptype) else 0 + 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 xy = label.offset - angle = numpy.deg2rad(angle_deg) + angle = numpy.deg2rad(angle_deg) if numpy.isfinite(angle_deg) else None if name in local_ports: logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"') From 9ee3c7ff890760f1d5d018236e85eff85c36da9f Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 20:01:00 -0700 Subject: [PATCH 077/157] [ILibrary] make referenced_patterns more robust to cyclical dependencies --- masque/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/library.py b/masque/library.py index b730fba..f3e2d3c 100644 --- a/masque/library.py +++ b/masque/library.py @@ -186,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 From 2a6458b1ac595deadb4961375627a310a20ddc1a Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 20:43:33 -0700 Subject: [PATCH 078/157] [repetitions.Arbitrary] reassign to displacements when scaling or mirroring to trigger re-sort --- masque/repetition.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/masque/repetition.py b/masque/repetition.py index 20ec0a3..0426c08 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -391,7 +391,9 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements[:, 1 - axis] *= -1 + new_displacements = self.displacements.copy() + new_displacements[:, 1 - axis] *= -1 + self.displacements = new_displacements return self def get_bounds(self) -> NDArray[numpy.float64] | None: @@ -416,6 +418,6 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements *= c + self.displacements = self.displacements * c return self From 7eec2b7acf2f47641ffbc5d7c97e522295c380a4 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 21:18:54 -0700 Subject: [PATCH 079/157] [LazyLibrary] report full cycle when one is detected --- masque/library.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/masque/library.py b/masque/library.py index f3e2d3c..efc3aad 100644 --- a/masque/library.py +++ b/masque/library.py @@ -1267,12 +1267,12 @@ class LazyLibrary(ILibrary): """ mapping: dict[str, Callable[[], 'Pattern']] cache: dict[str, 'Pattern'] - _lookups_in_progress: set[str] + _lookups_in_progress: list[str] def __init__(self) -> None: self.mapping = {} self.cache = {} - self._lookups_in_progress = set() + self._lookups_in_progress = [] def __setitem__( self, @@ -1303,16 +1303,20 @@ 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 multiple simultaneous lookups of "{key}".\n' + f'Detected circular reference or recursive lookup of "{key}".\n' + f'Lookup chain: {chain}\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.' # TODO give advice on finding cycles + 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' ) - self._lookups_in_progress.add(key) - func = self.mapping[key] - pat = func() - self._lookups_in_progress.remove(key) + self._lookups_in_progress.append(key) + try: + func = self.mapping[key] + pat = func() + finally: + self._lookups_in_progress.pop() self.cache[key] = pat return pat From 5989e45906c14bd0a3cb828efe3628535f366fce Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 21:38:47 -0700 Subject: [PATCH 080/157] [apply_transforms] fix handling of rotations while mirrored --- masque/utils/transform.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/masque/utils/transform.py b/masque/utils/transform.py index dfb6492..90647b1 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -114,9 +114,13 @@ def apply_transforms( tot = numpy.empty((outer.shape[0], inner.shape[0], 4)) tot[:, :, :2] = outer[:, None, :2] + xy - tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored - tot[:, :, 2] %= 2 * pi # clamp rot - tot[:, :, 3] %= 2 # clamp mirrored + + # 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 if tensor: return tot From 0b8e11e8bfa373c22f0efa14417bb87bc566551b Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:31:18 -0700 Subject: [PATCH 081/157] [dxf] improve manhattan check robustness --- masque/file/dxf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index da35531..d7bc7e8 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -304,13 +304,13 @@ def _mrefs_to_drefs( b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) 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: + if numpy.isclose(rotated_a[1], 0) and numpy.isclose(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 rotated_a[0] == 0 and rotated_b[1] == 0: + elif numpy.isclose(rotated_a[0], 0) and numpy.isclose(rotated_b[1], 0): attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] From c4dc9f95731c2bfc6ccbdd206d5b8ce16a56da57 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:32:16 -0700 Subject: [PATCH 082/157] [oasis] comment and code cleanup --- masque/file/oasis.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 0a11b24..5e343ea 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -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.write` - **kwargs: passed to `oasis.write` + *args: passed to `oasis.build()` + **kwargs: passed to `oasis.build()` """ path = pathlib.Path(filename) @@ -213,9 +213,9 @@ def readfile( Will automatically decompress gzipped files. Args: - filename: Filename to save to. - *args: passed to `oasis.read` - **kwargs: passed to `oasis.read` + filename: Filename to load from. + *args: passed to `oasis.read()` + **kwargs: passed to `oasis.read()` """ path = pathlib.Path(filename) if is_gzipped(path): @@ -717,10 +717,6 @@ 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], From 92d01400935e46c6a3228ae4a06a0e75f2b63284 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:33:59 -0700 Subject: [PATCH 083/157] [Pattern] fix pattern comparisons --- masque/pattern.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index ea8fdd5..e515614 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -201,7 +201,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 self.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in other.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)) @@ -215,7 +215,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 self.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in other.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)) @@ -224,21 +224,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(self.shapes[layer])) + shapes_theirs = tuple(sorted(other.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 self.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in other.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_layerkeys: + for _, _, layer in self_txtlayerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(self.labels[layer])) + labels_theirs = tuple(sorted(other.labels[layer])) if labels_ours != labels_theirs: return labels_ours < labels_theirs @@ -255,7 +255,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 self.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in other.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)) @@ -269,7 +269,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 self.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in other.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)) @@ -278,21 +278,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(self.shapes[layer])) + shapes_theirs = tuple(sorted(other.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 self.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in other.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_layerkeys: + for _, _, layer in self_txtlayerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(self.labels[layer])) + labels_theirs = tuple(sorted(other.labels[layer])) if labels_ours != labels_theirs: return False From 9296011d4b2372e3f8aafb4d952fa831496fad41 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:34:39 -0700 Subject: [PATCH 084/157] [Ref] deepcopy annotations and repetitions --- masque/ref.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/ref.py b/masque/ref.py index 3a64dce..f38c1a8 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -100,8 +100,8 @@ class Ref( def __deepcopy__(self, memo: dict | None = None) -> 'Ref': memo = {} if memo is None else memo new = copy.copy(self) - #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 __lt__(self, other: 'Ref') -> bool: From e3f8d2852990d48cdd7bdb89cef47ee630d19f2d Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:37:30 -0700 Subject: [PATCH 085/157] [Path] improve __lt__ for endcaps --- masque/shapes/path.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 654cfaa..ad6da5d 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -24,7 +24,16 @@ class PathCap(Enum): # # defined by path.cap_extensions def __lt__(self, other: Any) -> bool: - return self.value == other.value + if self.__class__ is not other.__class__: + return self.__class__.__name__ < other.__class__.__name__ + # Order: Flush, Square, Round, SquareCustom, Circle + order = { + PathCap.Flush: 0, + PathCap.Square: 1, + PathCap.Circle: 2, + PathCap.SquareCustom: 3, + } + return order[self] < order[other] @functools.total_ordering From 2019fc0d7480c45ca38ea87c5b4efb7d53dfcae1 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:40:08 -0700 Subject: [PATCH 086/157] [Path] Circular cap extensions should translate to square, not empty --- masque/shapes/path.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index ad6da5d..9c31561 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -477,11 +477,13 @@ class Path(Shape): def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: if self.cap == PathCap.Square: extensions = numpy.full(2, self.width / 2) + elif self.cap == PathCap.Circle: + extensions = numpy.full(2, self.width / 2) elif self.cap == PathCap.SquareCustom: assert isinstance(self.cap_extensions, numpy.ndarray) extensions = self.cap_extensions else: - # Flush or Circle + # Flush extensions = numpy.zeros(2) return extensions From 9a76ce5b66d14e3f536fff4e79faa3a20f4b166c Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:41:11 -0700 Subject: [PATCH 087/157] [Path] cap_extensions=None should mean [0, 0] when using custom extensions --- masque/shapes/path.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 9c31561..d4c7b24 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -88,10 +88,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,9 +218,12 @@ 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): From 838c74265180f087ad6f3a446309edea517a39ba Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:41:37 -0700 Subject: [PATCH 088/157] [Path] Improve comparisons: compare vertices --- masque/shapes/path.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index d4c7b24..706e2f4 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -265,6 +265,14 @@ 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) From fb822829ec7ee1a1b354830b9f73b599854d3943 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:42:48 -0700 Subject: [PATCH 089/157] [Polygon] rect() should call rectangle() with positive width/height no big deal, but this makes vertex order consistent --- masque/shapes/polygon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index dc5afa1..c9617a9 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -321,7 +321,7 @@ class Polygon(Shape): else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') - poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition) + poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition) return poly @staticmethod From 5eb460ecb738687dec698eaa69ad874ce24eabd7 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:43:58 -0700 Subject: [PATCH 090/157] [repetition.Grid] disallow b_vector=None (except when initializing) --- masque/repetition.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/masque/repetition.py b/masque/repetition.py index 0426c08..867adf7 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] | None + _b_vector: NDArray[numpy.float64] """ 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,9 +199,6 @@ 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 From f42114bf436af25f63d9b1ae47835f2d9c05ddce Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:47:22 -0700 Subject: [PATCH 091/157] [gdsii] explicitly cast cap_extensions to int --- masque/file/gdsii.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index fa49eb3..c20200c 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -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(shape.cap_extensions) # type: ignore + extension = tuple(rint_cast(shape.cap_extensions)) else: extension = (0, 0) From e2615858942892bbc65d255efb401a24d8729660 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 23:09:45 -0700 Subject: [PATCH 092/157] [gdsii] Try to close files if able --- masque/file/gdsii.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index c20200c..f589ad8 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -617,7 +617,12 @@ def load_libraryfile( stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore else: stream = path.open(mode='rb') # noqa: SIM115 - return load_library(stream, full_load=full_load, postprocess=postprocess) + + try: + return load_library(stream, full_load=full_load, postprocess=postprocess) + finally: + if full_load: + stream.close() def check_valid_names( From 564ff10db34d94f6512b920c0466ee44f3b2567a Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 00:17:23 -0700 Subject: [PATCH 093/157] [dxf] add roundtrip dxf test, enable refs and improve path handling --- masque/file/dxf.py | 71 ++++++++++++++++++------- masque/test/test_dxf.py | 111 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 masque/test/test_dxf.py diff --git a/masque/file/dxf.py b/masque/file/dxf.py index d7bc7e8..962d41d 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 +from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -217,9 +217,7 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) - if points.shape[1] == 2: - raise PatternError('Invalid or unimplemented polygon?') - + 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!)') @@ -230,14 +228,35 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if width == 0: width = attr.get('const_width', 0) - 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=points[:, :2]) + is_closed = element.closed + # If the last point is a repeat of the first, drop it. + if len(points) > 1 and numpy.allclose(points[0, :2], points[-1, :2]): + verts = points[:-1, :2] + else: + verts = points[:, :2] + + shape: Path | Polygon + if width == 0 and is_closed and len(verts) >= 3: + shape = Polygon(vertices=verts) + else: + 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], @@ -302,15 +321,23 @@ 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) - rotated_a = rotation_matrix_2d(-ref.rotation) @ a - rotated_b = rotation_matrix_2d(-ref.rotation) @ b - if numpy.isclose(rotated_a[1], 0) and numpy.isclose(rotated_b[0], 0): + # 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): 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) and numpy.isclose(rotated_b[1], 0): + elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8): attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] @@ -348,10 +375,18 @@ def _shapes_to_elements( displacements = shape.repetition.displacements for dd in displacements: - for polygon in shape.to_polygons(): - xy_open = polygon.vertices + dd - xy_closed = numpy.vstack((xy_open, xy_open[0, :])) - block.add_lwpolyline(xy_closed, dxfattribs=attribs) + 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) def _labels_to_texts( diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py new file mode 100644 index 0000000..e6e6e7e --- /dev/null +++ b/masque/test/test_dxf.py @@ -0,0 +1,111 @@ + +import numpy +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" From a467a0bacaa7f0d76f75a05cf2e07614d90ed38d Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 00:17:50 -0700 Subject: [PATCH 094/157] [Path] simplify conditional --- masque/shapes/path.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 706e2f4..a7b0ac6 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -486,9 +486,7 @@ class Path(Shape): return self def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: - if self.cap == PathCap.Square: - extensions = numpy.full(2, self.width / 2) - elif self.cap == PathCap.Circle: + if self.cap in (PathCap.Square, PathCap.Circle): extensions = numpy.full(2, self.width / 2) elif self.cap == PathCap.SquareCustom: assert isinstance(self.cap_extensions, numpy.ndarray) From 5e0936e15f47aac6ecd93485355f3275dbfe47f6 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 00:18:06 -0700 Subject: [PATCH 095/157] [dxf] update ezdxf dep --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ba7a240..760dfb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ path = "masque/__init__.py" [project.optional-dependencies] oasis = ["fatamorgana~=0.11"] -dxf = ["ezdxf~=1.0.2"] +dxf = ["ezdxf~=1.4"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] @@ -110,6 +110,9 @@ lint.ignore = [ [tool.pytest.ini_options] addopts = "-rsXx" testpaths = ["masque"] +filterwarnings = [ + "ignore::pyparsing.exceptions.PyparsingDeprecationWarning:ezdxf.*", +] [tool.mypy] mypy_path = "stubs" From 36cb86a15d2290b21f855792ca1f06556562614d Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 00:20:29 -0700 Subject: [PATCH 096/157] [tests] clean unused imports --- masque/test/test_file_roundtrip.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/masque/test/test_file_roundtrip.py b/masque/test/test_file_roundtrip.py index 6933d61..2cfb0d1 100644 --- a/masque/test/test_file_roundtrip.py +++ b/masque/test/test_file_roundtrip.py @@ -9,9 +9,6 @@ from ..shapes import Path as MPath, Circle, Polygon from ..repetition import Grid, Arbitrary def create_test_library(for_gds: bool = False) -> Library: - from ..file import gdsii - if not for_gds: - from ..file import oasis lib = Library() # 1. Polygons From 0cce5e058663538a7cebe7e157421045401f735e Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 01:07:50 -0700 Subject: [PATCH 097/157] [Ref] misc copy fixes -- don't deepcopy repetition or annotations in __copy__ --- masque/ref.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/masque/ref.py b/masque/ref.py index f38c1a8..8a72167 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -92,18 +92,22 @@ class Ref( rotation=self.rotation, scale=self.scale, mirrored=self.mirrored, - repetition=copy.deepcopy(self.repetition), - annotations=copy.deepcopy(self.annotations), + repetition=self.repetition, + annotations=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) 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) From b7143e32878bf79dc9b6ee990f5891ea4eb3c441 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 01:08:35 -0700 Subject: [PATCH 098/157] [repetition.Grid] fix __le__ comparison of b_vector --- masque/repetition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/repetition.py b/masque/repetition.py index 867adf7..d7b6ac2 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -303,7 +303,7 @@ class Grid(Repetition): if other.b_vector is None: return False if not numpy.array_equal(self.b_vector, other.b_vector): - return tuple(self.a_vector) < tuple(other.a_vector) + return tuple(self.b_vector) < tuple(other.b_vector) return False From 6c969683416aeeca6ff49ff4fa31e6554d6929ae Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 01:09:37 -0700 Subject: [PATCH 099/157] [Path] improve robustness of intersection calculations --- masque/shapes/path.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index a7b0ac6..ff1c6e9 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -344,11 +344,11 @@ class Path(Shape): bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] - rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0] - rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0] + rp = numpy.linalg.lstsq(As, bs[:, :, None], rcond=1e-12)[0][:, 0, 0] + rn = numpy.linalg.lstsq(As, ds[:, :, None], rcond=1e-12)[0][:, 0, 0] - intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] - intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1] + intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1] + intersection_n = v[:-2] + rn[:, None] * 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 From 0ad89d6d95954a6f699dbdd24982bc61ae7f3b48 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 01:10:26 -0700 Subject: [PATCH 100/157] [DeferredDict] capture value in set_const --- masque/utils/deferreddict.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/utils/deferreddict.py b/masque/utils/deferreddict.py index 02c1a22..31e6943 100644 --- a/masque/utils/deferreddict.py +++ b/masque/utils/deferreddict.py @@ -60,4 +60,4 @@ class DeferredDict(dict, Generic[Key, Value]): Convenience function to avoid having to manually wrap constant values into callables. """ - self[key] = lambda: value + self[key] = lambda v=value: v From a38c5bb0850a0a28cb1371a8caa0dfa7f6505a32 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 01:15:42 -0700 Subject: [PATCH 101/157] [ports2data] deal with cycles better --- masque/utils/ports2data.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/masque/utils/ports2data.py b/masque/utils/ports2data.py index b97b41e..889425e 100644 --- a/masque/utils/ports2data.py +++ b/masque/utils/ports2data.py @@ -57,11 +57,9 @@ 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, - # TODO missing ok? + visited: set[int] | None = None, ) -> 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. @@ -70,18 +68,28 @@ 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. - max_depth: Maximum hierarcy depth to search. Default 999_999. + name: Name of the pattern object. + max_depth: Maximum hierarcy depth to search. Default 0. 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() + + 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 @@ -99,12 +107,13 @@ 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, + layers = layers, + library = library, + pattern = library[target], + name = target, + max_depth = max_depth - 1, + skip_subcells = skip_subcells, + visited = visited, ) found_ports |= bool(pp.ports) From 169f66cc854b8971d34a2289f12dac1f18b351b0 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 01:16:54 -0700 Subject: [PATCH 102/157] [rotation_matrix_2d] improve manhattan angle detection modulo causes issues with negative numbers --- masque/utils/transform.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/masque/utils/transform.py b/masque/utils/transform.py index 90647b1..62e0f1e 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -28,8 +28,9 @@ 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 inacuraccies in sin & cos - if numpy.isclose(theta % (pi / 2), 0): + # 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): arr = numpy.round(arr) arr.flags.writeable = False From b8ee4bb05d4effea8a9bab0bc61a6a713008b33a Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 02:32:20 -0700 Subject: [PATCH 103/157] [ell] fix set_rotation check --- masque/builder/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/builder/utils.py b/masque/builder/utils.py index 3109f46..5680694 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 not None: + if set_rotation is None: raise BuildError('set_rotation must be specified if no ports have rotations!') rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) From da20922224517669fa96a62184904f23a987811c Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 02:34:11 -0700 Subject: [PATCH 104/157] [apply_transform] include scale in transform --- masque/library.py | 4 +++- masque/ref.py | 3 ++- masque/utils/transform.py | 29 +++++++++++++++++++---------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/masque/library.py b/masque/library.py index efc3aad..8dc63a4 100644 --- a/masque/library.py +++ b/masque/library.py @@ -466,9 +466,11 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): memo = {} if transform is None or transform is True: - transform = numpy.zeros(4) + transform = numpy.array([0, 0, 0, 0, 1], dtype=float) elif transform is not False: transform = numpy.asarray(transform, dtype=float) + if transform.size == 4: + transform = numpy.append(transform, 1.0) original_pattern = pattern diff --git a/masque/ref.py b/masque/ref.py index 8a72167..6423394 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -191,10 +191,11 @@ class Ref( xys = self.offset[None, :] if self.repetition is not None: xys = xys + self.repetition.displacements - transforms = numpy.empty((xys.shape[0], 4)) + transforms = numpy.empty((xys.shape[0], 5)) 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/utils/transform.py b/masque/utils/transform.py index 62e0f1e..ed0453b 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -87,33 +87,41 @@ 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 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`). + 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`). Args: - 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 + 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 to the `oo`th `outer` transform applied to the `ii`th inner transform. - If `False` (default), this is concatenated into `(O*I)x4` to allow simple + If `False` (default), this is concatenated into `(O*I)x5` to allow simple chaining into additional `apply_transforms()` calls. Returns: - OxIx4 or (O*I)x4 array. Final dimension is - `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`. + OxIx5 or (O*I)x5 array. Final dimension is + `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x, total_scale)`. """ 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], 4)) + tot = numpy.empty((outer.shape[0], inner.shape[0], 5)) tot[:, :, :2] = outer[:, None, :2] + xy # If mirrored, flip inner rotation @@ -122,6 +130,7 @@ def apply_transforms( 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 if tensor: return tot From 6c42049b23bb2d31e0c0fa1292fde9a85ce2ab29 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 02:34:57 -0700 Subject: [PATCH 105/157] [PortList] actually raise the error --- masque/ports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/ports.py b/masque/ports.py index 4be208d..93310f7 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -630,7 +630,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: - PortError('Must provide set_rotation if rotation is indeterminate') + raise PortError('Must provide set_rotation if rotation is indeterminate') rotations[:] = set_rotation else: rotations[~has_rot] = rotations[has_rot][0] From 5596e2b1afd2553812d96a6d872a31bf9c20e615 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 02:35:35 -0700 Subject: [PATCH 106/157] [tests] cover scale-aware transform --- masque/test/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py index 882b5bd..ffa3c63 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -64,7 +64,7 @@ def test_apply_transforms() -> None: 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], [10, 25, 0, 0]]) + assert_equal(combined, [[15, 20, 0, 0, 1], [10, 25, 0, 0, 1]]) def test_apply_transforms_advanced() -> None: @@ -80,4 +80,4 @@ def test_apply_transforms_advanced() -> None: # 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], atol=1e-10) + assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10) From f154303bef482ad543f55de53392715d820f30a2 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 02:38:33 -0700 Subject: [PATCH 107/157] [remove_colinear_vertices] treat unclosed paths correctly --- masque/utils/vertices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 176f0f5..f830696 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -43,7 +43,7 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N Returns: `vertices` with colinear (superflous) vertices removed. May be a view into the original array. """ - vertices = remove_duplicate_vertices(vertices) + vertices = remove_duplicate_vertices(vertices, closed_path=closed_path) # Check for dx0/dy0 == dx1/dy1 From 25b8fe8448f82311a3b355d9270af55646cc3cdb Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 02:41:15 -0700 Subject: [PATCH 108/157] [Path.to_polygons] Use linalg.solve() where possible; fallback to lstsq if singular --- masque/shapes/path.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index ff1c6e9..feb1700 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -344,8 +344,18 @@ class Path(Shape): bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] - rp = numpy.linalg.lstsq(As, bs[:, :, None], rcond=1e-12)[0][:, 0, 0] - rn = numpy.linalg.lstsq(As, ds[:, :, None], rcond=1e-12)[0][:, 0, 0] + 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] intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1] intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1] From 5d20a061fdff185178096e6469a53a2c840a5b72 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 02:41:41 -0700 Subject: [PATCH 109/157] [Path / Polygon] improve normalized_form approach to follow documented order --- masque/shapes/path.py | 14 +++++++++----- masque/shapes/polygon.py | 14 +++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index feb1700..43cff34 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -449,11 +449,15 @@ class Path(Shape): for v in normed_vertices]) # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min = rotated_vertices[:, 0].argmin() - if not is_scalar(x_min): - y_min = rotated_vertices[x_min, 1].argmin() - x_min = cast('Sequence', x_min)[y_min] - reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) + 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) width0 = self.width / norm_value diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index c9617a9..34a784b 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -417,11 +417,15 @@ class Polygon(Shape): for v in normed_vertices]) # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min = rotated_vertices[:, 0].argmin() - if not is_scalar(x_min): - y_min = rotated_vertices[x_min, 1].argmin() - x_min = cast('Sequence', x_min)[y_min] - reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) + 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) # TODO: normalize mirroring? From 495babf8377165e99bddaf61a51c08bdb9a3cad8 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 03:27:39 -0700 Subject: [PATCH 110/157] [Path] revert endcap changes to avoid double-counting --- masque/shapes/path.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 43cff34..b4ac650 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -500,13 +500,13 @@ class Path(Shape): return self def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: - if self.cap in (PathCap.Square, PathCap.Circle): + if self.cap == PathCap.Square: extensions = numpy.full(2, self.width / 2) elif self.cap == PathCap.SquareCustom: assert isinstance(self.cap_extensions, numpy.ndarray) extensions = self.cap_extensions else: - # Flush + # Flush or Circle extensions = numpy.zeros(2) return extensions From ea93a7ef3702d1117abce3622683eda368844e70 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 03:28:31 -0700 Subject: [PATCH 111/157] [remove_colinear_vertices / Path] add preserve_uturns and use it for paths --- masque/shapes/path.py | 40 +++++++++++++++++++++++++++------------ masque/test/test_utils.py | 13 +++++++++---- masque/utils/vertices.py | 22 ++++++++++++++++++--- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index b4ac650..542cf11 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -323,9 +323,30 @@ class Path(Shape): ) -> list['Polygon']: extensions = self._calculate_cap_extensions() - v = remove_colinear_vertices(self.vertices, closed_path=False) + v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) dv = numpy.diff(v, axis=0) - dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] + 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] if self.width == 0: verts = numpy.vstack((v, v[::-1])) @@ -448,16 +469,11 @@ class Path(Shape): rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v) for v in normed_vertices]) - # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min_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] + # 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: - start_ind = x_min_inds[0] - reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0) + reordered_vertices = rotated_vertices width0 = self.width / norm_value @@ -496,7 +512,7 @@ class Path(Shape): Returns: self """ - self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) + self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) return self def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py index ffa3c63..f495285 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -29,14 +29,19 @@ def test_remove_colinear_vertices() -> None: 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) + 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]? - # Yes, they are all on the same line. - assert len(v_clean) == 2 + # 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) + v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False) assert len(v_clean) == 2 diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index f830696..5a5df9f 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -30,7 +30,11 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> return result -def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: +def remove_colinear_vertices( + vertices: ArrayLike, + closed_path: bool = True, + preserve_uturns: bool = False, + ) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any superflous vertices (i.e. those which lie along the line formed by their neighbors) @@ -39,6 +43,8 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N 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. @@ -46,14 +52,24 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N vertices = remove_duplicate_vertices(vertices, closed_path=closed_path) # Check for dx0/dy0 == dx1/dy1 + dv = numpy.roll(vertices, -1, axis=0) - vertices + if not closed_path: + dv[-1] = 0 - 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[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] 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 From d3075899950b8b7ba49af74bf5830f2d6e8b9c5b Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 03:29:19 -0700 Subject: [PATCH 112/157] [ports2data] add note about using id rather than name --- masque/utils/ports2data.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/masque/utils/ports2data.py b/masque/utils/ports2data.py index 889425e..c7f42e1 100644 --- a/masque/utils/ports2data.py +++ b/masque/utils/ports2data.py @@ -86,6 +86,8 @@ def data_to_ports( 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)) From e8083cc24c5a0dacb2224f43ca04708e834be36c Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 03:37:42 -0700 Subject: [PATCH 113/157] [dxf] hide ezdxf warnings directly --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 760dfb9..a985f60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ lint.ignore = [ addopts = "-rsXx" testpaths = ["masque"] filterwarnings = [ - "ignore::pyparsing.exceptions.PyparsingDeprecationWarning:ezdxf.*", + "ignore::DeprecationWarning:ezdxf.*", ] [tool.mypy] From 3792248cd1d9bc90f1ae2102c5a87bfc17160a65 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 11:16:30 -0700 Subject: [PATCH 114/157] [dxf] improve dxf reader (ezdxf 1.4 related LWPolyLine changes) --- masque/file/dxf.py | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 962d41d..db01f51 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -218,28 +218,36 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> layer = attr.get('layer', DEFAULT_LAYER) 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!)') + 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] - width = points[0, 2] - if width == 0: - width = attr.get('const_width', 0) + if width == 0: + width = attr.get('const_width', 0) is_closed = element.closed - # If the last point is a repeat of the first, drop it. - if len(points) > 1 and numpy.allclose(points[0, :2], points[-1, :2]): - verts = points[:-1, :2] - else: - verts = points[:, :2] + verts = points[:, :2] + if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): + verts = numpy.vstack((verts, verts[0])) shape: Path | Polygon - if width == 0 and is_closed and len(verts) >= 3: - shape = Polygon(vertices=verts) + 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) + else: + shape = Path(width=width, vertices=verts) else: - shape = Path(width=width, vertices=points[:, :2]) + shape = Path(width=width, vertices=verts) pat.shapes[layer].append(shape) elif isinstance(element, Solid | Trace): From a6ea5c08e6cb1636f62e62d3c88b0ffa38dc199b Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 11:19:42 -0700 Subject: [PATCH 115/157] [repetition.Grid] drop b_vector=None handling (guaranteed to be zeros now) --- masque/repetition.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/masque/repetition.py b/masque/repetition.py index d7b6ac2..e1507b8 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -298,10 +298,6 @@ 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 False From db222373694a1911923c94c201cd41e39cc4cc46 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 11:20:04 -0700 Subject: [PATCH 116/157] [PathCap] clean up comment --- masque/shapes/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 542cf11..3df55f4 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -26,7 +26,7 @@ class PathCap(Enum): def __lt__(self, other: Any) -> bool: if self.__class__ is not other.__class__: return self.__class__.__name__ < other.__class__.__name__ - # Order: Flush, Square, Round, SquareCustom, Circle + # Order: Flush, Square, Circle, SquareCustom order = { PathCap.Flush: 0, PathCap.Square: 1, From 5f91bd9c6c57a825cf5732092de3450aeec84988 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 23:34:25 -0700 Subject: [PATCH 117/157] [BREAKING][Ref / Label / Pattern] Make rotate/mirror consistent intrinsic transfomations offset and repetition are extrinsic; use rotate_around() and flip() to alter both mirror() and rotate() only affect the object's intrinsic properties --- masque/label.py | 10 +- masque/pattern.py | 56 +++++++--- masque/ref.py | 6 +- masque/test/test_rotation_consistency.py | 133 +++++++++++++++++++++++ masque/traits/mirrorable.py | 6 +- masque/traits/rotatable.py | 18 ++- 6 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 masque/test/test_rotation_consistency.py diff --git a/masque/label.py b/masque/label.py index 8b67c65..b662035 100644 --- a/masque/label.py +++ b/masque/label.py @@ -98,18 +98,20 @@ 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: """ - Flip the label across a line in the pattern's coordinate system. - - This operation mirrors the label's offset relative to the pattern's origin. + 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 mirrors across y=0. 1 mirrors across x=0. + 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. diff --git a/masque/pattern.py b/masque/pattern.py index e515614..24f6397 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -2,7 +2,7 @@ Object representing a one multi-layer lithographic layout. A single level of hierarchical references is included. """ -from typing import cast, Self, Any, TypeVar, TYPE_CHECKING +from typing import cast, Self, Any, TypeVar from collections.abc import Sequence, Mapping, MutableMapping, Iterable, Callable import copy import logging @@ -25,8 +25,6 @@ from .error import PatternError, PortError from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable, Bounded from .ports import Port, PortList -if TYPE_CHECKING: - from .traits import Flippable logger = logging.getLogger(__name__) @@ -747,7 +745,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Rotate the Pattern around the a location. + Extrinsic transformation: Rotate the Pattern around the a location in the + container's coordinate system. This affects all elements' offsets and + their repetition grids. Args: pivot: (x, y) location to rotate around @@ -766,7 +766,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def rotate_element_centers(self, rotation: float) -> Self: """ - Rotate the offsets of all shapes, labels, refs, and ports around (0, 0) + 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. Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -777,11 +779,15 @@ 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: """ - Rotate each shape, ref, and port around its origin (offset) + 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. Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -789,13 +795,32 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): - cast('Rotatable', entry).rotate(rotation) + for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): + if isinstance(entry, Rotatable): + entry.rotate(rotation) + return self + + def mirror_element_centers(self, 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. + + Args: + axis: Axis to mirror across (0: x-axis, 1: 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) return self def mirror_elements(self, axis: int = 0) -> Self: """ - Mirror each shape, ref, and port relative to its offset. + Intrinsic transformation part: Mirror each shape, ref, label, and port relative + to its offset. This does NOT affect their repetition grids. Args: axis: Axis to mirror across @@ -805,14 +830,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): - cast('Mirrorable', entry).mirror(axis=axis) + 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})") return self def mirror(self, axis: int = 0) -> Self: """ - Mirror the Pattern across an axis through its origin. + Extrinsic transformation: Mirror the Pattern across an axis through its origin. + This affects all elements' offsets and their internal orientations. Args: axis: Axis to mirror across (0: x-axis, 1: y-axis). @@ -820,8 +847,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Flippable', entry).flip_across(axis=axis) + self.mirror_elements(axis=axis) + self.mirror_element_centers(axis=axis) self._log_bulk_update(f"mirror({axis})") return self @@ -1168,6 +1195,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): 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 diff --git a/masque/ref.py b/masque/ref.py index 6423394..f3241e4 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -166,9 +166,11 @@ 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: diff --git a/masque/test/test_rotation_consistency.py b/masque/test/test_rotation_consistency.py new file mode 100644 index 0000000..f574f52 --- /dev/null +++ b/masque/test/test_rotation_consistency.py @@ -0,0 +1,133 @@ + +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/traits/mirrorable.py b/masque/traits/mirrorable.py index ac00147..2a3a9fb 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -18,7 +18,8 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - Mirror the entity across an axis through its origin. + 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 @@ -75,7 +76,8 @@ class Flippable(Positionable, metaclass=ABCMeta): @abstractmethod 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. + 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 diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 2517e2e..436d0a2 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -25,7 +25,8 @@ class Rotatable(metaclass=ABCMeta): @abstractmethod def rotate(self, val: float) -> Self: """ - Rotate the shape around its origin (0, 0), ignoring its offset. + Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. + This does NOT affect the object's repetition grid. Args: val: Angle to rotate by (counterclockwise, radians) @@ -63,6 +64,10 @@ 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 @@ -82,7 +87,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): class Pivotable(Positionable, metaclass=ABCMeta): """ - Trait class for entites which can be rotated around a point. + Trait class for entities which can be rotated around a point. This requires that they are `Positionable` but not necessarily `Rotatable` themselves. """ __slots__ = () @@ -90,7 +95,11 @@ class Pivotable(Positionable, metaclass=ABCMeta): @abstractmethod def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Rotate the object around a point. + 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. Args: pivot: Point (x, y) to rotate around @@ -110,9 +119,12 @@ class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta): __slots__ = () 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) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) self.translate(+pivot) return self From feb5d87cf4bccbfb565b541efd5aa2fc53c11edd Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:29:10 -0700 Subject: [PATCH 118/157] [repetition.Arbitrary] fix zero-sized bounds --- masque/repetition.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/masque/repetition.py b/masque/repetition.py index e1507b8..a8de94c 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -397,6 +397,8 @@ 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)) From 75dc391540d021e6d3d44453e33a24a577890c6b Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:29:51 -0700 Subject: [PATCH 119/157] [pack2d] don't place rejects --- masque/utils/pack2d.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/masque/utils/pack2d.py b/masque/utils/pack2d.py index ce6b006..a99b01e 100644 --- a/masque/utils/pack2d.py +++ b/masque/utils/pack2d.py @@ -236,7 +236,9 @@ def pack_patterns( locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects) pat = Pattern() - for pp, oo, loc in zip(patterns, offsets, locations, strict=True): + for ii, (pp, oo, loc) in enumerate(zip(patterns, offsets, locations, strict=True)): + if ii in reject_inds: + continue pat.ref(pp, offset=oo + loc) rejects = [patterns[ii] for ii in reject_inds] From fa3dfa1e7495cbe775bf94edbec7f2d2aff20963 Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:31:11 -0700 Subject: [PATCH 120/157] [Pattern] improve clarity of .copy()->.deepcopy() --- masque/pattern.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index 24f6397..94068c9 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -172,7 +172,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return s def __copy__(self) -> 'Pattern': - logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!') + logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! ' + 'Consider using .deepcopy() if this was not intended.') new = Pattern( annotations=copy.deepcopy(self.annotations), ports=copy.deepcopy(self.ports), @@ -860,7 +861,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: A deep copy of the current Pattern. """ - return copy.deepcopy(self) + return self.deepcopy() def deepcopy(self) -> Self: """ From 2275bf415a44e97de8829f617a52b72c5ef2cde9 Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:31:58 -0700 Subject: [PATCH 121/157] [Pattern] improve error message when attempting to reference a Pattern --- masque/pattern.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index 94068c9..46b564a 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1576,8 +1576,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self._log_port_removal(ki) map_out[vi] = None - if isinstance(other, Pattern): - assert append or skip_geometry, 'Got a name (not an abstract) but was asked to reference (not append)' + 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.') self.place( other, From cfec9e8c7651d40eac8dd1ad87bc16cf72fce1cd Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:47:50 -0700 Subject: [PATCH 122/157] [euler_bend] speed up integration --- masque/utils/curves.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 8b3fcc4..2348678 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -69,14 +69,25 @@ def euler_bend( num_points_arc = num_points - 2 * num_points_spiral def gen_spiral(ll_max: float) -> NDArray[numpy.float64]: - 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 + 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) xy_spiral = gen_spiral(ll_max) xy_parts = [xy_spiral] From add82e955ddfe8e558da5d8e4a40e6dae905a931 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 19:39:25 -0700 Subject: [PATCH 123/157] update dev deps --- pyproject.toml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a985f60..af8802c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,3 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [project] name = "masque" description = "Lithography mask library" @@ -56,9 +52,15 @@ dev = [ "masque[text]", "masque[manhattanize]", "masque[manhattanize_slow]", - "ruff>=0.15.1", + "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" From 9ede16df5df0d40090f23c94d08a0b4e1f72a14c Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 20:22:40 -0700 Subject: [PATCH 124/157] [dxf] fix reading Polyline --- masque/file/dxf.py | 5 +++-- masque/test/test_dxf.py | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index db01f51..0c19b5a 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -212,8 +212,10 @@ 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()) - elif isinstance(element, Polyline): + is_closed = element.closed + else: points = numpy.asarray([pp.xyz for pp in element.points()]) + is_closed = element.is_closed attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) @@ -233,7 +235,6 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if width == 0: width = attr.get('const_width', 0) - is_closed = element.closed verts = points[:, :2] if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): verts = numpy.vstack((verts, verts[0])) diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py index e6e6e7e..0c0a1a3 100644 --- a/masque/test/test_dxf.py +++ b/masque/test/test_dxf.py @@ -1,5 +1,6 @@ - +import io import numpy +import ezdxf from numpy.testing import assert_allclose from pathlib import Path @@ -109,3 +110,20 @@ def test_dxf_manhattan_precision(tmp_path: Path): 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]]) From 06f8611a90a417e25e7c2767115c7d1c1d9fc52f Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 20:24:24 -0700 Subject: [PATCH 125/157] [svg] fix rotation in svg --- masque/file/svg.py | 17 +++++++++- masque/test/test_svg.py | 70 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 masque/test/test_svg.py diff --git a/masque/file/svg.py b/masque/file/svg.py index 7c77fd9..f235b50 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -10,11 +10,26 @@ 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, @@ -107,7 +122,7 @@ def writefile( if target is None: continue for ref in refs: - transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})' + transform = _ref_to_svg_transform(ref) use = svg.use(href='#' + mangle_name(target), transform=transform) svg_group.add(use) diff --git a/masque/test/test_svg.py b/masque/test/test_svg.py new file mode 100644 index 0000000..a3261b6 --- /dev/null +++ b/masque/test/test_svg.py @@ -0,0 +1,70 @@ +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) From 548b51df47f5cec4f5576a63ad2711ac60410584 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 20:25:45 -0700 Subject: [PATCH 126/157] [Port] fix printing of None rotation --- masque/ports.py | 6 +++--- masque/test/test_ports.py | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/masque/ports.py b/masque/ports.py index 93310f7..ac48681 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -147,7 +147,7 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): """ 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 "any" + deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None cardinal = "" travel_dir = "" @@ -168,7 +168,8 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3): travel_dir = f" (Travel -> {dirs[closest_t]})" - return f"pos=({self.x:g}, {self.y:g}), rot={deg:g}{cardinal}{travel_dir}" + 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: @@ -655,4 +656,3 @@ class PortList(metaclass=ABCMeta): raise PortError(msg) return translations[0], rotations[0], o_offsets[0] - diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py index e1dab87..070bf8e 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -46,6 +46,11 @@ def test_port_measure_travel() -> None: 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: From 26cc0290b975e6d782bc98faa51c447fa15d1ebb Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 21:06:51 -0700 Subject: [PATCH 127/157] [Abstract] respect ref scale --- masque/abstract.py | 4 ++++ masque/test/test_abstract.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/masque/abstract.py b/masque/abstract.py index 501e394..d23d7c7 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -157,6 +157,8 @@ 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 @@ -174,6 +176,8 @@ 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/test/test_abstract.py b/masque/test/test_abstract.py index 7c2dbbb..d2f54ed 100644 --- a/masque/test/test_abstract.py +++ b/masque/test/test_abstract.py @@ -54,6 +54,17 @@ def test_abstract_ref_transform() -> 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) @@ -62,3 +73,13 @@ def test_abstract_undo_transform() -> None: 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) From 9adfcac437572657ac5b290a4f5e7880c35815fd Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 21:07:13 -0700 Subject: [PATCH 128/157] [Ref] don't shadow ref property --- masque/ref.py | 2 +- masque/test/test_ref.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/masque/ref.py b/masque/ref.py index f3241e4..a40776a 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -44,7 +44,7 @@ class Ref( __slots__ = ( '_mirrored', # inherited - '_offset', '_rotation', 'scale', '_repetition', '_annotations', + '_offset', '_rotation', '_scale', '_repetition', '_annotations', ) _mirrored: bool diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py index e2d266b..c1dbf26 100644 --- a/masque/test/test_ref.py +++ b/masque/test/test_ref.py @@ -1,7 +1,9 @@ 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 @@ -70,3 +72,18 @@ def test_ref_copy() -> None: 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) From b843ffb4d33ed57fe83f7d4d6696b712da03d024 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 21:11:00 -0700 Subject: [PATCH 129/157] [ILibraryView / Pattern] flatten() shouldn't drop ports-only patterns if flatten_ports=True --- masque/library.py | 3 ++- masque/pattern.py | 3 ++- masque/test/test_library.py | 20 +++++++++++++++++++- masque/test/test_pattern.py | 15 +++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/masque/library.py b/masque/library.py index 8dc63a4..5b32260 100644 --- a/masque/library.py +++ b/masque/library.py @@ -303,7 +303,8 @@ 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}') - if target_pat.is_empty(): # avoid some extra allocations + ports_only = flatten_ports and bool(target_pat.ports) + if target_pat.is_empty() and not ports_only: # avoid some extra allocations continue for ref in pat.refs[target]: diff --git a/masque/pattern.py b/masque/pattern.py index 46b564a..9014298 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1077,7 +1077,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - if target_pat.is_empty(): # avoid some extra allocations + ports_only = flatten_ports and bool(target_pat.ports) + if target_pat.is_empty() and not ports_only: # avoid some extra allocations continue for ref in refs: diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 22ad42a..5b143fa 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -2,7 +2,9 @@ import pytest from typing import cast, TYPE_CHECKING from ..library import Library, LazyLibrary from ..pattern import Pattern -from ..error import LibraryError +from ..error import LibraryError, PatternError +from ..ports import Port +from ..repetition import Grid if TYPE_CHECKING: from ..shapes import Polygon @@ -59,6 +61,22 @@ def test_library_flatten() -> None: 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_lazy_library() -> None: lib = LazyLibrary() called = 0 diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py index f5da195..338bd39 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -1,12 +1,15 @@ +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: @@ -99,6 +102,18 @@ def test_pattern_get_bounds() -> None: 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_interface() -> None: source = Pattern() source.ports["A"] = Port((10, 20), 0, ptype="test") From c32168dc64d50cca87b7b44fbd888c4167fc7f4b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 21:17:33 -0700 Subject: [PATCH 130/157] [ILibraryView / Pattern] flatten() should raise PatternError if asked to preserve ports from a repeated ref --- masque/library.py | 5 +++++ masque/pattern.py | 5 +++++ masque/test/test_library.py | 14 ++++++++++++++ masque/test/test_pattern.py | 12 ++++++++++++ 4 files changed, 36 insertions(+) diff --git a/masque/library.py b/masque/library.py index 5b32260..afd25c6 100644 --- a/masque/library.py +++ b/masque/library.py @@ -308,6 +308,11 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): continue for ref in pat.refs[target]: + 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() diff --git a/masque/pattern.py b/masque/pattern.py index 9014298..0a66aee 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1082,6 +1082,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): 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() diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 5b143fa..9eb705e 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -77,6 +77,20 @@ def test_library_flatten_preserves_ports_only_child() -> None: 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_lazy_library() -> None: lib = LazyLibrary() called = 0 diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py index 338bd39..b459502 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -114,6 +114,18 @@ def test_pattern_flatten_preserves_ports_only_child() -> None: 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_interface() -> None: source = Pattern() source.ports["A"] = Port((10, 20), 0, ptype="test") From c2ef3e42174cea0fb7c3c35625189d95aff97169 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 21:19:10 -0700 Subject: [PATCH 131/157] [test] data_to_ports should accurately preserve ports from a scaled ref --- masque/test/test_ports2data.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py index f461cb8..72f6870 100644 --- a/masque/test/test_ports2data.py +++ b/masque/test/test_ports2data.py @@ -55,3 +55,22 @@ def test_data_to_ports_hierarchical() -> None: 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) From 4ae8115139982f3d9bd2b40821df37a824976a31 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 22:07:05 -0700 Subject: [PATCH 132/157] [DeferredDict] implement get/items/values for deferreddict --- masque/test/test_utils.py | 20 +++++++++++++++++++- masque/utils/deferreddict.py | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py index f495285..45e347e 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -2,7 +2,7 @@ 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 +from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms, DeferredDict def test_remove_duplicate_vertices() -> None: @@ -86,3 +86,21 @@ def test_apply_transforms_advanced() -> None: # 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/utils/deferreddict.py b/masque/utils/deferreddict.py index 31e6943..def9b10 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 +from collections.abc import Callable, Iterator from functools import lru_cache @@ -41,6 +41,19 @@ class DeferredDict(dict, Generic[Key, Value]): 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. From 20bd0640e16915c62f356a1b94594ec08095c678 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 22:10:26 -0700 Subject: [PATCH 133/157] [Library] improve handling of dangling refs --- masque/file/utils.py | 3 +- masque/library.py | 127 ++++++++++++++++++++++++++++++------ masque/test/test_library.py | 75 +++++++++++++++++++++ 3 files changed, 183 insertions(+), 22 deletions(-) diff --git a/masque/file/utils.py b/masque/file/utils.py index 33f68d4..25bc61d 100644 --- a/masque/file/utils.py +++ b/masque/file/utils.py @@ -75,7 +75,8 @@ def preflight( raise PatternError('Non-numeric layers found:' + pformat(named_layers)) if prune_empty_patterns: - pruned = lib.prune_empty() + prune_dangling = 'error' if allow_dangling_refs is False else 'ignore' + pruned = lib.prune_empty(dangling=prune_dangling) if pruned: logger.info(f'Preflight pruned {len(pruned)} empty patterns') logger.debug('Pruned: ' + pformat(pruned)) diff --git a/masque/library.py b/masque/library.py index afd25c6..cb50139 100644 --- a/masque/library.py +++ b/masque/library.py @@ -59,6 +59,9 @@ 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 = '_' """ @@ -418,6 +421,21 @@ 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', @@ -523,46 +541,88 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): return self - def child_graph(self) -> dict[str, set[str | None]]: + def child_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: """ 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 = {name: set(pat.refs.keys()) for name, pat in self.items()} + 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()) return graph - def parent_graph(self) -> dict[str, set[str]]: + def parent_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> 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. """ - 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) + 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) return igraph - def child_order(self) -> list[str]: + def child_order( + self, + dangling: dangling_mode_t = 'error', + ) -> list[str]: """ - Return a topologically sorted list of all contained pattern names. + Return a topologically sorted list of graph node 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()).static_order())) + return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).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`. @@ -575,6 +635,8 @@ 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 @@ -583,8 +645,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ instances = defaultdict(list) if parent_graph is None: - parent_graph = self.parent_graph() - for parent in parent_graph[name]: + 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()): if parent not in self: # parent_graph may be a for a superset of self continue for ref in self[parent].refs[name]: @@ -597,6 +669,7 @@ 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 @@ -613,18 +686,28 @@ 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)`. """ - if name not in self: - return {} + graph_mode = 'ignore' if dangling == 'ignore' else 'include' if order is None: - order = self.child_order() + order = self.child_order(dangling=graph_mode) if parent_graph is None: - parent_graph = self.parent_graph() + 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 {} self_keys = set(self.keys()) @@ -633,16 +716,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).items(): + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items(): transforms[parent] = [((name,), numpy.concatenate(vals))] for next_name in order: if next_name not in transforms: continue - if not parent_graph[next_name] & self_keys: + if not parent_graph.get(next_name, set()) & self_keys: continue - outers = self.find_refs_local(next_name, parent_graph=parent_graph) + outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) inners = transforms.pop(next_name) for parent, outer in outers.items(): for path, inner in inners: @@ -1119,17 +1202,19 @@ 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() + parent_graph = self.parent_graph(dangling=dangling) empty = {name for name, pat in self.items() if pat.is_empty()} trimmed = set() while empty: diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 9eb705e..1f65595 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -1,10 +1,12 @@ 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 ..file.utils import preflight if TYPE_CHECKING: from ..shapes import Polygon @@ -41,6 +43,79 @@ def test_library_dangling() -> None: 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() From b44c962e079882f119a6b524660349134fa1ae63 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 22:11:50 -0700 Subject: [PATCH 134/157] [Pattern] improve error handling in place() --- masque/pattern.py | 4 +++- masque/test/test_pattern.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/masque/pattern.py b/masque/pattern.py index 0a66aee..ab5f55a 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1411,7 +1411,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): other_copy.translate_elements(offset) self.append(other_copy) else: - assert not isinstance(other, Pattern) + 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.') ref = Ref(mirrored=mirrored) ref.rotate_around(pivot, rotation) ref.translate(offset) diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py index b459502..07e4150 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -126,6 +126,14 @@ def test_pattern_flatten_repeated_ref_with_ports_raises() -> None: 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") From ffbe15c465a6a13ffed3ca97d1b717b03e644976 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 23:30:03 -0700 Subject: [PATCH 135/157] [Port / PortList] raise PortError on missing port name --- masque/ports.py | 11 +++++++++++ masque/test/test_ports.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/masque/ports.py b/masque/ports.py index ac48681..ff3a0e3 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -328,6 +328,9 @@ 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: @@ -395,6 +398,14 @@ 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] diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py index 070bf8e..14dc982 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -70,6 +70,25 @@ def test_port_list_rename() -> None: 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_plugged() -> None: class MyPorts(PortList): def __init__(self) -> None: @@ -88,6 +107,25 @@ def test_port_list_plugged() -> None: 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_mismatch() -> None: class MyPorts(PortList): def __init__(self) -> None: From d3be6aeba3aba6ae30d8bf28c40a9e2889bc7568 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 23:33:33 -0700 Subject: [PATCH 136/157] [PortList] add_port_pair requires unique port names --- masque/ports.py | 2 ++ masque/test/test_ports.py | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/masque/ports.py b/masque/ports.py index ff3a0e3..3a67003 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -368,6 +368,8 @@ 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), diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py index 14dc982..0291a1c 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -89,6 +89,25 @@ def test_port_list_rename_missing_port_raises() -> None: 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: @@ -126,6 +145,29 @@ def test_port_list_plugged_empty_raises() -> None: 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: From d03fafcaf6718d9af4bb9d23667d92c961a17507 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 23:34:31 -0700 Subject: [PATCH 137/157] [ILibraryView] don't fail on nested dangling ref --- masque/library.py | 5 +++-- masque/test/test_library.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/masque/library.py b/masque/library.py index cb50139..bb2e3d2 100644 --- a/masque/library.py +++ b/masque/library.py @@ -294,8 +294,9 @@ 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 in pat.refs: + for target, refs in refs_by_target: if target is None: continue if dangling_ok and target not in self: @@ -310,7 +311,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): if target_pat.is_empty() and not ports_only: # avoid some extra allocations continue - for ref in pat.refs[target]: + 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}; ' diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 1f65595..0a04d98 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -166,6 +166,24 @@ def test_library_flatten_repeated_ref_with_ports_raises() -> None: 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 From aa7007881f7f0c1e9db03f2910b72a324cdd6209 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 00:16:58 -0700 Subject: [PATCH 138/157] [pack2d] bin-packing fixes --- masque/test/test_pack2d.py | 47 +++++++++++++++++++++++++++++++++++++- masque/utils/pack2d.py | 10 ++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/masque/test/test_pack2d.py b/masque/test/test_pack2d.py index 5390a4c..914c23e 100644 --- a/masque/test/test_pack2d.py +++ b/masque/test/test_pack2d.py @@ -1,4 +1,4 @@ -from ..utils.pack2d import maxrects_bssf, pack_patterns +from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns from ..library import Library from ..pattern import Pattern @@ -25,6 +25,34 @@ def test_maxrects_bssf_reject() -> None: 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() @@ -49,3 +77,20 @@ def test_pack_patterns() -> None: # 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/utils/pack2d.py b/masque/utils/pack2d.py index a99b01e..248f408 100644 --- a/masque/utils/pack2d.py +++ b/masque/utils/pack2d.py @@ -60,6 +60,12 @@ 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] @@ -102,7 +108,7 @@ def maxrects_bssf( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = set(unsort_order[list(rejected_inds)]) + rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} return rect_locs, rejected_inds @@ -187,7 +193,7 @@ def guillotine_bssf_sas( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = set(unsort_order[list(rejected_inds)]) + rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} return rect_locs, rejected_inds From 83ec64158a2afc285f6d8add776fbf2ad48f7e61 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 00:24:52 -0700 Subject: [PATCH 139/157] [AutoTool] fix exact s-bend validation --- masque/builder/tools.py | 2 +- masque/test/test_autotool_refactor.py | 56 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 4a82b34..68e89df 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1031,7 +1031,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[1] + otrans_dxy[1]) + success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[0] + otrans_dxy[0]) if success: b_transition = None straight_length = 0 diff --git a/masque/test/test_autotool_refactor.py b/masque/test/test_autotool_refactor.py index 677ddd6..d93f935 100644 --- a/masque/test/test_autotool_refactor.py +++ b/masque/test/test_autotool_refactor.py @@ -108,6 +108,62 @@ def test_autotool_planS_double_L(multi_bend_tool) -> None: 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) From 56e401196acb6f566dbe9cc3b5d014d25ec4c9af Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 00:25:14 -0700 Subject: [PATCH 140/157] [PathTool] fix pathtool L-shape --- masque/builder/tools.py | 11 ++++++++--- masque/test/test_renderpather.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 68e89df..f8779bd 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1244,7 +1244,7 @@ class PathTool(Tool, metaclass=ABCMeta): port_names: tuple[str, str] = ('A', 'B'), **kwargs, # noqa: ARG002 (unused) ) -> Library: - out_port, dxy = self.planL( + out_port, _data = self.planL( ccw, length, in_ptype=in_ptype, @@ -1252,7 +1252,12 @@ class PathTool(Tool, metaclass=ABCMeta): ) tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)]) + 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) if ccw is None: out_rot = pi @@ -1263,7 +1268,7 @@ class PathTool(Tool, metaclass=ABCMeta): pat.ports = { port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), - port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype), + port_names[1]: Port(out_port.offset, rotation=out_rot, ptype=self.ptype), } return tree diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index ee04671..3ad0d95 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -119,3 +119,14 @@ def test_renderpather_rename_port(rpather_setup: tuple[RenderPather, PathTool, L 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) From 1bcf5901d66544f3429cd7b248c509c57c0b533a Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 09:23:19 -0700 Subject: [PATCH 141/157] [Path] preserve width from normalized form --- masque/shapes/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 3df55f4..fc8793d 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -481,7 +481,7 @@ class Path(Shape): (offset, scale / norm_value, rotation, False), lambda: Path( reordered_vertices * norm_value, - width=self.width * norm_value, + width=width0 * norm_value, cap=self.cap, )) From 3beadd2bf0f104433cc54f57ca0830fbaa46af31 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 09:23:59 -0700 Subject: [PATCH 142/157] [Path] preserve cap extensions in normalized form, and scale them with scale() --- masque/shapes/path.py | 6 +++++- masque/test/test_library.py | 16 ++++++++++++++++ masque/test/test_path.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index fc8793d..3aa6f07 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -453,6 +453,8 @@ class Path(Shape): 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: @@ -476,13 +478,15 @@ class Path(Shape): reordered_vertices = rotated_vertices 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), + return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0), (offset, scale / norm_value, rotation, False), lambda: Path( reordered_vertices * norm_value, width=width0 * 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': diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 0a04d98..e58bd10 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -6,6 +6,7 @@ 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: @@ -243,3 +244,18 @@ def test_library_get_name() -> None: 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_path.py b/masque/test/test_path.py index 766798f..1cdd872 100644 --- a/masque/test/test_path.py +++ b/masque/test/test_path.py @@ -1,4 +1,4 @@ -from numpy.testing import assert_equal +from numpy.testing import assert_equal, assert_allclose from ..shapes import Path @@ -79,3 +79,33 @@ def test_path_scale() -> None: 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] From e7f847d4c773a621e30818e4f806a5e19e754e86 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 09:28:48 -0700 Subject: [PATCH 143/157] [Pather] make two-L path planning atomic (don't error out with only one half drawn) --- masque/builder/pather.py | 19 +++++++++++++++---- masque/test/test_pather_api.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 3478c32..e8804d1 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -415,8 +415,17 @@ class Pather(PortList): self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) return self - self._traceL(portspec, ccw0, L1, **(kwargs | {'out_ptype': None})) - self._traceL(portspec, not ccw0, L2, **(kwargs | {'plug_into': plug_into})) + 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) @@ -436,14 +445,16 @@ class Pather(PortList): try: R = self._get_tool_R(tool, ccw, in_ptype, **kwargs) L1, L2 = length + R, abs(jog) - R - self._traceL(portspec, ccw, L1, **(kwargs | {'out_ptype': None})) - self._traceL(portspec, ccw, L2, **(kwargs | {'plug_into': plug_into})) + 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) diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index 9ac1b78..c837280 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -1,7 +1,9 @@ +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() @@ -240,3 +242,31 @@ def test_pather_trace_into() -> None: 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 From 932565d531fbc406bae0e9db212249ecf923122e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 17:10:19 -0700 Subject: [PATCH 144/157] [Repetition] fix ordering --- masque/repetition.py | 7 +++---- masque/test/test_repetition.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/masque/repetition.py b/masque/repetition.py index a8de94c..99e1082 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -34,7 +34,7 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A pass @abstractmethod - def __le__(self, other: 'Repetition') -> bool: + def __lt__(self, other: 'Repetition') -> bool: pass @abstractmethod @@ -288,7 +288,7 @@ class Grid(Repetition): return False return True - def __le__(self, other: Repetition) -> bool: + def __lt__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) other = cast('Grid', other) @@ -347,7 +347,7 @@ class Arbitrary(Repetition): return False return numpy.array_equal(self.displacements, other.displacements) - def __le__(self, other: Repetition) -> bool: + def __lt__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) other = cast('Arbitrary', other) @@ -415,4 +415,3 @@ class Arbitrary(Repetition): """ self.displacements = self.displacements * c return self - diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py index 5ef2fa9..f423ab2 100644 --- a/masque/test/test_repetition.py +++ b/masque/test/test_repetition.py @@ -49,3 +49,17 @@ def test_arbitrary_transform() -> None: # self.displacements[:, 1 - axis] *= -1 # if axis=0, 1-axis=1, so y *= -1 assert_allclose(arb.displacements, [[0, -10]], atol=1e-10) + + +def test_repetition_less_equal_includes_equality() -> None: + grid_a = Grid(a_vector=(10, 0), a_count=2) + grid_b = Grid(a_vector=(10, 0), a_count=2) + assert grid_a == grid_b + assert grid_a <= grid_b + assert grid_a >= grid_b + + arb_a = Arbitrary([[0, 0], [1, 0]]) + arb_b = Arbitrary([[0, 0], [1, 0]]) + assert arb_a == arb_b + assert arb_a <= arb_b + assert arb_a >= arb_b From 707a16fe64c24ffe1fc9c2b26205fbe3f692b8e2 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 17:11:26 -0700 Subject: [PATCH 145/157] [RenderStep] fix mirroring a planned path --- masque/builder/tools.py | 4 ++-- masque/test/test_renderpather.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index f8779bd..c14b5d3 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -85,8 +85,8 @@ class RenderStep: new_start = self.start_port.copy() new_end = self.end_port.copy() - new_start.mirror(axis) - new_end.mirror(axis) + new_start.flip_across(axis=axis) + new_end.flip_across(axis=axis) return RenderStep( opcode = self.opcode, diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index 3ad0d95..73b5f46 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -65,6 +65,17 @@ def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library] assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10) +def test_renderpather_mirror_preserves_planned_bend_geometry(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: + rp, tool, lib = rpather_setup + rp.at("start").straight(10).cw(10) + + rp.mirror(0) + rp.render() + + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + 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") From 20c845a881d05c475315680cfd6847f1e63f7d0f Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 17:12:41 -0700 Subject: [PATCH 146/157] [Tool] avoid passing port_names down --- masque/builder/tools.py | 4 ++-- masque/test/test_pather_api.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index c14b5d3..5b1a0a9 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -240,7 +240,7 @@ class Tool: BuildError if an impossible or unsupported geometry is requested. """ # Fallback implementation using traceL - port_names = kwargs.get('port_names', ('A', 'B')) + port_names = kwargs.pop('port_names', ('A', 'B')) tree = self.traceL( ccw, length, @@ -288,7 +288,7 @@ class Tool: BuildError if an impossible or unsupported geometry is requested. """ # Fallback implementation using traceS - port_names = kwargs.get('port_names', ('A', 'B')) + port_names = kwargs.pop('port_names', ('A', 'B')) tree = self.traceS( length, jog, diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index c837280..41d0881 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -2,7 +2,7 @@ import pytest import numpy from numpy import pi from masque import Pather, RenderPather, Library, Pattern, Port -from masque.builder.tools import PathTool +from masque.builder.tools import PathTool, Tool from masque.error import BuildError def test_pather_trace_basic() -> None: @@ -258,6 +258,36 @@ def test_pather_jog_failed_fallback_is_atomic() -> None: assert len(p.paths['A']) == 0 +def test_tool_planL_fallback_accepts_custom_port_names() -> None: + class DummyTool(Tool): + def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: + lib = Library() + pat = Pattern() + pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire') + pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire') + lib['top'] = pat + return lib + + out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y')) + assert numpy.allclose(out_port.offset, (5, 0)) + assert numpy.isclose(out_port.rotation, pi) + + +def test_tool_planS_fallback_accepts_custom_port_names() -> None: + class DummyTool(Tool): + def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: + lib = Library() + pat = Pattern() + pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire') + pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire') + lib['top'] = pat + return lib + + out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y')) + assert numpy.allclose(out_port.offset, (5, 2)) + assert numpy.isclose(out_port.rotation, pi) + + def test_pather_uturn_failed_fallback_is_atomic() -> None: lib = Library() tool = PathTool(layer='M1', width=2, ptype='wire') From 2e0b64bdab8ef2ae9be19ac73a8b0d379f088c52 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 17:51:02 -0700 Subject: [PATCH 147/157] [Ref / Label] make equality safe for unrelated types --- masque/label.py | 2 ++ masque/ref.py | 2 ++ masque/test/test_label.py | 6 ++++++ masque/test/test_ref.py | 6 ++++++ 4 files changed, 16 insertions(+) diff --git a/masque/label.py b/masque/label.py index b662035..3dbbc08 100644 --- a/masque/label.py +++ b/masque/label.py @@ -78,6 +78,8 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl return annotations_lt(self.annotations, other.annotations) def __eq__(self, other: Any) -> bool: + if type(self) is not type(other): + return False return ( self.string == other.string and numpy.array_equal(self.offset, other.offset) diff --git a/masque/ref.py b/masque/ref.py index a40776a..b012365 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -122,6 +122,8 @@ class Ref( return annotations_lt(self.annotations, other.annotations) def __eq__(self, other: Any) -> bool: + if type(self) is not type(other): + return False return ( numpy.array_equal(self.offset, other.offset) and self.mirrored == other.mirrored diff --git a/masque/test/test_label.py b/masque/test/test_label.py index ad8c08b..f4f364b 100644 --- a/masque/test/test_label.py +++ b/masque/test/test_label.py @@ -46,3 +46,9 @@ def test_label_copy() -> None: assert l1 is not l2 l2.offset[0] = 100 assert l1.offset[0] == 1 + + +def test_label_eq_unrelated_objects_is_false() -> None: + lbl = Label("test") + assert not (lbl == None) + assert not (lbl == object()) diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py index c1dbf26..d3e9778 100644 --- a/masque/test/test_ref.py +++ b/masque/test/test_ref.py @@ -87,3 +87,9 @@ def test_ref_scale_by_rejects_nonpositive_scale() -> None: with pytest.raises(MasqueError, match='Scale must be positive'): ref.scale_by(-1) + + +def test_ref_eq_unrelated_objects_is_false() -> None: + ref = Ref() + assert not (ref == None) + assert not (ref == object()) From 2b29e46b93b20856bb37b10a6f8f9df6ec5500d4 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 18:49:41 -0700 Subject: [PATCH 148/157] [Pather] fix port rename/deletion tracking --- masque/builder/pather.py | 20 ++++++++++++++++---- masque/test/test_pather_api.py | 17 +++++++++++++++++ masque/test/test_renderpather.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index e8804d1..49ad3be 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -228,7 +228,17 @@ class Pather(PortList): @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} + renamed: dict[str, list[RenderStep]] = {} + for kk, vv in mapping.items(): + if kk not in self.paths: + continue + steps = self.paths.pop(kk) + # Preserve deferred geometry even if the live port is deleted. + # `render()` can still materialize the saved steps using their stored start/end ports. + # Current semantics intentionally keep deleted ports' queued steps under the old key, + # so if a new live port later reuses that name it does not retarget the old geometry; + # the old and new routes merely share a render bucket until `render()` consumes them. + renamed[kk if vv is None else vv] = steps self.paths.update(renamed) return self @@ -789,6 +799,9 @@ class PortPather: # # Delegate to port # + # These mutate only the selected live port state. They do not rewrite already planned + # RenderSteps, so deferred geometry remains as previously planned and only future routing + # starts from the updated port. def set_ptype(self, ptype: str) -> Self: for port in self.ports: self.pather.pattern[port].set_ptype(ptype) @@ -865,8 +878,7 @@ class PortPather: 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.pather.rename_ports({pp: None for pp in self.ports}) self.ports = [] return self @@ -880,7 +892,7 @@ class PortPather: if name is None: self.drop() return None - del self.pather.pattern.ports[name] + self.pather.rename_ports({name: None}) self.ports = [pp for pp in self.ports if pp != name] return self diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index 41d0881..495d305 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -300,3 +300,20 @@ def test_pather_uturn_failed_fallback_is_atomic() -> None: 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_renderpather_rename_to_none_keeps_pending_geometry_without_port() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + rp = RenderPather(lib, tools=tool) + rp.pattern.ports['A'] = Port((0, 0), rotation=0) + + rp.at('A').straight(5000) + rp.rename_ports({'A': None}) + + assert 'A' not in rp.pattern.ports + assert len(rp.paths['A']) == 1 + + rp.render() + assert rp.pattern.has_shapes() + assert 'A' not in rp.pattern.ports diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index 73b5f46..0da9588 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -90,6 +90,22 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar assert len(rp.pattern.shapes[(2, 0)]) == 1 +def test_portpather_translate_only_affects_future_steps(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: + rp, tool, lib = rpather_setup + pp = rp.at("start") + pp.straight(10) + pp.translate((5, 0)) + pp.straight(10) + + rp.render() + + shapes = rp.pattern.shapes[(1, 0)] + assert len(shapes) == 2 + assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10) + assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10) + assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10) + + def test_renderpather_dead_ports() -> None: lib = Library() tool = PathTool(layer=(1, 0), width=1) @@ -132,6 +148,20 @@ def test_renderpather_rename_port(rpather_setup: tuple[RenderPather, PathTool, L assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10) +def test_renderpather_drop_keeps_pending_geometry_without_port(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: + rp, tool, lib = rpather_setup + rp.at("start").straight(10).drop() + + assert "start" not in rp.ports + assert len(rp.paths["start"]) == 1 + + rp.render() + assert rp.pattern.has_shapes() + assert "start" not in rp.ports + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10) + + def test_pathtool_traceL_bend_geometry_matches_ports() -> None: tool = PathTool(layer=(1, 0), width=2, ptype="wire") From 462a05a665a5692b08f953313444baa01321ba5d Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 18:58:37 -0700 Subject: [PATCH 149/157] [Library] fix dedup() - use consistent deduplicated target name - remove shape indices per dedup --- masque/library.py | 20 ++++++++++++++++--- masque/test/test_library.py | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/masque/library.py b/masque/library.py index bb2e3d2..13b4d26 100644 --- a/masque/library.py +++ b/masque/library.py @@ -1037,6 +1037,18 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): def label2name(label: tuple) -> str: # noqa: ARG001 return self.get_name(SINGLE_USE_PREFIX + 'shape') + used_names = set(self.keys()) + + def reserve_target_name(label: tuple) -> str: + base_name = label2name(label) + name = base_name + ii = sum(1 for nn in used_names if nn.startswith(base_name)) if base_name in used_names else 0 + while name in used_names or name == '': + name = base_name + b64suffix(ii) + ii += 1 + used_names.add(name) + return name + shape_counts: MutableMapping[tuple, int] = defaultdict(int) shape_funcs = {} @@ -1053,6 +1065,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): shape_counts[label] += 1 shape_pats = {} + target_names = {} for label, count in shape_counts.items(): if count < threshold: continue @@ -1061,6 +1074,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): shape_pat = Pattern() shape_pat.shapes[label[-1]] += [shape_func()] shape_pats[label] = shape_pat + target_names[label] = reserve_target_name(label) # ## Second pass ## for pat in tuple(self.values()): @@ -1085,10 +1099,10 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): # For repeated shapes, create a `Pattern` holding a normalized shape object, # and add `pat.refs` entries for each occurrence in pat. Also, note down that # we should delete the `pat.shapes` entries for which we made `Ref`s. - shapes_to_remove = [] for label, shape_entries in shape_table.items(): layer = label[-1] - target = label2name(label) + target = target_names[label] + shapes_to_remove = [] for ii, values in shape_entries: offset, scale, rotation, mirror_x = values pat.ref(target=target, offset=offset, scale=scale, @@ -1100,7 +1114,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): del pat.shapes[layer][ii] for ll, pp in shape_pats.items(): - self[label2name(ll)] = pp + self[target_names[ll]] = pp return self diff --git a/masque/test/test_library.py b/masque/test/test_library.py index e58bd10..335b6ec 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -259,3 +259,43 @@ def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None: assert not lib["top"].refs assert len(lib["top"].shapes[(1, 0)]) == 2 + + +def test_library_dedup_handles_multiple_duplicate_groups() -> None: + from ..shapes import Circle + + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))] + pat.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)] + lib["top"] = pat + + lib.dedup(exclude_types=(), norm_value=1, threshold=2) + + assert len(lib["top"].refs) == 2 + assert all(len(refs) == 2 for refs in lib["top"].refs.values()) + assert len(lib["top"].shapes[(1, 0)]) == 0 + assert len(lib["top"].shapes[(2, 0)]) == 0 + + +def test_library_dedup_uses_stable_target_names_per_label() -> None: + from ..shapes import Circle + + lib = Library() + + p1 = Pattern() + p1.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))] + lib["p1"] = p1 + + p2 = Pattern() + p2.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)] + lib["p2"] = p2 + + lib.dedup(exclude_types=(), norm_value=1, threshold=2) + + circle_target = next(iter(lib["p1"].refs)) + path_target = next(iter(lib["p2"].refs)) + + assert circle_target != path_target + assert all(isinstance(shape, Circle) for shapes in lib[circle_target].shapes.values() for shape in shapes) + assert all(isinstance(shape, Path) for shapes in lib[path_target].shapes.values() for shape in shapes) From 08421d6a54519081a1d51f9e1aadd6f8ed70d739 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 19:00:38 -0700 Subject: [PATCH 150/157] [OASIS] repeated property keys should be merged, not overwritten --- masque/file/oasis.py | 2 +- masque/test/test_oasis.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 5e343ea..95a2039 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -714,7 +714,7 @@ def properties_to_annotations( string = repr(value) logger.warning(f'Converting property value for key ({key}) to string ({string})') values.append(string) - annotations[key] = values + annotations.setdefault(key, []).extend(values) return annotations diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py index b1129f4..ad9bbf9 100644 --- a/masque/test/test_oasis.py +++ b/masque/test/test_oasis.py @@ -4,6 +4,8 @@ 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") @@ -23,3 +25,20 @@ def test_oasis_roundtrip(tmp_path: Path) -> None: # Check bounds assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]]) + + +def test_oasis_properties_to_annotations_merges_repeated_keys() -> None: + pytest.importorskip("fatamorgana") + import fatamorgana.records as fatrec + from ..file.oasis import properties_to_annotations + + annotations = properties_to_annotations( + [ + fatrec.Property("k", [1], is_standard=False), + fatrec.Property("k", [2, 3], is_standard=False), + ], + {}, + {}, + ) + + assert annotations == {"k": [1, 2, 3]} From 46a3559391cd45f7666d47ab2b98f93618075060 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 21:19:29 -0700 Subject: [PATCH 151/157] [dxf] fix dxf repetition load --- masque/file/dxf.py | 55 +++++++++++++++++++++++++++++++++++++---- masque/test/test_dxf.py | 32 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0c19b5a..301910d 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -300,12 +300,57 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> ) if 'column_count' in attr: - args['repetition'] = Grid( - a_vector=(attr['column_spacing'], 0), - b_vector=(0, attr['row_spacing']), - a_count=attr['column_count'], - b_count=attr['row_count'], + col_spacing = attr['column_spacing'] + row_spacing = attr['row_spacing'] + col_count = attr['column_count'] + row_count = attr['row_count'] + local_x = numpy.array((col_spacing, 0.0)) + local_y = numpy.array((0.0, row_spacing)) + inv_rot = rotation_matrix_2d(-rotation) + + candidates = ( + (inv_rot @ local_x, inv_rot @ local_y, col_count, row_count), + (inv_rot @ local_y, inv_rot @ local_x, row_count, col_count), ) + repetition = None + for a_vector, b_vector, a_count, b_count in candidates: + rotated_a = rotation_matrix_2d(rotation) @ a_vector + rotated_b = rotation_matrix_2d(rotation) @ b_vector + if (numpy.isclose(rotated_a[1], 0, atol=1e-8) + and numpy.isclose(rotated_b[0], 0, atol=1e-8) + and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8) + and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8) + and a_count == col_count + and b_count == row_count): + repetition = Grid( + a_vector=a_vector, + b_vector=b_vector, + a_count=a_count, + b_count=b_count, + ) + break + if (numpy.isclose(rotated_a[0], 0, atol=1e-8) + and numpy.isclose(rotated_b[1], 0, atol=1e-8) + and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8) + and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8) + and b_count == col_count + and a_count == row_count): + repetition = Grid( + a_vector=a_vector, + b_vector=b_vector, + a_count=a_count, + b_count=b_count, + ) + break + + if repetition is None: + repetition = Grid( + a_vector=inv_rot @ local_x, + b_vector=inv_rot @ local_y, + a_count=col_count, + b_count=row_count, + ) + args['repetition'] = repetition pat.ref(**args) else: logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).') diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py index 0c0a1a3..5b038c6 100644 --- a/masque/test/test_dxf.py +++ b/masque/test/test_dxf.py @@ -112,6 +112,38 @@ def test_dxf_manhattan_precision(tmp_path: Path): assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation" +def test_dxf_rotated_grid_roundtrip_preserves_basis_and_counts(tmp_path: Path): + lib = Library() + sub = Pattern() + sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) + lib["sub"] = sub + + top = Pattern() + top.ref( + "sub", + offset=(0, 0), + rotation=numpy.pi / 2, + repetition=Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2), + ) + lib["top"] = top + + dxf_file = tmp_path / "rotated_grid.dxf" + dxf.writefile(lib, "top", dxf_file) + + 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) + actual = ref.repetition.displacements + expected = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2).displacements + assert_allclose( + actual[numpy.lexsort((actual[:, 1], actual[:, 0]))], + expected[numpy.lexsort((expected[:, 1], expected[:, 0]))], + ) + + def test_dxf_read_legacy_polyline() -> None: doc = ezdxf.new() msp = doc.modelspace() From 620b001af55209b42f77ba78142ab7d753de1e3a Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 21:21:16 -0700 Subject: [PATCH 152/157] [ILibrary] fix dedup messing up rotations --- masque/library.py | 2 +- masque/test/test_library.py | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/masque/library.py b/masque/library.py index 13b4d26..9d1f1b7 100644 --- a/masque/library.py +++ b/masque/library.py @@ -1106,7 +1106,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): for ii, values in shape_entries: offset, scale, rotation, mirror_x = values pat.ref(target=target, offset=offset, scale=scale, - rotation=rotation, mirrored=(mirror_x, False)) + rotation=rotation, mirrored=mirror_x) shapes_to_remove.append(ii) # Remove any shapes for which we have created refs. diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 335b6ec..09c258b 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -6,7 +6,7 @@ from ..pattern import Pattern from ..error import LibraryError, PatternError from ..ports import Port from ..repetition import Grid -from ..shapes import Path +from ..shapes import Arc, Ellipse, Path, Text from ..file.utils import preflight if TYPE_CHECKING: @@ -261,6 +261,29 @@ def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None: assert len(lib["top"].shapes[(1, 0)]) == 2 +def test_library_dedup_text_preserves_scale_and_mirror_flag() -> None: + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [ + Text("A", 10, "dummy.ttf", offset=(0, 0)), + Text("A", 10, "dummy.ttf", offset=(100, 0)), + ] + lib["top"] = pat + + lib.dedup(exclude_types=(), norm_value=5, threshold=2) + + target_name = next(iter(lib["top"].refs)) + refs = lib["top"].refs[target_name] + assert [ref.mirrored for ref in refs] == [False, False] + assert [ref.scale for ref in refs] == [2.0, 2.0] + assert cast("Text", lib[target_name].shapes[(1, 0)][0]).height == 5 + + flat = lib.flatten("top")["top"] + assert [cast("Text", shape).height for shape in flat.shapes[(1, 0)]] == [10, 10] + + + + def test_library_dedup_handles_multiple_duplicate_groups() -> None: from ..shapes import Circle From 89cdd23f00e343405d10a015f789db8af925b981 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 21:22:15 -0700 Subject: [PATCH 153/157] [Arc / Ellipse] make radii hashable --- masque/shapes/arc.py | 2 +- masque/shapes/ellipse.py | 2 +- masque/test/test_library.py | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 6f948cb..4befb6d 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -422,7 +422,7 @@ class Arc(PositionableImpl, Shape): rotation %= 2 * pi width = self.width - return ((type(self), radii, norm_angles, width / norm_value), + return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value), (self.offset, scale / norm_value, rotation, False), lambda: Arc( radii=radii * norm_value, diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 8e3fd49..985d252 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -206,7 +206,7 @@ class Ellipse(PositionableImpl, Shape): radii = self.radii[::-1] / self.radius_y scale = self.radius_y angle = (self.rotation + pi / 2) % pi - return ((type(self), radii), + return ((type(self), tuple(radii.tolist())), (self.offset, scale / norm_value, angle, False), lambda: Ellipse(radii=radii * norm_value)) diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 09c258b..6ac8536 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -282,6 +282,28 @@ def test_library_dedup_text_preserves_scale_and_mirror_flag() -> None: assert [cast("Text", shape).height for shape in flat.shapes[(1, 0)]] == [10, 10] +def test_library_dedup_handles_arc_and_ellipse_labels() -> None: + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [ + Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(0, 0)), + Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(50, 0)), + ] + pat.shapes[(2, 0)] += [ + Ellipse(radii=(10, 20), offset=(0, 0)), + Ellipse(radii=(10, 20), offset=(50, 0)), + ] + lib["top"] = pat + + lib.dedup(exclude_types=(), norm_value=1, threshold=2) + + assert len(lib["top"].refs) == 2 + assert lib["top"].shapes[(1, 0)] == [] + assert lib["top"].shapes[(2, 0)] == [] + + flat = lib.flatten("top")["top"] + assert sum(isinstance(shape, Arc) for shape in flat.shapes[(1, 0)]) == 2 + assert sum(isinstance(shape, Ellipse) for shape in flat.shapes[(2, 0)]) == 2 def test_library_dedup_handles_multiple_duplicate_groups() -> None: From f34b9b2f5cbef461041baf89d04ed85b1ec54106 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 21:22:35 -0700 Subject: [PATCH 154/157] [Text] fixup bounds and normalized form --- masque/shapes/text.py | 14 ++++++++++---- masque/test/test_shape_advanced.py | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index dec4c33..93f2024 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -108,6 +108,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): and self.string == other.string and self.height == other.height and self.font_path == other.font_path + and self.mirrored == other.mirrored and self.rotation == other.rotation and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) @@ -127,6 +128,8 @@ class Text(PositionableImpl, RotatableImpl, Shape): return self.font_path < other.font_path if not numpy.array_equal(self.offset, other.offset): return tuple(self.offset) < tuple(other.offset) + if self.mirrored != other.mirrored: + return self.mirrored < other.mirrored if self.rotation != other.rotation: return self.rotation < other.rotation if self.repetition != other.repetition: @@ -174,22 +177,25 @@ class Text(PositionableImpl, RotatableImpl, Shape): (self.offset, self.height / norm_value, rotation, bool(self.mirrored)), lambda: Text( string=self.string, - height=self.height * norm_value, + height=norm_value, font_path=self.font_path, rotation=rotation, ).mirror2d(across_x=self.mirrored), ) - def get_bounds_single(self) -> NDArray[numpy.float64]: + def get_bounds_single(self) -> NDArray[numpy.float64] | None: # rotation makes this a huge pain when using slot.advance and glyph.bbox(), so # just convert to polygons instead polys = self.to_polygons() + if not polys: + return None + pbounds = numpy.full((len(polys), 2, 2), nan) for pp, poly in enumerate(polys): pbounds[pp] = poly.get_bounds_nonempty() bounds = numpy.vstack(( - numpy.min(pbounds[: 0, :], axis=0), - numpy.max(pbounds[: 1, :], axis=0), + numpy.min(pbounds[:, 0, :], axis=0), + numpy.max(pbounds[:, 1, :], axis=0), )) return bounds diff --git a/masque/test/test_shape_advanced.py b/masque/test/test_shape_advanced.py index 4e38e55..046df1a 100644 --- a/masque/test/test_shape_advanced.py +++ b/masque/test/test_shape_advanced.py @@ -27,6 +27,33 @@ def test_text_to_polygons() -> None: assert len(set(char_x_means)) >= 2 +def test_text_bounds_and_normalized_form() -> 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") + + text = Text("Hi", height=10, font_path=font_path) + _intrinsic, extrinsic, ctor = text.normalized_form(5) + normalized = ctor() + + assert extrinsic[1] == 2 + assert normalized.height == 5 + + bounds = text.get_bounds_single() + assert bounds is not None + assert numpy.isfinite(bounds).all() + assert numpy.all(bounds[1] > bounds[0]) + + +def test_text_mirroring_affects_comparison() -> None: + text = Text("A", height=10, font_path="dummy.ttf") + mirrored = Text("A", height=10, font_path="dummy.ttf", mirrored=True) + + assert text != mirrored + assert (text < mirrored) != (mirrored < text) + + # 2. Manhattanization tests def test_manhattanize() -> None: pytest.importorskip("float_raster") From c303a0c1145dab3b697f2874f45c60684ce094dc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 21:41:49 -0700 Subject: [PATCH 155/157] [Ellipse / Arc] improve bounds calculation --- masque/shapes/arc.py | 95 +++++++++++------------------- masque/shapes/ellipse.py | 10 +++- masque/test/test_shape_advanced.py | 12 ++++ 3 files changed, 52 insertions(+), 65 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 4befb6d..a068700 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -313,77 +313,48 @@ class Arc(PositionableImpl, Shape): return [poly] def get_bounds_single(self) -> NDArray[numpy.float64]: - """ - Equation for rotated ellipse is - `x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)` - `y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)` - where `t` is our parameter. - - Differentiating and solving for 0 slope wrt. `t`, we find - `tan(t) = -+ b/a cot(phi)` - where -+ is for x, y cases, so that's where the extrema are. - - If the extrema are innaccessible due to arc constraints, check the arc endpoints instead. - """ a_ranges = cast('_array2x2_t', self._angles_to_parameters()) + sin_r = numpy.sin(self.rotation) + cos_r = numpy.cos(self.rotation) - mins = [] - maxs = [] + def point(rx: float, ry: float, tt: float) -> NDArray[numpy.float64]: + return numpy.array(( + rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r, + rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r, + )) + + def points_in_interval(rx: float, ry: float, a0: float, a1: float) -> list[NDArray[numpy.float64]]: + candidates = [a0, a1] + if rx != 0 and ry != 0: + tx = numpy.arctan2(-ry * sin_r, rx * cos_r) + ty = numpy.arctan2(ry * cos_r, rx * sin_r) + candidates.extend((tx, tx + pi, ty, ty + pi)) + + lo = min(a0, a1) + hi = max(a0, a1) + pts = [] + for base in candidates: + k_min = int(numpy.floor((lo - base) / (2 * pi))) - 1 + k_max = int(numpy.ceil((hi - base) / (2 * pi))) + 1 + for kk in range(k_min, k_max + 1): + tt = base + kk * 2 * pi + if lo <= tt <= hi: + pts.append(point(rx, ry, tt)) + return pts + + pts = [] for aa, sgn in zip(a_ranges, (-1, +1), strict=True): wh = sgn * self.width / 2 rx = self.radius_x + wh ry = self.radius_y + wh - if rx == 0 or ry == 0: - # Single point, at origin - mins.append([0, 0]) - maxs.append([0, 0]) + pts.append(numpy.zeros(2)) continue + pts.extend(points_in_interval(rx, ry, aa[0], aa[1])) - a0, a1 = aa - a0_offset = a0 - (a0 % (2 * pi)) - - sin_r = numpy.sin(self.rotation) - cos_r = numpy.cos(self.rotation) - sin_a = numpy.sin(aa) - cos_a = numpy.cos(aa) - - # Cutoff angles - xpt = (-self.rotation) % (2 * pi) + a0_offset - ypt = (pi / 2 - self.rotation) % (2 * pi) + a0_offset - xnt = (xpt - pi) % (2 * pi) + a0_offset - ynt = (ypt - pi) % (2 * pi) + a0_offset - - # Points along coordinate axes - rx2_inv = 1 / (rx * rx) - ry2_inv = 1 / (ry * ry) - xr = numpy.abs(cos_r * cos_r * rx2_inv + sin_r * sin_r * ry2_inv) ** -0.5 - yr = numpy.abs(-sin_r * -sin_r * rx2_inv + cos_r * cos_r * ry2_inv) ** -0.5 - - # Arc endpoints - xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a) - yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) - - # If 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 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1: - xn = -xr - - if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1: - yp = yr - - if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1: - yn = -yr - - mins.append([xn, yn]) - maxs.append([xp, yp]) - return numpy.vstack((numpy.min(mins, axis=0) + self.offset, - numpy.max(maxs, axis=0) + self.offset)) + all_pts = numpy.asarray(pts) + self.offset + return numpy.vstack((numpy.min(all_pts, axis=0), + numpy.max(all_pts, axis=0))) def rotate(self, theta: float) -> 'Arc': self.rotation += theta diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 985d252..8a5c287 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -180,9 +180,13 @@ class Ellipse(PositionableImpl, Shape): return [poly] def get_bounds_single(self) -> NDArray[numpy.float64]: - rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii) - return numpy.vstack((self.offset - rot_radii[0], - self.offset + rot_radii[1])) + cos_r = numpy.cos(self.rotation) + sin_r = numpy.sin(self.rotation) + x_extent = numpy.sqrt((self.radius_x * cos_r) ** 2 + (self.radius_y * sin_r) ** 2) + y_extent = numpy.sqrt((self.radius_x * sin_r) ** 2 + (self.radius_y * cos_r) ** 2) + extents = numpy.array((x_extent, y_extent)) + return numpy.vstack((self.offset - extents, + self.offset + extents)) def rotate(self, theta: float) -> Self: self.rotation += theta diff --git a/masque/test/test_shape_advanced.py b/masque/test/test_shape_advanced.py index 046df1a..ab42b4b 100644 --- a/masque/test/test_shape_advanced.py +++ b/masque/test/test_shape_advanced.py @@ -97,6 +97,18 @@ def test_arc_edge_cases() -> None: assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10) +def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None: + ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200)) + bounds = ellipse.get_bounds_single() + poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single() + assert_allclose(bounds, poly_bounds, atol=1e-3) + + +def test_rotated_arc_bounds_match_polygonized_geometry() -> None: + arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200)) + bounds = arc.get_bounds_single() + poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() + assert_allclose(bounds, poly_bounds, atol=1e-3) def test_path_edge_cases() -> None: # Zero-length segments p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2) From 2952e6ef8f687f97e60a13e1d0589b5e0796bb3e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 21:42:16 -0700 Subject: [PATCH 156/157] [Arc / Ellipse / Circle] gracefully handle large arclen --- masque/shapes/arc.py | 2 +- masque/shapes/circle.py | 2 +- masque/shapes/ellipse.py | 2 +- masque/test/test_shape_advanced.py | 13 +++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index a068700..ab2e47b 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -268,7 +268,7 @@ class Arc(PositionableImpl, Shape): """ Figure out the parameter values at which we should place vertices to meet the arclength constraint""" dr = -wh if inner else wh - n_pts = numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen).astype(int) + n_pts = max(2, int(numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen))) arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) keep = [0] diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 8dad165..a6f7af1 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -108,7 +108,7 @@ class Circle(PositionableImpl, Shape): n += [num_vertices] if max_arclen is not None: n += [2 * pi * self.radius / max_arclen] - num_vertices = int(round(max(n))) + num_vertices = max(3, int(round(max(n)))) thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False) xs = numpy.cos(thetas) * self.radius ys = numpy.sin(thetas) * self.radius diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 8a5c287..55ce9fd 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -168,7 +168,7 @@ class Ellipse(PositionableImpl, Shape): n += [num_vertices] if max_arclen is not None: n += [perimeter / max_arclen] - num_vertices = int(round(max(n))) + num_vertices = max(3, int(round(max(n)))) thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False) sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas)) diff --git a/masque/test/test_shape_advanced.py b/masque/test/test_shape_advanced.py index ab42b4b..fc76fcb 100644 --- a/masque/test/test_shape_advanced.py +++ b/masque/test/test_shape_advanced.py @@ -109,6 +109,19 @@ def test_rotated_arc_bounds_match_polygonized_geometry() -> None: bounds = arc.get_bounds_single() poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() assert_allclose(bounds, poly_bounds, atol=1e-3) + + +def test_curve_polygonizers_clamp_large_max_arclen() -> None: + for shape in ( + Circle(radius=10), + Ellipse(radii=(10, 20)), + Arc(radii=(10, 20), angles=(0, 1), width=2), + ): + polys = shape.to_polygons(num_vertices=None, max_arclen=1e9) + assert len(polys) == 1 + assert len(polys[0].vertices) >= 3 + + def test_path_edge_cases() -> None: # Zero-length segments p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2) From 4b07bb9e25ad4b2b886d59386e781eb57d9e4824 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 21:42:49 -0700 Subject: [PATCH 157/157] [OASIS] raise PatternError for unsuppored caps --- masque/file/oasis.py | 4 +++- masque/test/test_oasis.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 95a2039..b5d0cd8 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -562,7 +562,9 @@ def _shapes_to_elements( xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset) deltas = rint_cast(numpy.diff(shape.vertices, axis=0)) half_width = rint_cast(shape.width / 2) - path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup + if path_type is None: + raise PatternError(f'OASIS writer does not support path cap {shape.cap}') 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( diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py index ad9bbf9..f549db7 100644 --- a/masque/test/test_oasis.py +++ b/masque/test/test_oasis.py @@ -1,9 +1,12 @@ +import io from pathlib import Path import pytest from numpy.testing import assert_equal +from ..error import PatternError from ..pattern import Pattern from ..library import Library +from ..shapes import Path as MPath def test_oasis_roundtrip(tmp_path: Path) -> None: @@ -42,3 +45,16 @@ def test_oasis_properties_to_annotations_merges_repeated_keys() -> None: ) assert annotations == {"k": [1, 2, 3]} + + +def test_oasis_write_rejects_circle_path_caps() -> None: + pytest.importorskip("fatamorgana") + from ..file import oasis + + lib = Library() + pat = Pattern() + pat.path((1, 0), vertices=[[0, 0], [10, 0]], width=2, cap=MPath.Cap.Circle) + lib["cell1"] = pat + + with pytest.raises(PatternError, match="does not support path cap"): + oasis.write(lib, io.BytesIO(), units_per_micron=1000)