Compare commits
41 commits
master
...
mirror_con
| Author | SHA1 | Date | |
|---|---|---|---|
| ed021e3d81 | |||
| 07a25ec290 | |||
| 504f89796c | |||
| 0f49924aa6 | |||
| ebfe1b559c | |||
| 7ad59d6b89 | |||
| 5d040061f4 | |||
| f42e720c68 | |||
| cf822c7dcf | |||
| 59e996e680 | |||
| abf236a046 | |||
| d40bdb1cb2 | |||
| 5e08579498 | |||
| c18e5b8d3e | |||
| 48f7569c1f | |||
| 8a56679884 | |||
| 1cce6c1f70 | |||
| d9adb4e1b9 | |||
| 1de76bff47 | |||
| 9bb0d5190d | |||
| ad49276345 | |||
| fe70d0574b | |||
| 36fed84249 | |||
| 278f0783da | |||
| 72f462d077 | |||
| 66d6fae2bd | |||
| 2b7ad00204 | |||
| 2d63e72802 | |||
| 51ced2fe83 | |||
| 19fac463e4 | |||
| 44986bac67 | |||
| accad3db9f | |||
| 05098c0c13 | |||
| f64b080b15 | |||
| 54f3b273bc | |||
| add0600bac | |||
| 737d41d592 | |||
| 395244ee83 | |||
| 43ccd8de2f | |||
| dfa0259997 | |||
| 37418d2137 |
68 changed files with 3461 additions and 404 deletions
|
|
@ -6,7 +6,7 @@ from masque.file import gdsii
|
|||
from masque import Arc, Pattern
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
pat = Pattern()
|
||||
layer = (0, 0)
|
||||
pat.shapes[layer].extend([
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import numpy
|
||||
from pyclipper import (
|
||||
Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO,
|
||||
scale_to_clipper, scale_from_clipper,
|
||||
Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO,
|
||||
)
|
||||
p = Pyclipper()
|
||||
p.AddPaths([
|
||||
|
|
@ -12,8 +10,8 @@ p.AddPaths([
|
|||
], PT_SUBJECT, closed=True)
|
||||
#p.Execute2?
|
||||
#p.Execute?
|
||||
p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO)
|
||||
p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO)
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
|
||||
p = Pyclipper()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis
|
|||
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
lib = Library()
|
||||
|
||||
cell_name = 'ellip_grating'
|
||||
|
|
|
|||
|
|
@ -18,11 +18,14 @@ Contents
|
|||
* Design a pattern which is meant to plug into an existing pattern (via `.interface()`)
|
||||
- [pather](pather.py)
|
||||
* Use `Pather` to route individual wires and wire bundles
|
||||
* Use `BasicTool` to generate paths
|
||||
* Use `BasicTool` to automatically transition between path types
|
||||
- [renderpather](rendpather.py)
|
||||
* Use `AutoTool` to generate paths
|
||||
* Use `AutoTool` to automatically transition between path types
|
||||
- [renderpather](renderpather.py)
|
||||
* Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py),
|
||||
but using `Path` shapes instead of `Polygon`s.
|
||||
- [port_pather](port_pather.py)
|
||||
* Use `PortPather` and the `.at()` syntax for more concise routing
|
||||
* Advanced port manipulation and connections
|
||||
|
||||
|
||||
Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
from collections.abc import Sequence
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import (
|
||||
layer_t, Pattern, Label, Port,
|
||||
Circle, Arc, Polygon,
|
||||
)
|
||||
from masque import layer_t, Pattern, Circle, Arc, Ref
|
||||
from masque.repetition import Grid
|
||||
import masque.file.gdsii
|
||||
|
||||
|
||||
|
|
@ -39,6 +36,45 @@ def hole(
|
|||
return pat
|
||||
|
||||
|
||||
def hole_array(
|
||||
radius: float,
|
||||
num_x: int = 5,
|
||||
num_y: int = 3,
|
||||
pitch: float = 2000,
|
||||
layer: layer_t = (1, 0),
|
||||
) -> Pattern:
|
||||
"""
|
||||
Generate an array of circular holes using `Repetition`.
|
||||
|
||||
Args:
|
||||
radius: Circle radius.
|
||||
num_x, num_y: Number of holes in x and y.
|
||||
pitch: Center-to-center spacing.
|
||||
layer: Layer to draw the holes on.
|
||||
|
||||
Returns:
|
||||
Pattern containing a grid of holes.
|
||||
"""
|
||||
# First, make a pattern for a single hole
|
||||
hpat = hole(radius, layer)
|
||||
|
||||
# Now, create a pattern that references it multiple times using a Grid
|
||||
pat = Pattern()
|
||||
pat.refs['hole'] = [
|
||||
Ref(
|
||||
offset=(0, 0),
|
||||
repetition=Grid(a_vector=(pitch, 0), a_count=num_x,
|
||||
b_vector=(0, pitch), b_count=num_y)
|
||||
)]
|
||||
|
||||
# We can also add transformed references (rotation, mirroring, etc.)
|
||||
pat.refs['hole'].append(
|
||||
Ref(offset=(0, -pitch), rotation=pi / 4, mirrored=True)
|
||||
)
|
||||
|
||||
return pat, hpat
|
||||
|
||||
|
||||
def triangle(
|
||||
radius: float,
|
||||
layer: layer_t = (1, 0),
|
||||
|
|
@ -60,9 +96,7 @@ def triangle(
|
|||
]) * radius
|
||||
|
||||
pat = Pattern()
|
||||
pat.shapes[layer].extend([
|
||||
Polygon(offset=(0, 0), vertices=vertices),
|
||||
])
|
||||
pat.polygon(layer, vertices=vertices)
|
||||
return pat
|
||||
|
||||
|
||||
|
|
@ -111,9 +145,13 @@ def main() -> None:
|
|||
lib['smile'] = smile(1000)
|
||||
lib['triangle'] = triangle(1000)
|
||||
|
||||
# Use a Grid to make many holes efficiently
|
||||
lib['grid'], lib['hole'] = hole_array(1000)
|
||||
|
||||
masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS)
|
||||
|
||||
lib['triangle'].visualize()
|
||||
lib['grid'].visualize(lib)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import numpy
|
|||
from numpy import pi
|
||||
|
||||
from masque import (
|
||||
layer_t, Pattern, Ref, Label, Builder, Port, Polygon,
|
||||
Library, ILibraryView,
|
||||
layer_t, Pattern, Ref, Builder, Port, Polygon,
|
||||
Library,
|
||||
)
|
||||
from masque.utils import ports2data
|
||||
from masque.file.gdsii import writefile, check_valid_names
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
from typing import Any
|
||||
from collections.abc import Sequence, Callable
|
||||
from pprint import pformat
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import Pattern, Builder, LazyLibrary
|
||||
from masque import Builder, LazyLibrary
|
||||
from masque.file.gdsii import writefile, load_libraryfile
|
||||
|
||||
import pcgen
|
||||
import basic_shapes
|
||||
import devices
|
||||
from devices import ports_to_data, data_to_ports
|
||||
from devices import data_to_ports
|
||||
from basic_shapes import GDS_OPTS
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
"""
|
||||
Manual wire routing tutorial: Pather and BasicTool
|
||||
Manual wire routing tutorial: Pather and AutoTool
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
from numpy import pi
|
||||
from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers
|
||||
from masque.builder.tools import BasicTool, PathTool
|
||||
from masque import Pather, Library, Pattern, Port, layer_t
|
||||
from masque.builder.tools import AutoTool, Tool
|
||||
from masque.file.gdsii import writefile
|
||||
|
||||
from basic_shapes import GDS_OPTS
|
||||
|
|
@ -110,28 +109,24 @@ def map_layer(layer: layer_t) -> layer_t:
|
|||
return layer_mapping.get(layer, layer)
|
||||
|
||||
|
||||
#
|
||||
# Now we can start building up our library (collection of static cells) and pathing tools.
|
||||
#
|
||||
# If any of the operations below are confusing, you can cross-reference against the `RenderPather`
|
||||
# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others
|
||||
# (e.g. geometry definition).
|
||||
#
|
||||
def main() -> None:
|
||||
def prepare_tools() -> tuple[Library, Tool, Tool]:
|
||||
"""
|
||||
Create some basic library elements and tools for drawing M1 and M2
|
||||
"""
|
||||
# Build some patterns (static cells) using the above functions and store them in a library
|
||||
library = Library()
|
||||
library['pad'] = make_pad()
|
||||
library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH)
|
||||
library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH)
|
||||
library['v1_via'] = make_via(
|
||||
layer_top='M2',
|
||||
layer_via='V1',
|
||||
layer_bot='M1',
|
||||
width_top=M2_WIDTH,
|
||||
width_via=V1_WIDTH,
|
||||
width_bot=M1_WIDTH,
|
||||
ptype_bot='m1wire',
|
||||
ptype_top='m2wire',
|
||||
layer_top = 'M2',
|
||||
layer_via = 'V1',
|
||||
layer_bot = 'M1',
|
||||
width_top = M2_WIDTH,
|
||||
width_via = V1_WIDTH,
|
||||
width_bot = M1_WIDTH,
|
||||
ptype_bot = 'm1wire',
|
||||
ptype_top = 'm2wire',
|
||||
)
|
||||
|
||||
#
|
||||
|
|
@ -140,53 +135,79 @@ def main() -> None:
|
|||
# M2_tool will route on M2, using wires with M2_WIDTH
|
||||
# Both tools are able to automatically transition from the other wire type (with a via)
|
||||
#
|
||||
# Note that while we use BasicTool for this tutorial, you can define your own `Tool`
|
||||
# Note that while we use AutoTool for this tutorial, you can define your own `Tool`
|
||||
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
|
||||
# transmission line geometry, or other features.
|
||||
#
|
||||
M1_tool = BasicTool(
|
||||
straight = (
|
||||
# First, we need a function which takes in a length and spits out an M1 wire
|
||||
lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
|
||||
'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
|
||||
'output', # and use the port named 'output' as the output
|
||||
),
|
||||
bend = (
|
||||
library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
|
||||
'input', # To orient it clockwise, use the port named 'input' as the input
|
||||
'output', # and 'output' as the output
|
||||
),
|
||||
M1_tool = AutoTool(
|
||||
# First, we need a function which takes in a length and spits out an M1 wire
|
||||
straights = [
|
||||
AutoTool.Straight(
|
||||
ptype = 'm1wire',
|
||||
fn = lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
|
||||
in_port_name = 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
|
||||
out_port_name = 'output', # and use the port named 'output' as the output
|
||||
),
|
||||
],
|
||||
bends = [
|
||||
AutoTool.Bend(
|
||||
abstract = library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
|
||||
in_port_name = 'input',
|
||||
out_port_name = 'output',
|
||||
clockwise = True,
|
||||
),
|
||||
],
|
||||
transitions = { # We can automate transitions for different (normally incompatible) port types
|
||||
'm2wire': ( # For example, when we're attaching to a port with type 'm2wire'
|
||||
('m2wire', 'm1wire'): AutoTool.Transition( # For example, when we're attaching to a port with type 'm2wire'
|
||||
library.abstract('v1_via'), # we can place a V1 via
|
||||
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
|
||||
'bottom', # and using the port named 'bottom' as the output
|
||||
),
|
||||
},
|
||||
sbends = [],
|
||||
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
|
||||
)
|
||||
|
||||
M2_tool = BasicTool(
|
||||
straight = (
|
||||
M2_tool = AutoTool(
|
||||
straights = [
|
||||
# Again, we use make_straight_wire, but this time we set parameters for M2
|
||||
lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
|
||||
'input',
|
||||
'output',
|
||||
),
|
||||
bend = (
|
||||
library.abstract('m2_bend'), # and we use an M2 bend
|
||||
'input',
|
||||
'output',
|
||||
),
|
||||
AutoTool.Straight(
|
||||
ptype = 'm2wire',
|
||||
fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
|
||||
in_port_name = 'input',
|
||||
out_port_name = 'output',
|
||||
),
|
||||
],
|
||||
bends = [
|
||||
# and we use an M2 bend
|
||||
AutoTool.Bend(
|
||||
abstract = library.abstract('m2_bend'),
|
||||
in_port_name = 'input',
|
||||
out_port_name = 'output',
|
||||
),
|
||||
],
|
||||
transitions = {
|
||||
'm1wire': (
|
||||
('m1wire', 'm2wire'): AutoTool.Transition(
|
||||
library.abstract('v1_via'), # We still use the same via,
|
||||
'bottom', # but the input port is now 'bottom'
|
||||
'top', # and the output port is now 'top'
|
||||
),
|
||||
},
|
||||
sbends = [],
|
||||
default_out_ptype = 'm2wire', # We default to trying to stay on M2
|
||||
)
|
||||
return library, M1_tool, M2_tool
|
||||
|
||||
|
||||
#
|
||||
# Now we can start building up our library (collection of static cells) and pathing tools.
|
||||
#
|
||||
# If any of the operations below are confusing, you can cross-reference against the `RenderPather`
|
||||
# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others
|
||||
# (e.g. geometry definition).
|
||||
#
|
||||
def main() -> None:
|
||||
library, M1_tool, M2_tool = prepare_tools()
|
||||
|
||||
#
|
||||
# Create a new pather which writes to `library` and uses `M2_tool` as its default tool.
|
||||
|
|
@ -218,7 +239,7 @@ def main() -> None:
|
|||
pather.path_to('GND', None, x=pather['VCC'].offset[0])
|
||||
|
||||
# Now, start using M1_tool for GND.
|
||||
# Since we have defined an M2-to-M1 transition for BasicPather, we don't need to place one ourselves.
|
||||
# Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves.
|
||||
# If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here
|
||||
# and achieve the same result without having to define any transitions in M1_tool.
|
||||
# Note that even though we have changed the tool used for GND, the via doesn't get placed until
|
||||
|
|
@ -272,7 +293,7 @@ def main() -> None:
|
|||
pather.path_to('GND', None, -50_000)
|
||||
|
||||
# Save the pather's pattern into our library
|
||||
library['Pather_and_BasicTool'] = pather.pattern
|
||||
library['Pather_and_AutoTool'] = pather.pattern
|
||||
|
||||
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||
library.map_layers(map_layer)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Routines for creating normalized 2D lattices and common photonic crystal
|
||||
cavity designs.
|
||||
"""
|
||||
from collection.abc import Sequence
|
||||
from collections.abc import Sequence
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
|
@ -50,7 +50,7 @@ def triangular_lattice(
|
|||
elif origin == 'corner':
|
||||
pass
|
||||
else:
|
||||
raise Exception(f'Invalid value for `origin`: {origin}')
|
||||
raise ValueError(f'Invalid value for `origin`: {origin}')
|
||||
|
||||
return xy[xy[:, 0].argsort(), :]
|
||||
|
||||
|
|
@ -197,12 +197,12 @@ def ln_defect(
|
|||
`[[x0, y0], [x1, y1], ...]` for all the holes
|
||||
"""
|
||||
if defect_length % 2 != 1:
|
||||
raise Exception('defect_length must be odd!')
|
||||
p = triangular_lattice([2 * d + 1 for d in mirror_dims])
|
||||
raise ValueError('defect_length must be odd!')
|
||||
pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims])
|
||||
half_length = numpy.floor(defect_length / 2)
|
||||
hole_nums = numpy.arange(-half_length, half_length + 1)
|
||||
holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True)
|
||||
return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ]
|
||||
holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True)
|
||||
return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :]
|
||||
|
||||
|
||||
def ln_shift_defect(
|
||||
|
|
@ -248,7 +248,7 @@ def ln_shift_defect(
|
|||
for sign in (-1, 1):
|
||||
x_val = sign * (x_removed + ind + 1)
|
||||
which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0)
|
||||
xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind])
|
||||
xyr[which, :] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind])
|
||||
|
||||
return xyr
|
||||
|
||||
|
|
@ -309,7 +309,7 @@ def l3_shift_perturbed_defect(
|
|||
|
||||
# which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2])
|
||||
perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2)))
|
||||
for row in xyr:
|
||||
if numpy.fabs(row) in perturbed_holes:
|
||||
row[2] = perturbed_radius
|
||||
for xy in perturbed_holes:
|
||||
which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1)
|
||||
xyr[which, 2] = perturbed_radius
|
||||
return xyr
|
||||
|
|
|
|||
171
examples/tutorial/port_pather.py
Normal file
171
examples/tutorial/port_pather.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""
|
||||
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()
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
"""
|
||||
Manual wire routing tutorial: RenderPather an PathTool
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers
|
||||
from masque import RenderPather, Library
|
||||
from masque.builder.tools import PathTool
|
||||
from masque.file.gdsii import writefile
|
||||
|
||||
|
|
@ -13,7 +12,7 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via
|
|||
def main() -> None:
|
||||
#
|
||||
# To illustrate the advantages of using `RenderPather`, we use `PathTool` instead
|
||||
# of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
|
||||
# of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
|
||||
# but when used with `RenderPather`, it can consolidate multiple routing steps into
|
||||
# a single `Path` shape.
|
||||
#
|
||||
|
|
@ -25,24 +24,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 `BasicTool`. It only generates one type of shape
|
||||
# `PathTool` is more limited than `AutoTool`. It only generates one type of shape
|
||||
# (`Path`), so it only needs to know what layer to draw on, what width to draw with,
|
||||
# and what port type to present.
|
||||
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
|
||||
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
|
||||
rpather = RenderPather(tools=M2_ptool, library=library)
|
||||
|
||||
# As in the pather tutorial, we make soem pads and labels...
|
||||
# As in the pather tutorial, we make some pads and labels...
|
||||
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
||||
rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
|
||||
|
|
@ -52,7 +51,7 @@ def main() -> None:
|
|||
rpather.path('VCC', ccw=False, length=6_000)
|
||||
rpather.path_to('VCC', ccw=None, x=0)
|
||||
rpather.path('GND', 0, 5_000)
|
||||
rpather.path_to('GND', None, x=rpather['VCC'].offset[0])
|
||||
rpather.path_to('GND', None, x=rpather['VCC'].x)
|
||||
|
||||
# `PathTool` doesn't know how to transition betwen metal layers, so we have to
|
||||
# `plug` the via into the GND wire ourselves.
|
||||
|
|
@ -76,13 +75,15 @@ def main() -> None:
|
|||
# just ask it to transition to an 'm1wire' port at the end of the final VCC segment.
|
||||
# Instead, we have to calculate the via size ourselves, and adjust the final position
|
||||
# to account for it.
|
||||
via_size = abs(
|
||||
library['v1_via'].ports['top'].offset[0]
|
||||
- library['v1_via'].ports['bottom'].offset[0]
|
||||
)
|
||||
v1pat = library['v1_via']
|
||||
via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x)
|
||||
|
||||
# alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0]
|
||||
# would take into account the port orientations if we didn't already know they're along x
|
||||
rpather.path_to('VCC', None, -50_000 + via_size)
|
||||
rpather.plug('v1_via', {'VCC': 'top'})
|
||||
|
||||
# Render the path we defined
|
||||
rpather.render()
|
||||
library['RenderPather_and_PathTool'] = rpather.pattern
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ from .pattern import (
|
|||
map_targets as map_targets,
|
||||
chain_elements as chain_elements,
|
||||
)
|
||||
from .utils.boolean import boolean as boolean
|
||||
|
||||
from .library import (
|
||||
ILibraryView as ILibraryView,
|
||||
|
|
|
|||
|
|
@ -8,16 +8,13 @@ from numpy.typing import ArrayLike
|
|||
from .ref import Ref
|
||||
from .ports import PortList, Port
|
||||
from .utils import rotation_matrix_2d
|
||||
|
||||
#if TYPE_CHECKING:
|
||||
# from .builder import Builder, Tool
|
||||
# from .library import ILibrary
|
||||
from .traits import Mirrorable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Abstract(PortList):
|
||||
class Abstract(PortList, Mirrorable):
|
||||
"""
|
||||
An `Abstract` is a container for a name and associated ports.
|
||||
|
||||
|
|
@ -131,50 +128,18 @@ class Abstract(PortList):
|
|||
port.rotate(rotation)
|
||||
return self
|
||||
|
||||
def mirror_port_offsets(self, across_axis: int = 0) -> Self:
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the offsets of all shapes, labels, and refs across an axis
|
||||
Mirror the Abstract across an axis through its origin.
|
||||
|
||||
Args:
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.offset[across_axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def mirror_ports(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror each port's rotation across an axis, relative to its
|
||||
offset
|
||||
|
||||
Args:
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.mirror(across_axis)
|
||||
return self
|
||||
|
||||
def mirror(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the Pattern across an axis
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.mirror_ports(across_axis)
|
||||
self.mirror_port_offsets(across_axis)
|
||||
port.flip_across(axis=axis)
|
||||
return self
|
||||
|
||||
def apply_ref_transform(self, ref: Ref) -> Self:
|
||||
|
|
|
|||
|
|
@ -275,6 +275,10 @@ class Builder(PortList):
|
|||
Returns:
|
||||
self
|
||||
|
||||
Note:
|
||||
If the builder is 'dead' (see `set_dead()`), geometry generation is
|
||||
skipped but ports are still updated.
|
||||
|
||||
Raises:
|
||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other_names`.
|
||||
|
|
@ -284,8 +288,7 @@ class Builder(PortList):
|
|||
do not line up)
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping plug() since device is dead')
|
||||
return self
|
||||
logger.warning('Skipping geometry for plug() since device is dead')
|
||||
|
||||
if not isinstance(other, str | Abstract | Pattern):
|
||||
# We got a Tree; add it into self.library and grab an Abstract for it
|
||||
|
|
@ -305,6 +308,7 @@ class Builder(PortList):
|
|||
set_rotation = set_rotation,
|
||||
append = append,
|
||||
ok_connections = ok_connections,
|
||||
skip_geometry = self._dead,
|
||||
)
|
||||
return self
|
||||
|
||||
|
|
@ -350,6 +354,10 @@ class Builder(PortList):
|
|||
Returns:
|
||||
self
|
||||
|
||||
Note:
|
||||
If the builder is 'dead' (see `set_dead()`), geometry generation is
|
||||
skipped but ports are still updated.
|
||||
|
||||
Raises:
|
||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other.ports`.
|
||||
|
|
@ -357,8 +365,7 @@ class Builder(PortList):
|
|||
are applied.
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping place() since device is dead')
|
||||
return self
|
||||
logger.warning('Skipping geometry for place() since device is dead')
|
||||
|
||||
if not isinstance(other, str | Abstract | Pattern):
|
||||
# We got a Tree; add it into self.library and grab an Abstract for it
|
||||
|
|
@ -378,6 +385,7 @@ class Builder(PortList):
|
|||
port_map = port_map,
|
||||
skip_port_check = skip_port_check,
|
||||
append = append,
|
||||
skip_geometry = self._dead,
|
||||
)
|
||||
return self
|
||||
|
||||
|
|
@ -425,13 +433,18 @@ class Builder(PortList):
|
|||
|
||||
def set_dead(self) -> Self:
|
||||
"""
|
||||
Disallows further changes through `plug()` or `place()`.
|
||||
Suppresses geometry generation for subsequent `plug()` and `place()`
|
||||
operations. Unlike a complete skip, the port state is still tracked
|
||||
and updated, using 'best-effort' fallbacks for impossible transforms.
|
||||
This allows a layout script to execute through problematic sections
|
||||
while maintaining valid port references for downstream code.
|
||||
|
||||
This is meant for debugging:
|
||||
```
|
||||
dev.plug(a, ...)
|
||||
dev.set_dead() # added for debug purposes
|
||||
dev.plug(b, ...) # usually raises an error, but now skipped
|
||||
dev.plug(c, ...) # also skipped
|
||||
dev.plug(b, ...) # usually raises an error, but now uses fallback port update
|
||||
dev.plug(c, ...) # also updated via fallback
|
||||
dev.pattern.visualize() # shows the device as of the set_dead() call
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import copy
|
|||
import logging
|
||||
from pprint import pformat
|
||||
|
||||
from numpy import pi
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import ILibrary
|
||||
from ..error import BuildError
|
||||
|
|
@ -283,19 +285,48 @@ 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.error('Skipping path() since device is dead')
|
||||
return self
|
||||
logger.warning('Skipping geometry for path() since device is dead')
|
||||
|
||||
tool_port_names = ('A', 'B')
|
||||
|
||||
tool = self.tools.get(portspec, self.tools[None])
|
||||
in_ptype = self.pattern[portspec].ptype
|
||||
tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
|
||||
try:
|
||||
tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
|
||||
except (BuildError, NotImplementedError):
|
||||
if not self._dead:
|
||||
raise
|
||||
logger.warning("Tool path failed for dead pather. Using dummy extension.")
|
||||
# Fallback for dead pather: manually update the port instead of plugging
|
||||
port = self.pattern[portspec]
|
||||
port_rot = port.rotation
|
||||
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
|
||||
|
||||
tname = self.library << tree
|
||||
if plug_into is not None:
|
||||
output = {plug_into: tool_port_names[1]}
|
||||
|
|
@ -335,13 +366,18 @@ 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.error('Skipping pathS() since device is dead')
|
||||
return self
|
||||
logger.warning('Skipping geometry for pathS() since device is dead')
|
||||
|
||||
tool_port_names = ('A', 'B')
|
||||
|
||||
|
|
@ -353,16 +389,40 @@ class Pather(Builder, PatherMixin):
|
|||
# Fall back to drawing two L-bends
|
||||
ccw0 = jog > 0
|
||||
kwargs_no_out = kwargs | {'out_ptype': None}
|
||||
t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out)
|
||||
t_pat0 = t_tree0.top_pattern()
|
||||
(_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]])
|
||||
t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs)
|
||||
t_pat1 = t_tree1.top_pattern()
|
||||
(_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]])
|
||||
try:
|
||||
t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out)
|
||||
t_pat0 = t_tree0.top_pattern()
|
||||
(_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]])
|
||||
t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs)
|
||||
t_pat1 = t_tree1.top_pattern()
|
||||
(_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]])
|
||||
|
||||
kwargs_plug = kwargs | {'plug_into': plug_into}
|
||||
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
|
||||
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
|
||||
kwargs_plug = kwargs | {'plug_into': plug_into}
|
||||
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
|
||||
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
|
||||
return self
|
||||
except (BuildError, NotImplementedError):
|
||||
if not self._dead:
|
||||
raise
|
||||
# Fall through to dummy extension below
|
||||
except BuildError:
|
||||
if not self._dead:
|
||||
raise
|
||||
# Fall through to dummy extension below
|
||||
|
||||
if self._dead:
|
||||
logger.warning("Tool pathS failed for dead pather. Using dummy extension.")
|
||||
# Fallback for dead pather: manually update the port instead of plugging
|
||||
port = self.pattern[portspec]
|
||||
port_rot = port.rotation
|
||||
assert port_rot is not None
|
||||
out_port = Port((length, jog), rotation=pi, ptype=in_ptype)
|
||||
out_port.rotate_around((0, 0), pi + port_rot)
|
||||
out_port.translate(port.offset)
|
||||
self.pattern.ports[portspec] = out_port
|
||||
self._log_port_update(portspec)
|
||||
if plug_into is not None:
|
||||
self.plugged({portspec: plug_into})
|
||||
return self
|
||||
|
||||
tname = self.library << tree
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ class RenderPather(PatherMixin):
|
|||
def ports(self, value: dict[str, Port]) -> None:
|
||||
self.pattern.ports = value
|
||||
|
||||
def __del__(self) -> None:
|
||||
if any(pp for pp in self.paths):
|
||||
logger.warning('RenderPather had unrendered paths', stack_info=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: ILibrary,
|
||||
|
|
@ -249,8 +253,7 @@ class RenderPather(PatherMixin):
|
|||
do not line up)
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping plug() since device is dead')
|
||||
return self
|
||||
logger.warning('Skipping geometry for plug() since device is dead')
|
||||
|
||||
other_tgt: Pattern | Abstract
|
||||
if isinstance(other, str):
|
||||
|
|
@ -258,18 +261,19 @@ class RenderPather(PatherMixin):
|
|||
if append and isinstance(other, Abstract):
|
||||
other_tgt = self.library[other.name]
|
||||
|
||||
# get rid of plugged ports
|
||||
for kk in map_in:
|
||||
if kk in self.paths:
|
||||
self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))
|
||||
if not self._dead:
|
||||
# get rid of plugged ports
|
||||
for kk in map_in:
|
||||
if kk in self.paths:
|
||||
self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))
|
||||
|
||||
plugged = map_in.values()
|
||||
for name, port in other_tgt.ports.items():
|
||||
if name in plugged:
|
||||
continue
|
||||
new_name = map_out.get(name, name) if map_out is not None else name
|
||||
if new_name is not None and new_name in self.paths:
|
||||
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
||||
plugged = map_in.values()
|
||||
for name, port in other_tgt.ports.items():
|
||||
if name in plugged:
|
||||
continue
|
||||
new_name = map_out.get(name, name) if map_out is not None else name
|
||||
if new_name is not None and new_name in self.paths:
|
||||
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
||||
|
||||
self.pattern.plug(
|
||||
other = other_tgt,
|
||||
|
|
@ -280,6 +284,7 @@ class RenderPather(PatherMixin):
|
|||
set_rotation = set_rotation,
|
||||
append = append,
|
||||
ok_connections = ok_connections,
|
||||
skip_geometry = self._dead,
|
||||
)
|
||||
|
||||
return self
|
||||
|
|
@ -330,8 +335,7 @@ class RenderPather(PatherMixin):
|
|||
are applied.
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping place() since device is dead')
|
||||
return self
|
||||
logger.warning('Skipping geometry for place() since device is dead')
|
||||
|
||||
other_tgt: Pattern | Abstract
|
||||
if isinstance(other, str):
|
||||
|
|
@ -339,10 +343,11 @@ class RenderPather(PatherMixin):
|
|||
if append and isinstance(other, Abstract):
|
||||
other_tgt = self.library[other.name]
|
||||
|
||||
for name, port in other_tgt.ports.items():
|
||||
new_name = port_map.get(name, name) if port_map is not None else name
|
||||
if new_name is not None and new_name in self.paths:
|
||||
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
||||
if not self._dead:
|
||||
for name, port in other_tgt.ports.items():
|
||||
new_name = port_map.get(name, name) if port_map is not None else name
|
||||
if new_name is not None and new_name in self.paths:
|
||||
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
||||
|
||||
self.pattern.place(
|
||||
other = other_tgt,
|
||||
|
|
@ -353,6 +358,7 @@ class RenderPather(PatherMixin):
|
|||
port_map = port_map,
|
||||
skip_port_check = skip_port_check,
|
||||
append = append,
|
||||
skip_geometry = self._dead,
|
||||
)
|
||||
|
||||
return self
|
||||
|
|
@ -361,11 +367,12 @@ class RenderPather(PatherMixin):
|
|||
self,
|
||||
connections: dict[str, str],
|
||||
) -> Self:
|
||||
for aa, bb in connections.items():
|
||||
porta = self.ports[aa]
|
||||
portb = self.ports[bb]
|
||||
self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None))
|
||||
self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None))
|
||||
if not self._dead:
|
||||
for aa, bb in connections.items():
|
||||
porta = self.ports[aa]
|
||||
portb = self.ports[bb]
|
||||
self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None))
|
||||
self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None))
|
||||
PortList.plugged(self, connections)
|
||||
return self
|
||||
|
||||
|
|
@ -401,13 +408,18 @@ 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.error('Skipping path() since device is dead')
|
||||
return self
|
||||
logger.warning('Skipping geometry for path() since device is dead')
|
||||
|
||||
port = self.pattern[portspec]
|
||||
in_ptype = port.ptype
|
||||
|
|
@ -416,16 +428,31 @@ class RenderPather(PatherMixin):
|
|||
|
||||
tool = self.tools.get(portspec, self.tools[None])
|
||||
# ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
|
||||
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
|
||||
try:
|
||||
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
|
||||
except (BuildError, NotImplementedError):
|
||||
if not self._dead:
|
||||
raise
|
||||
logger.warning("Tool planning failed for dead pather. Using dummy extension.")
|
||||
if ccw is None:
|
||||
out_rot = pi
|
||||
elif bool(ccw):
|
||||
out_rot = -pi / 2
|
||||
else:
|
||||
out_rot = pi / 2
|
||||
out_port = Port((length, 0), rotation=out_rot, ptype=in_ptype)
|
||||
data = None
|
||||
|
||||
# Update port
|
||||
out_port.rotate_around((0, 0), pi + port_rot)
|
||||
out_port.translate(port.offset)
|
||||
|
||||
step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
|
||||
self.paths[portspec].append(step)
|
||||
if not self._dead:
|
||||
step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
|
||||
self.paths[portspec].append(step)
|
||||
|
||||
self.pattern.ports[portspec] = out_port.copy()
|
||||
self._log_port_update(portspec)
|
||||
|
||||
if plug_into is not None:
|
||||
self.plugged({portspec: plug_into})
|
||||
|
|
@ -465,13 +492,18 @@ 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.error('Skipping pathS() since device is dead')
|
||||
return self
|
||||
logger.warning('Skipping geometry for pathS() since device is dead')
|
||||
|
||||
port = self.pattern[portspec]
|
||||
in_ptype = port.ptype
|
||||
|
|
@ -487,21 +519,38 @@ class RenderPather(PatherMixin):
|
|||
# Fall back to drawing two L-bends
|
||||
ccw0 = jog > 0
|
||||
kwargs_no_out = (kwargs | {'out_ptype': None})
|
||||
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes
|
||||
jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
|
||||
t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
|
||||
jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]
|
||||
try:
|
||||
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail w/asymmetric ptypes
|
||||
jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
|
||||
t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
|
||||
jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]
|
||||
|
||||
kwargs_plug = kwargs | {'plug_into': plug_into}
|
||||
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
|
||||
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
|
||||
return self
|
||||
kwargs_plug = kwargs | {'plug_into': plug_into}
|
||||
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
|
||||
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
|
||||
return self
|
||||
except (BuildError, NotImplementedError):
|
||||
if not self._dead:
|
||||
raise
|
||||
# Fall through to dummy extension below
|
||||
except BuildError:
|
||||
if not self._dead:
|
||||
raise
|
||||
# Fall through to dummy extension below
|
||||
|
||||
out_port.rotate_around((0, 0), pi + port_rot)
|
||||
out_port.translate(port.offset)
|
||||
step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
|
||||
self.paths[portspec].append(step)
|
||||
self.pattern.ports[portspec] = out_port.copy()
|
||||
if self._dead:
|
||||
logger.warning("Tool planning failed for dead pather. Using dummy extension.")
|
||||
out_port = Port((length, jog), rotation=pi, ptype=in_ptype)
|
||||
data = None
|
||||
|
||||
if out_port is not None:
|
||||
out_port.rotate_around((0, 0), pi + port_rot)
|
||||
out_port.translate(port.offset)
|
||||
if not self._dead:
|
||||
step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
|
||||
self.paths[portspec].append(step)
|
||||
self.pattern.ports[portspec] = out_port.copy()
|
||||
self._log_port_update(portspec)
|
||||
|
||||
if plug_into is not None:
|
||||
self.plugged({portspec: plug_into})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir
|
|||
|
||||
# TODO document all tools
|
||||
"""
|
||||
from typing import Literal, Any, Self
|
||||
from typing import Literal, Any, Self, cast
|
||||
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,9 +543,10 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
|||
return self
|
||||
|
||||
@staticmethod
|
||||
def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
|
||||
def _bend2dxy(bend: Bend | None, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
|
||||
if ccw is None:
|
||||
return numpy.zeros(2), pi
|
||||
assert bend is not None
|
||||
bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port)
|
||||
assert bend_angle is not None
|
||||
if bool(ccw):
|
||||
|
|
@ -590,8 +591,20 @@ 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 self.bends:
|
||||
for bend in bends:
|
||||
bend_dxy, bend_angle = self._bend2dxy(bend, ccw)
|
||||
|
||||
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
|
||||
|
|
@ -600,14 +613,16 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
|||
|
||||
out_ptype_pair = (
|
||||
'unk' if out_ptype is None else out_ptype,
|
||||
straight.ptype if ccw is None else bend.out_port.ptype
|
||||
straight.ptype if ccw is None else cast('AutoTool.Bend', bend).out_port.ptype
|
||||
)
|
||||
out_transition = self.transitions.get(out_ptype_pair, None)
|
||||
otrans_dxy = self._otransition2dxy(out_transition, bend_angle)
|
||||
|
||||
b_transition = None
|
||||
if ccw is not None and bend.in_port.ptype != straight.ptype:
|
||||
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None)
|
||||
if ccw is not None:
|
||||
assert bend is not None
|
||||
if bend.in_port.ptype != straight.ptype:
|
||||
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None)
|
||||
btrans_dxy = self._itransition2dxy(b_transition)
|
||||
|
||||
straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0]
|
||||
|
|
@ -628,6 +643,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
|||
if out_transition is not None:
|
||||
out_ptype_actual = out_transition.their_port.ptype
|
||||
elif ccw is not None:
|
||||
assert bend is not None
|
||||
out_ptype_actual = bend.out_port.ptype
|
||||
elif not numpy.isclose(straight_length, 0):
|
||||
out_ptype_actual = straight.ptype
|
||||
|
|
|
|||
|
|
@ -120,10 +120,10 @@ def build(
|
|||
layer, data_type = _mlayer2oas(layer_num)
|
||||
lib.layers += [
|
||||
fatrec.LayerName(
|
||||
nstring=name,
|
||||
layer_interval=(layer, layer),
|
||||
type_interval=(data_type, data_type),
|
||||
is_textlayer=tt,
|
||||
nstring = name,
|
||||
layer_interval = (layer, layer),
|
||||
type_interval = (data_type, data_type),
|
||||
is_textlayer = tt,
|
||||
)
|
||||
for tt in (True, False)]
|
||||
|
||||
|
|
@ -286,11 +286,11 @@ def read(
|
|||
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.polygon(
|
||||
vertices=vertices,
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
annotations=annotations,
|
||||
repetition=repetition,
|
||||
vertices = vertices,
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
annotations = annotations,
|
||||
repetition = repetition,
|
||||
)
|
||||
elif isinstance(element, fatrec.Path):
|
||||
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
|
||||
|
|
@ -310,13 +310,13 @@ def read(
|
|||
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.path(
|
||||
vertices=vertices,
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
annotations=annotations,
|
||||
width=element.get_half_width() * 2,
|
||||
cap=cap,
|
||||
vertices = vertices,
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
annotations = annotations,
|
||||
width = element.get_half_width() * 2,
|
||||
cap = cap,
|
||||
**path_args,
|
||||
)
|
||||
|
||||
|
|
@ -325,11 +325,11 @@ def read(
|
|||
height = element.get_height()
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.polygon(
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
||||
annotations=annotations,
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
||||
annotations = annotations,
|
||||
)
|
||||
|
||||
elif isinstance(element, fatrec.Trapezoid):
|
||||
|
|
@ -440,11 +440,11 @@ def read(
|
|||
else:
|
||||
string = str_or_ref.string
|
||||
pat.label(
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
annotations=annotations,
|
||||
string=string,
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
annotations = annotations,
|
||||
string = string,
|
||||
)
|
||||
|
||||
else:
|
||||
|
|
@ -549,13 +549,13 @@ def _shapes_to_elements(
|
|||
offset = rint_cast(shape.offset + rep_offset)
|
||||
radius = rint_cast(shape.radius)
|
||||
circle = fatrec.Circle(
|
||||
layer=layer,
|
||||
datatype=datatype,
|
||||
radius=cast('int', radius),
|
||||
x=offset[0],
|
||||
y=offset[1],
|
||||
properties=properties,
|
||||
repetition=repetition,
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
radius = cast('int', radius),
|
||||
x = offset[0],
|
||||
y = offset[1],
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
)
|
||||
elements.append(circle)
|
||||
elif isinstance(shape, Path):
|
||||
|
|
@ -566,16 +566,16 @@ def _shapes_to_elements(
|
|||
extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
|
||||
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
|
||||
path = fatrec.Path(
|
||||
layer=layer,
|
||||
datatype=datatype,
|
||||
point_list=cast('Sequence[Sequence[int]]', deltas),
|
||||
half_width=cast('int', half_width),
|
||||
x=xy[0],
|
||||
y=xy[1],
|
||||
extension_start=extension_start, # TODO implement multiple cap types?
|
||||
extension_end=extension_end,
|
||||
properties=properties,
|
||||
repetition=repetition,
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
point_list = cast('Sequence[Sequence[int]]', deltas),
|
||||
half_width = cast('int', half_width),
|
||||
x = xy[0],
|
||||
y = xy[1],
|
||||
extension_start = extension_start, # TODO implement multiple cap types?
|
||||
extension_end = extension_end,
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
)
|
||||
elements.append(path)
|
||||
else:
|
||||
|
|
@ -583,13 +583,13 @@ def _shapes_to_elements(
|
|||
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
|
||||
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
|
||||
elements.append(fatrec.Polygon(
|
||||
layer=layer,
|
||||
datatype=datatype,
|
||||
x=xy[0],
|
||||
y=xy[1],
|
||||
point_list=cast('list[list[int]]', points),
|
||||
properties=properties,
|
||||
repetition=repetition,
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
x = xy[0],
|
||||
y = xy[1],
|
||||
point_list = cast('list[list[int]]', points),
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
))
|
||||
return elements
|
||||
|
||||
|
|
@ -606,13 +606,13 @@ def _labels_to_texts(
|
|||
xy = rint_cast(label.offset + rep_offset)
|
||||
properties = annotations_to_properties(label.annotations)
|
||||
texts.append(fatrec.Text(
|
||||
layer=layer,
|
||||
datatype=datatype,
|
||||
x=xy[0],
|
||||
y=xy[1],
|
||||
string=label.string,
|
||||
properties=properties,
|
||||
repetition=repetition,
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
x = xy[0],
|
||||
y = xy[1],
|
||||
string = label.string,
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
))
|
||||
return texts
|
||||
|
||||
|
|
@ -622,10 +622,12 @@ def repetition_fata2masq(
|
|||
) -> Repetition | None:
|
||||
mrep: Repetition | None
|
||||
if isinstance(rep, fatamorgana.GridRepetition):
|
||||
mrep = Grid(a_vector=rep.a_vector,
|
||||
b_vector=rep.b_vector,
|
||||
a_count=rep.a_count,
|
||||
b_count=rep.b_count)
|
||||
mrep = Grid(
|
||||
a_vector = rep.a_vector,
|
||||
b_vector = rep.b_vector,
|
||||
a_count = rep.a_count,
|
||||
b_count = rep.b_count,
|
||||
)
|
||||
elif isinstance(rep, fatamorgana.ArbitraryRepetition):
|
||||
displacements = numpy.cumsum(numpy.column_stack((
|
||||
rep.x_displacements,
|
||||
|
|
@ -647,14 +649,19 @@ def repetition_masq2fata(
|
|||
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
|
||||
if isinstance(rep, Grid):
|
||||
a_vector = rint_cast(rep.a_vector)
|
||||
b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None
|
||||
a_count = rint_cast(rep.a_count)
|
||||
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None
|
||||
a_count = int(rep.a_count)
|
||||
if rep.b_count > 1:
|
||||
b_vector = rint_cast(rep.b_vector)
|
||||
b_count = int(rep.b_count)
|
||||
else:
|
||||
b_vector = None
|
||||
b_count = None
|
||||
|
||||
frep = fatamorgana.GridRepetition(
|
||||
a_vector=cast('list[int]', a_vector),
|
||||
b_vector=cast('list[int] | None', b_vector),
|
||||
a_count=cast('int', a_count),
|
||||
b_count=cast('int | None', b_count),
|
||||
a_vector = a_vector,
|
||||
b_vector = b_vector,
|
||||
a_count = a_count,
|
||||
b_count = b_count,
|
||||
)
|
||||
offset = (0, 0)
|
||||
elif isinstance(rep, Arbitrary):
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray
|
|||
|
||||
from .repetition import Repetition
|
||||
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
|
||||
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded
|
||||
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable
|
||||
from .traits import AnnotatableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
|
||||
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable):
|
||||
"""
|
||||
A text annotation with a position (but no size; it is not drawn)
|
||||
"""
|
||||
|
|
@ -58,12 +58,14 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
|||
string=self.string,
|
||||
offset=self.offset.copy(),
|
||||
repetition=self.repetition,
|
||||
annotations=copy.copy(self.annotations),
|
||||
)
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
new._annotations = copy.deepcopy(self._annotations, memo)
|
||||
return new
|
||||
|
||||
def __lt__(self, other: 'Label') -> bool:
|
||||
|
|
@ -100,6 +102,28 @@ 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.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Object representing a one multi-layer lithographic layout.
|
||||
A single level of hierarchical references is included.
|
||||
"""
|
||||
from typing import cast, Self, Any, TypeVar
|
||||
from typing import cast, Self, Any, TypeVar, TYPE_CHECKING
|
||||
from collections.abc import Sequence, Mapping, MutableMapping, Iterable, Callable
|
||||
import copy
|
||||
import logging
|
||||
|
|
@ -25,6 +25,9 @@ from .error import PatternError, PortError
|
|||
from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable, Bounded
|
||||
from .ports import Port, PortList
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .traits import Flippable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -499,6 +502,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
]
|
||||
return polys
|
||||
|
||||
def layer_as_polygons(
|
||||
self,
|
||||
layer: layer_t,
|
||||
flatten: bool = True,
|
||||
library: Mapping[str, 'Pattern'] | None = None,
|
||||
) -> list[Polygon]:
|
||||
"""
|
||||
Collect all geometry effectively on a given layer as a list of polygons.
|
||||
|
||||
If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`.
|
||||
`Repetition` objects are expanded, and non-polygon shapes are converted
|
||||
to `Polygon` approximations.
|
||||
|
||||
Args:
|
||||
layer: The layer to collect geometry from.
|
||||
flatten: If `True`, include geometry from referenced patterns.
|
||||
library: Required if `flatten=True` to resolve references.
|
||||
|
||||
Returns:
|
||||
A list of `Polygon` objects.
|
||||
"""
|
||||
if flatten and self.has_refs() and library is None:
|
||||
raise PatternError("Must provide a library to layer_as_polygons() when flatten=True")
|
||||
|
||||
polys: list[Polygon] = []
|
||||
|
||||
# Local shapes
|
||||
for shape in self.shapes.get(layer, []):
|
||||
for p in shape.to_polygons():
|
||||
# expand repetitions
|
||||
if p.repetition is not None:
|
||||
for offset in p.repetition.displacements:
|
||||
polys.append(p.deepcopy().translate(offset).set_repetition(None))
|
||||
else:
|
||||
polys.append(p.deepcopy())
|
||||
|
||||
if flatten and self.has_refs():
|
||||
assert library is not None
|
||||
for target, refs in self.refs.items():
|
||||
if target is None:
|
||||
continue
|
||||
target_pat = library[target]
|
||||
for ref in refs:
|
||||
# Get polygons from target pattern on the same layer
|
||||
ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library)
|
||||
# Apply ref transformations
|
||||
for p in ref_polys:
|
||||
p_pat = ref.as_pattern(Pattern(shapes={layer: [p]}))
|
||||
# as_pattern expands repetition of the ref itself
|
||||
# but we need to pull the polygons back out
|
||||
for p_transformed in p_pat.shapes[layer]:
|
||||
polys.append(cast('Polygon', p_transformed))
|
||||
|
||||
return polys
|
||||
|
||||
def referenced_patterns(self) -> set[str | None]:
|
||||
"""
|
||||
Get all pattern namers referenced by this pattern. Non-recursive.
|
||||
|
|
@ -635,6 +693,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
"""
|
||||
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
|
||||
cast('Positionable', entry).translate(offset)
|
||||
self._log_bulk_update(f"translate({offset!r})")
|
||||
return self
|
||||
|
||||
def scale_elements(self, c: float) -> Self:
|
||||
|
|
@ -702,6 +761,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
self.rotate_elements(rotation)
|
||||
self.rotate_element_centers(rotation)
|
||||
self.translate_elements(+pivot)
|
||||
self._log_bulk_update(f"rotate_around({pivot}, {rotation})")
|
||||
return self
|
||||
|
||||
def rotate_element_centers(self, rotation: float) -> Self:
|
||||
|
|
@ -733,50 +793,36 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
cast('Rotatable', entry).rotate(rotation)
|
||||
return self
|
||||
|
||||
def mirror_element_centers(self, across_axis: int = 0) -> Self:
|
||||
def mirror_elements(self, axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the offsets of all shapes, labels, and refs across an axis
|
||||
Mirror each shape, ref, and port relative to its offset.
|
||||
|
||||
Args:
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||
cast('Positionable', entry).offset[1 - across_axis] *= -1
|
||||
return self
|
||||
|
||||
def mirror_elements(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror each shape, ref, and pattern across an axis, relative
|
||||
to its offset
|
||||
|
||||
Args:
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
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(across_axis)
|
||||
cast('Mirrorable', entry).mirror(axis=axis)
|
||||
self._log_bulk_update(f"mirror_elements({axis})")
|
||||
return self
|
||||
|
||||
def mirror(self, across_axis: int = 0) -> Self:
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the Pattern across an axis
|
||||
Mirror the Pattern across an axis through its origin.
|
||||
|
||||
Args:
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.mirror_elements(across_axis)
|
||||
self.mirror_element_centers(across_axis)
|
||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||
cast('Flippable', entry).flip_across(axis=axis)
|
||||
self._log_bulk_update(f"mirror({axis})")
|
||||
return self
|
||||
|
||||
def copy(self) -> Self:
|
||||
|
|
@ -1114,6 +1160,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
port_map: dict[str, str | None] | None = None,
|
||||
skip_port_check: bool = False,
|
||||
append: bool = False,
|
||||
skip_geometry: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Instantiate or append the pattern `other` into the current pattern, adding its
|
||||
|
|
@ -1145,6 +1192,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
append: If `True`, `other` is appended instead of being referenced.
|
||||
Note that this does not flatten `other`, so its refs will still
|
||||
be refs (now inside `self`).
|
||||
skip_geometry: If `True`, 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
|
||||
|
|
@ -1176,6 +1227,10 @@ 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):
|
||||
|
|
@ -1234,6 +1289,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
set_rotation: bool | None = None,
|
||||
append: bool = False,
|
||||
ok_connections: Iterable[tuple[str, str]] = (),
|
||||
skip_geometry: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Instantiate or append a pattern into the current pattern, connecting
|
||||
|
|
@ -1288,6 +1344,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
any other ptypte. Non-allowed ptype connections will emit a
|
||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||
`(b, a)`.
|
||||
skip_geometry: If `True`, only ports are updated 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
|
||||
|
|
@ -1320,21 +1381,41 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
map_out = {out_port_name: next(iter(map_in.keys()))}
|
||||
|
||||
self.check_ports(other.ports.keys(), map_in, map_out)
|
||||
translation, rotation, pivot = self.find_transform(
|
||||
other,
|
||||
map_in,
|
||||
mirrored = mirrored,
|
||||
set_rotation = set_rotation,
|
||||
ok_connections = ok_connections,
|
||||
)
|
||||
try:
|
||||
translation, rotation, pivot = self.find_transform(
|
||||
other,
|
||||
map_in,
|
||||
mirrored = mirrored,
|
||||
set_rotation = set_rotation,
|
||||
ok_connections = ok_connections,
|
||||
)
|
||||
except PortError:
|
||||
if not skip_geometry:
|
||||
raise
|
||||
logger.warning("Port transform failed for dead device. Using dummy transform.")
|
||||
if map_in:
|
||||
ki, vi = next(iter(map_in.items()))
|
||||
s_port = self.ports[ki]
|
||||
o_port = other.ports[vi].deepcopy()
|
||||
if mirrored:
|
||||
o_port.mirror()
|
||||
o_port.offset[1] *= -1
|
||||
translation = s_port.offset - o_port.offset
|
||||
rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0
|
||||
pivot = o_port.offset
|
||||
else:
|
||||
translation = numpy.zeros(2)
|
||||
rotation = 0.0
|
||||
pivot = numpy.zeros(2)
|
||||
|
||||
# get rid of plugged ports
|
||||
for ki, vi in map_in.items():
|
||||
del self.ports[ki]
|
||||
self._log_port_removal(ki)
|
||||
map_out[vi] = None
|
||||
|
||||
if isinstance(other, Pattern):
|
||||
assert append, 'Got a name (not an abstract) but was asked to reference (not append)'
|
||||
assert append or skip_geometry, 'Got a name (not an abstract) but was asked to reference (not append)'
|
||||
|
||||
self.place(
|
||||
other,
|
||||
|
|
@ -1345,6 +1426,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
port_map = map_out,
|
||||
skip_port_check = True,
|
||||
append = append,
|
||||
skip_geometry = skip_geometry,
|
||||
)
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from typing import overload, Self, NoReturn, Any
|
|||
from collections.abc import Iterable, KeysView, ValuesView, Mapping
|
||||
import logging
|
||||
import functools
|
||||
import copy
|
||||
from collections import Counter
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from itertools import chain
|
||||
|
|
@ -10,16 +11,17 @@ import numpy
|
|||
from numpy import pi
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
|
||||
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
|
||||
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(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
||||
class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
||||
"""
|
||||
A point at which a `Device` can be snapped to another `Device`.
|
||||
|
||||
|
|
@ -91,6 +93,12 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
|||
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))
|
||||
|
||||
|
|
@ -99,6 +107,27 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
|||
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
|
||||
|
|
@ -179,6 +208,19 @@ class PortList(metaclass=ABCMeta):
|
|||
def ports(self, value: dict[str, Port]) -> None:
|
||||
pass
|
||||
|
||||
def _log_port_update(self, name: str) -> None:
|
||||
""" Log the current state of the named port """
|
||||
port_logger.info("Port %s: %s", name, self.ports[name])
|
||||
|
||||
def _log_port_removal(self, name: str) -> None:
|
||||
""" Log that the named port has been removed """
|
||||
port_logger.info("Port %s: removed", name)
|
||||
|
||||
def _log_bulk_update(self, label: str) -> None:
|
||||
""" Log all current ports at DEBUG level """
|
||||
for name, port in self.ports.items():
|
||||
port_logger.debug("%s: Port %s: %s", label, name, port)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: str) -> Port:
|
||||
pass
|
||||
|
|
@ -232,6 +274,7 @@ class PortList(metaclass=ABCMeta):
|
|||
raise PortError(f'Port {name} already exists.')
|
||||
assert name not in self.ports
|
||||
self.ports[name] = value
|
||||
self._log_port_update(name)
|
||||
return self
|
||||
|
||||
def rename_ports(
|
||||
|
|
@ -258,11 +301,20 @@ 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(
|
||||
|
|
@ -291,6 +343,8 @@ class PortList(metaclass=ABCMeta):
|
|||
}
|
||||
self.check_ports(names)
|
||||
self.ports.update(new_ports)
|
||||
self._log_port_update(names[0])
|
||||
self._log_port_update(names[1])
|
||||
return self
|
||||
|
||||
def plugged(
|
||||
|
|
@ -360,6 +414,7 @@ class PortList(metaclass=ABCMeta):
|
|||
|
||||
for pp in chain(a_names, b_names):
|
||||
del self.ports[pp]
|
||||
self._log_port_removal(pp)
|
||||
return self
|
||||
|
||||
def check_ports(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation
|
|||
from .repetition import Repetition
|
||||
from .traits import (
|
||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||
Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||
FlippableImpl,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -25,8 +26,9 @@ if TYPE_CHECKING:
|
|||
|
||||
@functools.total_ordering
|
||||
class Ref(
|
||||
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
|
||||
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||
FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||
Copyable,
|
||||
):
|
||||
"""
|
||||
`Ref` provides basic support for nesting Pattern objects within each other.
|
||||
|
|
@ -168,8 +170,6 @@ class Ref(
|
|||
def mirror(self, axis: int = 0) -> Self:
|
||||
self.mirror_target(axis)
|
||||
self.rotation *= -1
|
||||
if self.repetition is not None:
|
||||
self.repetition.mirror(axis)
|
||||
return self
|
||||
|
||||
def mirror_target(self, axis: int = 0) -> Self:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -272,13 +272,16 @@ class Arc(PositionableImpl, Shape):
|
|||
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
|
||||
|
||||
keep = [0]
|
||||
removable = (numpy.cumsum(arc_lengths) <= max_arclen)
|
||||
start = 1
|
||||
start = 0
|
||||
while start < arc_lengths.size:
|
||||
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
|
||||
removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
|
||||
if not removable.any():
|
||||
next_to_keep = start + 1
|
||||
else:
|
||||
next_to_keep = start + numpy.where(removable)[0][-1] + 1
|
||||
keep.append(next_to_keep)
|
||||
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
|
||||
start = next_to_keep + 1
|
||||
start = next_to_keep
|
||||
|
||||
if keep[-1] != thetas.size - 1:
|
||||
keep.append(thetas.size - 1)
|
||||
|
||||
|
|
@ -362,17 +365,20 @@ class Arc(PositionableImpl, Shape):
|
|||
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
|
||||
|
||||
# If our arc subtends a coordinate axis, use the extremum along that axis
|
||||
if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
|
||||
xp = xr
|
||||
if abs(a1 - a0) >= 2 * pi:
|
||||
xn, xp, yn, yp = -xr, xr, -yr, yr
|
||||
else:
|
||||
if a0 <= xpt <= a1 or a0 <= xpt + 2 * pi <= a1:
|
||||
xp = xr
|
||||
|
||||
if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
|
||||
xn = -xr
|
||||
if a0 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1:
|
||||
xn = -xr
|
||||
|
||||
if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
|
||||
yp = yr
|
||||
if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1:
|
||||
yp = yr
|
||||
|
||||
if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
|
||||
yn = -yr
|
||||
if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1:
|
||||
yn = -yr
|
||||
|
||||
mins.append([xn, yn])
|
||||
maxs.append([xp, yp])
|
||||
|
|
@ -384,7 +390,6 @@ 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
|
||||
|
|
@ -464,13 +469,18 @@ class Arc(PositionableImpl, Shape):
|
|||
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
||||
"""
|
||||
aa = []
|
||||
d_angle = self.angles[1] - self.angles[0]
|
||||
if abs(d_angle) >= 2 * pi:
|
||||
# Full ring
|
||||
return numpy.tile([0, 2 * pi], (2, 1)).astype(float)
|
||||
|
||||
for sgn in (-1, +1):
|
||||
wh = sgn * self.width / 2.0
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
|
||||
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
|
||||
sign = numpy.sign(self.angles[1] - self.angles[0])
|
||||
sign = numpy.sign(d_angle)
|
||||
if sign != numpy.sign(a1 - a0):
|
||||
a1 += sign * 2 * pi
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ class Circle(PositionableImpl, Shape):
|
|||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
|
||||
self.offset[axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Circle':
|
||||
|
|
|
|||
|
|
@ -189,7 +189,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ class Path(Shape):
|
|||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Path':
|
||||
self.vertices[:, axis - 1] *= -1
|
||||
self.vertices[:, 1 - axis] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Path':
|
||||
|
|
|
|||
|
|
@ -56,9 +56,11 @@ class PolyCollection(Shape):
|
|||
"""
|
||||
Iterator which provides slices which index vertex_lists
|
||||
"""
|
||||
if self._vertex_offsets.size == 0:
|
||||
return
|
||||
for ii, ff in zip(
|
||||
self._vertex_offsets,
|
||||
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)),
|
||||
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
|
||||
strict=True,
|
||||
):
|
||||
yield slice(ii, ff)
|
||||
|
|
@ -168,7 +170,9 @@ class PolyCollection(Shape):
|
|||
annotations = copy.deepcopy(self.annotations),
|
||||
) for vv in self.polygon_vertices]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
|
||||
if self._vertex_lists.size == 0:
|
||||
return None
|
||||
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
|
||||
numpy.max(self._vertex_lists, axis=0)))
|
||||
|
||||
|
|
@ -179,7 +183,7 @@ class PolyCollection(Shape):
|
|||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
self._vertex_lists[:, axis - 1] *= -1
|
||||
self._vertex_lists[:, 1 - axis] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Any, cast, TYPE_CHECKING, Self
|
||||
from typing import Any, cast, TYPE_CHECKING, Self, Literal
|
||||
import copy
|
||||
import functools
|
||||
|
||||
|
|
@ -394,7 +394,7 @@ class Polygon(Shape):
|
|||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Polygon':
|
||||
self.vertices[:, axis - 1] *= -1
|
||||
self.vertices[:, 1 - axis] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Polygon':
|
||||
|
|
@ -462,3 +462,23 @@ class Polygon(Shape):
|
|||
def __repr__(self) -> str:
|
||||
centroid = self.vertices.mean(axis=0)
|
||||
return f'<Polygon centroid {centroid} v{len(self.vertices)}>'
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import numpy
|
|||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from ..traits import (
|
||||
Rotatable, Mirrorable, Copyable, Scalable,
|
||||
Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
Copyable, Scalable, FlippableImpl,
|
||||
PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -26,8 +26,9 @@ normalized_shape_tuple = tuple[
|
|||
DEFAULT_POLY_NUM_VERTICES = 24
|
||||
|
||||
|
||||
class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
||||
PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
|
||||
class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
Copyable, Scalable,
|
||||
metaclass=ABCMeta):
|
||||
"""
|
||||
Class specifying functions common to all shapes.
|
||||
"""
|
||||
|
|
@ -73,7 +74,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def normalized_form(self, norm_value: int) -> normalized_shape_tuple:
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
"""
|
||||
Writes the shape in a standardized notation, with offset, scale, and rotation
|
||||
information separated out from the remaining values.
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
mirrored: bool = False,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
|
|
@ -80,6 +81,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
self._string = string
|
||||
self._height = height
|
||||
self._rotation = rotation
|
||||
self._mirrored = mirrored
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
|
|
@ -87,6 +89,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
self.string = string
|
||||
self.height = height
|
||||
self.rotation = rotation
|
||||
self.mirrored = mirrored
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self.font_path = font_path
|
||||
|
|
@ -146,7 +149,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
if self.mirrored:
|
||||
poly.mirror()
|
||||
poly.scale_by(self.height)
|
||||
poly.offset = self.offset + [total_advance, 0]
|
||||
poly.translate(self.offset + [total_advance, 0])
|
||||
poly.rotate_around(self.offset, self.rotation)
|
||||
all_polygons += [poly]
|
||||
|
||||
|
|
|
|||
3
masque/test/__init__.py
Normal file
3
masque/test/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
|
||||
"""
|
||||
13
masque/test/conftest.py
Normal file
13
masque/test/conftest.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""
|
||||
|
||||
Test fixtures
|
||||
|
||||
"""
|
||||
|
||||
# ruff: noqa: ARG001
|
||||
from typing import Any
|
||||
import numpy
|
||||
|
||||
|
||||
FixtureRequest = Any
|
||||
PRNG = numpy.random.RandomState(12345)
|
||||
64
masque/test/test_abstract.py
Normal file
64
masque/test/test_abstract.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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)
|
||||
87
masque/test/test_advanced_routing.py
Normal file
87
masque/test/test_advanced_routing.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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
|
||||
81
masque/test/test_autotool.py
Normal file
81
masque/test/test_autotool.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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"
|
||||
119
masque/test/test_boolean.py
Normal file
119
masque/test/test_boolean.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_allclose
|
||||
from masque.pattern import Pattern
|
||||
from masque.shapes.polygon import Polygon
|
||||
from masque.repetition import Grid
|
||||
from masque.library import Library
|
||||
|
||||
def test_layer_as_polygons_basic() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
||||
assert len(polys) == 1
|
||||
assert isinstance(polys[0], Polygon)
|
||||
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
def test_layer_as_polygons_repetition() -> None:
|
||||
pat = Pattern()
|
||||
rep = Grid(a_vector=(2, 0), a_count=2)
|
||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep)
|
||||
|
||||
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
||||
assert len(polys) == 2
|
||||
# First polygon at (0,0)
|
||||
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
# Second polygon at (2,0)
|
||||
assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]])
|
||||
|
||||
def test_layer_as_polygons_flatten() -> None:
|
||||
lib = Library()
|
||||
|
||||
child = Pattern()
|
||||
child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]])
|
||||
lib['child'] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref('child', offset=(10, 10), rotation=numpy.pi/2)
|
||||
|
||||
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
|
||||
assert len(polys) == 1
|
||||
# Original child at (0,0) with rot pi/2 is still at (0,0) in its own space?
|
||||
# No, ref.as_pattern(child) will apply the transform.
|
||||
# Child (0,0), (1,0), (1,1) rotated pi/2 around (0,0) -> (0,0), (0,1), (-1,1)
|
||||
# Then offset by (10,10) -> (10,10), (10,11), (9,11)
|
||||
|
||||
# Let's verify the vertices
|
||||
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
|
||||
assert_allclose(polys[0].vertices, expected, atol=1e-10)
|
||||
|
||||
def test_boolean_import_error() -> None:
|
||||
from masque import boolean
|
||||
# If pyclipper is not installed, this should raise ImportError
|
||||
try:
|
||||
import pyclipper # noqa: F401
|
||||
pytest.skip("pyclipper is installed, cannot test ImportError")
|
||||
except ImportError:
|
||||
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
||||
boolean([], [], operation='union')
|
||||
|
||||
def test_polygon_boolean_shortcut() -> None:
|
||||
poly = Polygon([[0, 0], [1, 0], [1, 1]])
|
||||
# This should also raise ImportError if pyclipper is missing
|
||||
try:
|
||||
import pyclipper # noqa: F401
|
||||
pytest.skip("pyclipper is installed")
|
||||
except ImportError:
|
||||
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
||||
poly.boolean(poly)
|
||||
|
||||
def test_bridge_holes() -> None:
|
||||
from masque.utils.boolean import _bridge_holes
|
||||
|
||||
# Outer: 10x10 square
|
||||
outer = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
# Hole: 2x2 square in the middle
|
||||
hole = numpy.array([[4, 4], [6, 4], [6, 6], [4, 6]])
|
||||
|
||||
bridged = _bridge_holes(outer, [hole])
|
||||
|
||||
# We expect more vertices than outer + hole
|
||||
# Original outer has 4, hole has 4. Bridge adds 2 (to hole) and 2 (back to outer) + 1 to close hole loop?
|
||||
# Our implementation:
|
||||
# 1. outer up to bridge edge (best_edge_idx)
|
||||
# 2. bridge point on outer
|
||||
# 3. hole reordered starting at max X
|
||||
# 4. close hole loop (repeat max X)
|
||||
# 5. bridge point on outer again
|
||||
# 6. rest of outer
|
||||
|
||||
# max X of hole is 6 at (6,4) or (6,6). argmax will pick first one.
|
||||
# hole vertices: [4,4], [6,4], [6,6], [4,6]. argmax(x) is index 1: (6,4)
|
||||
# roll hole to start at (6,4): [6,4], [6,6], [4,6], [4,4]
|
||||
|
||||
# intersection of ray from (6,4) to right:
|
||||
# edges of outer: (0,0)-(10,0), (10,0)-(10,10), (10,10)-(0,10), (0,10)-(0,0)
|
||||
# edge (10,0)-(10,10) spans y=4.
|
||||
# intersection at (10,4). best_edge_idx = 1 (edge from index 1 to 2)
|
||||
|
||||
# vertices added:
|
||||
# outer[0:2]: (0,0), (10,0)
|
||||
# bridge pt: (10,4)
|
||||
# hole: (6,4), (6,6), (4,6), (4,4)
|
||||
# hole close: (6,4)
|
||||
# bridge pt back: (10,4)
|
||||
# outer[2:]: (10,10), (0,10)
|
||||
|
||||
expected_len = 11
|
||||
assert len(bridged) == expected_len
|
||||
|
||||
# verify it wraps around the hole and back
|
||||
# index 2 is bridge_pt
|
||||
assert_allclose(bridged[2], [10, 4])
|
||||
# index 3 is hole reordered max X
|
||||
assert_allclose(bridged[3], [6, 4])
|
||||
# index 7 is hole closed at max X
|
||||
assert_allclose(bridged[7], [6, 4])
|
||||
# index 8 is bridge_pt back
|
||||
assert_allclose(bridged[8], [10, 4])
|
||||
131
masque/test/test_builder.py
Normal file
131
masque/test/test_builder.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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)
|
||||
24
masque/test/test_fdfd.py
Normal file
24
masque/test/test_fdfd.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# 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
|
||||
151
masque/test/test_file_roundtrip.py
Normal file
151
masque/test/test_file_roundtrip.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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
|
||||
71
masque/test/test_gdsii.py
Normal file
71
masque/test/test_gdsii.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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"]
|
||||
50
masque/test/test_label.py
Normal file
50
masque/test/test_label.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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
|
||||
120
masque/test/test_library.py
Normal file
120
masque/test/test_library.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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"
|
||||
27
masque/test/test_oasis.py
Normal file
27
masque/test/test_oasis.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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]])
|
||||
51
masque/test/test_pack2d.py
Normal file
51
masque/test/test_pack2d.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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
|
||||
81
masque/test/test_path.py
Normal file
81
masque/test/test_path.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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
|
||||
108
masque/test/test_pather.py
Normal file
108
masque/test/test_pather.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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()
|
||||
115
masque/test/test_pattern.py
Normal file
115
masque/test/test_pattern.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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"
|
||||
125
masque/test/test_polygon.py
Normal file
125
masque/test/test_polygon.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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]])
|
||||
104
masque/test/test_ports.py
Normal file
104
masque/test/test_ports.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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"})
|
||||
57
masque/test/test_ports2data.py
Normal file
57
masque/test/test_ports2data.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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)
|
||||
72
masque/test/test_ref.py
Normal file
72
masque/test/test_ref.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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
|
||||
99
masque/test/test_renderpather.py
Normal file
99
masque/test/test_renderpather.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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()
|
||||
51
masque/test/test_repetition.py
Normal file
51
masque/test/test_repetition.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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)
|
||||
144
masque/test/test_shape_advanced.py
Normal file
144
masque/test/test_shape_advanced.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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
|
||||
142
masque/test/test_shapes.py
Normal file
142
masque/test/test_shapes.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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)
|
||||
83
masque/test/test_utils.py
Normal file
83
masque/test/test_utils.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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)
|
||||
|
|
@ -26,7 +26,11 @@ from .scalable import (
|
|||
Scalable as Scalable,
|
||||
ScalableImpl as ScalableImpl,
|
||||
)
|
||||
from .mirrorable import Mirrorable as Mirrorable
|
||||
from .mirrorable import (
|
||||
Mirrorable as Mirrorable,
|
||||
Flippable as Flippable,
|
||||
FlippableImpl as FlippableImpl,
|
||||
)
|
||||
from .copyable import Copyable as Copyable
|
||||
from .annotatable import (
|
||||
Annotatable as Annotatable,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
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):
|
||||
"""
|
||||
|
|
@ -11,11 +18,16 @@ class Mirrorable(metaclass=ABCMeta):
|
|||
@abstractmethod
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the entity across an axis.
|
||||
Mirror the entity across an axis through its origin.
|
||||
|
||||
This operation is performed relative to the object's internal origin (ignoring
|
||||
its offset). For objects like `Polygon` and `Path` where the offset is forced
|
||||
to (0, 0), this is equivalent to mirroring in the container's coordinate system.
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across.
|
||||
|
||||
axis: Axis to mirror across:
|
||||
0: X-axis (flip y coords),
|
||||
1: Y-axis (flip x coords)
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
|
|
@ -23,10 +35,11 @@ class Mirrorable(metaclass=ABCMeta):
|
|||
|
||||
def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self:
|
||||
"""
|
||||
Optionally mirror the entity across both axes
|
||||
Optionally mirror the entity across both axes through its origin.
|
||||
|
||||
Args:
|
||||
axes: (mirror_across_x, mirror_across_y)
|
||||
across_x: Mirror across the horizontal X-axis (flip Y coordinates).
|
||||
across_y: Mirror across the vertical Y-axis (flip X coordinates).
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
|
@ -38,30 +51,60 @@ class Mirrorable(metaclass=ABCMeta):
|
|||
return self
|
||||
|
||||
|
||||
#class MirrorableImpl(Mirrorable, metaclass=ABCMeta):
|
||||
# """
|
||||
# Simple implementation of `Mirrorable`
|
||||
# """
|
||||
# __slots__ = ()
|
||||
#
|
||||
# _mirrored: NDArray[numpy.bool]
|
||||
# """ Whether to mirror the instance across the x and/or y axes. """
|
||||
#
|
||||
# #
|
||||
# # Properties
|
||||
# #
|
||||
# # Mirrored property
|
||||
# @property
|
||||
# def mirrored(self) -> NDArray[numpy.bool]:
|
||||
# """ Whether to mirror across the [x, y] axes, respectively """
|
||||
# return self._mirrored
|
||||
#
|
||||
# @mirrored.setter
|
||||
# def mirrored(self, val: Sequence[bool]) -> None:
|
||||
# if is_scalar(val):
|
||||
# raise MasqueError('Mirrored must be a 2-element list of booleans')
|
||||
# self._mirrored = numpy.array(val, dtype=bool)
|
||||
#
|
||||
# #
|
||||
# # Methods
|
||||
# #
|
||||
class Flippable(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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Self, cast, Any, TYPE_CHECKING
|
||||
from typing import Self
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import numpy
|
||||
|
|
@ -8,8 +8,7 @@ from numpy.typing import ArrayLike
|
|||
from ..error import MasqueError
|
||||
from ..utils import rotation_matrix_2d
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .positionable import Positionable
|
||||
from .positionable import Positionable
|
||||
|
||||
_empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass
|
||||
|
||||
|
|
@ -81,7 +80,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta):
|
|||
return self
|
||||
|
||||
|
||||
class Pivotable(metaclass=ABCMeta):
|
||||
class Pivotable(Positionable, metaclass=ABCMeta):
|
||||
"""
|
||||
Trait class for entites which can be rotated around a point.
|
||||
This requires that they are `Positionable` but not necessarily `Rotatable` themselves.
|
||||
|
|
@ -103,20 +102,18 @@ class Pivotable(metaclass=ABCMeta):
|
|||
pass
|
||||
|
||||
|
||||
class PivotableImpl(Pivotable, metaclass=ABCMeta):
|
||||
class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta):
|
||||
"""
|
||||
Implementation of `Pivotable` for objects which are `Rotatable`
|
||||
and `Positionable`.
|
||||
"""
|
||||
__slots__ = ()
|
||||
|
||||
offset: Any # TODO see if we can get around defining `offset` in PivotableImpl
|
||||
""" `[x_offset, y_offset]` """
|
||||
|
||||
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
||||
pivot = numpy.asarray(pivot, dtype=float)
|
||||
cast('Positionable', self).translate(-pivot)
|
||||
cast('Rotatable', self).rotate(rotation)
|
||||
self.translate(-pivot)
|
||||
self.rotate(rotation)
|
||||
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
||||
cast('Positionable', self).translate(+pivot)
|
||||
self.translate(+pivot)
|
||||
return self
|
||||
|
||||
|
|
|
|||
180
masque/utils/boolean.py
Normal file
180
masque/utils/boolean.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
from typing import Any, Literal
|
||||
from collections.abc import Iterable
|
||||
import logging
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ..shapes.polygon import Polygon
|
||||
from ..error import PatternError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Bridge multiple holes into an outer boundary using zero-width slits.
|
||||
"""
|
||||
current_outer = outer_path
|
||||
|
||||
# Sort holes by max X to potentially minimize bridge lengths or complexity
|
||||
# (though not strictly necessary for correctness)
|
||||
holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True)
|
||||
|
||||
for hole in holes:
|
||||
# Find max X vertex of hole
|
||||
max_idx = numpy.argmax(hole[:, 0])
|
||||
m = hole[max_idx]
|
||||
|
||||
# Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges
|
||||
best_t = numpy.inf
|
||||
best_pt = None
|
||||
best_edge_idx = -1
|
||||
|
||||
n = len(current_outer)
|
||||
for i in range(n):
|
||||
p1 = current_outer[i]
|
||||
p2 = current_outer[(i + 1) % n]
|
||||
|
||||
# Check if edge (p1, p2) spans m.y
|
||||
if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]):
|
||||
# Intersection x:
|
||||
# x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y)
|
||||
t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0]
|
||||
if 0 <= t < best_t:
|
||||
best_t = t
|
||||
best_pt = numpy.array([m[0] + t, m[1]])
|
||||
best_edge_idx = i
|
||||
|
||||
if best_edge_idx == -1:
|
||||
# Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole)
|
||||
dists = numpy.linalg.norm(current_outer - m, axis=1)
|
||||
best_edge_idx = int(numpy.argmin(dists))
|
||||
best_pt = current_outer[best_edge_idx]
|
||||
# Adjust best_edge_idx to insert AFTER this vertex
|
||||
# (treating it as a degenerate edge)
|
||||
|
||||
assert best_pt is not None
|
||||
|
||||
# Reorder hole vertices to start at m
|
||||
hole_reordered = numpy.roll(hole, -max_idx, axis=0)
|
||||
|
||||
# Construct new outer:
|
||||
# 1. Start of outer up to best_edge_idx
|
||||
# 2. Intersection point
|
||||
# 3. Hole vertices (starting and ending at m)
|
||||
# 4. Intersection point (to close slit)
|
||||
# 5. Rest of outer
|
||||
|
||||
new_outer: list[NDArray[numpy.float64]] = []
|
||||
new_outer.extend(current_outer[:best_edge_idx + 1])
|
||||
new_outer.append(best_pt)
|
||||
new_outer.extend(hole_reordered)
|
||||
new_outer.append(hole_reordered[0]) # close hole loop at m
|
||||
new_outer.append(best_pt) # back to outer
|
||||
new_outer.extend(current_outer[best_edge_idx + 1:])
|
||||
|
||||
current_outer = numpy.array(new_outer)
|
||||
|
||||
return current_outer
|
||||
|
||||
def boolean(
|
||||
subjects: Iterable[Any],
|
||||
clips: Iterable[Any] | None = None,
|
||||
operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union',
|
||||
scale: float = 1e6,
|
||||
) -> list[Polygon]:
|
||||
"""
|
||||
Perform a boolean operation on two sets of polygons.
|
||||
|
||||
Args:
|
||||
subjects: List of subjects (Polygons or vertex arrays).
|
||||
clips: List of clips (Polygons or vertex arrays).
|
||||
operation: The boolean operation to perform.
|
||||
scale: Scaling factor for integer conversion (pyclipper uses integers).
|
||||
|
||||
Returns:
|
||||
A list of result Polygons.
|
||||
"""
|
||||
try:
|
||||
import pyclipper
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Boolean operations require 'pyclipper'. "
|
||||
"Install it with 'pip install pyclipper' or 'pip install masque[boolean]'."
|
||||
) from None
|
||||
|
||||
op_map = {
|
||||
'union': pyclipper.PT_UNION,
|
||||
'intersection': pyclipper.PT_INTERSECTION,
|
||||
'difference': pyclipper.PT_DIFFERENCE,
|
||||
'xor': pyclipper.PT_XOR,
|
||||
}
|
||||
|
||||
def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]:
|
||||
if objs is None:
|
||||
return []
|
||||
verts = []
|
||||
for obj in objs:
|
||||
if hasattr(obj, 'to_polygons'):
|
||||
for p in obj.to_polygons():
|
||||
verts.append(p.vertices)
|
||||
elif isinstance(obj, numpy.ndarray):
|
||||
verts.append(obj)
|
||||
elif isinstance(obj, Polygon):
|
||||
verts.append(obj.vertices)
|
||||
else:
|
||||
# Try to iterate if it's an iterable of shapes
|
||||
try:
|
||||
for sub in obj:
|
||||
if hasattr(sub, 'to_polygons'):
|
||||
for p in sub.to_polygons():
|
||||
verts.append(p.vertices)
|
||||
elif isinstance(sub, Polygon):
|
||||
verts.append(sub.vertices)
|
||||
except TypeError:
|
||||
raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None
|
||||
return verts
|
||||
|
||||
subject_verts = to_vertices(subjects)
|
||||
clip_verts = to_vertices(clips)
|
||||
|
||||
pc = pyclipper.Pyclipper()
|
||||
pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True)
|
||||
if clip_verts:
|
||||
pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True)
|
||||
|
||||
# Use GetPolyTree to distinguish between outers and holes
|
||||
polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
|
||||
|
||||
result_polygons = []
|
||||
|
||||
def process_node(node: Any) -> None:
|
||||
if not node.IsHole:
|
||||
# This is an outer boundary
|
||||
outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale))
|
||||
|
||||
# Find immediate holes
|
||||
holes = []
|
||||
for child in node.Childs:
|
||||
if child.IsHole:
|
||||
holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale)))
|
||||
|
||||
if holes:
|
||||
combined_vertices = _bridge_holes(outer_path, holes)
|
||||
result_polygons.append(Polygon(combined_vertices))
|
||||
else:
|
||||
result_polygons.append(Polygon(outer_path))
|
||||
|
||||
# Recursively process children of holes (which are nested outers)
|
||||
for child in node.Childs:
|
||||
if child.IsHole:
|
||||
for grandchild in child.Childs:
|
||||
process_node(grandchild)
|
||||
else:
|
||||
# Holes are processed as children of outers
|
||||
pass
|
||||
|
||||
for top_node in polytree.Childs:
|
||||
process_node(top_node)
|
||||
|
||||
return result_polygons
|
||||
|
|
@ -51,6 +51,10 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N
|
|||
if not closed_path:
|
||||
slopes_equal[[0, -1]] = False
|
||||
|
||||
if slopes_equal.all() and vertices.shape[0] > 0:
|
||||
# All colinear, keep the first and last
|
||||
return vertices[[0, vertices.shape[0] - 1]]
|
||||
|
||||
return vertices[~slopes_equal]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,19 @@ 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"
|
||||
|
|
@ -56,7 +69,9 @@ dxf = ["ezdxf~=1.0.2"]
|
|||
svg = ["svgwrite"]
|
||||
visualize = ["matplotlib"]
|
||||
text = ["matplotlib", "freetype-py"]
|
||||
manhatanize_slow = ["float_raster"]
|
||||
manhattanize = ["scikit-image"]
|
||||
manhattanize_slow = ["float_raster"]
|
||||
boolean = ["pyclipper"]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
|
|
@ -94,3 +109,9 @@ lint.ignore = [
|
|||
addopts = "-rsXx"
|
||||
testpaths = ["masque"]
|
||||
|
||||
[tool.mypy]
|
||||
mypy_path = "stubs"
|
||||
python_version = "3.11"
|
||||
strict = false
|
||||
check_untyped_defs = true
|
||||
|
||||
|
|
|
|||
12
stubs/ezdxf/__init__.pyi
Normal file
12
stubs/ezdxf/__init__.pyi
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from typing import Any, TextIO, Iterable
|
||||
from .layouts import Modelspace, BlockRecords
|
||||
|
||||
class Drawing:
|
||||
blocks: BlockRecords
|
||||
@property
|
||||
def layers(self) -> Iterable[Any]: ...
|
||||
def modelspace(self) -> Modelspace: ...
|
||||
def write(self, stream: TextIO) -> None: ...
|
||||
|
||||
def new(version: str = ..., setup: bool = ...) -> Drawing: ...
|
||||
def read(stream: TextIO) -> Drawing: ...
|
||||
17
stubs/ezdxf/entities.pyi
Normal file
17
stubs/ezdxf/entities.pyi
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from typing import Any, Iterable, Tuple, Sequence
|
||||
|
||||
class DXFEntity:
|
||||
def dxfattribs(self) -> dict[str, Any]: ...
|
||||
def dxftype(self) -> str: ...
|
||||
|
||||
class LWPolyline(DXFEntity):
|
||||
def get_points(self) -> Iterable[Tuple[float, ...]]: ...
|
||||
|
||||
class Polyline(DXFEntity):
|
||||
def points(self) -> Iterable[Any]: ... # has .xyz
|
||||
|
||||
class Text(DXFEntity):
|
||||
def get_placement(self) -> Tuple[int, Tuple[float, float, float]]: ...
|
||||
def set_placement(self, p: Sequence[float], align: int = ...) -> Text: ...
|
||||
|
||||
class Insert(DXFEntity): ...
|
||||
4
stubs/ezdxf/enums.pyi
Normal file
4
stubs/ezdxf/enums.pyi
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from enum import IntEnum
|
||||
|
||||
class TextEntityAlignment(IntEnum):
|
||||
BOTTOM_LEFT = ...
|
||||
20
stubs/ezdxf/layouts.pyi
Normal file
20
stubs/ezdxf/layouts.pyi
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from typing import Any, Iterator, Sequence, Union, Iterable
|
||||
from .entities import DXFEntity
|
||||
|
||||
class BaseLayout:
|
||||
def __iter__(self) -> Iterator[DXFEntity]: ...
|
||||
def add_lwpolyline(self, points: Iterable[Sequence[float]], dxfattribs: dict[str, Any] = ...) -> Any: ...
|
||||
def add_text(self, text: str, dxfattribs: dict[str, Any] = ...) -> Any: ...
|
||||
def add_blockref(self, name: str, insert: Any, dxfattribs: dict[str, Any] = ...) -> Any: ...
|
||||
|
||||
class Modelspace(BaseLayout):
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
|
||||
class BlockLayout(BaseLayout):
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
|
||||
class BlockRecords:
|
||||
def new(self, name: str) -> BlockLayout: ...
|
||||
def __iter__(self) -> Iterator[BlockLayout]: ...
|
||||
46
stubs/pyclipper/__init__.pyi
Normal file
46
stubs/pyclipper/__init__.pyi
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from typing import Any
|
||||
from collections.abc import Iterable, Sequence
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
# Basic types for Clipper integer coordinates
|
||||
Path = Sequence[tuple[int, int]]
|
||||
Paths = Sequence[Path]
|
||||
|
||||
# Types for input/output floating point coordinates
|
||||
FloatPoint = tuple[float, float] | NDArray[numpy.floating]
|
||||
FloatPath = Sequence[FloatPoint] | NDArray[numpy.floating]
|
||||
FloatPaths = Iterable[FloatPath]
|
||||
|
||||
# Constants
|
||||
PT_SUBJECT: int
|
||||
PT_CLIP: int
|
||||
|
||||
PT_UNION: int
|
||||
PT_INTERSECTION: int
|
||||
PT_DIFFERENCE: int
|
||||
PT_XOR: int
|
||||
|
||||
PFT_EVENODD: int
|
||||
PFT_NONZERO: int
|
||||
PFT_POSITIVE: int
|
||||
PFT_NEGATIVE: int
|
||||
|
||||
# Scaling functions
|
||||
def scale_to_clipper(paths: FloatPaths, scale: float = ...) -> Paths: ...
|
||||
def scale_from_clipper(paths: Path | Paths, scale: float = ...) -> Any: ...
|
||||
|
||||
class PolyNode:
|
||||
Contour: Path
|
||||
Childs: list[PolyNode]
|
||||
Parent: PolyNode
|
||||
IsHole: bool
|
||||
|
||||
class Pyclipper:
|
||||
def __init__(self) -> None: ...
|
||||
def AddPath(self, path: Path, poly_type: int, closed: bool) -> None: ...
|
||||
def AddPaths(self, paths: Paths, poly_type: int, closed: bool) -> None: ...
|
||||
def Execute(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> Paths: ...
|
||||
def Execute2(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> PolyNode: ...
|
||||
def Clear(self) -> None: ...
|
||||
Loading…
Add table
Add a link
Reference in a new issue