diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py index 57b170c..a51a27e 100644 --- a/examples/ellip_grating.py +++ b/examples/ellip_grating.py @@ -6,7 +6,7 @@ from masque.file import gdsii from masque import Arc, Pattern -def main() -> None: +def main(): pat = Pattern() layer = (0, 0) pat.shapes[layer].extend([ diff --git a/examples/nested_poly_test.py b/examples/nested_poly_test.py index 60e0a3e..de51d6a 100644 --- a/examples/nested_poly_test.py +++ b/examples/nested_poly_test.py @@ -1,5 +1,7 @@ +import numpy from pyclipper import ( - Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO, + Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO, + scale_to_clipper, scale_from_clipper, ) p = Pyclipper() p.AddPaths([ @@ -10,8 +12,8 @@ p.AddPaths([ ], PT_SUBJECT, closed=True) #p.Execute2? #p.Execute? -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) +p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO) +p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO) p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) p = Pyclipper() diff --git a/examples/test_rep.py b/examples/test_rep.py index d25fb55..f82575d 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis -def main() -> None: +def main(): lib = Library() cell_name = 'ellip_grating' diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 6e5730b..7210a93 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -18,14 +18,11 @@ Contents * Design a pattern which is meant to plug into an existing pattern (via `.interface()`) - [pather](pather.py) * Use `Pather` to route individual wires and wire bundles - * Use `AutoTool` to generate paths - * Use `AutoTool` to automatically transition between path types -- [renderpather](renderpather.py) + * Use `BasicTool` to generate paths + * Use `BasicTool` to automatically transition between path types +- [renderpather](rendpather.py) * Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py), but using `Path` shapes instead of `Polygon`s. -- [port_pather](port_pather.py) - * Use `PortPather` and the `.at()` syntax for more concise routing - * Advanced port manipulation and connections Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices. diff --git a/examples/tutorial/basic_shapes.py b/examples/tutorial/basic_shapes.py index d8f7e1e..87baaf0 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -1,9 +1,12 @@ +from collections.abc import Sequence import numpy from numpy import pi -from masque import layer_t, Pattern, Circle, Arc, Ref -from masque.repetition import Grid +from masque import ( + layer_t, Pattern, Label, Port, + Circle, Arc, Polygon, + ) import masque.file.gdsii @@ -36,45 +39,6 @@ def hole( return pat -def hole_array( - radius: float, - num_x: int = 5, - num_y: int = 3, - pitch: float = 2000, - layer: layer_t = (1, 0), - ) -> Pattern: - """ - Generate an array of circular holes using `Repetition`. - - Args: - radius: Circle radius. - num_x, num_y: Number of holes in x and y. - pitch: Center-to-center spacing. - layer: Layer to draw the holes on. - - Returns: - Pattern containing a grid of holes. - """ - # First, make a pattern for a single hole - hpat = hole(radius, layer) - - # Now, create a pattern that references it multiple times using a Grid - pat = Pattern() - pat.refs['hole'] = [ - Ref( - offset=(0, 0), - repetition=Grid(a_vector=(pitch, 0), a_count=num_x, - b_vector=(0, pitch), b_count=num_y) - )] - - # We can also add transformed references (rotation, mirroring, etc.) - pat.refs['hole'].append( - Ref(offset=(0, -pitch), rotation=pi / 4, mirrored=True) - ) - - return pat, hpat - - def triangle( radius: float, layer: layer_t = (1, 0), @@ -96,7 +60,9 @@ def triangle( ]) * radius pat = Pattern() - pat.polygon(layer, vertices=vertices) + pat.shapes[layer].extend([ + Polygon(offset=(0, 0), vertices=vertices), + ]) return pat @@ -145,13 +111,9 @@ def main() -> None: lib['smile'] = smile(1000) lib['triangle'] = triangle(1000) - # Use a Grid to make many holes efficiently - lib['grid'], lib['hole'] = hole_array(1000) - masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS) lib['triangle'].visualize() - lib['grid'].visualize(lib) if __name__ == '__main__': diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index 79d318a..6b9cfa2 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -4,8 +4,8 @@ import numpy from numpy import pi from masque import ( - layer_t, Pattern, Ref, Builder, Port, Polygon, - Library, + layer_t, Pattern, Ref, Label, Builder, Port, Polygon, + Library, ILibraryView, ) from masque.utils import ports2data from masque.file.gdsii import writefile, check_valid_names diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index abfbbf1..eab8a12 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,13 +1,17 @@ from typing import Any +from collections.abc import Sequence, Callable from pprint import pformat +import numpy +from numpy import pi -from masque import Builder, LazyLibrary +from masque import Pattern, Builder, LazyLibrary from masque.file.gdsii import writefile, load_libraryfile +import pcgen import basic_shapes import devices -from devices import data_to_ports +from devices import ports_to_data, data_to_ports from basic_shapes import GDS_OPTS diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index a9d9af9..101fbb5 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -1,9 +1,10 @@ """ -Manual wire routing tutorial: Pather and AutoTool +Manual wire routing tutorial: Pather and BasicTool """ +from collections.abc import Callable from numpy import pi -from masque import Pather, Library, Pattern, Port, layer_t -from masque.builder.tools import AutoTool, Tool +from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers +from masque.builder.tools import BasicTool, PathTool from masque.file.gdsii import writefile from basic_shapes import GDS_OPTS @@ -109,96 +110,6 @@ def map_layer(layer: layer_t) -> layer_t: return layer_mapping.get(layer, layer) -def prepare_tools() -> tuple[Library, Tool, Tool]: - """ - Create some basic library elements and tools for drawing M1 and M2 - """ - # Build some patterns (static cells) using the above functions and store them in a library - library = Library() - library['pad'] = make_pad() - library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH) - library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH) - library['v1_via'] = make_via( - layer_top = 'M2', - layer_via = 'V1', - layer_bot = 'M1', - width_top = M2_WIDTH, - width_via = V1_WIDTH, - width_bot = M1_WIDTH, - ptype_bot = 'm1wire', - ptype_top = 'm2wire', - ) - - # - # Now, define two tools. - # M1_tool will route on M1, using wires with M1_WIDTH - # M2_tool will route on M2, using wires with M2_WIDTH - # Both tools are able to automatically transition from the other wire type (with a via) - # - # Note that while we use AutoTool for this tutorial, you can define your own `Tool` - # with arbitrary logic inside -- e.g. with single-use bends, complex transition rules, - # transmission line geometry, or other features. - # - M1_tool = AutoTool( - # First, we need a function which takes in a length and spits out an M1 wire - straights = [ - AutoTool.Straight( - ptype = 'm1wire', - fn = lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length), - in_port_name = 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input - out_port_name = 'output', # and use the port named 'output' as the output - ), - ], - bends = [ - AutoTool.Bend( - abstract = library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier - in_port_name = 'input', - out_port_name = 'output', - clockwise = True, - ), - ], - transitions = { # We can automate transitions for different (normally incompatible) port types - ('m2wire', 'm1wire'): AutoTool.Transition( # For example, when we're attaching to a port with type 'm2wire' - library.abstract('v1_via'), # we can place a V1 via - 'top', # using the port named 'top' as the input (i.e. the M2 side of the via) - 'bottom', # and using the port named 'bottom' as the output - ), - }, - sbends = [], - default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1 - ) - - M2_tool = AutoTool( - straights = [ - # Again, we use make_straight_wire, but this time we set parameters for M2 - AutoTool.Straight( - ptype = 'm2wire', - fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length), - in_port_name = 'input', - out_port_name = 'output', - ), - ], - bends = [ - # and we use an M2 bend - AutoTool.Bend( - abstract = library.abstract('m2_bend'), - in_port_name = 'input', - out_port_name = 'output', - ), - ], - transitions = { - ('m1wire', 'm2wire'): AutoTool.Transition( - library.abstract('v1_via'), # We still use the same via, - 'bottom', # but the input port is now 'bottom' - 'top', # and the output port is now 'top' - ), - }, - sbends = [], - default_out_ptype = 'm2wire', # We default to trying to stay on M2 - ) - return library, M1_tool, M2_tool - - # # Now we can start building up our library (collection of static cells) and pathing tools. # @@ -207,7 +118,75 @@ def prepare_tools() -> tuple[Library, Tool, Tool]: # (e.g. geometry definition). # def main() -> None: - library, M1_tool, M2_tool = prepare_tools() + # Build some patterns (static cells) using the above functions and store them in a library + library = Library() + library['pad'] = make_pad() + library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH) + library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH) + library['v1_via'] = make_via( + layer_top='M2', + layer_via='V1', + layer_bot='M1', + width_top=M2_WIDTH, + width_via=V1_WIDTH, + width_bot=M1_WIDTH, + ptype_bot='m1wire', + ptype_top='m2wire', + ) + + # + # Now, define two tools. + # M1_tool will route on M1, using wires with M1_WIDTH + # M2_tool will route on M2, using wires with M2_WIDTH + # Both tools are able to automatically transition from the other wire type (with a via) + # + # Note that while we use BasicTool for this tutorial, you can define your own `Tool` + # with arbitrary logic inside -- e.g. with single-use bends, complex transition rules, + # transmission line geometry, or other features. + # + M1_tool = BasicTool( + straight = ( + # First, we need a function which takes in a length and spits out an M1 wire + lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length), + 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input + 'output', # and use the port named 'output' as the output + ), + bend = ( + library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier + 'input', # To orient it clockwise, use the port named 'input' as the input + 'output', # and 'output' as the output + ), + transitions = { # We can automate transitions for different (normally incompatible) port types + 'm2wire': ( # For example, when we're attaching to a port with type 'm2wire' + library.abstract('v1_via'), # we can place a V1 via + 'top', # using the port named 'top' as the input (i.e. the M2 side of the via) + 'bottom', # and using the port named 'bottom' as the output + ), + }, + default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1 + ) + + M2_tool = BasicTool( + straight = ( + # Again, we use make_straight_wire, but this time we set parameters for M2 + lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length), + 'input', + 'output', + ), + bend = ( + library.abstract('m2_bend'), # and we use an M2 bend + 'input', + 'output', + ), + transitions = { + 'm1wire': ( + library.abstract('v1_via'), # We still use the same via, + 'bottom', # but the input port is now 'bottom' + 'top', # and the output port is now 'top' + ), + }, + default_out_ptype = 'm2wire', # We default to trying to stay on M2 + ) # # Create a new pather which writes to `library` and uses `M2_tool` as its default tool. @@ -239,7 +218,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 Pather, we don't need to place one ourselves. + # Since we have defined an M2-to-M1 transition for BasicPather, we don't need to place one ourselves. # If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here # and achieve the same result without having to define any transitions in M1_tool. # Note that even though we have changed the tool used for GND, the via doesn't get placed until @@ -293,7 +272,7 @@ def main() -> None: pather.path_to('GND', None, -50_000) # Save the pather's pattern into our library - library['Pather_and_AutoTool'] = pather.pattern + library['Pather_and_BasicTool'] = pather.pattern # Convert from text-based layers to numeric layers for GDS, and output the file library.map_layers(map_layer) diff --git a/examples/tutorial/pcgen.py b/examples/tutorial/pcgen.py index 5c5c31b..023079c 100644 --- a/examples/tutorial/pcgen.py +++ b/examples/tutorial/pcgen.py @@ -2,7 +2,7 @@ Routines for creating normalized 2D lattices and common photonic crystal cavity designs. """ -from collections.abc import Sequence +from collection.abc import Sequence import numpy from numpy.typing import ArrayLike, NDArray @@ -50,7 +50,7 @@ def triangular_lattice( elif origin == 'corner': pass else: - raise ValueError(f'Invalid value for `origin`: {origin}') + raise Exception(f'Invalid value for `origin`: {origin}') return xy[xy[:, 0].argsort(), :] @@ -197,12 +197,12 @@ def ln_defect( `[[x0, y0], [x1, y1], ...]` for all the holes """ if defect_length % 2 != 1: - raise ValueError('defect_length must be odd!') - pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims]) + raise Exception('defect_length must be odd!') + p = triangular_lattice([2 * d + 1 for d in mirror_dims]) half_length = numpy.floor(defect_length / 2) hole_nums = numpy.arange(-half_length, half_length + 1) - holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True) - return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :] + holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True) + return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ] def ln_shift_defect( @@ -248,7 +248,7 @@ def ln_shift_defect( for sign in (-1, 1): x_val = sign * (x_removed + ind + 1) which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0) - xyr[which, :] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) + xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) return xyr @@ -309,7 +309,7 @@ def l3_shift_perturbed_defect( # which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2]) perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2))) - for xy in perturbed_holes: - which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1) - xyr[which, 2] = perturbed_radius + for row in xyr: + if numpy.fabs(row) in perturbed_holes: + row[2] = perturbed_radius return xyr diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py deleted file mode 100644 index 3fad6e7..0000000 --- a/examples/tutorial/port_pather.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -PortPather tutorial: Using .at() syntax -""" -from masque import RenderPather, Pattern, Port, R90 -from masque.file.gdsii import writefile - -from basic_shapes import GDS_OPTS -from pather import map_layer, prepare_tools - - -def main() -> None: - # Reuse the same patterns (pads, bends, vias) and tools as in pather.py - library, M1_tool, M2_tool = prepare_tools() - - # Create a RenderPather and place some initial pads (same as Pather tutorial) - rpather = RenderPather(library, tools=M2_tool) - - rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) - rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) - rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) - rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) - - # - # Routing with .at() chaining - # - # The .at(port_name) method returns a PortPather object which wraps the Pather - # and remembers the selected port(s). This allows method chaining. - - # Route VCC: 6um South, then West to x=0. - # (Note: since the port points North into the pad, path() moves South by default) - (rpather.at('VCC') - .path(ccw=False, length=6_000) # Move South, turn West (Clockwise) - .path_to(ccw=None, x=0) # Continue West to x=0 - ) - - # Route GND: 5um South, then West to match VCC's x-coordinate. - rpather.at('GND').path(ccw=False, length=5_000).path_to(ccw=None, x=rpather['VCC'].x) - - - # - # Tool management and manual plugging - # - # We can use .retool() to change the tool for specific ports. - # We can also use .plug() directly on a PortPather. - - # Manually add a via to GND and switch to M1_tool for subsequent segments - (rpather.at('GND') - .plug('v1_via', 'top') - .retool(M1_tool) # this only retools the 'GND' port - ) - - # We can also pass multiple ports to .at(), and then use .mpath() on them. - # Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via). - (rpather.at(['GND', 'VCC']) - .mpath(ccw=True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South - .retool(M1_tool) # Retools both GND and VCC - .mpath(ccw=True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension - .mpath(ccw=False, emin=1_000, spacing=1_200) # U-turn back South - .mpath(ccw=False, emin=2_000, spacing=4_500) # U-turn back West - ) - - # Retool VCC back to M2 and move both to x=-28k - rpather.at('VCC').retool(M2_tool) - rpather.at(['GND', 'VCC']).mpath(ccw=None, xmin=-28_000) - - # Final segments to -50k - rpather.at('VCC').path_to(ccw=None, x=-50_000, out_ptype='m1wire') - with rpather.at('GND').toolctx(M2_tool): - rpather.at('GND').path_to(ccw=None, x=-40_000) - rpather.at('GND').path_to(ccw=None, x=-50_000) - - - # - # Branching with save_copy and into_copy - # - # .save_copy(new_name) creates a port copy and keeps the original selected. - # .into_copy(new_name) creates a port copy and selects the new one. - - # Create a tap on GND - (rpather.at('GND') - .path(ccw=None, length=5_000) # Move GND further West - .save_copy('GND_TAP') # Mark this location for a later branch - .pathS(length=10_000, jog=-10_000) # Continue GND with an S-bend - ) - - # Branch VCC and follow the new branch - (rpather.at('VCC') - .path(ccw=None, length=5_000) - .into_copy('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH' - .path(ccw=True, length=5_000) # VCC_BRANCH turns South - ) - # The original 'VCC' port remains at x=-55k, y=VCC.y - - - # - # Port set management: add, drop, rename, delete - # - - # Route the GND_TAP we saved earlier. - (rpather.at('GND_TAP') - .retool(M1_tool) - .path(ccw=True, length=10_000) # Turn South - .rename_to('GND_FEED') # Give it a more descriptive name - .retool(M1_tool) # Re-apply tool to the new name - ) - - # We can manage the active set of ports in a PortPather - pp = rpather.at(['VCC_BRANCH', 'GND_FEED']) - pp.add_port('GND') # Now tracking 3 ports - pp.drop_port('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND - pp.path_each(ccw=None, length=5_000) # Move both 5um forward (length > transition size) - - # We can also delete ports from the pather entirely - rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead) - - - # - # Advanced Connections: path_into and path_from - # - - # path_into routes FROM the selected port TO a target port. - # path_from routes TO the selected port FROM a source port. - - # Create a destination component - dest_ports = { - 'in_A': Port((0, 0), rotation=R90, ptype='m2wire'), - 'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire') - } - library['dest'] = Pattern(ports=dest_ports) - # Place dest so that its ports are to the West and South of our current wires. - # Rotating by pi/2 makes the ports face West (pointing East). - rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'}) - - # Connect GND_FEED to DEST_A - # Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice. - rpather.at('GND_FEED').path_into('DEST_A') - - # Connect VCC_BRANCH to DEST_B using path_from - rpather.at('DEST_B').path_from('VCC_BRANCH') - - - # - # Direct Port Transformations and Metadata - # - (rpather.at('GND') - .set_ptype('m1wire') # Change metadata - .translate((1000, 0)) # Shift the port 1um East - .rotate(R90 / 2) # Rotate it 45 degrees - .set_rotation(R90) # Force it to face West - ) - - # Demonstrate .plugged() to acknowledge a manual connection - # (Normally used when you place components so their ports perfectly overlap) - rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2')) - rpather.at('TMP1').plugged('TMP2') # Removes both ports - - - # - # Rendering and Saving - # - # Since we used RenderPather, we must call .render() to generate the geometry. - rpather.render() - - library['PortPather_Tutorial'] = rpather.pattern - library.map_layers(map_layer) - writefile(library, 'port_pather.gds', **GDS_OPTS) - print("Tutorial complete. Output written to port_pather.gds") - - -if __name__ == '__main__': - main() diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py index ecc8bc8..cb002f3 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -1,7 +1,8 @@ """ Manual wire routing tutorial: RenderPather an PathTool """ -from masque import RenderPather, Library +from collections.abc import Callable +from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers from masque.builder.tools import PathTool from masque.file.gdsii import writefile @@ -12,7 +13,7 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via def main() -> None: # # To illustrate the advantages of using `RenderPather`, we use `PathTool` instead - # of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) + # of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) # but when used with `RenderPather`, it can consolidate multiple routing steps into # a single `Path` shape. # @@ -24,24 +25,24 @@ def main() -> None: library = Library() library['pad'] = make_pad() library['v1_via'] = make_via( - layer_top = 'M2', - layer_via = 'V1', - layer_bot = 'M1', - width_top = M2_WIDTH, - width_via = V1_WIDTH, - width_bot = M1_WIDTH, - ptype_bot = 'm1wire', - ptype_top = 'm2wire', + layer_top='M2', + layer_via='V1', + layer_bot='M1', + width_top=M2_WIDTH, + width_via=V1_WIDTH, + width_bot=M1_WIDTH, + ptype_bot='m1wire', + ptype_top='m2wire', ) - # `PathTool` is more limited than `AutoTool`. It only generates one type of shape + # `PathTool` is more limited than `BasicTool`. It only generates one type of shape # (`Path`), so it only needs to know what layer to draw on, what width to draw with, # and what port type to present. M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire') M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire') rpather = RenderPather(tools=M2_ptool, library=library) - # As in the pather tutorial, we make some pads and labels... + # As in the pather tutorial, we make soem pads and labels... rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) @@ -51,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'].x) + rpather.path_to('GND', None, x=rpather['VCC'].offset[0]) # `PathTool` doesn't know how to transition betwen metal layers, so we have to # `plug` the via into the GND wire ourselves. @@ -75,15 +76,13 @@ 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. - 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 + via_size = abs( + library['v1_via'].ports['top'].offset[0] + - library['v1_via'].ports['bottom'].offset[0] + ) rpather.path_to('VCC', None, -50_000 + via_size) rpather.plug('v1_via', {'VCC': 'top'}) - # Render the path we defined rpather.render() library['RenderPather_and_PathTool'] = rpather.pattern diff --git a/masque/__init__.py b/masque/__init__.py index e435fac..4ad7e69 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -55,7 +55,6 @@ from .pattern import ( map_targets as map_targets, chain_elements as chain_elements, ) -from .utils.boolean import boolean as boolean from .library import ( ILibraryView as ILibraryView, diff --git a/masque/abstract.py b/masque/abstract.py index 501e394..7135eba 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -8,13 +8,16 @@ from numpy.typing import ArrayLike from .ref import Ref from .ports import PortList, Port from .utils import rotation_matrix_2d -from .traits import Mirrorable + +#if TYPE_CHECKING: +# from .builder import Builder, Tool +# from .library import ILibrary logger = logging.getLogger(__name__) -class Abstract(PortList, Mirrorable): +class Abstract(PortList): """ An `Abstract` is a container for a name and associated ports. @@ -128,18 +131,50 @@ class Abstract(PortList, Mirrorable): port.rotate(rotation) return self - def mirror(self, axis: int = 0) -> Self: + def mirror_port_offsets(self, across_axis: int = 0) -> Self: """ - Mirror the Abstract across an axis through its origin. + Mirror the offsets of all shapes, labels, and refs across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ for port in self.ports.values(): - port.flip_across(axis=axis) + port.offset[across_axis - 1] *= -1 + return self + + def mirror_ports(self, across_axis: int = 0) -> Self: + """ + Mirror each port's rotation across an axis, relative to its + offset + + Args: + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) + + Returns: + self + """ + for port in self.ports.values(): + port.mirror(across_axis) + return self + + def mirror(self, across_axis: int = 0) -> Self: + """ + Mirror the Pattern across an axis + + Args: + axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) + + Returns: + self + """ + self.mirror_ports(across_axis) + self.mirror_port_offsets(across_axis) return self def apply_ref_transform(self, ref: Ref) -> Self: diff --git a/masque/builder/builder.py b/masque/builder/builder.py index 40ea109..1b534b5 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -275,10 +275,6 @@ 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`. @@ -288,7 +284,8 @@ class Builder(PortList): do not line up) """ if self._dead: - logger.warning('Skipping geometry for plug() since device is dead') + logger.error('Skipping plug() since device is dead') + return self if not isinstance(other, str | Abstract | Pattern): # We got a Tree; add it into self.library and grab an Abstract for it @@ -308,7 +305,6 @@ class Builder(PortList): set_rotation = set_rotation, append = append, ok_connections = ok_connections, - skip_geometry = self._dead, ) return self @@ -354,10 +350,6 @@ 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`. @@ -365,7 +357,8 @@ class Builder(PortList): are applied. """ if self._dead: - logger.warning('Skipping geometry for place() since device is dead') + logger.error('Skipping place() since device is dead') + return self if not isinstance(other, str | Abstract | Pattern): # We got a Tree; add it into self.library and grab an Abstract for it @@ -385,7 +378,6 @@ class Builder(PortList): port_map = port_map, skip_port_check = skip_port_check, append = append, - skip_geometry = self._dead, ) return self @@ -433,18 +425,13 @@ class Builder(PortList): 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. - + 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 uses fallback port update - dev.plug(c, ...) # also updated via fallback + 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 ``` diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 387b0d8..9af473d 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -7,8 +7,6 @@ import copy import logging from pprint import pformat -from numpy import pi - from ..pattern import Pattern from ..library import ILibrary from ..error import BuildError @@ -285,48 +283,19 @@ 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. """ if self._dead: - logger.warning('Skipping geometry for path() since device is dead') + logger.error('Skipping path() since device is dead') + return self tool_port_names = ('A', 'B') tool = self.tools.get(portspec, self.tools[None]) in_ptype = self.pattern[portspec].ptype - 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 - 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 - + tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) tname = self.library << tree if plug_into is not None: output = {plug_into: tool_port_names[1]} @@ -366,18 +335,13 @@ 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. """ if self._dead: - logger.warning('Skipping geometry for pathS() since device is dead') + logger.error('Skipping pathS() since device is dead') + return self tool_port_names = ('A', 'B') @@ -389,40 +353,16 @@ class Pather(Builder, PatherMixin): # 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_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]]) + 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) - 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 - 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}) + 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 tname = self.library << tree diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index c47232f..7f18e77 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -72,10 +72,6 @@ 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, @@ -253,7 +249,8 @@ class RenderPather(PatherMixin): do not line up) """ if self._dead: - logger.warning('Skipping geometry for plug() since device is dead') + logger.error('Skipping plug() since device is dead') + return self other_tgt: Pattern | Abstract if isinstance(other, str): @@ -261,19 +258,18 @@ class RenderPather(PatherMixin): 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)) + # 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,7 +280,6 @@ class RenderPather(PatherMixin): set_rotation = set_rotation, append = append, ok_connections = ok_connections, - skip_geometry = self._dead, ) return self @@ -335,7 +330,8 @@ class RenderPather(PatherMixin): are applied. """ if self._dead: - logger.warning('Skipping geometry for place() since device is dead') + logger.error('Skipping place() since device is dead') + return self other_tgt: Pattern | Abstract if isinstance(other, str): @@ -343,11 +339,10 @@ class RenderPather(PatherMixin): 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)) + 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, @@ -358,7 +353,6 @@ class RenderPather(PatherMixin): port_map = port_map, skip_port_check = skip_port_check, append = append, - skip_geometry = self._dead, ) return self @@ -367,12 +361,11 @@ class RenderPather(PatherMixin): 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)) + 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 @@ -408,18 +401,13 @@ 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. """ if self._dead: - logger.warning('Skipping geometry for path() since device is dead') + logger.error('Skipping path() since device is dead') + return self port = self.pattern[portspec] in_ptype = port.ptype @@ -428,31 +416,16 @@ 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 - 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 + out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) # Update port out_port.rotate_around((0, 0), pi + port_rot) out_port.translate(port.offset) - if not self._dead: - step = RenderStep('L', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) + 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}) @@ -492,18 +465,13 @@ 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. """ if self._dead: - logger.warning('Skipping geometry for pathS() since device is dead') + logger.error('Skipping pathS() since device is dead') + return self port = self.pattern[portspec] in_ptype = port.ptype @@ -519,38 +487,21 @@ class RenderPather(PatherMixin): # 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] + 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 - 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 + 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 - 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) + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + step = RenderStep('S', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) + self.pattern.ports[portspec] = out_port.copy() if plug_into is not None: self.plugged({portspec: plug_into}) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 27bc27e..6bd7547 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 +from typing import Literal, Any, Self from collections.abc import Sequence, Callable from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? from dataclasses import dataclass @@ -543,10 +543,9 @@ class AutoTool(Tool, metaclass=ABCMeta): return self @staticmethod - def _bend2dxy(bend: Bend | None, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: + def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: if ccw is None: return numpy.zeros(2), pi - assert bend is not None bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) assert bend_angle is not None if bool(ccw): @@ -591,20 +590,8 @@ 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 - itrans_dxy = numpy.zeros(2) - otrans_dxy = numpy.zeros(2) - btrans_dxy = numpy.zeros(2) - for straight in self.straights: - for bend in bends: + for bend in self.bends: bend_dxy, bend_angle = self._bend2dxy(bend, ccw) in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) @@ -613,16 +600,14 @@ 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 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) + if ccw is not None and bend.in_port.ptype != straight.ptype: + b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) btrans_dxy = self._itransition2dxy(b_transition) straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] @@ -643,7 +628,6 @@ 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/file/oasis.py b/masque/file/oasis.py index 0a11b24..672af25 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -120,10 +120,10 @@ def build( layer, data_type = _mlayer2oas(layer_num) lib.layers += [ fatrec.LayerName( - nstring = name, - layer_interval = (layer, layer), - type_interval = (data_type, data_type), - is_textlayer = tt, + nstring=name, + layer_interval=(layer, layer), + type_interval=(data_type, data_type), + is_textlayer=tt, ) for tt in (True, False)] @@ -286,11 +286,11 @@ def read( annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.polygon( - vertices = vertices, - layer = element.get_layer_tuple(), - offset = element.get_xy(), - annotations = annotations, - repetition = repetition, + vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + annotations=annotations, + repetition=repetition, ) elif isinstance(element, fatrec.Path): vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) @@ -310,13 +310,13 @@ def read( annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.path( - vertices = vertices, - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - annotations = annotations, - width = element.get_half_width() * 2, - cap = cap, + vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + width=element.get_half_width() * 2, + cap=cap, **path_args, ) @@ -325,11 +325,11 @@ def read( height = element.get_height() annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.polygon( - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), - annotations = annotations, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), + annotations=annotations, ) elif isinstance(element, fatrec.Trapezoid): @@ -440,11 +440,11 @@ def read( else: string = str_or_ref.string pat.label( - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - annotations = annotations, - string = string, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + string=string, ) else: @@ -549,13 +549,13 @@ def _shapes_to_elements( offset = rint_cast(shape.offset + rep_offset) radius = rint_cast(shape.radius) circle = fatrec.Circle( - layer = layer, - datatype = datatype, - radius = cast('int', radius), - x = offset[0], - y = offset[1], - properties = properties, - repetition = repetition, + layer=layer, + datatype=datatype, + radius=cast('int', radius), + x=offset[0], + y=offset[1], + properties=properties, + repetition=repetition, ) elements.append(circle) elif isinstance(shape, Path): @@ -566,16 +566,16 @@ def _shapes_to_elements( extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None) extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) path = fatrec.Path( - layer = layer, - datatype = datatype, - point_list = cast('Sequence[Sequence[int]]', deltas), - half_width = cast('int', half_width), - x = xy[0], - y = xy[1], - extension_start = extension_start, # TODO implement multiple cap types? - extension_end = extension_end, - properties = properties, - repetition = repetition, + layer=layer, + datatype=datatype, + point_list=cast('Sequence[Sequence[int]]', deltas), + half_width=cast('int', half_width), + x=xy[0], + y=xy[1], + extension_start=extension_start, # TODO implement multiple cap types? + extension_end=extension_end, + properties=properties, + repetition=repetition, ) elements.append(path) else: @@ -583,13 +583,13 @@ def _shapes_to_elements( xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset) points = rint_cast(numpy.diff(polygon.vertices, axis=0)) elements.append(fatrec.Polygon( - layer = layer, - datatype = datatype, - x = xy[0], - y = xy[1], - point_list = cast('list[list[int]]', points), - properties = properties, - repetition = repetition, + layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + point_list=cast('list[list[int]]', points), + properties=properties, + repetition=repetition, )) return elements @@ -606,13 +606,13 @@ def _labels_to_texts( xy = rint_cast(label.offset + rep_offset) properties = annotations_to_properties(label.annotations) texts.append(fatrec.Text( - layer = layer, - datatype = datatype, - x = xy[0], - y = xy[1], - string = label.string, - properties = properties, - repetition = repetition, + layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + string=label.string, + properties=properties, + repetition=repetition, )) return texts @@ -622,12 +622,10 @@ def repetition_fata2masq( ) -> Repetition | None: mrep: Repetition | None if isinstance(rep, fatamorgana.GridRepetition): - mrep = Grid( - a_vector = rep.a_vector, - b_vector = rep.b_vector, - a_count = rep.a_count, - b_count = rep.b_count, - ) + mrep = Grid(a_vector=rep.a_vector, + b_vector=rep.b_vector, + a_count=rep.a_count, + b_count=rep.b_count) elif isinstance(rep, fatamorgana.ArbitraryRepetition): displacements = numpy.cumsum(numpy.column_stack(( rep.x_displacements, @@ -649,19 +647,14 @@ def repetition_masq2fata( frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None if isinstance(rep, Grid): a_vector = rint_cast(rep.a_vector) - a_count = int(rep.a_count) - if rep.b_count > 1: - b_vector = rint_cast(rep.b_vector) - b_count = int(rep.b_count) - else: - b_vector = None - b_count = None - + b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None + a_count = rint_cast(rep.a_count) + b_count = rint_cast(rep.b_count) if rep.b_count is not None else None frep = fatamorgana.GridRepetition( - a_vector = a_vector, - b_vector = b_vector, - a_count = a_count, - b_count = b_count, + a_vector=cast('list[int]', a_vector), + b_vector=cast('list[int] | None', b_vector), + a_count=cast('int', a_count), + b_count=cast('int | None', b_count), ) offset = (0, 0) elif isinstance(rep, Arbitrary): diff --git a/masque/label.py b/masque/label.py index 8b67c65..711ef35 100644 --- a/masque/label.py +++ b/masque/label.py @@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray from .repetition import Repetition from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key -from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable +from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded from .traits import AnnotatableImpl @functools.total_ordering -class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable): +class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): """ A text annotation with a position (but no size; it is not drawn) """ @@ -58,14 +58,12 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl string=self.string, offset=self.offset.copy(), repetition=self.repetition, - annotations=copy.copy(self.annotations), ) def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() - new._annotations = copy.deepcopy(self._annotations, memo) return new def __lt__(self, other: 'Label') -> bool: @@ -102,28 +100,6 @@ 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: - """ - 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 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) - if self.repetition is not None: - self.repetition.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self - def get_bounds_single(self) -> NDArray[numpy.float64]: """ Return the bounds of the label. diff --git a/masque/pattern.py b/masque/pattern.py index 6b2de99..dc7d058 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,9 +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__) @@ -502,61 +499,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ] return polys - def layer_as_polygons( - self, - layer: layer_t, - flatten: bool = True, - library: Mapping[str, 'Pattern'] | None = None, - ) -> list[Polygon]: - """ - Collect all geometry effectively on a given layer as a list of polygons. - - If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`. - `Repetition` objects are expanded, and non-polygon shapes are converted - to `Polygon` approximations. - - Args: - layer: The layer to collect geometry from. - flatten: If `True`, include geometry from referenced patterns. - library: Required if `flatten=True` to resolve references. - - Returns: - A list of `Polygon` objects. - """ - if flatten and self.has_refs() and library is None: - raise PatternError("Must provide a library to layer_as_polygons() when flatten=True") - - polys: list[Polygon] = [] - - # Local shapes - for shape in self.shapes.get(layer, []): - for p in shape.to_polygons(): - # expand repetitions - if p.repetition is not None: - for offset in p.repetition.displacements: - polys.append(p.deepcopy().translate(offset).set_repetition(None)) - else: - polys.append(p.deepcopy()) - - if flatten and self.has_refs(): - assert library is not None - for target, refs in self.refs.items(): - if target is None: - continue - target_pat = library[target] - for ref in refs: - # Get polygons from target pattern on the same layer - ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library) - # Apply ref transformations - for p in ref_polys: - p_pat = ref.as_pattern(Pattern(shapes={layer: [p]})) - # as_pattern expands repetition of the ref itself - # but we need to pull the polygons back out - for p_transformed in p_pat.shapes[layer]: - polys.append(cast('Polygon', p_transformed)) - - return polys - def referenced_patterns(self) -> set[str | None]: """ Get all pattern namers referenced by this pattern. Non-recursive. @@ -693,7 +635,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): """ for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()): cast('Positionable', entry).translate(offset) - self._log_bulk_update(f"translate({offset!r})") return self def scale_elements(self, c: float) -> Self: @@ -761,7 +702,6 @@ 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: @@ -793,36 +733,50 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): cast('Rotatable', entry).rotate(rotation) return self - def mirror_elements(self, axis: int = 0) -> Self: + def mirror_element_centers(self, across_axis: int = 0) -> Self: """ - Mirror each shape, ref, and port relative to its offset. + Mirror the offsets of all shapes, labels, and refs across an axis Args: - axis: Axis to mirror across - 0: mirror across x axis (flip y), - 1: mirror across y axis (flip x) - - Returns: - self - """ - 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: - """ - Mirror the Pattern across an axis through its origin. - - Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Flippable', entry).flip_across(axis=axis) - self._log_bulk_update(f"mirror({axis})") + 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) return self def copy(self) -> Self: @@ -1160,7 +1114,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map: dict[str, str | None] | None = None, skip_port_check: bool = False, append: bool = False, - skip_geometry: bool = False, ) -> Self: """ Instantiate or append the pattern `other` into the current pattern, adding its @@ -1192,10 +1145,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). - skip_geometry: If `True`, the operation only updates the port list and - skips adding any geometry (shapes, labels, or references). This - allows the pattern assembly to proceed for port-tracking purposes - even when layout generation is suppressed. Returns: self @@ -1227,10 +1176,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): pp.rotate_around(pivot, rotation) pp.translate(offset) self.ports[name] = pp - self._log_port_update(name) - - if skip_geometry: - return self if append: if isinstance(other, Abstract): @@ -1289,7 +1234,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): set_rotation: bool | None = None, append: bool = False, ok_connections: Iterable[tuple[str, str]] = (), - skip_geometry: bool = False, ) -> Self: """ Instantiate or append a pattern into the current pattern, connecting @@ -1344,11 +1288,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): any other ptypte. Non-allowed ptype connections will emit a warning. Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. - skip_geometry: If `True`, only ports are updated and geometry is - skipped. If a valid transform cannot be found (e.g. due to - misaligned ports), a 'best-effort' dummy transform is used - to ensure new ports are still added at approximate locations, - allowing downstream routing to continue. Returns: self @@ -1381,41 +1320,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_out = {out_port_name: next(iter(map_in.keys()))} self.check_ports(other.ports.keys(), map_in, map_out) - try: - translation, rotation, pivot = self.find_transform( - other, - map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, - ) - except PortError: - if not skip_geometry: - raise - logger.warning("Port transform failed for dead device. Using dummy transform.") - if map_in: - ki, vi = next(iter(map_in.items())) - s_port = self.ports[ki] - o_port = other.ports[vi].deepcopy() - if mirrored: - o_port.mirror() - o_port.offset[1] *= -1 - translation = s_port.offset - o_port.offset - rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0 - pivot = o_port.offset - else: - translation = numpy.zeros(2) - rotation = 0.0 - pivot = numpy.zeros(2) + translation, rotation, pivot = self.find_transform( + other, + map_in, + mirrored = mirrored, + set_rotation = set_rotation, + ok_connections = ok_connections, + ) # get rid of plugged ports for ki, vi in map_in.items(): del self.ports[ki] - self._log_port_removal(ki) map_out[vi] = None if isinstance(other, Pattern): - assert append or skip_geometry, 'Got a name (not an abstract) but was asked to reference (not append)' + assert append, 'Got a name (not an abstract) but was asked to reference (not append)' self.place( other, @@ -1426,7 +1345,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map = map_out, skip_port_check = True, append = append, - skip_geometry = skip_geometry, ) return self diff --git a/masque/ports.py b/masque/ports.py index 04ab061..0211723 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -2,7 +2,6 @@ from typing import overload, Self, NoReturn, Any from collections.abc import Iterable, KeysView, ValuesView, Mapping import logging import functools -import copy from collections import Counter from abc import ABCMeta, abstractmethod from itertools import chain @@ -11,17 +10,16 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable +from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable from .utils import rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace logger = logging.getLogger(__name__) -port_logger = logging.getLogger('masque.ports') @functools.total_ordering -class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): +class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): """ A point at which a `Device` can be snapped to another `Device`. @@ -93,12 +91,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): def copy(self) -> Self: return self.deepcopy() - def __deepcopy__(self, memo: dict | None = None) -> Self: - memo = {} if memo is None else memo - new = copy.copy(self) - new._offset = self._offset.copy() - return new - def get_bounds(self) -> NDArray[numpy.float64]: return numpy.vstack((self.offset, self.offset)) @@ -107,27 +99,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): self.ptype = ptype return self - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - """ - Mirror the object across a line in the container's coordinate system. - - Note this operation is performed relative to the pattern's origin and modifies the port's offset. - - Args: - axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0. - x: Vertical line x=val to mirror across. - y: Horizontal line y=val to mirror across. - - Returns: - self - """ - axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) - self.translate(-pivot) - self.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self - def mirror(self, axis: int = 0) -> Self: if self.rotation is not None: self.rotation *= -1 @@ -208,19 +179,6 @@ class PortList(metaclass=ABCMeta): def ports(self, value: dict[str, Port]) -> None: pass - def _log_port_update(self, name: str) -> None: - """ Log the current state of the named port """ - port_logger.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 @@ -274,7 +232,6 @@ class PortList(metaclass=ABCMeta): raise PortError(f'Port {name} already exists.') assert name not in self.ports self.ports[name] = value - self._log_port_update(name) return self def rename_ports( @@ -301,20 +258,11 @@ 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 or vv != kk: - self._log_port_removal(kk) - renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} if None in renamed: del renamed[None] self.ports.update(renamed) # type: ignore - - for vv in mapping.values(): - if vv is not None: - self._log_port_update(vv) - return self def add_port_pair( @@ -343,8 +291,6 @@ 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( @@ -414,7 +360,6 @@ class PortList(metaclass=ABCMeta): for pp in chain(a_names, b_names): del self.ports[pp] - self._log_port_removal(pp) return self def check_ports( diff --git a/masque/ref.py b/masque/ref.py index 3a64dce..b3a684c 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -15,8 +15,7 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, - PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, - FlippableImpl, + Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ) @@ -26,9 +25,8 @@ if TYPE_CHECKING: @functools.total_ordering class Ref( - FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, - PositionableImpl, RotatableImpl, ScalableImpl, - Copyable, + PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, + PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ): """ `Ref` provides basic support for nesting Pattern objects within each other. @@ -170,6 +168,8 @@ 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/repetition.py b/masque/repetition.py index a774f7e..5e7a7f0 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: diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 6f948cb..480835e 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -272,16 +272,13 @@ class Arc(PositionableImpl, Shape): arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) keep = [0] - start = 0 + removable = (numpy.cumsum(arc_lengths) <= max_arclen) + start = 1 while start < arc_lengths.size: - removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen) - if not removable.any(): - next_to_keep = start + 1 - else: - next_to_keep = start + numpy.where(removable)[0][-1] + 1 + next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough? keep.append(next_to_keep) - start = next_to_keep - + removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen) + start = next_to_keep + 1 if keep[-1] != thetas.size - 1: keep.append(thetas.size - 1) @@ -365,20 +362,17 @@ class Arc(PositionableImpl, Shape): yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) # If our arc subtends a coordinate axis, use the extremum along that axis - if abs(a1 - a0) >= 2 * pi: - xn, xp, yn, yp = -xr, xr, -yr, yr - else: - if a0 <= xpt <= a1 or a0 <= xpt + 2 * pi <= a1: - xp = xr + if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1: + xp = xr - if a0 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1: - xn = -xr + if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1: + xn = -xr - if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1: - yp = yr + if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1: + yp = yr - if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1: - yn = -yr + if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1: + yn = -yr mins.append([xn, yn]) maxs.append([xp, yp]) @@ -390,6 +384,7 @@ class Arc(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Arc': + self.offset[axis - 1] *= -1 self.rotation *= -1 self.rotation += axis * pi self.angles *= -1 @@ -469,18 +464,13 @@ class Arc(PositionableImpl, Shape): `[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]` """ aa = [] - d_angle = self.angles[1] - self.angles[0] - if abs(d_angle) >= 2 * pi: - # Full ring - return numpy.tile([0, 2 * pi], (2, 1)).astype(float) - for sgn in (-1, +1): wh = sgn * self.width / 2.0 rx = self.radius_x + wh ry = self.radius_y + wh a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles) - sign = numpy.sign(d_angle) + sign = numpy.sign(self.angles[1] - self.angles[0]) if sign != numpy.sign(a1 - a0): a1 += sign * 2 * pi diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 8dad165..b20a681 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -124,6 +124,7 @@ class Circle(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused) + self.offset[axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Circle': diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 8e3fd49..6029f2f 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -189,6 +189,7 @@ class Ellipse(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> Self: + self.offset[axis - 1] *= -1 self.rotation *= -1 self.rotation += axis * pi return self diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 654cfaa..7778428 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[:, 1 - axis] *= -1 + self.vertices[:, axis - 1] *= -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 c714ed5..6048f24 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -56,11 +56,9 @@ class PolyCollection(Shape): """ Iterator which provides slices which index vertex_lists """ - if self._vertex_offsets.size == 0: - return for ii, ff in zip( self._vertex_offsets, - chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]), + chain(self._vertex_offsets, (self._vertex_lists.shape[0],)), strict=True, ): yield slice(ii, ff) @@ -170,9 +168,7 @@ class PolyCollection(Shape): annotations = copy.deepcopy(self.annotations), ) for vv in self.polygon_vertices] - def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition - if self._vertex_lists.size == 0: - return None + def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition return numpy.vstack((numpy.min(self._vertex_lists, axis=0), numpy.max(self._vertex_lists, axis=0))) @@ -183,7 +179,7 @@ class PolyCollection(Shape): return self def mirror(self, axis: int = 0) -> Self: - self._vertex_lists[:, 1 - axis] *= -1 + self._vertex_lists[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> Self: diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index a243901..c8c3ddd 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING, Self, Literal +from typing import Any, cast, TYPE_CHECKING, Self import copy import functools @@ -394,7 +394,7 @@ class Polygon(Shape): return self def mirror(self, axis: int = 0) -> 'Polygon': - self.vertices[:, 1 - axis] *= -1 + self.vertices[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Polygon': @@ -462,23 +462,3 @@ class Polygon(Shape): def __repr__(self) -> str: centroid = self.vertices.mean(axis=0) return f'' - - def boolean( - self, - other: Any, - operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', - scale: float = 1e6, - ) -> list['Polygon']: - """ - Perform a boolean operation using this polygon as the subject. - - Args: - other: Polygon, Iterable[Polygon], or raw vertices acting as the CLIP. - operation: 'union', 'intersection', 'difference', 'xor'. - scale: Scaling factor for integer conversion. - - Returns: - A list of resulting Polygons. - """ - from ..utils.boolean import boolean - return boolean([self], other, operation=operation, scale=scale) diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 13d2e1e..90bca2b 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -6,8 +6,8 @@ import numpy from numpy.typing import NDArray, ArrayLike from ..traits import ( - Copyable, Scalable, FlippableImpl, - PivotableImpl, RepeatableImpl, AnnotatableImpl, + Rotatable, Mirrorable, Copyable, Scalable, + Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,9 +26,8 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, - Copyable, Scalable, - metaclass=ABCMeta): +class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, + PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): """ Class specifying functions common to all shapes. """ @@ -74,7 +73,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, pass @abstractmethod - def normalized_form(self, norm_value: float) -> normalized_shape_tuple: + def normalized_form(self, norm_value: int) -> normalized_shape_tuple: """ Writes the shape in a standardized notation, with offset, scale, and rotation information separated out from the remaining values. diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 65a9213..78632f6 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -70,7 +70,6 @@ class Text(PositionableImpl, RotatableImpl, Shape): *, offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, - mirrored: bool = False, repetition: Repetition | None = None, annotations: annotations_t = None, raw: bool = False, @@ -81,7 +80,6 @@ class Text(PositionableImpl, RotatableImpl, Shape): self._string = string self._height = height self._rotation = rotation - self._mirrored = mirrored self._repetition = repetition self._annotations = annotations else: @@ -89,7 +87,6 @@ class Text(PositionableImpl, RotatableImpl, Shape): self.string = string self.height = height self.rotation = rotation - self.mirrored = mirrored self.repetition = repetition self.annotations = annotations self.font_path = font_path @@ -149,7 +146,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): if self.mirrored: poly.mirror() poly.scale_by(self.height) - poly.translate(self.offset + [total_advance, 0]) + poly.offset = self.offset + [total_advance, 0] poly.rotate_around(self.offset, self.rotation) all_polygons += [poly] diff --git a/masque/test/__init__.py b/masque/test/__init__.py deleted file mode 100644 index e02b636..0000000 --- a/masque/test/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests (run with `python3 -m pytest -rxPXs | tee results.txt`) -""" diff --git a/masque/test/conftest.py b/masque/test/conftest.py deleted file mode 100644 index 3116ee2..0000000 --- a/masque/test/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -""" - -Test fixtures - -""" - -# ruff: noqa: ARG001 -from typing import Any -import numpy - - -FixtureRequest = Any -PRNG = numpy.random.RandomState(12345) diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py deleted file mode 100644 index 7c2dbbb..0000000 --- a/masque/test/test_abstract.py +++ /dev/null @@ -1,64 +0,0 @@ -from numpy.testing import assert_allclose -from numpy import pi - -from ..abstract import Abstract -from ..ports import Port -from ..ref import Ref - - -def test_abstract_init() -> None: - ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)} - abs_obj = Abstract("test", ports) - assert abs_obj.name == "test" - assert len(abs_obj.ports) == 2 - assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied - - -def test_abstract_transform() -> None: - abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) - # Rotate 90 deg around (0,0) - abs_obj.rotate_around((0, 0), pi / 2) - # (10, 0) rot 0 -> (0, 10) rot pi/2 - assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) - - # Mirror across x axis (axis 0): flips y-offset - abs_obj.mirror(0) - # (0, 10) mirrored(0) -> (0, -10) - # rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2 - assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10) - - -def test_abstract_ref_transform() -> None: - abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) - - # Apply ref transform - abs_obj.apply_ref_transform(ref) - # Ref order: mirror, rotate, scale, translate - - # 1. mirror (across x: y -> -y) - # (10, 0) rot 0 -> (10, 0) rot 0 - - # 2. rotate pi/2 around (0,0) - # (10, 0) rot 0 -> (0, 10) rot pi/2 - - # 3. translate (100, 100) - # (0, 10) -> (100, 110) - - assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) - - -def test_abstract_undo_transform() -> None: - abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) - - abs_obj.undo_ref_transform(ref) - assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py deleted file mode 100644 index 5afcc21..0000000 --- a/masque/test/test_advanced_routing.py +++ /dev/null @@ -1,87 +0,0 @@ -import pytest -from numpy.testing import assert_equal -from numpy import pi - -from ..builder import Pather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - - -@pytest.fixture -def advanced_pather() -> tuple[Pather, PathTool, Library]: - lib = Library() - # Simple PathTool: 2um width on layer (1,0) - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool) - return p, tool, lib - - -def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = advanced_pather - # Facing ports - p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device) - # Forward (+pi relative to port) is West (-x). - # Put destination at (-20, 0) pointing East (pi). - p.ports["dst"] = Port((-20, 0), pi, ptype="wire") - - p.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: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = advanced_pather - # Source at (0,0) rot 0 (facing East). Forward is West (-x). - p.ports["src"] = Port((0, 0), 0, ptype="wire") - # Destination at (-20, -20) rot pi (facing West). Forward is East (+x). - # Wait, src forward is -x. dst is at -20, -20. - # To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South). - # Forward for South is North (+y). - p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire") - - p.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: 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.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") - - assert "src" not in p.ports - assert "dst" not in p.ports - - -def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = advanced_pather - p.ports["src"] = Port((0, 0), 0, ptype="wire") - p.ports["dst"] = Port((-20, 0), pi, ptype="wire") - p.ports["other"] = Port((10, 10), 0) - - p.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 deleted file mode 100644 index 5686193..0000000 --- a/masque/test/test_autotool.py +++ /dev/null @@ -1,81 +0,0 @@ -import pytest -from numpy.testing import assert_allclose -from numpy import pi - -from ..builder import Pather -from ..builder.tools import AutoTool -from ..library import Library -from ..pattern import Pattern -from ..ports import Port - - -def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) - pat.ports["in"] = Port((0, 0), 0, ptype=ptype) - pat.ports["out"] = Port((length, 0), pi, ptype=ptype) - return pat - - -@pytest.fixture -def autotool_setup() -> tuple[Pather, AutoTool, Library]: - lib = Library() - - # Define a simple bend - bend_pat = Pattern() - # 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise) - bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire") - bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire") - lib["bend"] = bend_pat - lib.abstract("bend") - - # Define a transition (e.g., via) - via_pat = Pattern() - via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1") - via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2") - lib["via"] = via_pat - via_abs = lib.abstract("via") - - tool_m1 = AutoTool( - straights=[ - AutoTool.Straight(ptype="wire_m1", fn=lambda length: make_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out") - ], - bends=[], - sbends=[], - transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")}, - default_out_ptype="wire_m1", - ) - - p = Pather(lib, tools=tool_m1) - # Start with an m2 port - p.ports["start"] = Port((0, 0), pi, ptype="wire_m2") - - return p, tool_m1, lib - - -def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: - p, tool, lib = autotool_setup - - # Route m1 from an m2 port. Should trigger via. - # length 10. Via length is 1. So straight m1 should be 9. - p.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_boolean.py b/masque/test/test_boolean.py deleted file mode 100644 index c1a2d7b..0000000 --- a/masque/test/test_boolean.py +++ /dev/null @@ -1,119 +0,0 @@ -import pytest -import numpy -from numpy.testing import assert_allclose -from masque.pattern import Pattern -from masque.shapes.polygon import Polygon -from masque.repetition import Grid -from masque.library import Library - -def test_layer_as_polygons_basic() -> None: - pat = Pattern() - pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]]) - - polys = pat.layer_as_polygons((1, 0), flatten=False) - assert len(polys) == 1 - assert isinstance(polys[0], Polygon) - assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - -def test_layer_as_polygons_repetition() -> None: - pat = Pattern() - rep = Grid(a_vector=(2, 0), a_count=2) - pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep) - - polys = pat.layer_as_polygons((1, 0), flatten=False) - assert len(polys) == 2 - # First polygon at (0,0) - assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - # Second polygon at (2,0) - assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]]) - -def test_layer_as_polygons_flatten() -> None: - lib = Library() - - child = Pattern() - child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]]) - lib['child'] = child - - parent = Pattern() - parent.ref('child', offset=(10, 10), rotation=numpy.pi/2) - - polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib) - assert len(polys) == 1 - # Original child at (0,0) with rot pi/2 is still at (0,0) in its own space? - # No, ref.as_pattern(child) will apply the transform. - # Child (0,0), (1,0), (1,1) rotated pi/2 around (0,0) -> (0,0), (0,1), (-1,1) - # Then offset by (10,10) -> (10,10), (10,11), (9,11) - - # Let's verify the vertices - expected = numpy.array([[10, 10], [10, 11], [9, 11]]) - assert_allclose(polys[0].vertices, expected, atol=1e-10) - -def test_boolean_import_error() -> None: - from masque import boolean - # If pyclipper is not installed, this should raise ImportError - try: - import pyclipper # noqa: F401 - pytest.skip("pyclipper is installed, cannot test ImportError") - except ImportError: - with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): - boolean([], [], operation='union') - -def test_polygon_boolean_shortcut() -> None: - poly = Polygon([[0, 0], [1, 0], [1, 1]]) - # This should also raise ImportError if pyclipper is missing - try: - import pyclipper # noqa: F401 - pytest.skip("pyclipper is installed") - except ImportError: - with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): - poly.boolean(poly) - -def test_bridge_holes() -> None: - from masque.utils.boolean import _bridge_holes - - # Outer: 10x10 square - outer = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) - # Hole: 2x2 square in the middle - hole = numpy.array([[4, 4], [6, 4], [6, 6], [4, 6]]) - - bridged = _bridge_holes(outer, [hole]) - - # We expect more vertices than outer + hole - # Original outer has 4, hole has 4. Bridge adds 2 (to hole) and 2 (back to outer) + 1 to close hole loop? - # Our implementation: - # 1. outer up to bridge edge (best_edge_idx) - # 2. bridge point on outer - # 3. hole reordered starting at max X - # 4. close hole loop (repeat max X) - # 5. bridge point on outer again - # 6. rest of outer - - # max X of hole is 6 at (6,4) or (6,6). argmax will pick first one. - # hole vertices: [4,4], [6,4], [6,6], [4,6]. argmax(x) is index 1: (6,4) - # roll hole to start at (6,4): [6,4], [6,6], [4,6], [4,4] - - # intersection of ray from (6,4) to right: - # edges of outer: (0,0)-(10,0), (10,0)-(10,10), (10,10)-(0,10), (0,10)-(0,0) - # edge (10,0)-(10,10) spans y=4. - # intersection at (10,4). best_edge_idx = 1 (edge from index 1 to 2) - - # vertices added: - # outer[0:2]: (0,0), (10,0) - # bridge pt: (10,4) - # hole: (6,4), (6,6), (4,6), (4,4) - # hole close: (6,4) - # bridge pt back: (10,4) - # outer[2:]: (10,10), (0,10) - - expected_len = 11 - assert len(bridged) == expected_len - - # verify it wraps around the hole and back - # index 2 is bridge_pt - assert_allclose(bridged[2], [10, 4]) - # index 3 is hole reordered max X - assert_allclose(bridged[3], [6, 4]) - # index 7 is hole closed at max X - assert_allclose(bridged[7], [6, 4]) - # index 8 is bridge_pt back - assert_allclose(bridged[8], [10, 4]) diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py deleted file mode 100644 index 0ad6e80..0000000 --- a/masque/test/test_builder.py +++ /dev/null @@ -1,131 +0,0 @@ -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..builder import Builder -from ..library import Library -from ..pattern import Pattern -from ..ports import Port - - -def test_builder_init() -> None: - lib = Library() - b = Builder(lib, name="mypat") - assert b.pattern is lib["mypat"] - assert b.library is lib - - -def test_builder_place() -> None: - lib = Library() - child = Pattern() - child.ports["A"] = Port((0, 0), 0) - lib["child"] = child - - b = Builder(lib) - b.place("child", offset=(10, 20), port_map={"A": "child_A"}) - - assert "child_A" in b.ports - assert_equal(b.ports["child_A"].offset, [10, 20]) - assert "child" in b.pattern.refs - - -def test_builder_plug() -> None: - lib = Library() - - wire = Pattern() - wire.ports["in"] = Port((0, 0), 0) - wire.ports["out"] = Port((10, 0), pi) - lib["wire"] = wire - - b = Builder(lib) - b.ports["start"] = Port((100, 100), 0) - - # Plug wire's "in" port into builder's "start" port - # Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports - # builder start: (100, 100) rotation 0 - # wire in: (0, 0) rotation 0 - # wire out: (10, 0) rotation pi - # Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg) - # so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100) - b.plug("wire", map_in={"start": "in"}) - - assert "start" in b.ports - assert_equal(b.ports["start"].offset, [90, 100]) - assert b.ports["start"].rotation is not None - assert_allclose(b.ports["start"].rotation, 0, atol=1e-10) - - -def test_builder_interface() -> None: - lib = Library() - source = Pattern() - source.ports["P1"] = Port((0, 0), 0) - lib["source"] = source - - b = Builder.interface("source", library=lib, name="iface") - assert "in_P1" in b.ports - assert "P1" in b.ports - assert b.pattern is lib["iface"] - - -def test_builder_set_dead() -> None: - lib = Library() - lib["sub"] = Pattern() - b = Builder(lib) - b.set_dead() - - b.place("sub") - assert not b.pattern.has_refs() - - -def test_builder_dead_ports() -> None: - lib = Library() - pat = Pattern() - pat.ports['A'] = Port((0, 0), 0) - b = Builder(lib, pattern=pat) - b.set_dead() - - # Attempt to plug a device where ports don't line up - # A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference). - other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)}) - - # This should NOT raise PortError because b is dead - b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'}) - - # Port A should be removed, and Port B (renamed from D) should be added - assert 'A' not in b.ports - assert 'B' in b.ports - - # Verify geometry was not added - assert not b.pattern.has_refs() - assert not b.pattern.has_shapes() - - -def test_dead_plug_best_effort() -> None: - lib = Library() - pat = Pattern() - pat.ports['A'] = Port((0, 0), 0) - b = Builder(lib, pattern=pat) - b.set_dead() - - # Device with multiple ports, none of which line up correctly - other = Pattern(ports={ - 'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi) - 'P2': Port((20, 20), pi) # Correct rotation but wrong offset - }) - - # Try to plug. find_transform will fail. - # It should fall back to aligning the first pair ('A' and 'P1'). - b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'}) - - assert 'A' not in b.ports - assert 'B' in b.ports - - # Dummy transform aligns A (0,0) with P1 (10,10) - # A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi - # P2 (20,20) rotation pi: - # 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10) - # 2. Rotate (10,10) by -pi: (-10,-10) - # 3. Translate by s_port.offset (0,0): (-10,-10) - assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10) - # P2 rot pi + transform rot -pi = 0 - assert b.ports['B'].rotation is not None - assert_allclose(b.ports['B'].rotation, 0, atol=1e-10) diff --git a/masque/test/test_fdfd.py b/masque/test/test_fdfd.py deleted file mode 100644 index 2b4f3d3..0000000 --- a/masque/test/test_fdfd.py +++ /dev/null @@ -1,24 +0,0 @@ -# ruff: noqa -# ruff: noqa: ARG001 - - -import dataclasses -import pytest # type: ignore -import numpy -from numpy import pi -from numpy.typing import NDArray -# from numpy.testing import assert_allclose, assert_array_equal - -from .. import Pattern, Arc, Circle - - -def test_circle_mirror(): - cc = Circle(radius=4, offset=(10, 20)) - cc.flip_across(axis=0) # flip across y=0 - assert cc.offset[0] == 10 - assert cc.offset[1] == -20 - assert cc.radius == 4 - cc.flip_across(axis=1) # flip across x=0 - assert cc.offset[0] == -10 - assert cc.offset[1] == -20 - assert cc.radius == 4 diff --git a/masque/test/test_file_roundtrip.py b/masque/test/test_file_roundtrip.py deleted file mode 100644 index c7536a5..0000000 --- a/masque/test/test_file_roundtrip.py +++ /dev/null @@ -1,151 +0,0 @@ -from pathlib import Path -from typing import cast -import pytest -from numpy.testing import assert_allclose - -from ..pattern import Pattern -from ..library import Library -from ..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 = cast("MPath", read_paths.shapes[(2, 0)][0]) - assert p_flush.cap == MPath.Cap.Flush - - p_square = cast("MPath", read_paths.shapes[(2, 1)][0]) - assert p_square.cap == MPath.Cap.Square - - p_circle = cast("MPath", read_paths.shapes[(2, 2)][0]) - assert p_circle.cap == MPath.Cap.Circle - - p_custom = cast("MPath", read_paths.shapes[(2, 3)][0]) - assert p_custom.cap == MPath.Cap.SquareCustom - assert p_custom.cap_extensions is not None - assert_allclose(p_custom.cap_extensions, (1, 5)) - - # Check Refs with repetitions - read_refs = read_lib["refs"] - assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs) - - # AREF check - arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None] - assert len(arefs) == 1 - assert isinstance(arefs[0].repetition, Grid) - assert arefs[0].repetition.a_count == 3 - assert arefs[0].repetition.b_count == 2 - - # Check wrapped shapes - # lib.wrap_repeated_shapes() created new patterns - # Original pattern "rep_shapes" now should have a Ref - assert len(read_lib["rep_shapes"].refs) > 0 - -def test_oasis_full_roundtrip(tmp_path: Path) -> None: - pytest.importorskip("fatamorgana") - lib = create_test_library(for_gds=False) - oas_file = tmp_path / "full_test.oas" - oasis.writefile(lib, oas_file, units_per_micron=1000) - - read_lib, _ = oasis.readfile(oas_file) - - # Check existence - for name in lib: - assert name in read_lib - - # Check Circle - read_circles = read_lib["circles"] - assert isinstance(read_circles.shapes[(3, 0)][0], Circle) - assert read_circles.shapes[(3, 0)][0].radius == 5 - - # Check Path caps - read_paths = read_lib["paths"] - assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush - assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square - # OASIS HalfWidth is Square. masque's Square is also HalfWidth extension. - # Wait, Circle cap in OASIS? - # masque/file/oasis.py: - # path_cap_map = { - # PathExtensionScheme.Flush: Path.Cap.Flush, - # PathExtensionScheme.HalfWidth: Path.Cap.Square, - # PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom, - # } - # It seems Circle cap is NOT supported in OASIS by masque currently. - # Let's verify what happens with Circle cap in OASIS write. - # _shapes_to_elements in oasis.py: - # path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) - # This will raise StopIteration if Circle is not in path_cap_map. - - # Check Shape repetition - read_rep_shapes = read_lib["rep_shapes"] - poly = read_rep_shapes.shapes[(4, 0)][0] - assert poly.repetition is not None - assert isinstance(poly.repetition, Grid) - assert poly.repetition.a_count == 5 diff --git a/masque/test/test_gdsii.py b/masque/test/test_gdsii.py deleted file mode 100644 index 7ce8c88..0000000 --- a/masque/test/test_gdsii.py +++ /dev/null @@ -1,71 +0,0 @@ -from pathlib import Path -from typing import cast -import numpy -from numpy.testing import assert_equal, assert_allclose - -from ..pattern import Pattern -from ..library import Library -from ..file import gdsii -from ..shapes import Path as MPath, Polygon - - -def test_gdsii_roundtrip(tmp_path: Path) -> None: - lib = Library() - - # Simple polygon cell - pat1 = Pattern() - pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) - lib["poly_cell"] = pat1 - - # Path cell - pat2 = Pattern() - pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10) - lib["path_cell"] = pat2 - - # Cell with Ref - pat3 = Pattern() - pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2) - lib["ref_cell"] = pat3 - - gds_file = tmp_path / "test.gds" - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, info = gdsii.readfile(gds_file) - - assert "poly_cell" in read_lib - assert "path_cell" in read_lib - assert "ref_cell" in read_lib - - # Check polygon - read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0]) - # GDSII closes polygons, so it might have an extra vertex or different order - assert len(read_poly.vertices) >= 4 - # Check bounds as a proxy for geometry correctness - assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]]) - - # Check path - read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0]) - assert isinstance(read_path, MPath) - assert read_path.width == 10 - assert_equal(read_path.vertices, [[0, 0], [100, 0]]) - - # Check Ref - read_ref = read_lib["ref_cell"].refs["poly_cell"][0] - assert_equal(read_ref.offset, [50, 50]) - assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5) - - -def test_gdsii_annotations(tmp_path: Path) -> None: - lib = Library() - pat = Pattern() - # GDS only supports integer keys in range [1, 126] for properties - pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]}) - lib["cell"] = pat - - gds_file = tmp_path / "test_ann.gds" - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, _ = gdsii.readfile(gds_file) - read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations - assert read_ann is not None - assert read_ann["1"] == ["hello"] diff --git a/masque/test/test_label.py b/masque/test/test_label.py deleted file mode 100644 index ed40614..0000000 --- a/masque/test/test_label.py +++ /dev/null @@ -1,50 +0,0 @@ -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() -> None: - lbl = Label("test", offset=(10, 20)) - assert lbl.string == "test" - assert_equal(lbl.offset, [10, 20]) - - -def test_label_transform() -> None: - lbl = Label("test", offset=(10, 0)) - # Rotate 90 deg CCW around (0,0) - lbl.rotate_around((0, 0), pi / 2) - assert_allclose(lbl.offset, [0, 10], atol=1e-10) - - # Translate - lbl.translate((5, 5)) - assert_allclose(lbl.offset, [5, 15], atol=1e-10) - - -def test_label_repetition() -> None: - rep = Grid(a_vector=(10, 0), a_count=3) - lbl = Label("rep", offset=(0, 0), repetition=rep) - assert lbl.repetition is rep - assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]]) - # Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't - # seem to automatically include repetition bounds in label.py itself, - # it's handled during pattern bounding. - - -def test_label_copy() -> None: - l1 = Label("test", offset=(1, 2), annotations={"a": [1]}) - l2 = copy.deepcopy(l1) - - print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}") - print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}") - - 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 diff --git a/masque/test/test_library.py b/masque/test/test_library.py deleted file mode 100644 index 22ad42a..0000000 --- a/masque/test/test_library.py +++ /dev/null @@ -1,120 +0,0 @@ -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() - pat = Pattern() - lib["cell1"] = pat - - assert "cell1" in lib - assert lib["cell1"] is pat - assert len(lib) == 1 - - with pytest.raises(LibraryError): - lib["cell1"] = Pattern() # Overwriting not allowed - - -def test_library_tops() -> None: - lib = Library() - lib["child"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("child") - - assert set(lib.tops()) == {"parent"} - assert lib.top() == "parent" - - -def test_library_dangling() -> None: - lib = Library() - lib["parent"] = Pattern() - lib["parent"].ref("missing") - - assert lib.dangling_refs() == {"missing"} - - -def test_library_flatten() -> None: - lib = Library() - child = Pattern() - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - lib["child"] = child - - parent = Pattern() - parent.ref("child", offset=(10, 10)) - lib["parent"] = parent - - flat_lib = lib.flatten("parent") - flat_parent = flat_lib["parent"] - - assert not flat_parent.has_refs() - assert len(flat_parent.shapes[(1, 0)]) == 1 - # Transformations are baked into vertices for Polygon - assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices - assert tuple(assert_vertices[0]) == (10.0, 10.0) - - -def test_lazy_library() -> None: - lib = LazyLibrary() - called = 0 - - def make_pat() -> Pattern: - nonlocal called - called += 1 - return Pattern() - - lib["lazy"] = make_pat - assert called == 0 - - pat = lib["lazy"] - assert called == 1 - assert isinstance(pat, Pattern) - - # Second access should be cached - pat2 = lib["lazy"] - assert called == 1 - assert pat is pat2 - - -def test_library_rename() -> None: - lib = Library() - lib["old"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("old") - - lib.rename("old", "new", move_references=True) - - assert "old" not in lib - assert "new" in lib - assert "new" in lib["parent"].refs - assert "old" not in lib["parent"].refs - - -def test_library_subtree() -> None: - lib = Library() - lib["a"] = Pattern() - lib["b"] = Pattern() - lib["c"] = Pattern() - lib["a"].ref("b") - - sub = lib.subtree("a") - assert "a" in sub - assert "b" in sub - assert "c" not in sub - - -def test_library_get_name() -> None: - lib = Library() - lib["cell"] = Pattern() - - name1 = lib.get_name("cell") - assert name1 != "cell" - assert name1.startswith("cell") - - name2 = lib.get_name("other") - assert name2 == "other" diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py deleted file mode 100644 index faffa58..0000000 --- a/masque/test/test_oasis.py +++ /dev/null @@ -1,27 +0,0 @@ -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: 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 deleted file mode 100644 index 5390a4c..0000000 --- a/masque/test/test_pack2d.py +++ /dev/null @@ -1,51 +0,0 @@ -from ..utils.pack2d import maxrects_bssf, pack_patterns -from ..library import Library -from ..pattern import Pattern - - -def test_maxrects_bssf_simple() -> None: - # Pack two 10x10 squares into one 20x10 container - rects = [[10, 10], [10, 10]] - containers = [[0, 0, 20, 10]] - - locs, rejects = maxrects_bssf(rects, containers) - - assert not rejects - # They should be at (0,0) and (10,0) - assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)} - - -def test_maxrects_bssf_reject() -> None: - # Try to pack a too-large rectangle - rects = [[10, 10], [30, 30]] - containers = [[0, 0, 20, 20]] - - locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True) - assert 1 in rejects # Second rect rejected - assert 0 not in rejects - - -def test_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 deleted file mode 100644 index 766798f..0000000 --- a/masque/test/test_path.py +++ /dev/null @@ -1,81 +0,0 @@ -from numpy.testing import assert_equal - -from ..shapes import Path - - -def test_path_init() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) - assert_equal(p.vertices, [[0, 0], [10, 0]]) - assert p.width == 2 - assert p.cap == Path.Cap.Flush - - -def test_path_to_polygons_flush() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) - polys = p.to_polygons() - assert len(polys) == 1 - # Rectangle from (0, -1) to (10, 1) - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[0, -1], [10, 1]]) - - -def test_path_to_polygons_square() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square) - polys = p.to_polygons() - assert len(polys) == 1 - # Square cap adds width/2 = 1 to each end - # Rectangle from (-1, -1) to (11, 1) - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[-1, -1], [11, 1]]) - - -def test_path_to_polygons_circle() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle) - polys = p.to_polygons(num_vertices=32) - # Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps - assert len(polys) >= 3 - - # Combined bounds should be from (-1, -1) to (11, 1) - # But wait, Path.get_bounds_single() handles this more directly - bounds = p.get_bounds_single() - assert_equal(bounds, [[-1, -1], [11, 1]]) - - -def test_path_custom_cap() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10)) - polys = p.to_polygons() - assert len(polys) == 1 - # Extends 5 units at start, 10 at end - # Starts at -5, ends at 20 - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[-5, -1], [20, 1]]) - - -def test_path_bend() -> None: - # L-shaped path - p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2) - polys = p.to_polygons() - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - # Outer corner at (11, -1) is not right. - # Segments: (0,0)-(10,0) and (10,0)-(10,10) - # Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1) - # Corners of segment 2: (9,0), (9,10), (11,10), (11,0) - # Bounds should be [[-1 (if start is square), -1], [11, 11]]? - # Flush cap start at (0,0) with width 2 means y from -1 to 1. - # Vertical segment end at (10,10) with width 2 means x from 9 to 11. - # So bounds should be x: [0, 11], y: [-1, 10] - assert_equal(bounds, [[0, -1], [11, 10]]) - - -def test_path_mirror() -> None: - p = Path(vertices=[[10, 5], [20, 10]], width=2) - p.mirror(0) # Mirror across x axis (y -> -y) - assert_equal(p.vertices, [[10, -5], [20, -10]]) - - -def test_path_scale() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2) - p.scale_by(2) - assert_equal(p.vertices, [[0, 0], [20, 0]]) - assert p.width == 4 diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py deleted file mode 100644 index 35e9f53..0000000 --- a/masque/test/test_pather.py +++ /dev/null @@ -1,108 +0,0 @@ -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..builder import Pather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - - -@pytest.fixture -def pather_setup() -> tuple[Pather, PathTool, Library]: - lib = Library() - # Simple PathTool: 2um width on layer (1,0) - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool) - # Add an initial port facing North (pi/2) - # Port rotation points INTO device. So "North" rotation means device is North of port. - # Pathing "forward" moves South. - p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - return p, tool, lib - - -def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # Route 10um "forward" - p.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 p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) - - -def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # Start (0,0) rot pi/2 (North). - # Path 10um "forward" (South), then turn Clockwise (ccw=False). - # Facing South, turn Right -> West. - p.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 p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) - - -def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # start at (0,0) rot pi/2 (North) - # path "forward" (South) to y=-50 - p.path_to("start", ccw=None, y=-50) - assert_equal(p.ports["start"].offset, [0, -50]) - - -def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - p.ports["A"] = Port((0, 0), pi / 2, ptype="wire") - p.ports["B"] = Port((10, 0), pi / 2, ptype="wire") - - # Path both "forward" (South) to y=-20 - p.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: 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) - # 10um South -> (0, -10) rot pi/2 - # then 10um South and turn CCW (Facing South, CCW is East) - # PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0 - # Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10) - # (0, -10) + (1, -10) = (1, -20) - assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10) - # pi/2 (North) + CCW (90 deg) -> 0 (East)? - # Actual behavior results in pi (West). - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) - - -def test_pather_dead_ports() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=1) - p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool) - p.set_dead() - - # Path with negative length (impossible for PathTool, would normally raise BuildError) - p.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_pattern.py b/masque/test/test_pattern.py deleted file mode 100644 index f5da195..0000000 --- a/masque/test/test_pattern.py +++ /dev/null @@ -1,115 +0,0 @@ -from typing import cast -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..pattern import Pattern -from ..shapes import Polygon -from ..ref import Ref -from ..ports import Port -from ..label import Label - - -def test_pattern_init() -> None: - pat = Pattern() - assert pat.is_empty() - assert not pat.has_shapes() - assert not pat.has_refs() - assert not pat.has_labels() - assert not pat.has_ports() - - -def test_pattern_with_elements() -> None: - poly = Polygon.square(10) - label = Label("test", offset=(5, 5)) - ref = Ref(offset=(100, 100)) - port = Port((0, 0), 0) - - pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port}) - - assert pat.has_shapes() - assert pat.has_labels() - assert pat.has_refs() - assert pat.has_ports() - assert not pat.is_empty() - assert pat.shapes[(1, 0)] == [poly] - assert pat.labels[(1, 2)] == [label] - assert pat.refs["sub"] == [ref] - assert pat.ports["P1"] == port - - -def test_pattern_append() -> None: - pat1 = Pattern() - pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) - - pat2 = Pattern() - pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]]) - - pat1.append(pat2) - assert len(pat1.shapes[(1, 0)]) == 1 - assert len(pat1.shapes[(2, 0)]) == 1 - - -def test_pattern_translate() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) - pat.ports["P1"] = Port((5, 5), 0) - - pat.translate_elements((10, 20)) - - # Polygon.translate adds to vertices, and offset is always (0,0) - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20]) - assert_equal(pat.ports["P1"].offset, [15, 25]) - - -def test_pattern_scale() -> None: - pat = Pattern() - # Polygon.rect sets an offset in its constructor which is immediately translated into vertices - pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1) - pat.scale_by(2) - - # Vertices should be scaled - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) - - -def test_pattern_rotate() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]]) - # Rotate 90 degrees CCW around (0,0) - pat.rotate_around((0, 0), pi / 2) - - # [10, 0] rotated 90 deg around (0,0) is [0, 10] - assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10) - - -def test_pattern_mirror() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]]) - # Mirror across X axis (y -> -y) - pat.mirror(0) - - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5]) - - -def test_pattern_get_bounds() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]]) - pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]]) - - bounds = pat.get_bounds() - assert_equal(bounds, [[-5, -5], [10, 10]]) - - -def test_pattern_interface() -> None: - source = Pattern() - source.ports["A"] = Port((10, 20), 0, ptype="test") - - iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_") - - assert "in_A" in iface.ports - assert "out_A" in iface.ports - assert iface.ports["in_A"].rotation is not None - assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10) - assert iface.ports["out_A"].rotation is not None - assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10) - assert iface.ports["in_A"].ptype == "test" - assert iface.ports["out_A"].ptype == "test" diff --git a/masque/test/test_polygon.py b/masque/test/test_polygon.py deleted file mode 100644 index 5d98ad9..0000000 --- a/masque/test/test_polygon.py +++ /dev/null @@ -1,125 +0,0 @@ -import pytest -import numpy -from numpy.testing import assert_equal - - -from ..shapes import Polygon -from ..utils import R90 -from ..error import PatternError - - -@pytest.fixture -def polygon() -> Polygon: - return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) - - -def test_vertices(polygon: Polygon) -> None: - assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - - -def test_xs(polygon: Polygon) -> None: - assert_equal(polygon.xs, [0, 1, 1, 0]) - - -def test_ys(polygon: Polygon) -> None: - assert_equal(polygon.ys, [0, 0, 1, 1]) - - -def test_offset(polygon: Polygon) -> None: - assert_equal(polygon.offset, [0, 0]) - - -def test_square() -> None: - square = Polygon.square(1) - assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]]) - - -def test_rectangle() -> None: - rectangle = Polygon.rectangle(1, 2) - assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]]) - - -def test_rect() -> None: - rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1) - assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) - - rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2) - assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) - - rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2) - assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]]) - - rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0) - assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]]) - - with pytest.raises(PatternError): - Polygon.rect(xctr=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(xmin=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(xmax=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(lx=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(yctr=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ymin=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ymax=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ly=0, xctr=-2, xmax=0) - - -def test_octagon() -> None: - octagon = Polygon.octagon(side_length=1) # regular=True - assert_equal(octagon.vertices.shape, (8, 2)) - diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0) - side_len = numpy.sqrt((diff * diff).sum(axis=1)) - assert numpy.allclose(side_len, 1) - - -def test_to_polygons(polygon: Polygon) -> None: - assert polygon.to_polygons() == [polygon] - - -def test_get_bounds_single(polygon: Polygon) -> None: - assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]]) - - -def test_rotate(polygon: Polygon) -> None: - rotated_polygon = polygon.rotate(R90) - assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]]) - - -def test_mirror(polygon: Polygon) -> None: - mirrored_by_y = polygon.deepcopy().mirror(1) - assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]]) - print(polygon.vertices) - mirrored_by_x = polygon.deepcopy().mirror(0) - assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]]) - - -def test_scale_by(polygon: Polygon) -> None: - scaled_polygon = polygon.scale_by(2) - assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]]) - - -def test_clean_vertices(polygon: Polygon) -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices() - assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) - - -def test_remove_duplicate_vertices() -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices() - assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]]) - - -def test_remove_colinear_vertices() -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices() - assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) - - -def test_vertices_dtype() -> None: - polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32)) - polygon.scale_by(0.5) - assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]]) diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py deleted file mode 100644 index e1dab87..0000000 --- a/masque/test/test_ports.py +++ /dev/null @@ -1,104 +0,0 @@ -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..ports import Port, PortList -from ..error import PortError - - -def test_port_init() -> None: - p = Port(offset=(10, 20), rotation=pi / 2, ptype="test") - assert_equal(p.offset, [10, 20]) - assert p.rotation == pi / 2 - assert p.ptype == "test" - - -def test_port_transform() -> None: - p = Port(offset=(10, 0), rotation=0) - p.rotate_around((0, 0), pi / 2) - assert_allclose(p.offset, [0, 10], atol=1e-10) - assert p.rotation is not None - assert_allclose(p.rotation, pi / 2, atol=1e-10) - - p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset - assert_allclose(p.offset, [0, 10], atol=1e-10) - # rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2 - assert p.rotation is not None - assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10) - - -def test_port_flip_across() -> None: - p = Port(offset=(10, 0), rotation=0) - p.flip_across(axis=1) # Mirror across x=0: flips x-offset - assert_equal(p.offset, [-10, 0]) - # rotation was 0, mirrored(1) -> pi - assert p.rotation is not None - assert_allclose(p.rotation, pi, atol=1e-10) - - -def test_port_measure_travel() -> None: - p1 = Port((0, 0), 0) - p2 = Port((10, 5), pi) # Facing each other - - (travel, jog), rotation = p1.measure_travel(p2) - assert travel == 10 - assert jog == 5 - assert rotation == pi - - -def test_port_list_rename() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((0, 0), 0)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - pl.rename_ports({"A": "B"}) - assert "A" not in pl.ports - assert "B" in pl.ports - - -def test_port_list_plugged() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - pl.plugged({"A": "B"}) - assert not pl.ports # Both should be removed - - -def test_port_list_plugged_mismatch() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = { - "A": Port((10, 10), 0), - "B": Port((11, 10), pi), # Offset mismatch - } - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError): - pl.plugged({"A": "B"}) diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py deleted file mode 100644 index f461cb8..0000000 --- a/masque/test/test_ports2data.py +++ /dev/null @@ -1,57 +0,0 @@ -import numpy -from numpy.testing import assert_allclose - -from ..utils.ports2data import ports_to_data, data_to_ports -from ..pattern import Pattern -from ..ports import Port -from ..library import Library - - -def test_ports2data_roundtrip() -> None: - pat = Pattern() - pat.ports["P1"] = Port((10, 20), numpy.pi / 2, ptype="test") - - layer = (10, 0) - ports_to_data(pat, layer) - - assert len(pat.labels[layer]) == 1 - assert pat.labels[layer][0].string == "P1:test 90" - assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0) - - # New pattern, read ports back - pat2 = Pattern() - pat2.labels[layer] = pat.labels[layer] - data_to_ports([layer], {}, pat2) - - assert "P1" in pat2.ports - assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10) - assert pat2.ports["P1"].rotation is not None - assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10) - assert pat2.ports["P1"].ptype == "test" - - -def test_data_to_ports_hierarchical() -> None: - lib = Library() - - # Child has port data in labels - child = Pattern() - layer = (10, 0) - child.label(layer=layer, string="A:type1 0", offset=(5, 0)) - lib["child"] = child - - # Parent references child - parent = Pattern() - parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2) - - # Read ports hierarchically (max_depth > 0) - data_to_ports([layer], lib, parent, max_depth=1) - - # child port A (5,0) rot 0 - # transformed by parent ref: rot pi/2, trans (100, 100) - # (5,0) rot pi/2 -> (0, 5) - # (0, 5) + (100, 100) = (100, 105) - # rot 0 + pi/2 = pi/2 - assert "A" in parent.ports - assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) - assert parent.ports["A"].rotation is not None - assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py deleted file mode 100644 index e2d266b..0000000 --- a/masque/test/test_ref.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import cast, TYPE_CHECKING -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..pattern import Pattern -from ..ref import Ref -from ..repetition import Grid - -if TYPE_CHECKING: - from ..shapes import Polygon - - -def test_ref_init() -> None: - ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0) - assert_equal(ref.offset, [10, 20]) - assert ref.rotation == pi / 4 - assert ref.mirrored is True - assert ref.scale == 2.0 - - -def test_ref_as_pattern() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - ref = Ref(offset=(10, 10), rotation=pi / 2, scale=2.0) - transformed_pat = ref.as_pattern(sub_pat) - - # Check transformed shape - shape = cast("Polygon", transformed_pat.shapes[(1, 0)][0]) - # ref.as_pattern deepcopies sub_pat then applies transformations: - # 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]] - # 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]] - # 3. pattern.translate_elements((10,10)) -> vertices [[10,10], [10,12], [8,10]] - - assert_allclose(shape.vertices, [[10, 10], [10, 12], [8, 10]], atol=1e-10) - - -def test_ref_with_repetition() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - rep = Grid(a_vector=(10, 0), b_vector=(0, 10), a_count=2, b_count=2) - ref = Ref(repetition=rep) - - repeated_pat = ref.as_pattern(sub_pat) - # Should have 4 shapes - assert len(repeated_pat.shapes[(1, 0)]) == 4 - - first_verts = sorted([tuple(cast("Polygon", s).vertices[0]) for s in repeated_pat.shapes[(1, 0)]]) - assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)] - - -def test_ref_get_bounds() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [5, 0], [0, 5]]) - - ref = Ref(offset=(10, 10), scale=2.0) - bounds = ref.get_bounds_single(sub_pat) - # sub_pat bounds [[0,0], [5,5]] - # scaled [[0,0], [10,10]] - # translated [[10,10], [20,20]] - assert_equal(bounds, [[10, 10], [20, 20]]) - - -def test_ref_copy() -> None: - ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]}) - ref2 = ref1.copy() - assert ref1 == ref2 - assert ref1 is not ref2 - - ref2.offset[0] = 100 - assert ref1.offset[0] == 1 diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py deleted file mode 100644 index 5d2c8c3..0000000 --- a/masque/test/test_renderpather.py +++ /dev/null @@ -1,99 +0,0 @@ -import pytest -from typing import cast, TYPE_CHECKING -from numpy.testing import assert_allclose -from numpy import pi - -from ..builder import RenderPather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - -if TYPE_CHECKING: - from ..shapes import Path - - -@pytest.fixture -def rpather_setup() -> tuple[RenderPather, PathTool, Library]: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - rp = RenderPather(lib, tools=tool) - rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - return rp, tool, lib - - -def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - # Plan two segments - rp.at("start").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 = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert len(path_shape.vertices) == 3 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) - - -def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - # Plan straight then bend - rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10) - - rp.render() - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - # Path vertices: - # 1. Start (0,0) - # 2. Straight end: (0, -10) - # 3. Bend end: (-1, -20) - # PathTool.planL(ccw=False, length=10) returns data=[10, -1] - # start_port for 2nd segment is at (0, -10) with rotation pi/2 - # dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20). - # and final end_port.offset is (-1, -20). - assert len(path_shape.vertices) == 4 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10) - - -def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None: - rp, tool1, lib = rpather_setup - tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") - - rp.at("start").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 - - -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() diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py deleted file mode 100644 index 5ef2fa9..0000000 --- a/masque/test/test_repetition.py +++ /dev/null @@ -1,51 +0,0 @@ -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..repetition import Grid, Arbitrary - - -def test_grid_displacements() -> None: - # 2x2 grid - grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) - disps = sorted([tuple(d) for d in grid.displacements]) - assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)] - - -def test_grid_1d() -> None: - grid = Grid(a_vector=(10, 0), a_count=3) - disps = sorted([tuple(d) for d in grid.displacements]) - assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)] - - -def test_grid_rotate() -> None: - grid = Grid(a_vector=(10, 0), a_count=2) - grid.rotate(pi / 2) - assert_allclose(grid.a_vector, [0, 10], atol=1e-10) - - -def test_grid_get_bounds() -> None: - grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) - bounds = grid.get_bounds() - assert_equal(bounds, [[0, 0], [10, 5]]) - - -def test_arbitrary_displacements() -> None: - pts = [[0, 0], [10, 20], [-5, 30]] - arb = Arbitrary(pts) - # They should be sorted by displacements.setter - disps = arb.displacements - assert len(disps) == 3 - assert any((disps == [0, 0]).all(axis=1)) - assert any((disps == [10, 20]).all(axis=1)) - assert any((disps == [-5, 30]).all(axis=1)) - - -def test_arbitrary_transform() -> None: - arb = Arbitrary([[10, 0]]) - arb.rotate(pi / 2) - assert_allclose(arb.displacements, [[0, 10]], atol=1e-10) - - arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is: - # self.displacements[:, 1 - axis] *= -1 - # if axis=0, 1-axis=1, so y *= -1 - assert_allclose(arb.displacements, [[0, -10]], atol=1e-10) diff --git a/masque/test/test_shape_advanced.py b/masque/test/test_shape_advanced.py deleted file mode 100644 index f6ba69d..0000000 --- a/masque/test/test_shape_advanced.py +++ /dev/null @@ -1,144 +0,0 @@ -from pathlib import Path -import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection -from ..error import PatternError - - -# 1. Text shape tests -def test_text_to_polygons() -> None: - font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" - if not Path(font_path).exists(): - pytest.skip("Font file not found") - - t = Text("Hi", height=10, font_path=font_path) - polys = t.to_polygons() - assert len(polys) > 0 - assert all(isinstance(p, Polygon) for p in polys) - - # Check that it advances - # Character 'H' and 'i' should have different vertices - # Each character is a set of polygons. We check the mean x of vertices for each character. - char_x_means = [p.vertices[:, 0].mean() for p in polys] - assert len(set(char_x_means)) >= 2 - - -# 2. Manhattanization tests -def test_manhattanize() -> None: - # Diamond shape - poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]]) - grid = numpy.arange(0, 11, 1) - - manhattan_polys = poly.manhattanize(grid, grid) - assert len(manhattan_polys) >= 1 - for mp in manhattan_polys: - # Check that all edges are axis-aligned - dv = numpy.diff(mp.vertices, axis=0) - # For each segment, either dx or dy must be zero - assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0)) - - -# 3. Comparison and Sorting tests -def test_shape_comparisons() -> None: - c1 = Circle(radius=10) - c2 = Circle(radius=20) - assert c1 < c2 - assert not (c2 < c1) - - p1 = Polygon([[0, 0], [10, 0], [10, 10]]) - p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex - assert p1 < p2 - - # Different types - assert c1 < p1 or p1 < c1 - assert (c1 < p1) != (p1 < c1) - - -# 4. Arc/Path Edge Cases -def test_arc_edge_cases() -> None: - # Wrapped arc (> 360 deg) - a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2) - a.to_polygons(num_vertices=64) - # Should basically be a ring - bounds = a.get_bounds_single() - assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10) - - -def test_path_edge_cases() -> None: - # Zero-length segments - p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2) - polys = p.to_polygons() - assert len(polys) == 1 - assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]]) - - -# 5. PolyCollection with holes -def test_poly_collection_holes() -> None: - # Outer square, inner square hole - # PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do? - # wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple - # polygons or using specific winding rules. - # masque.shapes.Polygon doc says "specify an implicitly-closed boundary". - # Pyclipper is used in connectivity.py for holes. - - # Let's test PolyCollection with multiple polygons - verts = [ - [0, 0], - [10, 0], - [10, 10], - [0, 10], # Poly 1 - [2, 2], - [2, 8], - [8, 8], - [8, 2], # Poly 2 - ] - offsets = [0, 4] - pc = PolyCollection(verts, offsets) - polys = pc.to_polygons() - assert len(polys) == 2 - assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]]) - assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]]) - - -def test_poly_collection_constituent_empty() -> None: - # One real triangle, one "empty" polygon (0 vertices), one real square - # Note: Polygon requires 3 vertices, so "empty" here might mean just some junk - # that to_polygons should handle. - # Actually PolyCollection doesn't check vertex count per polygon. - verts = [ - [0, 0], - [1, 0], - [0, 1], # Tri - # Empty space - [10, 10], - [11, 10], - [11, 11], - [10, 11], # Square - ] - offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square? - # No, offsets should be strictly increasing or handle 0-length slices. - # vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)])) - # if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7] - offsets = [0, 3, 3] - pc = PolyCollection(verts, offsets) - # Polygon(vertices=[]) will fail because of the setter check. - # Let's see if pc.to_polygons() handles it. - # It calls Polygon(vertices=vv) for each slice. - # slice [3:3] gives empty vv. - with pytest.raises(PatternError): - pc.to_polygons() - - -def test_poly_collection_valid() -> None: - verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 3] - pc = PolyCollection(verts, offsets) - assert len(pc.to_polygons()) == 2 - shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))] - sorted_shapes = sorted(shapes) - assert len(sorted_shapes) == 4 - # Just verify it doesn't crash and is stable - assert sorted(sorted_shapes) == sorted_shapes diff --git a/masque/test/test_shapes.py b/masque/test/test_shapes.py deleted file mode 100644 index b19d6bc..0000000 --- a/masque/test/test_shapes.py +++ /dev/null @@ -1,142 +0,0 @@ -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection - - -def test_poly_collection_init() -> None: - # Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]] - verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 4] - pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) - assert len(list(pc.polygon_vertices)) == 2 - assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) - - -def test_poly_collection_to_polygons() -> None: - verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 4] - pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) - polys = pc.to_polygons() - assert len(polys) == 2 - assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]]) - - -def test_circle_init() -> None: - c = Circle(radius=10, offset=(5, 5)) - assert c.radius == 10 - assert_equal(c.offset, [5, 5]) - - -def test_circle_to_polygons() -> None: - c = Circle(radius=10) - polys = c.to_polygons(num_vertices=32) - assert len(polys) == 1 - assert isinstance(polys[0], Polygon) - # A circle with 32 vertices should have vertices distributed around (0,0) - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10) - - -def test_ellipse_init() -> None: - e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4) - assert_equal(e.radii, [10, 5]) - assert_equal(e.offset, [1, 2]) - assert e.rotation == pi / 4 - - -def test_ellipse_to_polygons() -> None: - e = Ellipse(radii=(10, 5)) - polys = e.to_polygons(num_vertices=64) - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10) - - -def test_arc_init() -> None: - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0)) - assert_equal(a.radii, [10, 10]) - assert_equal(a.angles, [0, pi / 2]) - assert a.width == 2 - - -def test_arc_to_polygons() -> None: - # Quarter circle arc - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - polys = a.to_polygons(num_vertices=32) - assert len(polys) == 1 - # Outer radius 11, inner radius 9 - # Quarter circle from 0 to 90 deg - bounds = polys[0].get_bounds_single() - # Min x should be 0 (inner edge start/stop or center if width is large) - # But wait, the arc is centered at 0,0. - # Outer edge goes from (11, 0) to (0, 11) - # Inner edge goes from (9, 0) to (0, 9) - # So x ranges from 0 to 11, y ranges from 0 to 11. - assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10) - - -def test_shape_mirror() -> None: - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) - e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset - assert_equal(e.offset, [10, 20]) - # rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi) - assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) - - a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20)) - a.mirror(0) - assert_equal(a.offset, [10, 20]) - # For Arc, mirror(0) negates rotation and angles - assert_allclose(a.angles, [0, -pi / 4], atol=1e-10) - - -def test_shape_flip_across() -> None: - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) - e.flip_across(axis=0) # Mirror across y=0: flips y-offset - assert_equal(e.offset, [10, -20]) - # rotation also flips: -pi/4 == 3pi/4 (mod pi) - assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) - # Mirror across specific y - e = Ellipse(radii=(10, 5), offset=(10, 20)) - e.flip_across(y=10) # Mirror across y=10 - # y=20 mirrored across y=10 -> y=0 - assert_equal(e.offset, [10, 0]) - - -def test_shape_scale() -> None: - e = Ellipse(radii=(10, 5)) - e.scale_by(2) - assert_equal(e.radii, [20, 10]) - - a = Arc(radii=(10, 5), angles=(0, pi), width=2) - a.scale_by(0.5) - assert_equal(a.radii, [5, 2.5]) - assert a.width == 1 - - -def test_shape_arclen() -> None: - # Test that max_arclen correctly limits segment lengths - - # Ellipse - e = Ellipse(radii=(10, 5)) - # Approximate perimeter is ~48.4 - # With max_arclen=5, should have > 10 segments - polys = e.to_polygons(max_arclen=5) - v = polys[0].vertices - dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1)) - assert numpy.all(dist <= 5.000001) - assert len(v) > 10 - - # Arc - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - # Outer perimeter is 11 * pi/2 ~ 17.27 - # Inner perimeter is 9 * pi/2 ~ 14.14 - # With max_arclen=2, should have > 8 segments on outer edge - polys = a.to_polygons(max_arclen=2) - v = polys[0].vertices - # Arc polygons are closed, but contain both inner and outer edges and caps - # Let's just check that all segment lengths are within limit - dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1)) - assert numpy.all(dist <= 2.000001) diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py deleted file mode 100644 index 882b5bd..0000000 --- a/masque/test/test_utils.py +++ /dev/null @@ -1,83 +0,0 @@ -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms - - -def test_remove_duplicate_vertices() -> None: - # Closed path (default) - v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]] - v_clean = remove_duplicate_vertices(v, closed_path=True) - # The last [0,0] is a duplicate of the first [0,0] if closed_path=True - assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]]) - - # Open path - v_clean_open = remove_duplicate_vertices(v, closed_path=False) - assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]]) - - -def test_remove_colinear_vertices() -> None: - v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]] - v_clean = remove_colinear_vertices(v, closed_path=True) - # [1, 0] is between [0, 0] and [2, 0] - # [2, 1] is between [2, 0] and [2, 2] - # [1, 1] is between [2, 2] and [0, 0] - assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]]) - - -def test_remove_colinear_vertices_exhaustive() -> None: - # U-turn - v = [[0, 0], [10, 0], [0, 0]] - v_clean = remove_colinear_vertices(v, closed_path=False) - # 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() -> None: - v = [[0, 0], [10, 0], [10, 10], [0, 10]] - pts = [[5, 5], [-1, -1], [10, 10], [11, 5]] - inside = poly_contains_points(v, pts) - assert_equal(inside, [True, False, True, False]) - - -def test_rotation_matrix_2d() -> None: - m = rotation_matrix_2d(pi / 2) - assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10) - - -def test_rotation_matrix_non_manhattan() -> None: - # 45 degrees - m = rotation_matrix_2d(pi / 4) - s = numpy.sqrt(2) / 2 - assert_allclose(m, [[s, -s], [s, s]], atol=1e-10) - - -def test_apply_transforms() -> None: - # cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)] - t1 = [10, 20, 0, 0] - t2 = [[5, 0, 0, 0], [0, 5, 0, 0]] - combined = apply_transforms(t1, t2) - assert_equal(combined, [[15, 20, 0, 0], [10, 25, 0, 0]]) - - -def test_apply_transforms_advanced() -> None: - # Ox4: (x, y, rot, mir) - # Outer: mirror x (axis 0), then rotate 90 deg CCW - # apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror) - outer = [0, 0, pi / 2, 1] - - # Inner: (10, 0, 0, 0) - inner = [10, 0, 0, 0] - - combined = apply_transforms(outer, inner) - # 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0) - # 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10) - # 3. add outer offset (0, 0) -> (0, 10) - assert_allclose(combined[0], [0, 10, pi / 2, 1], atol=1e-10) diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py index cca38f3..7c7360c 100644 --- a/masque/traits/__init__.py +++ b/masque/traits/__init__.py @@ -26,11 +26,7 @@ from .scalable import ( Scalable as Scalable, ScalableImpl as ScalableImpl, ) -from .mirrorable import ( - Mirrorable as Mirrorable, - Flippable as Flippable, - FlippableImpl as FlippableImpl, - ) +from .mirrorable import Mirrorable as Mirrorable from .copyable import Copyable as Copyable from .annotatable import ( Annotatable as Annotatable, diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index ac00147..6d4ec3c 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,13 +1,6 @@ from typing import Self from abc import ABCMeta, abstractmethod -import numpy -from numpy.typing import NDArray - -from ..error import MasqueError -from .positionable import Positionable -from .repeatable import Repeatable - class Mirrorable(metaclass=ABCMeta): """ @@ -18,16 +11,11 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - 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. + Mirror the entity across an axis. Args: - axis: Axis to mirror across: - 0: X-axis (flip y coords), - 1: Y-axis (flip x coords) + axis: Axis to mirror across. + Returns: self """ @@ -35,11 +23,10 @@ class Mirrorable(metaclass=ABCMeta): def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self: """ - Optionally mirror the entity across both axes through its origin. + Optionally mirror the entity across both axes Args: - across_x: Mirror across the horizontal X-axis (flip Y coordinates). - across_y: Mirror across the vertical Y-axis (flip X coordinates). + axes: (mirror_across_x, mirror_across_y) Returns: self @@ -51,60 +38,30 @@ class Mirrorable(metaclass=ABCMeta): return self -class Flippable(Positionable, metaclass=ABCMeta): - """ - Trait class for entities which can be mirrored relative to an external line. - """ - __slots__ = () - - @staticmethod - def _check_flip_args(axis: int | None = None, *, x: float | None = None, y: float | None = None) -> tuple[int, NDArray[numpy.float64]]: - pivot = numpy.zeros(2) - if axis is not None: - if x is not None or y is not None: - raise MasqueError('Cannot specify both axis and x or y') - return axis, pivot - if x is not None: - if y is not None: - raise MasqueError('Cannot specify both x and y') - return 1, pivot + (x, 0) - if y is not None: - return 0, pivot + (0, y) - raise MasqueError('Must specify one of axis, x, or y') - - @abstractmethod - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - """ - 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: x-axis (flip y coord), 1: y-axis (flip x coord). - x: Vertical line x=val to mirror across. - y: Horizontal line y=val to mirror across. - - Returns: - self - """ - pass - - -class FlippableImpl(Flippable, Mirrorable, Repeatable, metaclass=ABCMeta): - """ - Implementation of `Flippable` for objects which are `Mirrorable`, `Positionable`, - and `Repeatable`. - """ - __slots__ = () - - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) - self.translate(-pivot) - self.mirror(axis) - if self.repetition is not None: - self.repetition.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self +#class MirrorableImpl(Mirrorable, metaclass=ABCMeta): +# """ +# Simple implementation of `Mirrorable` +# """ +# __slots__ = () +# +# _mirrored: NDArray[numpy.bool] +# """ Whether to mirror the instance across the x and/or y axes. """ +# +# # +# # Properties +# # +# # Mirrored property +# @property +# def mirrored(self) -> NDArray[numpy.bool]: +# """ Whether to mirror across the [x, y] axes, respectively """ +# return self._mirrored +# +# @mirrored.setter +# def mirrored(self, val: Sequence[bool]) -> None: +# if is_scalar(val): +# raise MasqueError('Mirrored must be a 2-element list of booleans') +# self._mirrored = numpy.array(val, dtype=bool) +# +# # +# # Methods +# # diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 2517e2e..2fa86c1 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Self, cast, Any, TYPE_CHECKING from abc import ABCMeta, abstractmethod import numpy @@ -8,7 +8,8 @@ from numpy.typing import ArrayLike from ..error import MasqueError from ..utils import rotation_matrix_2d -from .positionable import Positionable +if TYPE_CHECKING: + from .positionable import Positionable _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass @@ -80,7 +81,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): return self -class Pivotable(Positionable, metaclass=ABCMeta): +class Pivotable(metaclass=ABCMeta): """ Trait class for entites which can be rotated around a point. This requires that they are `Positionable` but not necessarily `Rotatable` themselves. @@ -102,18 +103,20 @@ class Pivotable(Positionable, metaclass=ABCMeta): pass -class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta): +class PivotableImpl(Pivotable, metaclass=ABCMeta): """ Implementation of `Pivotable` for objects which are `Rotatable` - and `Positionable`. """ __slots__ = () + offset: Any # TODO see if we can get around defining `offset` in PivotableImpl + """ `[x_offset, y_offset]` """ + def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: pivot = numpy.asarray(pivot, dtype=float) - self.translate(-pivot) - self.rotate(rotation) + cast('Positionable', self).translate(-pivot) + cast('Rotatable', self).rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - self.translate(+pivot) + cast('Positionable', self).translate(+pivot) return self diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py deleted file mode 100644 index 9b9514e..0000000 --- a/masque/utils/boolean.py +++ /dev/null @@ -1,180 +0,0 @@ -from typing import Any, Literal -from collections.abc import Iterable -import logging - -import numpy -from numpy.typing import NDArray - -from ..shapes.polygon import Polygon -from ..error import PatternError - -logger = logging.getLogger(__name__) - -def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]: - """ - Bridge multiple holes into an outer boundary using zero-width slits. - """ - current_outer = outer_path - - # Sort holes by max X to potentially minimize bridge lengths or complexity - # (though not strictly necessary for correctness) - holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True) - - for hole in holes: - # Find max X vertex of hole - max_idx = numpy.argmax(hole[:, 0]) - m = hole[max_idx] - - # Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges - best_t = numpy.inf - best_pt = None - best_edge_idx = -1 - - n = len(current_outer) - for i in range(n): - p1 = current_outer[i] - p2 = current_outer[(i + 1) % n] - - # Check if edge (p1, p2) spans m.y - if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]): - # Intersection x: - # x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y) - t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0] - if 0 <= t < best_t: - best_t = t - best_pt = numpy.array([m[0] + t, m[1]]) - best_edge_idx = i - - if best_edge_idx == -1: - # Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole) - dists = numpy.linalg.norm(current_outer - m, axis=1) - best_edge_idx = int(numpy.argmin(dists)) - best_pt = current_outer[best_edge_idx] - # Adjust best_edge_idx to insert AFTER this vertex - # (treating it as a degenerate edge) - - assert best_pt is not None - - # Reorder hole vertices to start at m - hole_reordered = numpy.roll(hole, -max_idx, axis=0) - - # Construct new outer: - # 1. Start of outer up to best_edge_idx - # 2. Intersection point - # 3. Hole vertices (starting and ending at m) - # 4. Intersection point (to close slit) - # 5. Rest of outer - - new_outer: list[NDArray[numpy.float64]] = [] - new_outer.extend(current_outer[:best_edge_idx + 1]) - new_outer.append(best_pt) - new_outer.extend(hole_reordered) - new_outer.append(hole_reordered[0]) # close hole loop at m - new_outer.append(best_pt) # back to outer - new_outer.extend(current_outer[best_edge_idx + 1:]) - - current_outer = numpy.array(new_outer) - - return current_outer - -def boolean( - subjects: Iterable[Any], - clips: Iterable[Any] | None = None, - operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', - scale: float = 1e6, - ) -> list[Polygon]: - """ - Perform a boolean operation on two sets of polygons. - - Args: - subjects: List of subjects (Polygons or vertex arrays). - clips: List of clips (Polygons or vertex arrays). - operation: The boolean operation to perform. - scale: Scaling factor for integer conversion (pyclipper uses integers). - - Returns: - A list of result Polygons. - """ - try: - import pyclipper - except ImportError: - raise ImportError( - "Boolean operations require 'pyclipper'. " - "Install it with 'pip install pyclipper' or 'pip install masque[boolean]'." - ) from None - - op_map = { - 'union': pyclipper.PT_UNION, - 'intersection': pyclipper.PT_INTERSECTION, - 'difference': pyclipper.PT_DIFFERENCE, - 'xor': pyclipper.PT_XOR, - } - - def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]: - if objs is None: - return [] - verts = [] - for obj in objs: - if hasattr(obj, 'to_polygons'): - for p in obj.to_polygons(): - verts.append(p.vertices) - elif isinstance(obj, numpy.ndarray): - verts.append(obj) - elif isinstance(obj, Polygon): - verts.append(obj.vertices) - else: - # Try to iterate if it's an iterable of shapes - try: - for sub in obj: - if hasattr(sub, 'to_polygons'): - for p in sub.to_polygons(): - verts.append(p.vertices) - elif isinstance(sub, Polygon): - verts.append(sub.vertices) - except TypeError: - raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None - return verts - - subject_verts = to_vertices(subjects) - clip_verts = to_vertices(clips) - - pc = pyclipper.Pyclipper() - pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) - if clip_verts: - pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True) - - # Use GetPolyTree to distinguish between outers and holes - polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) - - result_polygons = [] - - def process_node(node: Any) -> None: - if not node.IsHole: - # This is an outer boundary - outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale)) - - # Find immediate holes - holes = [] - for child in node.Childs: - if child.IsHole: - holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale))) - - if holes: - combined_vertices = _bridge_holes(outer_path, holes) - result_polygons.append(Polygon(combined_vertices)) - else: - result_polygons.append(Polygon(outer_path)) - - # Recursively process children of holes (which are nested outers) - for child in node.Childs: - if child.IsHole: - for grandchild in child.Childs: - process_node(grandchild) - else: - # Holes are processed as children of outers - pass - - for top_node in polytree.Childs: - process_node(top_node) - - return result_polygons diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 36d2e59..5fddd52 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -51,10 +51,6 @@ 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] diff --git a/pyproject.toml b/pyproject.toml index 15ba402..9a29065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,19 +46,6 @@ dependencies = [ "klamath~=1.4", ] -[dependency-groups] -dev = [ - "pytest", - "masque[oasis]", - "masque[dxf]", - "masque[svg]", - "masque[visualize]", - "masque[text]", - "masque[manhattanize]", - "masque[manhattanize_slow]", - "ruff>=0.15.1", - "mypy>=1.19.1", -] [tool.hatch.version] path = "masque/__init__.py" @@ -69,9 +56,7 @@ dxf = ["ezdxf~=1.0.2"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] -manhattanize = ["scikit-image"] -manhattanize_slow = ["float_raster"] -boolean = ["pyclipper"] +manhatanize_slow = ["float_raster"] [tool.ruff] @@ -109,9 +94,3 @@ 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/ezdxf/__init__.pyi b/stubs/ezdxf/__init__.pyi deleted file mode 100644 index 0198407..0000000 --- a/stubs/ezdxf/__init__.pyi +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index c8e6a4b..0000000 --- a/stubs/ezdxf/entities.pyi +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 0dcf600..0000000 --- a/stubs/ezdxf/enums.pyi +++ /dev/null @@ -1,4 +0,0 @@ -from enum import IntEnum - -class TextEntityAlignment(IntEnum): - BOTTOM_LEFT = ... diff --git a/stubs/ezdxf/layouts.pyi b/stubs/ezdxf/layouts.pyi deleted file mode 100644 index 4e713e6..0000000 --- a/stubs/ezdxf/layouts.pyi +++ /dev/null @@ -1,20 +0,0 @@ -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]: ... diff --git a/stubs/pyclipper/__init__.pyi b/stubs/pyclipper/__init__.pyi deleted file mode 100644 index 08d77c8..0000000 --- a/stubs/pyclipper/__init__.pyi +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any -from collections.abc import Iterable, Sequence -import numpy -from numpy.typing import NDArray - - -# Basic types for Clipper integer coordinates -Path = Sequence[tuple[int, int]] -Paths = Sequence[Path] - -# Types for input/output floating point coordinates -FloatPoint = tuple[float, float] | NDArray[numpy.floating] -FloatPath = Sequence[FloatPoint] | NDArray[numpy.floating] -FloatPaths = Iterable[FloatPath] - -# Constants -PT_SUBJECT: int -PT_CLIP: int - -PT_UNION: int -PT_INTERSECTION: int -PT_DIFFERENCE: int -PT_XOR: int - -PFT_EVENODD: int -PFT_NONZERO: int -PFT_POSITIVE: int -PFT_NEGATIVE: int - -# Scaling functions -def scale_to_clipper(paths: FloatPaths, scale: float = ...) -> Paths: ... -def scale_from_clipper(paths: Path | Paths, scale: float = ...) -> Any: ... - -class PolyNode: - Contour: Path - Childs: list[PolyNode] - Parent: PolyNode - IsHole: bool - -class Pyclipper: - def __init__(self) -> None: ... - def AddPath(self, path: Path, poly_type: int, closed: bool) -> None: ... - def AddPaths(self, paths: Paths, poly_type: int, closed: bool) -> None: ... - def Execute(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> Paths: ... - def Execute2(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> PolyNode: ... - def Clear(self) -> None: ...