Compare commits
No commits in common. "pather_rework" and "master" have entirely different histories.
pather_rew
...
master
92 changed files with 2808 additions and 6951 deletions
|
|
@ -277,6 +277,12 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
|
* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath)
|
||||||
* PolyCollection & arrow-based read/write
|
* PolyCollection & arrow-based read/write
|
||||||
|
* pather and renderpather examples, including .at() (PortPather)
|
||||||
* Bus-to-bus connections?
|
* Bus-to-bus connections?
|
||||||
|
* Tests tests tests
|
||||||
|
* Better interface for polygon operations (e.g. with `pyclipper`)
|
||||||
|
- de-embedding
|
||||||
|
- boolean ops
|
||||||
* tuple / string layer auto-translation
|
* tuple / string layer auto-translation
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from masque.file import gdsii
|
||||||
from masque import Arc, Pattern
|
from masque import Arc, Pattern
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main():
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
layer = (0, 0)
|
layer = (0, 0)
|
||||||
pat.shapes[layer].extend([
|
pat.shapes[layer].extend([
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import numpy
|
||||||
from pyclipper import (
|
from pyclipper import (
|
||||||
Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO,
|
Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO,
|
||||||
|
scale_to_clipper, scale_from_clipper,
|
||||||
)
|
)
|
||||||
p = Pyclipper()
|
p = Pyclipper()
|
||||||
p.AddPaths([
|
p.AddPaths([
|
||||||
|
|
@ -10,8 +12,8 @@ p.AddPaths([
|
||||||
], PT_SUBJECT, closed=True)
|
], PT_SUBJECT, closed=True)
|
||||||
#p.Execute2?
|
#p.Execute2?
|
||||||
#p.Execute?
|
#p.Execute?
|
||||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO)
|
||||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_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 = Pyclipper()
|
p = Pyclipper()
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main():
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
||||||
cell_name = 'ellip_grating'
|
cell_name = 'ellip_grating'
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,11 @@ Contents
|
||||||
* Design a pattern which is meant to plug into an existing pattern (via `.interface()`)
|
* Design a pattern which is meant to plug into an existing pattern (via `.interface()`)
|
||||||
- [pather](pather.py)
|
- [pather](pather.py)
|
||||||
* Use `Pather` to route individual wires and wire bundles
|
* Use `Pather` to route individual wires and wire bundles
|
||||||
* Use `AutoTool` to generate paths
|
* Use `BasicTool` to generate paths
|
||||||
* Use `AutoTool` to automatically transition between path types
|
* Use `BasicTool` to automatically transition between path types
|
||||||
- [renderpather](renderpather.py)
|
- [renderpather](rendpather.py)
|
||||||
* Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.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.
|
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.
|
Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
||||||
from masque import layer_t, Pattern, Circle, Arc, Ref
|
from masque import (
|
||||||
from masque.repetition import Grid
|
layer_t, Pattern, Label, Port,
|
||||||
|
Circle, Arc, Polygon,
|
||||||
|
)
|
||||||
import masque.file.gdsii
|
import masque.file.gdsii
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -36,45 +39,6 @@ def hole(
|
||||||
return pat
|
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(
|
def triangle(
|
||||||
radius: float,
|
radius: float,
|
||||||
layer: layer_t = (1, 0),
|
layer: layer_t = (1, 0),
|
||||||
|
|
@ -96,7 +60,9 @@ def triangle(
|
||||||
]) * radius
|
]) * radius
|
||||||
|
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
pat.polygon(layer, vertices=vertices)
|
pat.shapes[layer].extend([
|
||||||
|
Polygon(offset=(0, 0), vertices=vertices),
|
||||||
|
])
|
||||||
return pat
|
return pat
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -145,13 +111,9 @@ def main() -> None:
|
||||||
lib['smile'] = smile(1000)
|
lib['smile'] = smile(1000)
|
||||||
lib['triangle'] = triangle(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)
|
masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS)
|
||||||
|
|
||||||
lib['triangle'].visualize()
|
lib['triangle'].visualize()
|
||||||
lib['grid'].visualize(lib)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
||||||
from masque import (
|
from masque import (
|
||||||
layer_t, Pattern, Ref, Builder, Port, Polygon,
|
layer_t, Pattern, Ref, Label, Builder, Port, Polygon,
|
||||||
Library,
|
Library, ILibraryView,
|
||||||
)
|
)
|
||||||
from masque.utils import ports2data
|
from masque.utils import ports2data
|
||||||
from masque.file.gdsii import writefile, check_valid_names
|
from masque.file.gdsii import writefile, check_valid_names
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from collections.abc import Sequence, Callable
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
|
||||||
from masque import Builder, LazyLibrary
|
from masque import Pattern, Builder, LazyLibrary
|
||||||
from masque.file.gdsii import writefile, load_libraryfile
|
from masque.file.gdsii import writefile, load_libraryfile
|
||||||
|
|
||||||
|
import pcgen
|
||||||
import basic_shapes
|
import basic_shapes
|
||||||
import devices
|
import devices
|
||||||
from devices import data_to_ports
|
from devices import ports_to_data, data_to_ports
|
||||||
from basic_shapes import GDS_OPTS
|
from basic_shapes import GDS_OPTS
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"""
|
"""
|
||||||
Manual wire routing tutorial: Pather and AutoTool
|
Manual wire routing tutorial: Pather and BasicTool
|
||||||
"""
|
"""
|
||||||
|
from collections.abc import Callable
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from masque import Pather, Library, Pattern, Port, layer_t
|
from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers
|
||||||
from masque.builder.tools import AutoTool, Tool
|
from masque.builder.tools import BasicTool, PathTool
|
||||||
from masque.file.gdsii import writefile
|
from masque.file.gdsii import writefile
|
||||||
|
|
||||||
from basic_shapes import GDS_OPTS
|
from basic_shapes import GDS_OPTS
|
||||||
|
|
@ -106,99 +107,7 @@ def map_layer(layer: layer_t) -> layer_t:
|
||||||
'M2': (20, 0),
|
'M2': (20, 0),
|
||||||
'V1': (30, 0),
|
'V1': (30, 0),
|
||||||
}
|
}
|
||||||
if isinstance(layer, str):
|
return layer_mapping.get(layer, layer)
|
||||||
return layer_mapping.get(layer, layer)
|
|
||||||
return layer
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_tools() -> tuple[Library, Tool, Tool]:
|
|
||||||
"""
|
|
||||||
Create some basic library elements and tools for drawing M1 and M2
|
|
||||||
"""
|
|
||||||
# Build some patterns (static cells) using the above functions and store them in a library
|
|
||||||
library = Library()
|
|
||||||
library['pad'] = make_pad()
|
|
||||||
library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH)
|
|
||||||
library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH)
|
|
||||||
library['v1_via'] = make_via(
|
|
||||||
layer_top = 'M2',
|
|
||||||
layer_via = 'V1',
|
|
||||||
layer_bot = 'M1',
|
|
||||||
width_top = M2_WIDTH,
|
|
||||||
width_via = V1_WIDTH,
|
|
||||||
width_bot = M1_WIDTH,
|
|
||||||
ptype_bot = 'm1wire',
|
|
||||||
ptype_top = 'm2wire',
|
|
||||||
)
|
|
||||||
|
|
||||||
#
|
|
||||||
# Now, define two tools.
|
|
||||||
# M1_tool will route on M1, using wires with M1_WIDTH
|
|
||||||
# M2_tool will route on M2, using wires with M2_WIDTH
|
|
||||||
# Both tools are able to automatically transition from the other wire type (with a via)
|
|
||||||
#
|
|
||||||
# Note that while we use AutoTool for this tutorial, you can define your own `Tool`
|
|
||||||
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
|
|
||||||
# transmission line geometry, or other features.
|
|
||||||
#
|
|
||||||
M1_tool = AutoTool(
|
|
||||||
# First, we need a function which takes in a length and spits out an M1 wire
|
|
||||||
straights = [
|
|
||||||
AutoTool.Straight(
|
|
||||||
ptype = 'm1wire',
|
|
||||||
fn = lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
|
|
||||||
in_port_name = 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
|
|
||||||
out_port_name = 'output', # and use the port named 'output' as the output
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bends = [
|
|
||||||
AutoTool.Bend(
|
|
||||||
abstract = library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
|
|
||||||
in_port_name = 'input',
|
|
||||||
out_port_name = 'output',
|
|
||||||
clockwise = True,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
transitions = { # We can automate transitions for different (normally incompatible) port types
|
|
||||||
('m2wire', 'm1wire'): AutoTool.Transition( # For example, when we're attaching to a port with type 'm2wire'
|
|
||||||
library.abstract('v1_via'), # we can place a V1 via
|
|
||||||
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
|
|
||||||
'bottom', # and using the port named 'bottom' as the output
|
|
||||||
),
|
|
||||||
},
|
|
||||||
sbends = [],
|
|
||||||
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
|
|
||||||
)
|
|
||||||
|
|
||||||
M2_tool = AutoTool(
|
|
||||||
straights = [
|
|
||||||
# Again, we use make_straight_wire, but this time we set parameters for M2
|
|
||||||
AutoTool.Straight(
|
|
||||||
ptype = 'm2wire',
|
|
||||||
fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
|
|
||||||
in_port_name = 'input',
|
|
||||||
out_port_name = 'output',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bends = [
|
|
||||||
# and we use an M2 bend
|
|
||||||
AutoTool.Bend(
|
|
||||||
abstract = library.abstract('m2_bend'),
|
|
||||||
in_port_name = 'input',
|
|
||||||
out_port_name = 'output',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
transitions = {
|
|
||||||
('m1wire', 'm2wire'): AutoTool.Transition(
|
|
||||||
library.abstract('v1_via'), # We still use the same via,
|
|
||||||
'bottom', # but the input port is now 'bottom'
|
|
||||||
'top', # and the output port is now 'top'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
sbends = [],
|
|
||||||
default_out_ptype = 'm2wire', # We default to trying to stay on M2
|
|
||||||
)
|
|
||||||
return library, M1_tool, M2_tool
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
@ -209,7 +118,75 @@ def prepare_tools() -> tuple[Library, Tool, Tool]:
|
||||||
# (e.g. geometry definition).
|
# (e.g. geometry definition).
|
||||||
#
|
#
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
library, M1_tool, M2_tool = prepare_tools()
|
# Build some patterns (static cells) using the above functions and store them in a library
|
||||||
|
library = Library()
|
||||||
|
library['pad'] = make_pad()
|
||||||
|
library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH)
|
||||||
|
library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH)
|
||||||
|
library['v1_via'] = make_via(
|
||||||
|
layer_top='M2',
|
||||||
|
layer_via='V1',
|
||||||
|
layer_bot='M1',
|
||||||
|
width_top=M2_WIDTH,
|
||||||
|
width_via=V1_WIDTH,
|
||||||
|
width_bot=M1_WIDTH,
|
||||||
|
ptype_bot='m1wire',
|
||||||
|
ptype_top='m2wire',
|
||||||
|
)
|
||||||
|
|
||||||
|
#
|
||||||
|
# Now, define two tools.
|
||||||
|
# M1_tool will route on M1, using wires with M1_WIDTH
|
||||||
|
# M2_tool will route on M2, using wires with M2_WIDTH
|
||||||
|
# Both tools are able to automatically transition from the other wire type (with a via)
|
||||||
|
#
|
||||||
|
# Note that while we use BasicTool for this tutorial, you can define your own `Tool`
|
||||||
|
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
|
||||||
|
# transmission line geometry, or other features.
|
||||||
|
#
|
||||||
|
M1_tool = BasicTool(
|
||||||
|
straight = (
|
||||||
|
# First, we need a function which takes in a length and spits out an M1 wire
|
||||||
|
lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
|
||||||
|
'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
|
||||||
|
'output', # and use the port named 'output' as the output
|
||||||
|
),
|
||||||
|
bend = (
|
||||||
|
library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
|
||||||
|
'input', # To orient it clockwise, use the port named 'input' as the input
|
||||||
|
'output', # and 'output' as the output
|
||||||
|
),
|
||||||
|
transitions = { # We can automate transitions for different (normally incompatible) port types
|
||||||
|
'm2wire': ( # For example, when we're attaching to a port with type 'm2wire'
|
||||||
|
library.abstract('v1_via'), # we can place a V1 via
|
||||||
|
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
|
||||||
|
'bottom', # and using the port named 'bottom' as the output
|
||||||
|
),
|
||||||
|
},
|
||||||
|
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
|
||||||
|
)
|
||||||
|
|
||||||
|
M2_tool = BasicTool(
|
||||||
|
straight = (
|
||||||
|
# Again, we use make_straight_wire, but this time we set parameters for M2
|
||||||
|
lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
|
||||||
|
'input',
|
||||||
|
'output',
|
||||||
|
),
|
||||||
|
bend = (
|
||||||
|
library.abstract('m2_bend'), # and we use an M2 bend
|
||||||
|
'input',
|
||||||
|
'output',
|
||||||
|
),
|
||||||
|
transitions = {
|
||||||
|
'm1wire': (
|
||||||
|
library.abstract('v1_via'), # We still use the same via,
|
||||||
|
'bottom', # but the input port is now 'bottom'
|
||||||
|
'top', # and the output port is now 'top'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
default_out_ptype = 'm2wire', # We default to trying to stay on M2
|
||||||
|
)
|
||||||
|
|
||||||
#
|
#
|
||||||
# Create a new pather which writes to `library` and uses `M2_tool` as its default tool.
|
# Create a new pather which writes to `library` and uses `M2_tool` as its default tool.
|
||||||
|
|
@ -226,25 +203,27 @@ def main() -> None:
|
||||||
|
|
||||||
# Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False)
|
# Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False)
|
||||||
# The total distance forward (including the bend's forward component) must be 6um
|
# The total distance forward (including the bend's forward component) must be 6um
|
||||||
pather.cw('VCC', 6_000)
|
pather.path('VCC', ccw=False, length=6_000)
|
||||||
|
|
||||||
# Now path VCC to x=0. This time, don't include any bend.
|
# Now path VCC to x=0. This time, don't include any bend (ccw=None).
|
||||||
# Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction.
|
# Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction.
|
||||||
pather.straight('VCC', x=0)
|
pather.path_to('VCC', ccw=None, x=0)
|
||||||
|
|
||||||
# Path GND forward by 5um, turning clockwise 90 degrees.
|
# Path GND forward by 5um, turning clockwise 90 degrees.
|
||||||
pather.cw('GND', 5_000)
|
# This time we use shorthand (bool(0) == False) and omit the parameter labels
|
||||||
|
# Note that although ccw=0 is equivalent to ccw=False, ccw=None is not!
|
||||||
|
pather.path('GND', 0, 5_000)
|
||||||
|
|
||||||
# This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend.
|
# This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend.
|
||||||
pather.straight('GND', x=pather['VCC'].offset[0])
|
pather.path_to('GND', None, x=pather['VCC'].offset[0])
|
||||||
|
|
||||||
# Now, start using M1_tool for GND.
|
# Now, start using M1_tool for GND.
|
||||||
# Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves.
|
# Since we have defined an M2-to-M1 transition for BasicPather, we don't need to place one ourselves.
|
||||||
# If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here
|
# 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.
|
# 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
|
# Note that even though we have changed the tool used for GND, the via doesn't get placed until
|
||||||
# the next time we draw a path on GND (the pather.mpath() statement below).
|
# the next time we draw a path on GND (the pather.mpath() statement below).
|
||||||
pather.retool(M1_tool, keys='GND')
|
pather.retool(M1_tool, keys=['GND'])
|
||||||
|
|
||||||
# Bundle together GND and VCC, and path the bundle forward and counterclockwise.
|
# Bundle together GND and VCC, and path the bundle forward and counterclockwise.
|
||||||
# Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000.
|
# Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000.
|
||||||
|
|
@ -252,7 +231,7 @@ def main() -> None:
|
||||||
#
|
#
|
||||||
# Since we recently retooled GND, its path starts with a via down to M1 (included in the distance
|
# Since we recently retooled GND, its path starts with a via down to M1 (included in the distance
|
||||||
# calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2.
|
# calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2.
|
||||||
pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
pather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000)
|
||||||
|
|
||||||
# Now use M1_tool as the default tool for all ports/signals.
|
# Now use M1_tool as the default tool for all ports/signals.
|
||||||
# Since VCC does not have an explicitly assigned tool, it will now transition down to M1.
|
# Since VCC does not have an explicitly assigned tool, it will now transition down to M1.
|
||||||
|
|
@ -262,37 +241,38 @@ def main() -> None:
|
||||||
# The total extension (travel distance along the forward direction) for the longest segment (in
|
# The total extension (travel distance along the forward direction) for the longest segment (in
|
||||||
# this case the segment being added to GND) should be exactly 50um.
|
# this case the segment being added to GND) should be exactly 50um.
|
||||||
# After turning, the wire pitch should be reduced only 1.2um.
|
# After turning, the wire pitch should be reduced only 1.2um.
|
||||||
pather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
|
pather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200)
|
||||||
|
|
||||||
# Make a U-turn with the bundle and expand back out to 4.5um wire pitch.
|
# Make a U-turn with the bundle and expand back out to 4.5um wire pitch.
|
||||||
# Here, emin specifies the travel distance for the shortest segment. For the first call
|
# Here, emin specifies the travel distance for the shortest segment. For the first mpath() call
|
||||||
# that applies to VCC, and for the second call, that applies to GND; the relative lengths of the
|
# that applies to VCC, and for teh second call, that applies to GND; the relative lengths of the
|
||||||
# segments depend on their starting positions and their ordering within the bundle.
|
# segments depend on their starting positions and their ordering within the bundle.
|
||||||
pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
|
pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200)
|
||||||
pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
|
pather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500)
|
||||||
|
|
||||||
# Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been
|
# Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been
|
||||||
# explicitly assigned a tool.
|
# explicitly assigned a tool. We could `del pather.tools['GND']` to force it to use the default.
|
||||||
pather.retool(M2_tool)
|
pather.retool(M2_tool)
|
||||||
|
|
||||||
# Now path both ports to x=-28_000.
|
# Now path both ports to x=-28_000.
|
||||||
# With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is
|
# When ccw is not None, xmin constrains the trailing/innermost port to stop at the target x coordinate,
|
||||||
|
# However, with ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is
|
||||||
# equivalent.
|
# equivalent.
|
||||||
pather.straight(['GND', 'VCC'], xmin=-28_000)
|
pather.mpath(['GND', 'VCC'], None, xmin=-28_000)
|
||||||
|
|
||||||
# Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1.
|
# Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1.
|
||||||
# This results in a via at the end of the wire (instead of having one at the start like we got
|
# This results in a via at the end of the wire (instead of having one at the start like we got
|
||||||
# when using pather.retool().
|
# when using pather.retool().
|
||||||
pather.straight('VCC', x=-50_000, out_ptype='m1wire')
|
pather.path_to('VCC', None, -50_000, out_ptype='m1wire')
|
||||||
|
|
||||||
# Now extend GND out to x=-50_000, using M2 for a portion of the path.
|
# Now extend GND out to x=-50_000, using M2 for a portion of the path.
|
||||||
# We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice.
|
# We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice.
|
||||||
with pather.toolctx(M2_tool, keys='GND'):
|
with pather.toolctx(M2_tool, keys=['GND']):
|
||||||
pather.straight('GND', x=-40_000)
|
pather.path_to('GND', None, -40_000)
|
||||||
pather.straight('GND', x=-50_000)
|
pather.path_to('GND', None, -50_000)
|
||||||
|
|
||||||
# Save the pather's pattern into our library
|
# Save the pather's pattern into our library
|
||||||
library['Pather_and_AutoTool'] = pather.pattern
|
library['Pather_and_BasicTool'] = pather.pattern
|
||||||
|
|
||||||
# Convert from text-based layers to numeric layers for GDS, and output the file
|
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||||
library.map_layers(map_layer)
|
library.map_layers(map_layer)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Routines for creating normalized 2D lattices and common photonic crystal
|
Routines for creating normalized 2D lattices and common photonic crystal
|
||||||
cavity designs.
|
cavity designs.
|
||||||
"""
|
"""
|
||||||
from collections.abc import Sequence
|
from collection.abc import Sequence
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
@ -50,7 +50,7 @@ def triangular_lattice(
|
||||||
elif origin == 'corner':
|
elif origin == 'corner':
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Invalid value for `origin`: {origin}')
|
raise Exception(f'Invalid value for `origin`: {origin}')
|
||||||
|
|
||||||
return xy[xy[:, 0].argsort(), :]
|
return xy[xy[:, 0].argsort(), :]
|
||||||
|
|
||||||
|
|
@ -197,12 +197,12 @@ def ln_defect(
|
||||||
`[[x0, y0], [x1, y1], ...]` for all the holes
|
`[[x0, y0], [x1, y1], ...]` for all the holes
|
||||||
"""
|
"""
|
||||||
if defect_length % 2 != 1:
|
if defect_length % 2 != 1:
|
||||||
raise ValueError('defect_length must be odd!')
|
raise Exception('defect_length must be odd!')
|
||||||
pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims])
|
p = triangular_lattice([2 * d + 1 for d in mirror_dims])
|
||||||
half_length = numpy.floor(defect_length / 2)
|
half_length = numpy.floor(defect_length / 2)
|
||||||
hole_nums = numpy.arange(-half_length, half_length + 1)
|
hole_nums = numpy.arange(-half_length, half_length + 1)
|
||||||
holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True)
|
holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True)
|
||||||
return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :]
|
return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ]
|
||||||
|
|
||||||
|
|
||||||
def ln_shift_defect(
|
def ln_shift_defect(
|
||||||
|
|
@ -248,7 +248,7 @@ def ln_shift_defect(
|
||||||
for sign in (-1, 1):
|
for sign in (-1, 1):
|
||||||
x_val = sign * (x_removed + ind + 1)
|
x_val = sign * (x_removed + ind + 1)
|
||||||
which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0)
|
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
|
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])
|
# 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)))
|
perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2)))
|
||||||
for xy in perturbed_holes:
|
for row in xyr:
|
||||||
which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1)
|
if numpy.fabs(row) in perturbed_holes:
|
||||||
xyr[which, 2] = perturbed_radius
|
row[2] = perturbed_radius
|
||||||
return xyr
|
return xyr
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
"""
|
|
||||||
PortPather tutorial: Using .at() syntax
|
|
||||||
"""
|
|
||||||
from masque import RenderPather, Pattern, Port, R90
|
|
||||||
from masque.file.gdsii import writefile
|
|
||||||
|
|
||||||
from basic_shapes import GDS_OPTS
|
|
||||||
from pather import map_layer, prepare_tools
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
# Reuse the same patterns (pads, bends, vias) and tools as in pather.py
|
|
||||||
library, M1_tool, M2_tool = prepare_tools()
|
|
||||||
|
|
||||||
# Create a RenderPather and place some initial pads (same as Pather tutorial)
|
|
||||||
rpather = RenderPather(library, tools=M2_tool)
|
|
||||||
|
|
||||||
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
|
||||||
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
|
||||||
rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
|
|
||||||
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
|
||||||
|
|
||||||
#
|
|
||||||
# Routing with .at() chaining
|
|
||||||
#
|
|
||||||
# The .at(port_name) method returns a PortPather object which wraps the Pather
|
|
||||||
# and remembers the selected port(s). This allows method chaining.
|
|
||||||
|
|
||||||
# Route VCC: 6um South, then West to x=0.
|
|
||||||
# (Note: since the port points North into the pad, path() moves South by default)
|
|
||||||
(rpather.at('VCC')
|
|
||||||
.path(ccw=False, length=6_000) # Move South, turn West (Clockwise)
|
|
||||||
.path_to(ccw=None, x=0) # Continue West to x=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Route GND: 5um South, then West to match VCC's x-coordinate.
|
|
||||||
rpather.at('GND').path(ccw=False, length=5_000).path_to(ccw=None, x=rpather['VCC'].x)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Tool management and manual plugging
|
|
||||||
#
|
|
||||||
# We can use .retool() to change the tool for specific ports.
|
|
||||||
# We can also use .plug() directly on a PortPather.
|
|
||||||
|
|
||||||
# Manually add a via to GND and switch to M1_tool for subsequent segments
|
|
||||||
(rpather.at('GND')
|
|
||||||
.plug('v1_via', 'top')
|
|
||||||
.retool(M1_tool) # this only retools the 'GND' port
|
|
||||||
)
|
|
||||||
|
|
||||||
# We can also pass multiple ports to .at(), and then use .mpath() on them.
|
|
||||||
# Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via).
|
|
||||||
(rpather.at(['GND', 'VCC'])
|
|
||||||
.mpath(ccw=True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South
|
|
||||||
.retool(M1_tool) # Retools both GND and VCC
|
|
||||||
.mpath(ccw=True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension
|
|
||||||
.mpath(ccw=False, emin=1_000, spacing=1_200) # U-turn back South
|
|
||||||
.mpath(ccw=False, emin=2_000, spacing=4_500) # U-turn back West
|
|
||||||
)
|
|
||||||
|
|
||||||
# Retool VCC back to M2 and move both to x=-28k
|
|
||||||
rpather.at('VCC').retool(M2_tool)
|
|
||||||
rpather.at(['GND', 'VCC']).mpath(ccw=None, xmin=-28_000)
|
|
||||||
|
|
||||||
# Final segments to -50k
|
|
||||||
rpather.at('VCC').path_to(ccw=None, x=-50_000, out_ptype='m1wire')
|
|
||||||
with rpather.at('GND').toolctx(M2_tool):
|
|
||||||
rpather.at('GND').path_to(ccw=None, x=-40_000)
|
|
||||||
rpather.at('GND').path_to(ccw=None, x=-50_000)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Branching with save_copy and into_copy
|
|
||||||
#
|
|
||||||
# .save_copy(new_name) creates a port copy and keeps the original selected.
|
|
||||||
# .into_copy(new_name) creates a port copy and selects the new one.
|
|
||||||
|
|
||||||
# Create a tap on GND
|
|
||||||
(rpather.at('GND')
|
|
||||||
.path(ccw=None, length=5_000) # Move GND further West
|
|
||||||
.save_copy('GND_TAP') # Mark this location for a later branch
|
|
||||||
.pathS(length=10_000, jog=-10_000) # Continue GND with an S-bend
|
|
||||||
)
|
|
||||||
|
|
||||||
# Branch VCC and follow the new branch
|
|
||||||
(rpather.at('VCC')
|
|
||||||
.path(ccw=None, length=5_000)
|
|
||||||
.into_copy('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH'
|
|
||||||
.path(ccw=True, length=5_000) # VCC_BRANCH turns South
|
|
||||||
)
|
|
||||||
# The original 'VCC' port remains at x=-55k, y=VCC.y
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Port set management: add, drop, rename, delete
|
|
||||||
#
|
|
||||||
|
|
||||||
# Route the GND_TAP we saved earlier.
|
|
||||||
(rpather.at('GND_TAP')
|
|
||||||
.retool(M1_tool)
|
|
||||||
.path(ccw=True, length=10_000) # Turn South
|
|
||||||
.rename_to('GND_FEED') # Give it a more descriptive name
|
|
||||||
.retool(M1_tool) # Re-apply tool to the new name
|
|
||||||
)
|
|
||||||
|
|
||||||
# We can manage the active set of ports in a PortPather
|
|
||||||
pp = rpather.at(['VCC_BRANCH', 'GND_FEED'])
|
|
||||||
pp.add_port('GND') # Now tracking 3 ports
|
|
||||||
pp.drop_port('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND
|
|
||||||
pp.path_each(ccw=None, length=5_000) # Move both 5um forward (length > transition size)
|
|
||||||
|
|
||||||
# We can also delete ports from the pather entirely
|
|
||||||
rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Advanced Connections: path_into and path_from
|
|
||||||
#
|
|
||||||
|
|
||||||
# path_into routes FROM the selected port TO a target port.
|
|
||||||
# path_from routes TO the selected port FROM a source port.
|
|
||||||
|
|
||||||
# Create a destination component
|
|
||||||
dest_ports = {
|
|
||||||
'in_A': Port((0, 0), rotation=R90, ptype='m2wire'),
|
|
||||||
'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire')
|
|
||||||
}
|
|
||||||
library['dest'] = Pattern(ports=dest_ports)
|
|
||||||
# Place dest so that its ports are to the West and South of our current wires.
|
|
||||||
# Rotating by pi/2 makes the ports face West (pointing East).
|
|
||||||
rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'})
|
|
||||||
|
|
||||||
# Connect GND_FEED to DEST_A
|
|
||||||
# Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice.
|
|
||||||
rpather.at('GND_FEED').path_into('DEST_A')
|
|
||||||
|
|
||||||
# Connect VCC_BRANCH to DEST_B using path_from
|
|
||||||
rpather.at('DEST_B').path_from('VCC_BRANCH')
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Direct Port Transformations and Metadata
|
|
||||||
#
|
|
||||||
(rpather.at('GND')
|
|
||||||
.set_ptype('m1wire') # Change metadata
|
|
||||||
.translate((1000, 0)) # Shift the port 1um East
|
|
||||||
.rotate(R90 / 2) # Rotate it 45 degrees
|
|
||||||
.set_rotation(R90) # Force it to face West
|
|
||||||
)
|
|
||||||
|
|
||||||
# Demonstrate .plugged() to acknowledge a manual connection
|
|
||||||
# (Normally used when you place components so their ports perfectly overlap)
|
|
||||||
rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2'))
|
|
||||||
rpather.at('TMP1').plugged('TMP2') # Removes both ports
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Rendering and Saving
|
|
||||||
#
|
|
||||||
# Since we used RenderPather, we must call .render() to generate the geometry.
|
|
||||||
rpather.render()
|
|
||||||
|
|
||||||
library['PortPather_Tutorial'] = rpather.pattern
|
|
||||||
library.map_layers(map_layer)
|
|
||||||
writefile(library, 'port_pather.gds', **GDS_OPTS)
|
|
||||||
print("Tutorial complete. Output written to port_pather.gds")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Manual wire routing tutorial: RenderPather an PathTool
|
Manual wire routing tutorial: RenderPather an PathTool
|
||||||
"""
|
"""
|
||||||
from masque import RenderPather, Library
|
from collections.abc import Callable
|
||||||
|
from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers
|
||||||
from masque.builder.tools import PathTool
|
from masque.builder.tools import PathTool
|
||||||
from masque.file.gdsii import writefile
|
from masque.file.gdsii import writefile
|
||||||
|
|
||||||
|
|
@ -12,7 +13,7 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
#
|
#
|
||||||
# To illustrate the advantages of using `RenderPather`, we use `PathTool` instead
|
# To illustrate the advantages of using `RenderPather`, we use `PathTool` instead
|
||||||
# of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
|
# of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
|
||||||
# but when used with `RenderPather`, it can consolidate multiple routing steps into
|
# but when used with `RenderPather`, it can consolidate multiple routing steps into
|
||||||
# a single `Path` shape.
|
# a single `Path` shape.
|
||||||
#
|
#
|
||||||
|
|
@ -24,66 +25,64 @@ def main() -> None:
|
||||||
library = Library()
|
library = Library()
|
||||||
library['pad'] = make_pad()
|
library['pad'] = make_pad()
|
||||||
library['v1_via'] = make_via(
|
library['v1_via'] = make_via(
|
||||||
layer_top = 'M2',
|
layer_top='M2',
|
||||||
layer_via = 'V1',
|
layer_via='V1',
|
||||||
layer_bot = 'M1',
|
layer_bot='M1',
|
||||||
width_top = M2_WIDTH,
|
width_top=M2_WIDTH,
|
||||||
width_via = V1_WIDTH,
|
width_via=V1_WIDTH,
|
||||||
width_bot = M1_WIDTH,
|
width_bot=M1_WIDTH,
|
||||||
ptype_bot = 'm1wire',
|
ptype_bot='m1wire',
|
||||||
ptype_top = 'm2wire',
|
ptype_top='m2wire',
|
||||||
)
|
)
|
||||||
|
|
||||||
# `PathTool` is more limited than `AutoTool`. It only generates one type of shape
|
# `PathTool` is more limited than `BasicTool`. It only generates one type of shape
|
||||||
# (`Path`), so it only needs to know what layer to draw on, what width to draw with,
|
# (`Path`), so it only needs to know what layer to draw on, what width to draw with,
|
||||||
# and what port type to present.
|
# and what port type to present.
|
||||||
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
|
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
|
||||||
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
|
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
|
||||||
rpather = RenderPather(tools=M2_ptool, library=library)
|
rpather = RenderPather(tools=M2_ptool, library=library)
|
||||||
|
|
||||||
# As in the pather tutorial, we make some pads and labels...
|
# As in the pather tutorial, we make soem pads and labels...
|
||||||
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||||
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
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='VCC', offset=(18e3, 30e3))
|
||||||
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
||||||
|
|
||||||
# ...and start routing the signals.
|
# ...and start routing the signals.
|
||||||
rpather.cw('VCC', 6_000)
|
rpather.path('VCC', ccw=False, length=6_000)
|
||||||
rpather.straight('VCC', x=0)
|
rpather.path_to('VCC', ccw=None, x=0)
|
||||||
rpather.cw('GND', 5_000)
|
rpather.path('GND', 0, 5_000)
|
||||||
rpather.straight('GND', x=rpather.pattern['VCC'].x)
|
rpather.path_to('GND', None, x=rpather['VCC'].offset[0])
|
||||||
|
|
||||||
# `PathTool` doesn't know how to transition betwen metal layers, so we have to
|
# `PathTool` doesn't know how to transition betwen metal layers, so we have to
|
||||||
# `plug` the via into the GND wire ourselves.
|
# `plug` the via into the GND wire ourselves.
|
||||||
rpather.plug('v1_via', {'GND': 'top'})
|
rpather.plug('v1_via', {'GND': 'top'})
|
||||||
rpather.retool(M1_ptool, keys='GND')
|
rpather.retool(M1_ptool, keys=['GND'])
|
||||||
rpather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
rpather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000)
|
||||||
|
|
||||||
# Same thing on the VCC wire when it goes down to M1.
|
# Same thing on the VCC wire when it goes down to M1.
|
||||||
rpather.plug('v1_via', {'VCC': 'top'})
|
rpather.plug('v1_via', {'VCC': 'top'})
|
||||||
rpather.retool(M1_ptool)
|
rpather.retool(M1_ptool)
|
||||||
rpather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
|
rpather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200)
|
||||||
rpather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
|
rpather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200)
|
||||||
rpather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
|
rpather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500)
|
||||||
|
|
||||||
# And again when VCC goes back up to M2.
|
# And again when VCC goes back up to M2.
|
||||||
rpather.plug('v1_via', {'VCC': 'bottom'})
|
rpather.plug('v1_via', {'VCC': 'bottom'})
|
||||||
rpather.retool(M2_ptool)
|
rpather.retool(M2_ptool)
|
||||||
rpather.straight(['GND', 'VCC'], xmin=-28_000)
|
rpather.mpath(['GND', 'VCC'], None, xmin=-28_000)
|
||||||
|
|
||||||
# Finally, since PathTool has no conception of transitions, we can't
|
# Finally, since PathTool has no conception of transitions, we can't
|
||||||
# just ask it to transition to an 'm1wire' port at the end of the final VCC segment.
|
# 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
|
# Instead, we have to calculate the via size ourselves, and adjust the final position
|
||||||
# to account for it.
|
# to account for it.
|
||||||
v1pat = library['v1_via']
|
via_size = abs(
|
||||||
via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x)
|
library['v1_via'].ports['top'].offset[0]
|
||||||
|
- library['v1_via'].ports['bottom'].offset[0]
|
||||||
# alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0]
|
)
|
||||||
# would take into account the port orientations if we didn't already know they're along x
|
rpather.path_to('VCC', None, -50_000 + via_size)
|
||||||
rpather.straight('VCC', x=-50_000 + via_size)
|
|
||||||
rpather.plug('v1_via', {'VCC': 'top'})
|
rpather.plug('v1_via', {'VCC': 'top'})
|
||||||
|
|
||||||
# Render the path we defined
|
|
||||||
rpather.render()
|
rpather.render()
|
||||||
library['RenderPather_and_PathTool'] = rpather.pattern
|
library['RenderPather_and_PathTool'] = rpather.pattern
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ from .pattern import (
|
||||||
map_targets as map_targets,
|
map_targets as map_targets,
|
||||||
chain_elements as chain_elements,
|
chain_elements as chain_elements,
|
||||||
)
|
)
|
||||||
from .utils.boolean import boolean as boolean
|
|
||||||
|
|
||||||
from .library import (
|
from .library import (
|
||||||
ILibraryView as ILibraryView,
|
ILibraryView as ILibraryView,
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,16 @@ from numpy.typing import ArrayLike
|
||||||
from .ref import Ref
|
from .ref import Ref
|
||||||
from .ports import PortList, Port
|
from .ports import PortList, Port
|
||||||
from .utils import rotation_matrix_2d
|
from .utils import rotation_matrix_2d
|
||||||
from .traits import Mirrorable
|
|
||||||
|
#if TYPE_CHECKING:
|
||||||
|
# from .builder import Builder, Tool
|
||||||
|
# from .library import ILibrary
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Abstract(PortList, Mirrorable):
|
class Abstract(PortList):
|
||||||
"""
|
"""
|
||||||
An `Abstract` is a container for a name and associated ports.
|
An `Abstract` is a container for a name and associated ports.
|
||||||
|
|
||||||
|
|
@ -128,18 +131,50 @@ class Abstract(PortList, Mirrorable):
|
||||||
port.rotate(rotation)
|
port.rotate(rotation)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror_port_offsets(self, across_axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Mirror the Abstract across an axis through its origin.
|
Mirror the offsets of all shapes, labels, and refs across an axis
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
across_axis: Axis to mirror across
|
||||||
|
(0: mirror across x axis, 1: mirror across y axis)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
for port in self.ports.values():
|
for port in self.ports.values():
|
||||||
port.flip_across(axis=axis)
|
port.offset[across_axis - 1] *= -1
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mirror_ports(self, across_axis: int = 0) -> Self:
|
||||||
|
"""
|
||||||
|
Mirror each port's rotation across an axis, relative to its
|
||||||
|
offset
|
||||||
|
|
||||||
|
Args:
|
||||||
|
across_axis: Axis to mirror across
|
||||||
|
(0: mirror across x axis, 1: mirror across y axis)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
for port in self.ports.values():
|
||||||
|
port.mirror(across_axis)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mirror(self, across_axis: int = 0) -> Self:
|
||||||
|
"""
|
||||||
|
Mirror the Pattern across an axis
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: Axis to mirror across
|
||||||
|
(0: mirror across x axis, 1: mirror across y axis)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self.mirror_ports(across_axis)
|
||||||
|
self.mirror_port_offsets(across_axis)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def apply_ref_transform(self, ref: Ref) -> Self:
|
def apply_ref_transform(self, ref: Ref) -> Self:
|
||||||
|
|
@ -157,8 +192,6 @@ class Abstract(PortList, Mirrorable):
|
||||||
self.mirror()
|
self.mirror()
|
||||||
self.rotate_ports(ref.rotation)
|
self.rotate_ports(ref.rotation)
|
||||||
self.rotate_port_offsets(ref.rotation)
|
self.rotate_port_offsets(ref.rotation)
|
||||||
if ref.scale != 1:
|
|
||||||
self.scale_by(ref.scale)
|
|
||||||
self.translate_ports(ref.offset)
|
self.translate_ports(ref.offset)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -176,8 +209,6 @@ class Abstract(PortList, Mirrorable):
|
||||||
# TODO test undo_ref_transform
|
# TODO test undo_ref_transform
|
||||||
"""
|
"""
|
||||||
self.translate_ports(-ref.offset)
|
self.translate_ports(-ref.offset)
|
||||||
if ref.scale != 1:
|
|
||||||
self.scale_by(1 / ref.scale)
|
|
||||||
self.rotate_port_offsets(-ref.rotation)
|
self.rotate_port_offsets(-ref.rotation)
|
||||||
self.rotate_ports(-ref.rotation)
|
self.rotate_ports(-ref.rotation)
|
||||||
if ref.mirrored:
|
if ref.mirrored:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
from .pather import (
|
from .builder import Builder as Builder
|
||||||
Pather as Pather,
|
from .pather import Pather as Pather
|
||||||
PortPather as PortPather,
|
from .renderpather import RenderPather as RenderPather
|
||||||
Builder as Builder,
|
from .pather_mixin import PortPather as PortPather
|
||||||
RenderPather as RenderPather,
|
|
||||||
)
|
|
||||||
from .utils import ell as ell
|
from .utils import ell as ell
|
||||||
from .tools import (
|
from .tools import (
|
||||||
Tool as Tool,
|
Tool as Tool,
|
||||||
|
|
@ -11,5 +9,4 @@ from .tools import (
|
||||||
SimpleTool as SimpleTool,
|
SimpleTool as SimpleTool,
|
||||||
AutoTool as AutoTool,
|
AutoTool as AutoTool,
|
||||||
PathTool as PathTool,
|
PathTool as PathTool,
|
||||||
)
|
)
|
||||||
from .logging import logged_op as logged_op
|
|
||||||
|
|
|
||||||
448
masque/builder/builder.py
Normal file
448
masque/builder/builder.py
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
"""
|
||||||
|
Simplified Pattern assembly (`Builder`)
|
||||||
|
"""
|
||||||
|
from typing import Self
|
||||||
|
from collections.abc import Iterable, Sequence, Mapping
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from numpy.typing import ArrayLike
|
||||||
|
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..library import ILibrary, TreeView
|
||||||
|
from ..error import BuildError
|
||||||
|
from ..ports import PortList, Port
|
||||||
|
from ..abstract import Abstract
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Builder(PortList):
|
||||||
|
"""
|
||||||
|
A `Builder` is a helper object used for snapping together multiple
|
||||||
|
lower-level patterns at their `Port`s.
|
||||||
|
|
||||||
|
The `Builder` mostly just holds context, in the form of a `Library`,
|
||||||
|
in addition to its underlying pattern. This simplifies some calls
|
||||||
|
to `plug` and `place`, by making the library implicit.
|
||||||
|
|
||||||
|
`Builder` can also be `set_dead()`, at which point further calls to `plug()`
|
||||||
|
and `place()` are ignored (intended for debugging).
|
||||||
|
|
||||||
|
|
||||||
|
Examples: Creating a Builder
|
||||||
|
===========================
|
||||||
|
- `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes
|
||||||
|
an empty pattern, adds the given ports, and places it into `library`
|
||||||
|
under the name `'mypat'`.
|
||||||
|
|
||||||
|
- `Builder(library)` makes an empty pattern with no ports. The pattern
|
||||||
|
is not added into `library` and must later be added with e.g.
|
||||||
|
`library['mypat'] = builder.pattern`
|
||||||
|
|
||||||
|
- `Builder(library, pattern=pattern, name='mypat')` uses an existing
|
||||||
|
pattern (including its ports) and sets `library['mypat'] = pattern`.
|
||||||
|
|
||||||
|
- `Builder.interface(other_pat, port_map=['A', 'B'], library=library)`
|
||||||
|
makes a new (empty) pattern, copies over ports 'A' and 'B' from
|
||||||
|
`other_pat`, and creates additional ports 'in_A' and 'in_B' facing
|
||||||
|
in the opposite directions. This can be used to build a device which
|
||||||
|
can plug into `other_pat` (using the 'in_*' ports) but which does not
|
||||||
|
itself include `other_pat` as a subcomponent.
|
||||||
|
|
||||||
|
- `Builder.interface(other_builder, ...)` does the same thing as
|
||||||
|
`Builder.interface(other_builder.pattern, ...)` but also uses
|
||||||
|
`other_builder.library` as its library by default.
|
||||||
|
|
||||||
|
|
||||||
|
Examples: Adding to a pattern
|
||||||
|
=============================
|
||||||
|
- `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
|
||||||
|
instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B'
|
||||||
|
of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports
|
||||||
|
are removed and any unconnected ports from `subdevice` are added to
|
||||||
|
`my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'.
|
||||||
|
|
||||||
|
- `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
|
||||||
|
of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`,
|
||||||
|
argument is provided, and the `thru` argument is not explicitly
|
||||||
|
set to `False`, the unconnected port of `wire` is automatically renamed to
|
||||||
|
'myport'. This allows easy extension of existing ports without changing
|
||||||
|
their names or having to provide `map_out` each time `plug` is called.
|
||||||
|
|
||||||
|
- `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
|
||||||
|
instantiates `pad` at the specified (x, y) offset and with the specified
|
||||||
|
rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is
|
||||||
|
renamed to 'gnd' so that further routing can use this signal or net name
|
||||||
|
rather than the port name on the original `pad` device.
|
||||||
|
"""
|
||||||
|
__slots__ = ('pattern', 'library', '_dead')
|
||||||
|
|
||||||
|
pattern: Pattern
|
||||||
|
""" Layout of this device """
|
||||||
|
|
||||||
|
library: ILibrary
|
||||||
|
"""
|
||||||
|
Library from which patterns should be referenced
|
||||||
|
"""
|
||||||
|
|
||||||
|
_dead: bool
|
||||||
|
""" If True, plug()/place() are skipped (for debugging)"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ports(self) -> dict[str, Port]:
|
||||||
|
return self.pattern.ports
|
||||||
|
|
||||||
|
@ports.setter
|
||||||
|
def ports(self, value: dict[str, Port]) -> None:
|
||||||
|
self.pattern.ports = value
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
library: ILibrary,
|
||||||
|
*,
|
||||||
|
pattern: Pattern | None = None,
|
||||||
|
ports: str | Mapping[str, Port] | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
library: The library from which referenced patterns will be taken
|
||||||
|
pattern: The pattern which will be modified by subsequent operations.
|
||||||
|
If `None` (default), a new pattern is created.
|
||||||
|
ports: Allows specifying the initial set of ports, if `pattern` does
|
||||||
|
not already have any ports (or is not provided). May be a string,
|
||||||
|
in which case it is interpreted as a name in `library`.
|
||||||
|
Default `None` (no ports).
|
||||||
|
name: If specified, `library[name]` is set to `self.pattern`.
|
||||||
|
"""
|
||||||
|
self._dead = False
|
||||||
|
self.library = library
|
||||||
|
if pattern is not None:
|
||||||
|
self.pattern = pattern
|
||||||
|
else:
|
||||||
|
self.pattern = Pattern()
|
||||||
|
|
||||||
|
if ports is not None:
|
||||||
|
if self.pattern.ports:
|
||||||
|
raise BuildError('Ports supplied for pattern with pre-existing ports!')
|
||||||
|
if isinstance(ports, str):
|
||||||
|
ports = library.abstract(ports).ports
|
||||||
|
|
||||||
|
self.pattern.ports.update(copy.deepcopy(dict(ports)))
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
library[name] = self.pattern
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def interface(
|
||||||
|
cls: type['Builder'],
|
||||||
|
source: PortList | Mapping[str, Port] | str,
|
||||||
|
*,
|
||||||
|
library: ILibrary | None = None,
|
||||||
|
in_prefix: str = 'in_',
|
||||||
|
out_prefix: str = '',
|
||||||
|
port_map: dict[str, str] | Sequence[str] | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> 'Builder':
|
||||||
|
"""
|
||||||
|
Wrapper for `Pattern.interface()`, which returns a Builder instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: A collection of ports (e.g. Pattern, Builder, or dict)
|
||||||
|
from which to create the interface. May be a pattern name if
|
||||||
|
`library` is provided.
|
||||||
|
library: Library from which existing patterns should be referenced,
|
||||||
|
and to which the new one should be added (if named). If not provided,
|
||||||
|
`source.library` must exist and will be used.
|
||||||
|
in_prefix: Prepended to port names for newly-created ports with
|
||||||
|
reversed directions compared to the current device.
|
||||||
|
out_prefix: Prepended to port names for ports which are directly
|
||||||
|
copied from the current device.
|
||||||
|
port_map: Specification for ports to copy into the new device:
|
||||||
|
- If `None`, all ports are copied.
|
||||||
|
- If a sequence, only the listed ports are copied
|
||||||
|
- If a mapping, the listed ports (keys) are copied and
|
||||||
|
renamed (to the values).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The new builder, with an empty pattern and 2x as many ports as
|
||||||
|
listed in port_map.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
`PortError` if `port_map` contains port names not present in the
|
||||||
|
current device.
|
||||||
|
`PortError` if applying the prefixes results in duplicate port
|
||||||
|
names.
|
||||||
|
"""
|
||||||
|
if library is None:
|
||||||
|
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
|
||||||
|
library = source.library
|
||||||
|
else:
|
||||||
|
raise BuildError('No library was given, and `source.library` does not have one either.')
|
||||||
|
|
||||||
|
if isinstance(source, str):
|
||||||
|
source = library.abstract(source).ports
|
||||||
|
|
||||||
|
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
|
||||||
|
new = Builder(library=library, pattern=pat, name=name)
|
||||||
|
return new
|
||||||
|
|
||||||
|
@wraps(Pattern.label)
|
||||||
|
def label(self, *args, **kwargs) -> Self:
|
||||||
|
self.pattern.label(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@wraps(Pattern.ref)
|
||||||
|
def ref(self, *args, **kwargs) -> Self:
|
||||||
|
self.pattern.ref(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@wraps(Pattern.polygon)
|
||||||
|
def polygon(self, *args, **kwargs) -> Self:
|
||||||
|
self.pattern.polygon(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@wraps(Pattern.rect)
|
||||||
|
def rect(self, *args, **kwargs) -> Self:
|
||||||
|
self.pattern.rect(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
# Note: We're a superclass of `Pather`, where path() means something different,
|
||||||
|
# so we shouldn't wrap Pattern.path()
|
||||||
|
#@wraps(Pattern.path)
|
||||||
|
#def path(self, *args, **kwargs) -> Self:
|
||||||
|
# self.pattern.path(*args, **kwargs)
|
||||||
|
# return self
|
||||||
|
|
||||||
|
def plug(
|
||||||
|
self,
|
||||||
|
other: Abstract | str | Pattern | TreeView,
|
||||||
|
map_in: dict[str, str],
|
||||||
|
map_out: dict[str, str | None] | None = None,
|
||||||
|
*,
|
||||||
|
mirrored: bool = False,
|
||||||
|
thru: bool | str = True,
|
||||||
|
set_rotation: bool | None = None,
|
||||||
|
append: bool = False,
|
||||||
|
ok_connections: Iterable[tuple[str, str]] = (),
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Wrapper around `Pattern.plug` which allows a string for `other`.
|
||||||
|
|
||||||
|
The `Builder`'s library is used to dereference the string (or `Abstract`, if
|
||||||
|
one is passed with `append=True`). If a `TreeView` is passed, it is first
|
||||||
|
added into `self.library`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
|
||||||
|
device to be instatiated. If it is a `TreeView`, it is first
|
||||||
|
added into `self.library`, after which the topcell is plugged;
|
||||||
|
an equivalent statement is `self.plug(self.library << other, ...)`.
|
||||||
|
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
|
||||||
|
port connections between the two devices.
|
||||||
|
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||||
|
new names for ports in `other`.
|
||||||
|
mirrored: Enables mirroring `other` across the x axis prior to
|
||||||
|
connecting any ports.
|
||||||
|
thru: If map_in specifies only a single port, `thru` provides a mechainsm
|
||||||
|
to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
|
||||||
|
- If True (default), and `other` has only two ports total, and map_out
|
||||||
|
doesn't specify a name for the other port, its name is set to the key
|
||||||
|
in `map_in`, i.e. 'myport'.
|
||||||
|
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
|
||||||
|
An error is raised if that entry already exists.
|
||||||
|
|
||||||
|
This makes it easy to extend a pattern with simple 2-port devices
|
||||||
|
(e.g. wires) without providing `map_out` each time `plug` is
|
||||||
|
called. See "Examples" above for more info. Default `True`.
|
||||||
|
set_rotation: If the necessary rotation cannot be determined from
|
||||||
|
the ports being connected (i.e. all pairs have at least one
|
||||||
|
port with `rotation=None`), `set_rotation` must be provided
|
||||||
|
to indicate how much `other` should be rotated. Otherwise,
|
||||||
|
`set_rotation` must remain `None`.
|
||||||
|
append: If `True`, `other` is appended instead of being referenced.
|
||||||
|
Note that this does not flatten `other`, so its refs will still
|
||||||
|
be refs (now inside `self`).
|
||||||
|
ok_connections: Set of "allowed" ptype combinations. Identical
|
||||||
|
ptypes are always allowed to connect, as is `'unk'` with
|
||||||
|
any other ptypte. Non-allowed ptype connections will emit a
|
||||||
|
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||||
|
`(b, a)`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||||
|
exist in `self.ports` or `other_names`.
|
||||||
|
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||||
|
are applied.
|
||||||
|
`PortError` if the specified port mapping is not achieveable (the ports
|
||||||
|
do not line up)
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping plug() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
if not isinstance(other, str | Abstract | Pattern):
|
||||||
|
# We got a Tree; add it into self.library and grab an Abstract for it
|
||||||
|
other = self.library << other
|
||||||
|
|
||||||
|
if isinstance(other, str):
|
||||||
|
other = self.library.abstract(other)
|
||||||
|
if append and isinstance(other, Abstract):
|
||||||
|
other = self.library[other.name]
|
||||||
|
|
||||||
|
self.pattern.plug(
|
||||||
|
other = other,
|
||||||
|
map_in = map_in,
|
||||||
|
map_out = map_out,
|
||||||
|
mirrored = mirrored,
|
||||||
|
thru = thru,
|
||||||
|
set_rotation = set_rotation,
|
||||||
|
append = append,
|
||||||
|
ok_connections = ok_connections,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def place(
|
||||||
|
self,
|
||||||
|
other: Abstract | str | Pattern | TreeView,
|
||||||
|
*,
|
||||||
|
offset: ArrayLike = (0, 0),
|
||||||
|
rotation: float = 0,
|
||||||
|
pivot: ArrayLike = (0, 0),
|
||||||
|
mirrored: bool = False,
|
||||||
|
port_map: dict[str, str | None] | None = None,
|
||||||
|
skip_port_check: bool = False,
|
||||||
|
append: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`.
|
||||||
|
|
||||||
|
The `Builder`'s library is used to dereference the string (or `Abstract`, if
|
||||||
|
one is passed with `append=True`). If a `TreeView` is passed, it is first
|
||||||
|
added into `self.library`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
|
||||||
|
device to be instatiated. If it is a `TreeView`, it is first
|
||||||
|
added into `self.library`, after which the topcell is plugged;
|
||||||
|
an equivalent statement is `self.plug(self.library << other, ...)`.
|
||||||
|
offset: Offset at which to place the instance. Default (0, 0).
|
||||||
|
rotation: Rotation applied to the instance before placement. Default 0.
|
||||||
|
pivot: Rotation is applied around this pivot point (default (0, 0)).
|
||||||
|
Rotation is applied prior to translation (`offset`).
|
||||||
|
mirrored: Whether theinstance should be mirrored across the x axis.
|
||||||
|
Mirroring is applied before translation and rotation.
|
||||||
|
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||||
|
new names for ports in the instantiated device. New names can be
|
||||||
|
`None`, which will delete those ports.
|
||||||
|
skip_port_check: Can be used to skip the internal call to `check_ports`,
|
||||||
|
in case it has already been performed elsewhere.
|
||||||
|
append: If `True`, `other` is appended instead of being referenced.
|
||||||
|
Note that this does not flatten `other`, so its refs will still
|
||||||
|
be refs (now inside `self`).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||||
|
exist in `self.ports` or `other.ports`.
|
||||||
|
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||||
|
are applied.
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping place() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
if not isinstance(other, str | Abstract | Pattern):
|
||||||
|
# We got a Tree; add it into self.library and grab an Abstract for it
|
||||||
|
other = self.library << other
|
||||||
|
|
||||||
|
if isinstance(other, str):
|
||||||
|
other = self.library.abstract(other)
|
||||||
|
if append and isinstance(other, Abstract):
|
||||||
|
other = self.library[other.name]
|
||||||
|
|
||||||
|
self.pattern.place(
|
||||||
|
other = other,
|
||||||
|
offset = offset,
|
||||||
|
rotation = rotation,
|
||||||
|
pivot = pivot,
|
||||||
|
mirrored = mirrored,
|
||||||
|
port_map = port_map,
|
||||||
|
skip_port_check = skip_port_check,
|
||||||
|
append = append,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def translate(self, offset: ArrayLike) -> Self:
|
||||||
|
"""
|
||||||
|
Translate the pattern and all ports.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
offset: (x, y) distance to translate by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self.pattern.translate_elements(offset)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
|
||||||
|
"""
|
||||||
|
Rotate the pattern and all ports.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
angle: angle (radians, counterclockwise) to rotate by
|
||||||
|
pivot: location to rotate around
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self.pattern.rotate_around(pivot, angle)
|
||||||
|
for port in self.ports.values():
|
||||||
|
port.rotate_around(pivot, angle)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
|
"""
|
||||||
|
Mirror the pattern and all ports across the specified axis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: Axis to mirror across (x=0, y=1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self.pattern.mirror(axis)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_dead(self) -> Self:
|
||||||
|
"""
|
||||||
|
Disallows further changes through `plug()` or `place()`.
|
||||||
|
This is meant for debugging:
|
||||||
|
```
|
||||||
|
dev.plug(a, ...)
|
||||||
|
dev.set_dead() # added for debug purposes
|
||||||
|
dev.plug(b, ...) # usually raises an error, but now skipped
|
||||||
|
dev.plug(c, ...) # also skipped
|
||||||
|
dev.pattern.visualize() # shows the device as of the set_dead() call
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self._dead = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
s = f'<Builder {self.pattern} L({len(self.library)})>'
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"""
|
|
||||||
Logging and operation decorators for Builder/Pather
|
|
||||||
"""
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from collections.abc import Iterator, Sequence, Callable
|
|
||||||
import logging
|
|
||||||
from functools import wraps
|
|
||||||
import inspect
|
|
||||||
import numpy
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .pather import Pather
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_log_args(**kwargs) -> str:
|
|
||||||
arg_strs = []
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
if isinstance(v, str | int | float | bool | None):
|
|
||||||
arg_strs.append(f"{k}={v}")
|
|
||||||
elif isinstance(v, numpy.ndarray):
|
|
||||||
arg_strs.append(f"{k}={v.tolist()}")
|
|
||||||
elif isinstance(v, list | tuple) and len(v) <= 10:
|
|
||||||
arg_strs.append(f"{k}={v}")
|
|
||||||
else:
|
|
||||||
arg_strs.append(f"{k}=...")
|
|
||||||
return ", ".join(arg_strs)
|
|
||||||
|
|
||||||
|
|
||||||
class PatherLogger:
|
|
||||||
"""
|
|
||||||
Encapsulates state for Pather/Builder diagnostic logging.
|
|
||||||
"""
|
|
||||||
debug: bool
|
|
||||||
indent: int
|
|
||||||
depth: int
|
|
||||||
|
|
||||||
def __init__(self, debug: bool = False) -> None:
|
|
||||||
self.debug = debug
|
|
||||||
self.indent = 0
|
|
||||||
self.depth = 0
|
|
||||||
|
|
||||||
def _log(self, module_name: str, msg: str) -> None:
|
|
||||||
if self.debug and self.depth <= 1:
|
|
||||||
log_obj = logging.getLogger(module_name)
|
|
||||||
log_obj.info(' ' * self.indent + msg)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def log_operation(
|
|
||||||
self,
|
|
||||||
pather: 'Pather',
|
|
||||||
op: str,
|
|
||||||
portspec: str | Sequence[str] | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Iterator[None]:
|
|
||||||
if not self.debug or self.depth > 0:
|
|
||||||
self.depth += 1
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
self.depth -= 1
|
|
||||||
return
|
|
||||||
|
|
||||||
target = f"({portspec})" if portspec else ""
|
|
||||||
module_name = pather.__class__.__module__
|
|
||||||
self._log(module_name, f"Operation: {op}{target} {_format_log_args(**kwargs)}")
|
|
||||||
|
|
||||||
before_ports = {name: port.copy() for name, port in pather.ports.items()}
|
|
||||||
self.depth += 1
|
|
||||||
self.indent += 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
after_ports = pather.ports
|
|
||||||
for name in sorted(after_ports.keys()):
|
|
||||||
if name not in before_ports or after_ports[name] != before_ports[name]:
|
|
||||||
self._log(module_name, f"Port {name}: {pather.ports[name].describe()}")
|
|
||||||
for name in sorted(before_ports.keys()):
|
|
||||||
if name not in after_ports:
|
|
||||||
self._log(module_name, f"Port {name}: removed")
|
|
||||||
|
|
||||||
self.indent -= 1
|
|
||||||
self.depth -= 1
|
|
||||||
|
|
||||||
|
|
||||||
def logged_op(
|
|
||||||
portspec_getter: Callable[[dict[str, Any]], str | Sequence[str] | None] | None = None,
|
|
||||||
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
||||||
"""
|
|
||||||
Decorator to wrap Builder methods with logging.
|
|
||||||
"""
|
|
||||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(self: 'Pather', *args: Any, **kwargs: Any) -> Any:
|
|
||||||
logger_obj = getattr(self, '_logger', None)
|
|
||||||
if logger_obj is None or not logger_obj.debug:
|
|
||||||
return func(self, *args, **kwargs)
|
|
||||||
|
|
||||||
bound = sig.bind(self, *args, **kwargs)
|
|
||||||
bound.apply_defaults()
|
|
||||||
all_args = bound.arguments
|
|
||||||
# remove 'self' from logged args
|
|
||||||
logged_args = {k: v for k, v in all_args.items() if k != 'self'}
|
|
||||||
|
|
||||||
ps = portspec_getter(all_args) if portspec_getter else None
|
|
||||||
|
|
||||||
# Remove portspec from logged_args if it's there to avoid duplicate arg to log_operation
|
|
||||||
logged_args.pop('portspec', None)
|
|
||||||
|
|
||||||
with logger_obj.log_operation(self, func.__name__, ps, **logged_args):
|
|
||||||
if getattr(self, '_dead', False) and func.__name__ in ('plug', 'place'):
|
|
||||||
logger.warning(f"Skipping geometry for {func.__name__}() since device is dead")
|
|
||||||
return func(self, *args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
File diff suppressed because it is too large
Load diff
677
masque/builder/pather_mixin.py
Normal file
677
masque/builder/pather_mixin.py
Normal file
|
|
@ -0,0 +1,677 @@
|
||||||
|
from typing import Self, overload
|
||||||
|
from collections.abc import Sequence, Iterator, Iterable
|
||||||
|
import logging
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from abc import abstractmethod, ABCMeta
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.typing import ArrayLike
|
||||||
|
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..library import ILibrary, TreeView
|
||||||
|
from ..error import PortError, BuildError
|
||||||
|
from ..utils import SupportsBool
|
||||||
|
from ..abstract import Abstract
|
||||||
|
from .tools import Tool
|
||||||
|
from .utils import ell
|
||||||
|
from ..ports import PortList
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PatherMixin(PortList, metaclass=ABCMeta):
|
||||||
|
pattern: Pattern
|
||||||
|
""" Layout of this device """
|
||||||
|
|
||||||
|
library: ILibrary
|
||||||
|
""" Library from which patterns should be referenced """
|
||||||
|
|
||||||
|
_dead: bool
|
||||||
|
""" If True, plug()/place() are skipped (for debugging) """
|
||||||
|
|
||||||
|
tools: dict[str | None, Tool]
|
||||||
|
"""
|
||||||
|
Tool objects are used to dynamically generate new single-use Devices
|
||||||
|
(e.g wires or waveguides) to be plugged into this device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def path(
|
||||||
|
self,
|
||||||
|
portspec: str,
|
||||||
|
ccw: SupportsBool | None,
|
||||||
|
length: float,
|
||||||
|
*,
|
||||||
|
plug_into: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Self:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def pathS(
|
||||||
|
self,
|
||||||
|
portspec: str,
|
||||||
|
length: float,
|
||||||
|
jog: float,
|
||||||
|
*,
|
||||||
|
plug_into: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Self:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def plug(
|
||||||
|
self,
|
||||||
|
other: Abstract | str | Pattern | TreeView,
|
||||||
|
map_in: dict[str, str],
|
||||||
|
map_out: dict[str, str | None] | None = None,
|
||||||
|
*,
|
||||||
|
mirrored: bool = False,
|
||||||
|
thru: bool | str = True,
|
||||||
|
set_rotation: bool | None = None,
|
||||||
|
append: bool = False,
|
||||||
|
ok_connections: Iterable[tuple[str, str]] = (),
|
||||||
|
) -> Self:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def retool(
|
||||||
|
self,
|
||||||
|
tool: Tool,
|
||||||
|
keys: str | Sequence[str | None] | None = None,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Update the `Tool` which will be used when generating `Pattern`s for the ports
|
||||||
|
given by `keys`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: The new `Tool` to use for the given ports.
|
||||||
|
keys: Which ports the tool should apply to. `None` indicates the default tool,
|
||||||
|
used when there is no matching entry in `self.tools` for the port in question.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
if keys is None or isinstance(keys, str):
|
||||||
|
self.tools[keys] = tool
|
||||||
|
else:
|
||||||
|
for key in keys:
|
||||||
|
self.tools[key] = tool
|
||||||
|
return self
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def toolctx(
|
||||||
|
self,
|
||||||
|
tool: Tool,
|
||||||
|
keys: str | Sequence[str | None] | None = None,
|
||||||
|
) -> Iterator[Self]:
|
||||||
|
"""
|
||||||
|
Context manager for temporarily `retool`-ing and reverting the `retool`
|
||||||
|
upon exiting the context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: The new `Tool` to use for the given ports.
|
||||||
|
keys: Which ports the tool should apply to. `None` indicates the default tool,
|
||||||
|
used when there is no matching entry in `self.tools` for the port in question.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
if keys is None or isinstance(keys, str):
|
||||||
|
keys = [keys]
|
||||||
|
saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None`
|
||||||
|
try:
|
||||||
|
yield self.retool(tool=tool, keys=keys)
|
||||||
|
finally:
|
||||||
|
for kk, tt in saved_tools.items():
|
||||||
|
if tt is None:
|
||||||
|
# delete if present
|
||||||
|
self.tools.pop(kk, None)
|
||||||
|
else:
|
||||||
|
self.tools[kk] = tt
|
||||||
|
|
||||||
|
def path_to(
|
||||||
|
self,
|
||||||
|
portspec: str,
|
||||||
|
ccw: SupportsBool | None,
|
||||||
|
position: float | None = None,
|
||||||
|
*,
|
||||||
|
x: float | None = None,
|
||||||
|
y: float | None = None,
|
||||||
|
plug_into: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Build a "wire"/"waveguide" extending from the port `portspec`, with the aim
|
||||||
|
of ending exactly at a target position.
|
||||||
|
|
||||||
|
The wire will travel so that the output port will be placed at exactly the target
|
||||||
|
position along the input port's axis. There can be an unspecified (tool-dependent)
|
||||||
|
offset in the perpendicular direction. The output port will be rotated (or not)
|
||||||
|
based on the `ccw` parameter.
|
||||||
|
|
||||||
|
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
portspec: The name of the port into which the wire will be plugged.
|
||||||
|
ccw: If `None`, the output should be along the same axis as the input.
|
||||||
|
Otherwise, cast to bool and turn counterclockwise if True
|
||||||
|
and clockwise otherwise.
|
||||||
|
position: The final port position, along the input's axis only.
|
||||||
|
(There may be a tool-dependent offset along the other axis.)
|
||||||
|
Only one of `position`, `x`, and `y` may be specified.
|
||||||
|
x: The final port position along the x axis.
|
||||||
|
`portspec` must refer to a horizontal port if `x` is passed, otherwise a
|
||||||
|
BuildError will be raised.
|
||||||
|
y: The final port position along the y axis.
|
||||||
|
`portspec` must refer to a vertical port if `y` is passed, otherwise a
|
||||||
|
BuildError will be raised.
|
||||||
|
plug_into: If not None, attempts to plug the wire's output port into the provided
|
||||||
|
port on `self`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend
|
||||||
|
is present).
|
||||||
|
BuildError if `x` or `y` is specified but does not match the axis of `portspec`.
|
||||||
|
BuildError if more than one of `x`, `y`, and `position` is specified.
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping path_to() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
pos_count = sum(vv is not None for vv in (position, x, y))
|
||||||
|
if pos_count > 1:
|
||||||
|
raise BuildError('Only one of `position`, `x`, and `y` may be specified at once')
|
||||||
|
if pos_count < 1:
|
||||||
|
raise BuildError('One of `position`, `x`, and `y` must be specified')
|
||||||
|
|
||||||
|
port = self.pattern[portspec]
|
||||||
|
if port.rotation is None:
|
||||||
|
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()')
|
||||||
|
|
||||||
|
if not numpy.isclose(port.rotation % (pi / 2), 0):
|
||||||
|
raise BuildError('path_to was asked to route from non-manhattan port')
|
||||||
|
|
||||||
|
is_horizontal = numpy.isclose(port.rotation % pi, 0)
|
||||||
|
if is_horizontal:
|
||||||
|
if y is not None:
|
||||||
|
raise BuildError('Asked to path to y-coordinate, but port is horizontal')
|
||||||
|
if position is None:
|
||||||
|
position = x
|
||||||
|
else:
|
||||||
|
if x is not None:
|
||||||
|
raise BuildError('Asked to path to x-coordinate, but port is vertical')
|
||||||
|
if position is None:
|
||||||
|
position = y
|
||||||
|
|
||||||
|
x0, y0 = port.offset
|
||||||
|
if is_horizontal:
|
||||||
|
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0):
|
||||||
|
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
|
||||||
|
length = numpy.abs(position - x0)
|
||||||
|
else:
|
||||||
|
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
|
||||||
|
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
|
||||||
|
length = numpy.abs(position - y0)
|
||||||
|
|
||||||
|
return self.path(
|
||||||
|
portspec,
|
||||||
|
ccw,
|
||||||
|
length,
|
||||||
|
plug_into = plug_into,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def path_into(
|
||||||
|
self,
|
||||||
|
portspec_src: str,
|
||||||
|
portspec_dst: str,
|
||||||
|
*,
|
||||||
|
out_ptype: str | None = None,
|
||||||
|
plug_destination: bool = True,
|
||||||
|
thru: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Create a "wire"/"waveguide" traveling between the ports `portspec_src` and
|
||||||
|
`portspec_dst`, and `plug` it into both (or just the source port).
|
||||||
|
|
||||||
|
Only unambiguous scenarios are allowed:
|
||||||
|
- Straight connector between facing ports
|
||||||
|
- Single 90 degree bend
|
||||||
|
- Jog between facing ports
|
||||||
|
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
|
||||||
|
|
||||||
|
By default, the destination's `pytpe` will be used as the `out_ptype` for the
|
||||||
|
wire, and the `portspec_dst` will be plugged (i.e. removed).
|
||||||
|
|
||||||
|
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
portspec_src: The name of the starting port into which the wire will be plugged.
|
||||||
|
portspec_dst: The name of the destination port.
|
||||||
|
out_ptype: Passed to the pathing tool in order to specify the desired port type
|
||||||
|
to be generated at the destination end. If `None` (default), the destination
|
||||||
|
port's `ptype` will be used.
|
||||||
|
thru: If not `None`, the port by this name will be rename to `portspec_src`.
|
||||||
|
This can be used when routing a signal through a pre-placed 2-port device.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PortError if either port does not have a specified rotation.
|
||||||
|
BuildError if and invalid port config is encountered:
|
||||||
|
- Non-manhattan ports
|
||||||
|
- U-bend
|
||||||
|
- Destination too close to (or behind) source
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping path_into() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
port_src = self.pattern[portspec_src]
|
||||||
|
port_dst = self.pattern[portspec_dst]
|
||||||
|
|
||||||
|
if out_ptype is None:
|
||||||
|
out_ptype = port_dst.ptype
|
||||||
|
|
||||||
|
if port_src.rotation is None:
|
||||||
|
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()')
|
||||||
|
if port_dst.rotation is None:
|
||||||
|
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()')
|
||||||
|
|
||||||
|
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
|
||||||
|
raise BuildError('path_into was asked to route from non-manhattan port')
|
||||||
|
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
|
||||||
|
raise BuildError('path_into was asked to route to non-manhattan port')
|
||||||
|
|
||||||
|
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
|
||||||
|
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
|
||||||
|
xs, ys = port_src.offset
|
||||||
|
xd, yd = port_dst.offset
|
||||||
|
|
||||||
|
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
|
||||||
|
|
||||||
|
dst_extra_args = {'out_ptype': out_ptype}
|
||||||
|
if plug_destination:
|
||||||
|
dst_extra_args['plug_into'] = portspec_dst
|
||||||
|
|
||||||
|
src_args = {**kwargs}
|
||||||
|
dst_args = {**src_args, **dst_extra_args}
|
||||||
|
if src_is_horizontal and not dst_is_horizontal:
|
||||||
|
# single bend should suffice
|
||||||
|
self.path_to(portspec_src, angle > pi, x=xd, **src_args)
|
||||||
|
self.path_to(portspec_src, None, y=yd, **dst_args)
|
||||||
|
elif dst_is_horizontal and not src_is_horizontal:
|
||||||
|
# single bend should suffice
|
||||||
|
self.path_to(portspec_src, angle > pi, y=yd, **src_args)
|
||||||
|
self.path_to(portspec_src, None, x=xd, **dst_args)
|
||||||
|
elif numpy.isclose(angle, pi):
|
||||||
|
if src_is_horizontal and ys == yd:
|
||||||
|
# straight connector
|
||||||
|
self.path_to(portspec_src, None, x=xd, **dst_args)
|
||||||
|
elif not src_is_horizontal and xs == xd:
|
||||||
|
# straight connector
|
||||||
|
self.path_to(portspec_src, None, y=yd, **dst_args)
|
||||||
|
else:
|
||||||
|
# S-bend, delegate to implementations
|
||||||
|
(travel, jog), _ = port_src.measure_travel(port_dst)
|
||||||
|
self.pathS(portspec_src, -travel, -jog, **dst_args)
|
||||||
|
elif numpy.isclose(angle, 0):
|
||||||
|
raise BuildError('Don\'t know how to route a U-bend yet (TODO)!')
|
||||||
|
else:
|
||||||
|
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}')
|
||||||
|
|
||||||
|
if thru is not None:
|
||||||
|
self.rename_ports({thru: portspec_src})
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mpath(
|
||||||
|
self,
|
||||||
|
portspec: str | Sequence[str],
|
||||||
|
ccw: SupportsBool | None,
|
||||||
|
*,
|
||||||
|
spacing: float | ArrayLike | None = None,
|
||||||
|
set_rotation: float | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
|
||||||
|
of "wires or "waveguides".
|
||||||
|
|
||||||
|
The wires will travel so that the output ports will be placed at well-defined
|
||||||
|
locations along the axis of their input ports, but may have arbitrary (tool-
|
||||||
|
dependent) offsets in the perpendicular direction.
|
||||||
|
|
||||||
|
If `ccw` is not `None`, the wire bundle will turn 90 degres in either the
|
||||||
|
clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the
|
||||||
|
bundle, the center-to-center wire spacings after the turn are set by `spacing`,
|
||||||
|
which is required when `ccw` is not `None`. The final position of bundle as a
|
||||||
|
whole can be set in a number of ways:
|
||||||
|
|
||||||
|
=A>---------------------------V turn direction: `ccw=False`
|
||||||
|
=B>-------------V |
|
||||||
|
=C>-----------------------V |
|
||||||
|
=D=>----------------V |
|
||||||
|
|
|
||||||
|
|
||||||
|
x---x---x---x `spacing` (can be scalar or array)
|
||||||
|
|
||||||
|
<--------------> `emin=`
|
||||||
|
<------> `bound_type='min_past_furthest', bound=`
|
||||||
|
<--------------------------------> `emax=`
|
||||||
|
x `pmin=`
|
||||||
|
x `pmax=`
|
||||||
|
|
||||||
|
- `emin=`, equivalent to `bound_type='min_extension', bound=`
|
||||||
|
The total extension value for the furthest-out port (B in the diagram).
|
||||||
|
- `emax=`, equivalent to `bound_type='max_extension', bound=`:
|
||||||
|
The total extension value for the closest-in port (C in the diagram).
|
||||||
|
- `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`:
|
||||||
|
The coordinate of the innermost bend (D's bend).
|
||||||
|
The x/y versions throw an error if they do not match the port axis (for debug)
|
||||||
|
- `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`:
|
||||||
|
The coordinate of the outermost bend (A's bend).
|
||||||
|
The x/y versions throw an error if they do not match the port axis (for debug)
|
||||||
|
- `bound_type='min_past_furthest', bound=`:
|
||||||
|
The distance between furthest out-port (B) and the innermost bend (D's bend).
|
||||||
|
|
||||||
|
If `ccw=None`, final output positions (along the input axis) of all wires will be
|
||||||
|
identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is
|
||||||
|
required. In this case, `emin=` and `emax=` are equivalent to each other, and
|
||||||
|
`pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other.
|
||||||
|
|
||||||
|
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
portspec: The names of the ports which are to be routed.
|
||||||
|
ccw: If `None`, the outputs should be along the same axis as the inputs.
|
||||||
|
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
|
||||||
|
and clockwise otherwise.
|
||||||
|
spacing: Center-to-center distance between output ports along the input port's axis.
|
||||||
|
Must be provided if (and only if) `ccw` is not `None`.
|
||||||
|
set_rotation: If the provided ports have `rotation=None`, this can be used
|
||||||
|
to set a rotation for them.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BuildError if the implied length for any wire is too close to fit the bend
|
||||||
|
(if a bend is requested).
|
||||||
|
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
|
||||||
|
match the axis of `portspec`.
|
||||||
|
BuildError if an incorrect bound type or spacing is specified.
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping mpath() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
bound_types = set()
|
||||||
|
if 'bound_type' in kwargs:
|
||||||
|
bound_types.add(kwargs.pop('bound_type'))
|
||||||
|
bound = kwargs.pop('bound')
|
||||||
|
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
|
||||||
|
if bt in kwargs:
|
||||||
|
bound_types.add(bt)
|
||||||
|
bound = kwargs.pop(bt)
|
||||||
|
|
||||||
|
if not bound_types:
|
||||||
|
raise BuildError('No bound type specified for mpath')
|
||||||
|
if len(bound_types) > 1:
|
||||||
|
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
|
||||||
|
bound_type = tuple(bound_types)[0]
|
||||||
|
|
||||||
|
if isinstance(portspec, str):
|
||||||
|
portspec = [portspec]
|
||||||
|
ports = self.pattern[tuple(portspec)]
|
||||||
|
|
||||||
|
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
|
||||||
|
|
||||||
|
#if container:
|
||||||
|
# assert not getattr(self, 'render'), 'Containers not implemented for RenderPather'
|
||||||
|
# bld = self.interface(source=ports, library=self.library, tools=self.tools)
|
||||||
|
# for port_name, length in extensions.items():
|
||||||
|
# bld.path(port_name, ccw, length, **kwargs)
|
||||||
|
# self.library[container] = bld.pattern
|
||||||
|
# self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'?
|
||||||
|
#else:
|
||||||
|
for port_name, length in extensions.items():
|
||||||
|
self.path(port_name, ccw, length, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
# TODO def bus_join()?
|
||||||
|
|
||||||
|
def flatten(self) -> Self:
|
||||||
|
"""
|
||||||
|
Flatten the contained pattern, using the contained library to resolve references.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self.pattern.flatten(self.library)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def at(self, portspec: str | Iterable[str]) -> 'PortPather':
|
||||||
|
return PortPather(portspec, self)
|
||||||
|
|
||||||
|
|
||||||
|
class PortPather:
|
||||||
|
"""
|
||||||
|
Port state manager
|
||||||
|
|
||||||
|
This class provides a convenient way to perform multiple pathing operations on a
|
||||||
|
set of ports without needing to repeatedly pass their names.
|
||||||
|
"""
|
||||||
|
ports: list[str]
|
||||||
|
pather: PatherMixin
|
||||||
|
|
||||||
|
def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None:
|
||||||
|
self.ports = [ports] if isinstance(ports, str) else list(ports)
|
||||||
|
self.pather = pather
|
||||||
|
|
||||||
|
#
|
||||||
|
# Delegate to pather
|
||||||
|
#
|
||||||
|
def retool(self, tool: Tool) -> Self:
|
||||||
|
self.pather.retool(tool, keys=self.ports)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def toolctx(self, tool: Tool) -> Iterator[Self]:
|
||||||
|
with self.pather.toolctx(tool, keys=self.ports):
|
||||||
|
yield self
|
||||||
|
|
||||||
|
def path(self, *args, **kwargs) -> Self:
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
logger.warning('Use path_each() when pathing multiple ports independently')
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather.path(port, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def path_each(self, *args, **kwargs) -> Self:
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather.path(port, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def pathS(self, *args, **kwargs) -> Self:
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
logger.warning('Use pathS_each() when pathing multiple ports independently')
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather.pathS(port, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def pathS_each(self, *args, **kwargs) -> Self:
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather.pathS(port, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def path_to(self, *args, **kwargs) -> Self:
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
logger.warning('Use path_each_to() when pathing multiple ports independently')
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather.path_to(port, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def path_each_to(self, *args, **kwargs) -> Self:
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather.path_to(port, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mpath(self, *args, **kwargs) -> Self:
|
||||||
|
self.pather.mpath(self.ports, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def path_into(self, *args, **kwargs) -> Self:
|
||||||
|
""" Path_into, using the current port as the source """
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.')
|
||||||
|
self.pather.path_into(self.ports[0], *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def path_from(self, *args, **kwargs) -> Self:
|
||||||
|
""" Path_into, using the current port as the destination """
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.')
|
||||||
|
thru = kwargs.pop('thru', None)
|
||||||
|
self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs)
|
||||||
|
if thru is not None:
|
||||||
|
self.rename_from(thru)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def plug(
|
||||||
|
self,
|
||||||
|
other: Abstract | str,
|
||||||
|
other_port: str,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> Self:
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.'
|
||||||
|
'Use the pather or pattern directly to plug multiple ports.')
|
||||||
|
self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def plugged(self, other_port: str) -> Self:
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.')
|
||||||
|
self.pather.plugged({self.ports[0]: other_port})
|
||||||
|
return self
|
||||||
|
|
||||||
|
#
|
||||||
|
# Delegate to port
|
||||||
|
#
|
||||||
|
def set_ptype(self, ptype: str) -> Self:
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather[port].set_ptype(ptype)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def translate(self, *args, **kwargs) -> Self:
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather[port].translate(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mirror(self, *args, **kwargs) -> Self:
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather[port].mirror(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def rotate(self, rotation: float) -> Self:
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather[port].rotate(rotation)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_rotation(self, rotation: float | None) -> Self:
|
||||||
|
for port in self.ports:
|
||||||
|
self.pather[port].set_rotation(rotation)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def rename_to(self, new_name: str) -> Self:
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
BuildError('Use rename_ports() for >1 port')
|
||||||
|
self.pather.rename_ports({self.ports[0]: new_name})
|
||||||
|
self.ports[0] = new_name
|
||||||
|
return self
|
||||||
|
|
||||||
|
def rename_from(self, old_name: str) -> Self:
|
||||||
|
if len(self.ports) > 1:
|
||||||
|
BuildError('Use rename_ports() for >1 port')
|
||||||
|
self.pather.rename_ports({old_name: self.ports[0]})
|
||||||
|
return self
|
||||||
|
|
||||||
|
def rename_ports(self, name_map: dict[str, str | None]) -> Self:
|
||||||
|
self.pather.rename_ports(name_map)
|
||||||
|
self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_ports(self, ports: Iterable[str]) -> Self:
|
||||||
|
ports = list(ports)
|
||||||
|
conflicts = set(ports) & set(self.ports)
|
||||||
|
if conflicts:
|
||||||
|
raise BuildError(f'ports {conflicts} already selected')
|
||||||
|
self.ports += ports
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_port(self, port: str, index: int | None = None) -> Self:
|
||||||
|
if port in self.ports:
|
||||||
|
raise BuildError(f'{port=} already selected')
|
||||||
|
if index is not None:
|
||||||
|
self.ports.insert(index, port)
|
||||||
|
else:
|
||||||
|
self.ports.append(port)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def drop_port(self, port: str) -> Self:
|
||||||
|
if port not in self.ports:
|
||||||
|
raise BuildError(f'{port=} already not selected')
|
||||||
|
self.ports = [pp for pp in self.ports if pp != port]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def into_copy(self, new_name: str, src: str | None = None) -> Self:
|
||||||
|
""" Copy a port and replace it with the copy """
|
||||||
|
if not self.ports:
|
||||||
|
raise BuildError('Have no ports to copy')
|
||||||
|
if len(self.ports) == 1:
|
||||||
|
src = self.ports[0]
|
||||||
|
elif src is None:
|
||||||
|
raise BuildError('Must specify src when >1 port is available')
|
||||||
|
if src not in self.ports:
|
||||||
|
raise BuildError(f'{src=} not available')
|
||||||
|
self.pather.ports[new_name] = self.pather[src].copy()
|
||||||
|
self.ports = [(new_name if pp == src else pp) for pp in self.ports]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def save_copy(self, new_name: str, src: str | None = None) -> Self:
|
||||||
|
""" Copy a port and but keep using the original """
|
||||||
|
if not self.ports:
|
||||||
|
raise BuildError('Have no ports to copy')
|
||||||
|
if len(self.ports) == 1:
|
||||||
|
src = self.ports[0]
|
||||||
|
elif src is None:
|
||||||
|
raise BuildError('Must specify src when >1 port is available')
|
||||||
|
if src not in self.ports:
|
||||||
|
raise BuildError(f'{src=} not available')
|
||||||
|
self.pather.ports[new_name] = self.pather[src].copy()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def delete(self, name: None) -> None: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def delete(self, name: str) -> Self: ...
|
||||||
|
|
||||||
|
def delete(self, name: str | None = None) -> Self | None:
|
||||||
|
if name is None:
|
||||||
|
for pp in self.ports:
|
||||||
|
del self.pather.ports[pp]
|
||||||
|
return None
|
||||||
|
del self.pather.ports[name]
|
||||||
|
self.ports = [pp for pp in self.ports if pp != name]
|
||||||
|
return self
|
||||||
|
|
||||||
646
masque/builder/renderpather.py
Normal file
646
masque/builder/renderpather.py
Normal file
|
|
@ -0,0 +1,646 @@
|
||||||
|
"""
|
||||||
|
Pather with batched (multi-step) rendering
|
||||||
|
"""
|
||||||
|
from typing import Self
|
||||||
|
from collections.abc import Sequence, Mapping, MutableMapping, Iterable
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from functools import wraps
|
||||||
|
from pprint import pformat
|
||||||
|
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.typing import ArrayLike
|
||||||
|
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..library import ILibrary, TreeView
|
||||||
|
from ..error import BuildError
|
||||||
|
from ..ports import PortList, Port
|
||||||
|
from ..abstract import Abstract
|
||||||
|
from ..utils import SupportsBool
|
||||||
|
from .tools import Tool, RenderStep
|
||||||
|
from .pather_mixin import PatherMixin
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderPather(PatherMixin):
|
||||||
|
"""
|
||||||
|
`RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath`
|
||||||
|
functions to plan out wire paths without incrementally generating the layout. Instead,
|
||||||
|
it waits until `render` is called, at which point it draws all the planned segments
|
||||||
|
simultaneously. This allows it to e.g. draw each wire using a single `Path` or
|
||||||
|
`Polygon` shape instead of multiple rectangles.
|
||||||
|
|
||||||
|
`RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific
|
||||||
|
dimensions and build the final geometry for each wire. `Tool.planL` provides the
|
||||||
|
output port data (relative to the input) for each segment. The tool, input and output
|
||||||
|
ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for
|
||||||
|
each port. When `render` is called, it bundles `RenderStep`s into batches which use
|
||||||
|
the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build
|
||||||
|
the geometry.
|
||||||
|
|
||||||
|
See `Pather` for routing examples. After routing is complete, `render` must be called
|
||||||
|
to generate the final geometry.
|
||||||
|
"""
|
||||||
|
__slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', )
|
||||||
|
|
||||||
|
pattern: Pattern
|
||||||
|
""" Layout of this device """
|
||||||
|
|
||||||
|
library: ILibrary
|
||||||
|
""" Library from which patterns should be referenced """
|
||||||
|
|
||||||
|
_dead: bool
|
||||||
|
""" If True, plug()/place() are skipped (for debugging) """
|
||||||
|
|
||||||
|
paths: defaultdict[str, list[RenderStep]]
|
||||||
|
""" Per-port list of operations, to be used by `render` """
|
||||||
|
|
||||||
|
tools: dict[str | None, Tool]
|
||||||
|
"""
|
||||||
|
Tool objects are used to dynamically generate new single-use Devices
|
||||||
|
(e.g wires or waveguides) to be plugged into this device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ports(self) -> dict[str, Port]:
|
||||||
|
return self.pattern.ports
|
||||||
|
|
||||||
|
@ports.setter
|
||||||
|
def ports(self, value: dict[str, Port]) -> None:
|
||||||
|
self.pattern.ports = value
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
library: ILibrary,
|
||||||
|
*,
|
||||||
|
pattern: Pattern | None = None,
|
||||||
|
ports: str | Mapping[str, Port] | None = None,
|
||||||
|
tools: Tool | MutableMapping[str | None, Tool] | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
library: The library from which referenced patterns will be taken,
|
||||||
|
and where new patterns (e.g. generated by the `tools`) will be placed.
|
||||||
|
pattern: The pattern which will be modified by subsequent operations.
|
||||||
|
If `None` (default), a new pattern is created.
|
||||||
|
ports: Allows specifying the initial set of ports, if `pattern` does
|
||||||
|
not already have any ports (or is not provided). May be a string,
|
||||||
|
in which case it is interpreted as a name in `library`.
|
||||||
|
Default `None` (no ports).
|
||||||
|
tools: A mapping of {port: tool} which specifies what `Tool` should be used
|
||||||
|
to generate waveguide or wire segments when `path`/`path_to`/`mpath`
|
||||||
|
are called. Relies on `Tool.planL` and `Tool.render` implementations.
|
||||||
|
name: If specified, `library[name]` is set to `self.pattern`.
|
||||||
|
"""
|
||||||
|
self._dead = False
|
||||||
|
self.paths = defaultdict(list)
|
||||||
|
self.library = library
|
||||||
|
if pattern is not None:
|
||||||
|
self.pattern = pattern
|
||||||
|
else:
|
||||||
|
self.pattern = Pattern()
|
||||||
|
|
||||||
|
if ports is not None:
|
||||||
|
if self.pattern.ports:
|
||||||
|
raise BuildError('Ports supplied for pattern with pre-existing ports!')
|
||||||
|
if isinstance(ports, str):
|
||||||
|
ports = library.abstract(ports).ports
|
||||||
|
|
||||||
|
self.pattern.ports.update(copy.deepcopy(dict(ports)))
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
library[name] = self.pattern
|
||||||
|
|
||||||
|
if tools is None:
|
||||||
|
self.tools = {}
|
||||||
|
elif isinstance(tools, Tool):
|
||||||
|
self.tools = {None: tools}
|
||||||
|
else:
|
||||||
|
self.tools = dict(tools)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def interface(
|
||||||
|
cls: type['RenderPather'],
|
||||||
|
source: PortList | Mapping[str, Port] | str,
|
||||||
|
*,
|
||||||
|
library: ILibrary | None = None,
|
||||||
|
tools: Tool | MutableMapping[str | None, Tool] | None = None,
|
||||||
|
in_prefix: str = 'in_',
|
||||||
|
out_prefix: str = '',
|
||||||
|
port_map: dict[str, str] | Sequence[str] | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> 'RenderPather':
|
||||||
|
"""
|
||||||
|
Wrapper for `Pattern.interface()`, which returns a RenderPather instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: A collection of ports (e.g. Pattern, Builder, or dict)
|
||||||
|
from which to create the interface. May be a pattern name if
|
||||||
|
`library` is provided.
|
||||||
|
library: Library from which existing patterns should be referenced,
|
||||||
|
and to which the new one should be added (if named). If not provided,
|
||||||
|
`source.library` must exist and will be used.
|
||||||
|
tools: `Tool`s which will be used by the pather for generating new wires
|
||||||
|
or waveguides (via `path`/`path_to`/`mpath`).
|
||||||
|
in_prefix: Prepended to port names for newly-created ports with
|
||||||
|
reversed directions compared to the current device.
|
||||||
|
out_prefix: Prepended to port names for ports which are directly
|
||||||
|
copied from the current device.
|
||||||
|
port_map: Specification for ports to copy into the new device:
|
||||||
|
- If `None`, all ports are copied.
|
||||||
|
- If a sequence, only the listed ports are copied
|
||||||
|
- If a mapping, the listed ports (keys) are copied and
|
||||||
|
renamed (to the values).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The new `RenderPather`, with an empty pattern and 2x as many ports as
|
||||||
|
listed in port_map.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
`PortError` if `port_map` contains port names not present in the
|
||||||
|
current device.
|
||||||
|
`PortError` if applying the prefixes results in duplicate port
|
||||||
|
names.
|
||||||
|
"""
|
||||||
|
if library is None:
|
||||||
|
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
|
||||||
|
library = source.library
|
||||||
|
else:
|
||||||
|
raise BuildError('No library provided (and not present in `source.library`')
|
||||||
|
|
||||||
|
if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict):
|
||||||
|
tools = source.tools
|
||||||
|
|
||||||
|
if isinstance(source, str):
|
||||||
|
source = library.abstract(source).ports
|
||||||
|
|
||||||
|
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
|
||||||
|
new = RenderPather(library=library, pattern=pat, name=name, tools=tools)
|
||||||
|
return new
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
s = f'<RenderPather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
|
||||||
|
return s
|
||||||
|
|
||||||
|
def plug(
|
||||||
|
self,
|
||||||
|
other: Abstract | str | Pattern | TreeView,
|
||||||
|
map_in: dict[str, str],
|
||||||
|
map_out: dict[str, str | None] | None = None,
|
||||||
|
*,
|
||||||
|
mirrored: bool = False,
|
||||||
|
thru: bool | str = True,
|
||||||
|
set_rotation: bool | None = None,
|
||||||
|
append: bool = False,
|
||||||
|
ok_connections: Iterable[tuple[str, str]] = (),
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P'
|
||||||
|
for any affected ports. This separates any future `RenderStep`s on the
|
||||||
|
same port into a new batch, since the plugged device interferes with drawing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
other: An `Abstract`, string, or `Pattern` describing the device to be instatiated.
|
||||||
|
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
|
||||||
|
port connections between the two devices.
|
||||||
|
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||||
|
new names for ports in `other`.
|
||||||
|
mirrored: Enables mirroring `other` across the x axis prior to
|
||||||
|
connecting any ports.
|
||||||
|
thru: If map_in specifies only a single port, `thru` provides a mechainsm
|
||||||
|
to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
|
||||||
|
- If True (default), and `other` has only two ports total, and map_out
|
||||||
|
doesn't specify a name for the other port, its name is set to the key
|
||||||
|
in `map_in`, i.e. 'myport'.
|
||||||
|
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
|
||||||
|
An error is raised if that entry already exists.
|
||||||
|
|
||||||
|
This makes it easy to extend a pattern with simple 2-port devices
|
||||||
|
(e.g. wires) without providing `map_out` each time `plug` is
|
||||||
|
called. See "Examples" above for more info. Default `True`.
|
||||||
|
set_rotation: If the necessary rotation cannot be determined from
|
||||||
|
the ports being connected (i.e. all pairs have at least one
|
||||||
|
port with `rotation=None`), `set_rotation` must be provided
|
||||||
|
to indicate how much `other` should be rotated. Otherwise,
|
||||||
|
`set_rotation` must remain `None`.
|
||||||
|
append: If `True`, `other` is appended instead of being referenced.
|
||||||
|
Note that this does not flatten `other`, so its refs will still
|
||||||
|
be refs (now inside `self`).
|
||||||
|
ok_connections: Set of "allowed" ptype combinations. Identical
|
||||||
|
ptypes are always allowed to connect, as is `'unk'` with
|
||||||
|
any other ptypte. Non-allowed ptype connections will emit a
|
||||||
|
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||||
|
`(b, a)`.
|
||||||
|
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||||
|
exist in `self.ports` or `other_names`.
|
||||||
|
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||||
|
are applied.
|
||||||
|
`PortError` if the specified port mapping is not achieveable (the ports
|
||||||
|
do not line up)
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping plug() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
other_tgt: Pattern | Abstract
|
||||||
|
if isinstance(other, str):
|
||||||
|
other_tgt = self.library.abstract(other)
|
||||||
|
if append and isinstance(other, Abstract):
|
||||||
|
other_tgt = self.library[other.name]
|
||||||
|
|
||||||
|
# get rid of plugged ports
|
||||||
|
for kk in map_in:
|
||||||
|
if kk in self.paths:
|
||||||
|
self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))
|
||||||
|
|
||||||
|
plugged = map_in.values()
|
||||||
|
for name, port in other_tgt.ports.items():
|
||||||
|
if name in plugged:
|
||||||
|
continue
|
||||||
|
new_name = map_out.get(name, name) if map_out is not None else name
|
||||||
|
if new_name is not None and new_name in self.paths:
|
||||||
|
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
||||||
|
|
||||||
|
self.pattern.plug(
|
||||||
|
other = other_tgt,
|
||||||
|
map_in = map_in,
|
||||||
|
map_out = map_out,
|
||||||
|
mirrored = mirrored,
|
||||||
|
thru = thru,
|
||||||
|
set_rotation = set_rotation,
|
||||||
|
append = append,
|
||||||
|
ok_connections = ok_connections,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def place(
|
||||||
|
self,
|
||||||
|
other: Abstract | str,
|
||||||
|
*,
|
||||||
|
offset: ArrayLike = (0, 0),
|
||||||
|
rotation: float = 0,
|
||||||
|
pivot: ArrayLike = (0, 0),
|
||||||
|
mirrored: bool = False,
|
||||||
|
port_map: dict[str, str | None] | None = None,
|
||||||
|
skip_port_check: bool = False,
|
||||||
|
append: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P'
|
||||||
|
for any affected ports. This separates any future `RenderStep`s on the
|
||||||
|
same port into a new batch, since the placed device interferes with drawing.
|
||||||
|
|
||||||
|
Note that mirroring is applied before rotation; translation (`offset`) is applied last.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
other: An `Abstract` or `Pattern` describing the device to be instatiated.
|
||||||
|
offset: Offset at which to place the instance. Default (0, 0).
|
||||||
|
rotation: Rotation applied to the instance before placement. Default 0.
|
||||||
|
pivot: Rotation is applied around this pivot point (default (0, 0)).
|
||||||
|
Rotation is applied prior to translation (`offset`).
|
||||||
|
mirrored: Whether theinstance should be mirrored across the x axis.
|
||||||
|
Mirroring is applied before translation and rotation.
|
||||||
|
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||||
|
new names for ports in the instantiated pattern. New names can be
|
||||||
|
`None`, which will delete those ports.
|
||||||
|
skip_port_check: Can be used to skip the internal call to `check_ports`,
|
||||||
|
in case it has already been performed elsewhere.
|
||||||
|
append: If `True`, `other` is appended instead of being referenced.
|
||||||
|
Note that this does not flatten `other`, so its refs will still
|
||||||
|
be refs (now inside `self`).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||||
|
exist in `self.ports` or `other.ports`.
|
||||||
|
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||||
|
are applied.
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping place() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
other_tgt: Pattern | Abstract
|
||||||
|
if isinstance(other, str):
|
||||||
|
other_tgt = self.library.abstract(other)
|
||||||
|
if append and isinstance(other, Abstract):
|
||||||
|
other_tgt = self.library[other.name]
|
||||||
|
|
||||||
|
for name, port in other_tgt.ports.items():
|
||||||
|
new_name = port_map.get(name, name) if port_map is not None else name
|
||||||
|
if new_name is not None and new_name in self.paths:
|
||||||
|
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
||||||
|
|
||||||
|
self.pattern.place(
|
||||||
|
other = other_tgt,
|
||||||
|
offset = offset,
|
||||||
|
rotation = rotation,
|
||||||
|
pivot = pivot,
|
||||||
|
mirrored = mirrored,
|
||||||
|
port_map = port_map,
|
||||||
|
skip_port_check = skip_port_check,
|
||||||
|
append = append,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def plugged(
|
||||||
|
self,
|
||||||
|
connections: dict[str, str],
|
||||||
|
) -> Self:
|
||||||
|
for aa, bb in connections.items():
|
||||||
|
porta = self.ports[aa]
|
||||||
|
portb = self.ports[bb]
|
||||||
|
self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None))
|
||||||
|
self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None))
|
||||||
|
PortList.plugged(self, connections)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def path(
|
||||||
|
self,
|
||||||
|
portspec: str,
|
||||||
|
ccw: SupportsBool | None,
|
||||||
|
length: float,
|
||||||
|
*,
|
||||||
|
plug_into: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim
|
||||||
|
of traveling exactly `length` distance.
|
||||||
|
|
||||||
|
The wire will travel `length` distance along the port's axis, an an unspecified
|
||||||
|
(tool-dependent) distance in the perpendicular direction. The output port will
|
||||||
|
be rotated (or not) based on the `ccw` parameter.
|
||||||
|
|
||||||
|
`RenderPather.render` must be called after all paths have been fully planned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
portspec: The name of the port into which the wire will be plugged.
|
||||||
|
ccw: If `None`, the output should be along the same axis as the input.
|
||||||
|
Otherwise, cast to bool and turn counterclockwise if True
|
||||||
|
and clockwise otherwise.
|
||||||
|
length: The total distance from input to output, along the input's axis only.
|
||||||
|
(There may be a tool-dependent offset along the other axis.)
|
||||||
|
plug_into: If not None, attempts to plug the wire's output port into the provided
|
||||||
|
port on `self`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BuildError if `distance` is too small to fit the bend (if a bend is present).
|
||||||
|
LibraryError if no valid name could be picked for the pattern.
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping path() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
port = self.pattern[portspec]
|
||||||
|
in_ptype = port.ptype
|
||||||
|
port_rot = port.rotation
|
||||||
|
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
|
||||||
|
|
||||||
|
tool = self.tools.get(portspec, self.tools[None])
|
||||||
|
# ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
|
||||||
|
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
|
||||||
|
|
||||||
|
# Update port
|
||||||
|
out_port.rotate_around((0, 0), pi + port_rot)
|
||||||
|
out_port.translate(port.offset)
|
||||||
|
|
||||||
|
step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
|
||||||
|
self.paths[portspec].append(step)
|
||||||
|
|
||||||
|
self.pattern.ports[portspec] = out_port.copy()
|
||||||
|
|
||||||
|
if plug_into is not None:
|
||||||
|
self.plugged({portspec: plug_into})
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def pathS(
|
||||||
|
self,
|
||||||
|
portspec: str,
|
||||||
|
length: float,
|
||||||
|
jog: float,
|
||||||
|
*,
|
||||||
|
plug_into: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim
|
||||||
|
of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is
|
||||||
|
left of direction of travel).
|
||||||
|
|
||||||
|
The output port will have the same orientation as the source port (`portspec`).
|
||||||
|
|
||||||
|
`RenderPather.render` must be called after all paths have been fully planned.
|
||||||
|
|
||||||
|
This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former
|
||||||
|
raises a NotImplementedError.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
portspec: The name of the port into which the wire will be plugged.
|
||||||
|
jog: Total manhattan distance perpendicular to the direction of travel.
|
||||||
|
Positive values are to the left of the direction of travel.
|
||||||
|
length: The total manhattan distance from input to output, along the input's axis only.
|
||||||
|
(There may be a tool-dependent offset along the other axis.)
|
||||||
|
plug_into: If not None, attempts to plug the wire's output port into the provided
|
||||||
|
port on `self`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
|
||||||
|
LibraryError if no valid name could be picked for the pattern.
|
||||||
|
"""
|
||||||
|
if self._dead:
|
||||||
|
logger.error('Skipping pathS() since device is dead')
|
||||||
|
return self
|
||||||
|
|
||||||
|
port = self.pattern[portspec]
|
||||||
|
in_ptype = port.ptype
|
||||||
|
port_rot = port.rotation
|
||||||
|
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
|
||||||
|
|
||||||
|
tool = self.tools.get(portspec, self.tools[None])
|
||||||
|
|
||||||
|
# check feasibility, get output port and data
|
||||||
|
try:
|
||||||
|
out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
|
||||||
|
except NotImplementedError:
|
||||||
|
# Fall back to drawing two L-bends
|
||||||
|
ccw0 = jog > 0
|
||||||
|
kwargs_no_out = (kwargs | {'out_ptype': None})
|
||||||
|
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes
|
||||||
|
jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
|
||||||
|
t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
|
||||||
|
jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]
|
||||||
|
|
||||||
|
kwargs_plug = kwargs | {'plug_into': plug_into}
|
||||||
|
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
|
||||||
|
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
|
||||||
|
return self
|
||||||
|
|
||||||
|
out_port.rotate_around((0, 0), pi + port_rot)
|
||||||
|
out_port.translate(port.offset)
|
||||||
|
step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
|
||||||
|
self.paths[portspec].append(step)
|
||||||
|
self.pattern.ports[portspec] = out_port.copy()
|
||||||
|
|
||||||
|
if plug_into is not None:
|
||||||
|
self.plugged({portspec: plug_into})
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
append: bool = True,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Generate the geometry which has been planned out with `path`/`path_to`/etc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
append: If `True`, the rendered geometry will be directly appended to
|
||||||
|
`self.pattern`. Note that it will not be flattened, so if only one
|
||||||
|
layer of hierarchy is eliminated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
lib = self.library
|
||||||
|
tool_port_names = ('A', 'B')
|
||||||
|
pat = Pattern()
|
||||||
|
|
||||||
|
def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None:
|
||||||
|
assert batch[0].tool is not None
|
||||||
|
name = lib << batch[0].tool.render(batch, port_names=tool_port_names)
|
||||||
|
pat.ports[portspec] = batch[0].start_port.copy()
|
||||||
|
if append:
|
||||||
|
pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append)
|
||||||
|
del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened
|
||||||
|
else:
|
||||||
|
pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append)
|
||||||
|
|
||||||
|
for portspec, steps in self.paths.items():
|
||||||
|
batch: list[RenderStep] = []
|
||||||
|
for step in steps:
|
||||||
|
appendable_op = step.opcode in ('L', 'S', 'U')
|
||||||
|
same_tool = batch and step.tool == batch[0].tool
|
||||||
|
|
||||||
|
# If we can't continue a batch, render it
|
||||||
|
if batch and (not appendable_op or not same_tool):
|
||||||
|
render_batch(portspec, batch, append)
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
# batch is emptied already if we couldn't continue it
|
||||||
|
if appendable_op:
|
||||||
|
batch.append(step)
|
||||||
|
|
||||||
|
# Opcodes which break the batch go below this line
|
||||||
|
if not appendable_op and portspec in pat.ports:
|
||||||
|
del pat.ports[portspec]
|
||||||
|
|
||||||
|
#If the last batch didn't end yet
|
||||||
|
if batch:
|
||||||
|
render_batch(portspec, batch, append)
|
||||||
|
|
||||||
|
self.paths.clear()
|
||||||
|
pat.ports.clear()
|
||||||
|
self.pattern.append(pat)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def translate(self, offset: ArrayLike) -> Self:
|
||||||
|
"""
|
||||||
|
Translate the pattern and all ports.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
offset: (x, y) distance to translate by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self.pattern.translate_elements(offset)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
|
||||||
|
"""
|
||||||
|
Rotate the pattern and all ports.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
angle: angle (radians, counterclockwise) to rotate by
|
||||||
|
pivot: location to rotate around
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self.pattern.rotate_around(pivot, angle)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mirror(self, axis: int) -> Self:
|
||||||
|
"""
|
||||||
|
Mirror the pattern and all ports across the specified axis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: Axis to mirror across (x=0, y=1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self.pattern.mirror(axis)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_dead(self) -> Self:
|
||||||
|
"""
|
||||||
|
Disallows further changes through `plug()` or `place()`.
|
||||||
|
This is meant for debugging:
|
||||||
|
```
|
||||||
|
dev.plug(a, ...)
|
||||||
|
dev.set_dead() # added for debug purposes
|
||||||
|
dev.plug(b, ...) # usually raises an error, but now skipped
|
||||||
|
dev.plug(c, ...) # also skipped
|
||||||
|
dev.pattern.visualize() # shows the device as of the set_dead() call
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
self._dead = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
@wraps(Pattern.label)
|
||||||
|
def label(self, *args, **kwargs) -> Self:
|
||||||
|
self.pattern.label(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@wraps(Pattern.ref)
|
||||||
|
def ref(self, *args, **kwargs) -> Self:
|
||||||
|
self.pattern.ref(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@wraps(Pattern.polygon)
|
||||||
|
def polygon(self, *args, **kwargs) -> Self:
|
||||||
|
self.pattern.polygon(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@wraps(Pattern.rect)
|
||||||
|
def rect(self, *args, **kwargs) -> Self:
|
||||||
|
self.pattern.rect(*args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
@ -3,8 +3,8 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir
|
||||||
|
|
||||||
# TODO document all tools
|
# TODO document all tools
|
||||||
"""
|
"""
|
||||||
from typing import Literal, Any, Self, cast
|
from typing import Literal, Any, Self
|
||||||
from collections.abc import Sequence, Callable, Iterator
|
from collections.abc import Sequence, Callable
|
||||||
from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method?
|
from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method?
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
@ -47,72 +47,16 @@ class RenderStep:
|
||||||
if self.opcode != 'P' and self.tool is None:
|
if self.opcode != 'P' and self.tool is None:
|
||||||
raise BuildError('Got tool=None but the opcode is not "P"')
|
raise BuildError('Got tool=None but the opcode is not "P"')
|
||||||
|
|
||||||
def is_continuous_with(self, other: 'RenderStep') -> bool:
|
|
||||||
"""
|
|
||||||
Check if another RenderStep can be appended to this one.
|
|
||||||
"""
|
|
||||||
# Check continuity with tolerance
|
|
||||||
offsets_match = bool(numpy.allclose(other.start_port.offset, self.end_port.offset))
|
|
||||||
rotations_match = (other.start_port.rotation is None and self.end_port.rotation is None) or (
|
|
||||||
other.start_port.rotation is not None and self.end_port.rotation is not None and
|
|
||||||
bool(numpy.isclose(other.start_port.rotation, self.end_port.rotation))
|
|
||||||
)
|
|
||||||
return offsets_match and rotations_match
|
|
||||||
|
|
||||||
def transformed(self, translation: NDArray[numpy.float64], rotation: float, pivot: NDArray[numpy.float64]) -> 'RenderStep':
|
|
||||||
"""
|
|
||||||
Return a new RenderStep with transformed start and end ports.
|
|
||||||
"""
|
|
||||||
new_start = self.start_port.copy()
|
|
||||||
new_end = self.end_port.copy()
|
|
||||||
|
|
||||||
for pp in (new_start, new_end):
|
|
||||||
pp.rotate_around(pivot, rotation)
|
|
||||||
pp.translate(translation)
|
|
||||||
|
|
||||||
return RenderStep(
|
|
||||||
opcode = self.opcode,
|
|
||||||
tool = self.tool,
|
|
||||||
start_port = new_start,
|
|
||||||
end_port = new_end,
|
|
||||||
data = self.data,
|
|
||||||
)
|
|
||||||
|
|
||||||
def mirrored(self, axis: int) -> 'RenderStep':
|
|
||||||
"""
|
|
||||||
Return a new RenderStep with mirrored start and end ports.
|
|
||||||
"""
|
|
||||||
new_start = self.start_port.copy()
|
|
||||||
new_end = self.end_port.copy()
|
|
||||||
|
|
||||||
new_start.mirror(axis)
|
|
||||||
new_end.mirror(axis)
|
|
||||||
|
|
||||||
return RenderStep(
|
|
||||||
opcode = self.opcode,
|
|
||||||
tool = self.tool,
|
|
||||||
start_port = new_start,
|
|
||||||
end_port = new_end,
|
|
||||||
data = self.data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port, Any]:
|
|
||||||
"""
|
|
||||||
Extracts a Port and returns the tree (as data) for tool planning fallbacks.
|
|
||||||
"""
|
|
||||||
pat = tree.top_pattern()
|
|
||||||
in_p = pat[port_names[0]]
|
|
||||||
out_p = pat[port_names[1]]
|
|
||||||
(travel, jog), rot = in_p.measure_travel(out_p)
|
|
||||||
return Port((travel, jog), rotation=rot, ptype=out_p.ptype), tree
|
|
||||||
|
|
||||||
|
|
||||||
class Tool:
|
class Tool:
|
||||||
"""
|
"""
|
||||||
Interface for path (e.g. wire or waveguide) generation.
|
Interface for path (e.g. wire or waveguide) generation.
|
||||||
|
|
||||||
|
Note that subclasses may implement only a subset of the methods and leave others
|
||||||
|
unimplemented (e.g. in cases where they don't make sense or the required components
|
||||||
|
are impractical or unavailable).
|
||||||
"""
|
"""
|
||||||
def traceL(
|
def path(
|
||||||
self,
|
self,
|
||||||
ccw: SupportsBool | None,
|
ccw: SupportsBool | None,
|
||||||
length: float,
|
length: float,
|
||||||
|
|
@ -155,9 +99,9 @@ class Tool:
|
||||||
Raises:
|
Raises:
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
BuildError if an impossible or unsupported geometry is requested.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(f'traceL() not implemented for {type(self)}')
|
raise NotImplementedError(f'path() not implemented for {type(self)}')
|
||||||
|
|
||||||
def traceS(
|
def pathS(
|
||||||
self,
|
self,
|
||||||
length: float,
|
length: float,
|
||||||
jog: float,
|
jog: float,
|
||||||
|
|
@ -197,7 +141,7 @@ class Tool:
|
||||||
Raises:
|
Raises:
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
BuildError if an impossible or unsupported geometry is requested.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(f'traceS() not implemented for {type(self)}')
|
raise NotImplementedError(f'path() not implemented for {type(self)}')
|
||||||
|
|
||||||
def planL(
|
def planL(
|
||||||
self,
|
self,
|
||||||
|
|
@ -239,17 +183,7 @@ class Tool:
|
||||||
Raises:
|
Raises:
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
BuildError if an impossible or unsupported geometry is requested.
|
||||||
"""
|
"""
|
||||||
# Fallback implementation using traceL
|
raise NotImplementedError(f'planL() not implemented for {type(self)}')
|
||||||
port_names = kwargs.get('port_names', ('A', 'B'))
|
|
||||||
tree = self.traceL(
|
|
||||||
ccw,
|
|
||||||
length,
|
|
||||||
in_ptype=in_ptype,
|
|
||||||
out_ptype=out_ptype,
|
|
||||||
port_names=port_names,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
return measure_tool_plan(tree, port_names)
|
|
||||||
|
|
||||||
def planS(
|
def planS(
|
||||||
self,
|
self,
|
||||||
|
|
@ -287,57 +221,7 @@ class Tool:
|
||||||
Raises:
|
Raises:
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
BuildError if an impossible or unsupported geometry is requested.
|
||||||
"""
|
"""
|
||||||
# Fallback implementation using traceS
|
raise NotImplementedError(f'planS() not implemented for {type(self)}')
|
||||||
port_names = kwargs.get('port_names', ('A', 'B'))
|
|
||||||
tree = self.traceS(
|
|
||||||
length,
|
|
||||||
jog,
|
|
||||||
in_ptype=in_ptype,
|
|
||||||
out_ptype=out_ptype,
|
|
||||||
port_names=port_names,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
return measure_tool_plan(tree, port_names)
|
|
||||||
|
|
||||||
def traceU(
|
|
||||||
self,
|
|
||||||
jog: float,
|
|
||||||
*,
|
|
||||||
length: float = 0,
|
|
||||||
in_ptype: str | None = None,
|
|
||||||
out_ptype: str | None = None,
|
|
||||||
port_names: tuple[str, str] = ('A', 'B'),
|
|
||||||
**kwargs,
|
|
||||||
) -> Library:
|
|
||||||
"""
|
|
||||||
Create a wire or waveguide that travels exactly `jog` distance along the axis
|
|
||||||
perpendicular to its input port (i.e. a U-bend).
|
|
||||||
|
|
||||||
Used by `Pather` and `RenderPather`.
|
|
||||||
|
|
||||||
The output port must have an orientation identical to the input port.
|
|
||||||
|
|
||||||
The input and output ports should be compatible with `in_ptype` and
|
|
||||||
`out_ptype`, respectively. They should also be named `port_names[0]` and
|
|
||||||
`port_names[1]`, respectively.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
jog: The total offset from the input to output, along the perpendicular axis.
|
|
||||||
A positive number implies a leftwards shift (i.e. counterclockwise bend
|
|
||||||
followed by a clockwise bend)
|
|
||||||
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
|
|
||||||
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
|
|
||||||
port_names: The output pattern will have its input port named `port_names[0]` and
|
|
||||||
its output named `port_names[1]`.
|
|
||||||
kwargs: Custom tool-specific parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A pattern tree containing the requested U-shaped wire or waveguide
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(f'traceU() not implemented for {type(self)}')
|
|
||||||
|
|
||||||
def planU(
|
def planU(
|
||||||
self,
|
self,
|
||||||
|
|
@ -362,7 +246,7 @@ class Tool:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
jog: The total offset from the input to output, along the perpendicular axis.
|
jog: The total offset from the input to output, along the perpendicular axis.
|
||||||
A positive number implies a leftwards shift (i.e. counterclockwise_bend
|
A positive number implies a leftwards shift (i.e. counterclockwise bend
|
||||||
followed by a clockwise bend)
|
followed by a clockwise bend)
|
||||||
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
|
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
|
||||||
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
|
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
|
||||||
|
|
@ -375,26 +259,14 @@ class Tool:
|
||||||
Raises:
|
Raises:
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
BuildError if an impossible or unsupported geometry is requested.
|
||||||
"""
|
"""
|
||||||
# Fallback implementation using traceU
|
raise NotImplementedError(f'planU() not implemented for {type(self)}')
|
||||||
kwargs = dict(kwargs)
|
|
||||||
length = kwargs.pop('length', 0)
|
|
||||||
port_names = kwargs.pop('port_names', ('A', 'B'))
|
|
||||||
tree = self.traceU(
|
|
||||||
jog,
|
|
||||||
length=length,
|
|
||||||
in_ptype=in_ptype,
|
|
||||||
out_ptype=out_ptype,
|
|
||||||
port_names=port_names,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
return measure_tool_plan(tree, port_names)
|
|
||||||
|
|
||||||
def render(
|
def render(
|
||||||
self,
|
self,
|
||||||
batch: Sequence[RenderStep],
|
batch: Sequence[RenderStep],
|
||||||
*,
|
*,
|
||||||
port_names: tuple[str, str] = ('A', 'B'),
|
port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused)
|
||||||
**kwargs,
|
**kwargs, # noqa: ARG002 (unused)
|
||||||
) -> ILibrary:
|
) -> ILibrary:
|
||||||
"""
|
"""
|
||||||
Render the provided `batch` of `RenderStep`s into geometry, returning a tree
|
Render the provided `batch` of `RenderStep`s into geometry, returning a tree
|
||||||
|
|
@ -408,48 +280,7 @@ class Tool:
|
||||||
kwargs: Custom tool-specific parameters.
|
kwargs: Custom tool-specific parameters.
|
||||||
"""
|
"""
|
||||||
assert not batch or batch[0].tool == self
|
assert not batch or batch[0].tool == self
|
||||||
# Fallback: render each step individually
|
raise NotImplementedError(f'render() not implemented for {type(self)}')
|
||||||
lib, pat = Library.mktree(SINGLE_USE_PREFIX + 'batch')
|
|
||||||
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk')
|
|
||||||
|
|
||||||
for step in batch:
|
|
||||||
if step.opcode == 'L':
|
|
||||||
if isinstance(step.data, ILibrary):
|
|
||||||
seg_tree = step.data
|
|
||||||
else:
|
|
||||||
# extract parameters from kwargs or data
|
|
||||||
seg_tree = self.traceL(
|
|
||||||
ccw=step.data.get('ccw') if isinstance(step.data, dict) else None,
|
|
||||||
length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0,
|
|
||||||
port_names=port_names,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
elif step.opcode == 'S':
|
|
||||||
if isinstance(step.data, ILibrary):
|
|
||||||
seg_tree = step.data
|
|
||||||
else:
|
|
||||||
seg_tree = self.traceS(
|
|
||||||
length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0,
|
|
||||||
jog=float(step.data.get('jog', 0)) if isinstance(step.data, dict) else 0.0,
|
|
||||||
port_names=port_names,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
elif step.opcode == 'U':
|
|
||||||
if isinstance(step.data, ILibrary):
|
|
||||||
seg_tree = step.data
|
|
||||||
else:
|
|
||||||
seg_tree = self.traceU(
|
|
||||||
jog=float(step.data.get('jog', 0)) if isinstance(step.data, dict) else 0.0,
|
|
||||||
length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0,
|
|
||||||
port_names=port_names,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
pat.plug(seg_tree.top_pattern(), {port_names[1]: port_names[0]}, append=True)
|
|
||||||
|
|
||||||
return lib
|
|
||||||
|
|
||||||
|
|
||||||
abstract_tuple_t = tuple[Abstract, str, str]
|
abstract_tuple_t = tuple[Abstract, str, str]
|
||||||
|
|
@ -559,7 +390,7 @@ class SimpleTool(Tool, metaclass=ABCMeta):
|
||||||
pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored)
|
pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored)
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
def traceL(
|
def path(
|
||||||
self,
|
self,
|
||||||
ccw: SupportsBool | None,
|
ccw: SupportsBool | None,
|
||||||
length: float,
|
length: float,
|
||||||
|
|
@ -576,7 +407,7 @@ class SimpleTool(Tool, metaclass=ABCMeta):
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
|
|
||||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
|
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
|
||||||
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
|
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
|
||||||
self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
|
self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
|
||||||
return tree
|
return tree
|
||||||
|
|
@ -589,7 +420,7 @@ class SimpleTool(Tool, metaclass=ABCMeta):
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> ILibrary:
|
) -> ILibrary:
|
||||||
|
|
||||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
|
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
|
||||||
pat.add_port_pair(names=(port_names[0], port_names[1]))
|
pat.add_port_pair(names=(port_names[0], port_names[1]))
|
||||||
|
|
||||||
for step in batch:
|
for step in batch:
|
||||||
|
|
@ -666,19 +497,6 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
def reversed(self) -> Self:
|
def reversed(self) -> Self:
|
||||||
return type(self)(self.abstract, self.our_port_name, self.their_port_name)
|
return type(self)(self.abstract, self.our_port_name, self.their_port_name)
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class LPlan:
|
|
||||||
""" Template for an L-path configuration """
|
|
||||||
straight: 'AutoTool.Straight'
|
|
||||||
bend: 'AutoTool.Bend | None'
|
|
||||||
in_trans: 'AutoTool.Transition | None'
|
|
||||||
b_trans: 'AutoTool.Transition | None'
|
|
||||||
out_trans: 'AutoTool.Transition | None'
|
|
||||||
overhead_x: float
|
|
||||||
overhead_y: float
|
|
||||||
bend_angle: float
|
|
||||||
out_ptype: str
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LData:
|
class LData:
|
||||||
""" Data for planL """
|
""" Data for planL """
|
||||||
|
|
@ -691,65 +509,6 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
b_transition: 'AutoTool.Transition | None'
|
b_transition: 'AutoTool.Transition | None'
|
||||||
out_transition: 'AutoTool.Transition | None'
|
out_transition: 'AutoTool.Transition | None'
|
||||||
|
|
||||||
def _iter_l_plans(
|
|
||||||
self,
|
|
||||||
ccw: SupportsBool | None,
|
|
||||||
in_ptype: str | None,
|
|
||||||
out_ptype: str | None,
|
|
||||||
) -> Iterator[LPlan]:
|
|
||||||
"""
|
|
||||||
Iterate over all possible combinations of straights and bends that
|
|
||||||
could form an L-path.
|
|
||||||
"""
|
|
||||||
bends = cast('list[AutoTool.Bend | None]', self.bends)
|
|
||||||
if ccw is None and not bends:
|
|
||||||
bends = [None]
|
|
||||||
|
|
||||||
for straight in self.straights:
|
|
||||||
for bend in bends:
|
|
||||||
bend_dxy, bend_angle = self._bend2dxy(bend, ccw)
|
|
||||||
|
|
||||||
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
|
|
||||||
in_transition = self.transitions.get(in_ptype_pair, None)
|
|
||||||
itrans_dxy = self._itransition2dxy(in_transition)
|
|
||||||
|
|
||||||
out_ptype_pair = (
|
|
||||||
'unk' if out_ptype is None else out_ptype,
|
|
||||||
straight.ptype if ccw is None else cast('AutoTool.Bend', bend).out_port.ptype
|
|
||||||
)
|
|
||||||
out_transition = self.transitions.get(out_ptype_pair, None)
|
|
||||||
otrans_dxy = self._otransition2dxy(out_transition, bend_angle)
|
|
||||||
|
|
||||||
b_transition = None
|
|
||||||
if ccw is not None:
|
|
||||||
assert bend is not None
|
|
||||||
if bend.in_port.ptype != straight.ptype:
|
|
||||||
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None)
|
|
||||||
btrans_dxy = self._itransition2dxy(b_transition)
|
|
||||||
|
|
||||||
overhead_x = bend_dxy[0] + itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0]
|
|
||||||
overhead_y = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1]
|
|
||||||
|
|
||||||
if out_transition is not None:
|
|
||||||
out_ptype_actual = out_transition.their_port.ptype
|
|
||||||
elif ccw is not None:
|
|
||||||
assert bend is not None
|
|
||||||
out_ptype_actual = bend.out_port.ptype
|
|
||||||
else:
|
|
||||||
out_ptype_actual = straight.ptype
|
|
||||||
|
|
||||||
yield self.LPlan(
|
|
||||||
straight = straight,
|
|
||||||
bend = bend,
|
|
||||||
in_trans = in_transition,
|
|
||||||
b_trans = b_transition,
|
|
||||||
out_trans = out_transition,
|
|
||||||
overhead_x = overhead_x,
|
|
||||||
overhead_y = overhead_y,
|
|
||||||
bend_angle = bend_angle,
|
|
||||||
out_ptype = out_ptype_actual,
|
|
||||||
)
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SData:
|
class SData:
|
||||||
""" Data for planS """
|
""" Data for planS """
|
||||||
|
|
@ -762,80 +521,6 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
b_transition: 'AutoTool.Transition | None'
|
b_transition: 'AutoTool.Transition | None'
|
||||||
out_transition: 'AutoTool.Transition | None'
|
out_transition: 'AutoTool.Transition | None'
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class UData:
|
|
||||||
""" Data for planU or planS (double-L) """
|
|
||||||
ldata0: 'AutoTool.LData'
|
|
||||||
ldata1: 'AutoTool.LData'
|
|
||||||
straight2: 'AutoTool.Straight'
|
|
||||||
l2_length: float
|
|
||||||
mid_transition: 'AutoTool.Transition | None'
|
|
||||||
|
|
||||||
def _solve_double_l(
|
|
||||||
self,
|
|
||||||
length: float,
|
|
||||||
jog: float,
|
|
||||||
ccw1: SupportsBool,
|
|
||||||
ccw2: SupportsBool,
|
|
||||||
in_ptype: str | None,
|
|
||||||
out_ptype: str | None,
|
|
||||||
**kwargs,
|
|
||||||
) -> tuple[Port, UData]:
|
|
||||||
"""
|
|
||||||
Solve for a path consisting of two L-bends connected by a straight segment.
|
|
||||||
Used for both U-turns (ccw1 == ccw2) and S-bends (ccw1 != ccw2).
|
|
||||||
"""
|
|
||||||
for plan1 in self._iter_l_plans(ccw1, in_ptype, None):
|
|
||||||
for plan2 in self._iter_l_plans(ccw2, plan1.out_ptype, out_ptype):
|
|
||||||
# Solving for:
|
|
||||||
# X = L1_total +/- R2_actual = length
|
|
||||||
# Y = R1_actual + L2_straight + overhead_mid + overhead_b2 + L3_total = jog
|
|
||||||
|
|
||||||
# Sign for overhead_y2 depends on whether it's a U-turn or S-bend
|
|
||||||
is_u = bool(ccw1) == bool(ccw2)
|
|
||||||
# U-turn: X = L1_total - R2 = length => L1_total = length + R2
|
|
||||||
# S-bend: X = L1_total + R2 = length => L1_total = length - R2
|
|
||||||
l1_total = length + (abs(plan2.overhead_y) if is_u else -abs(plan2.overhead_y))
|
|
||||||
l1_straight = l1_total - plan1.overhead_x
|
|
||||||
|
|
||||||
|
|
||||||
if plan1.straight.length_range[0] <= l1_straight < plan1.straight.length_range[1]:
|
|
||||||
for straight_mid in self.straights:
|
|
||||||
# overhead_mid accounts for the transition from bend1 to straight_mid
|
|
||||||
mid_ptype_pair = (plan1.out_ptype, straight_mid.ptype)
|
|
||||||
mid_trans = self.transitions.get(mid_ptype_pair, None)
|
|
||||||
mid_trans_dxy = self._itransition2dxy(mid_trans)
|
|
||||||
|
|
||||||
# b_trans2 accounts for the transition from straight_mid to bend2
|
|
||||||
b2_trans = None
|
|
||||||
if plan2.bend is not None and plan2.bend.in_port.ptype != straight_mid.ptype:
|
|
||||||
b2_trans = self.transitions.get((plan2.bend.in_port.ptype, straight_mid.ptype), None)
|
|
||||||
b2_trans_dxy = self._itransition2dxy(b2_trans)
|
|
||||||
|
|
||||||
l2_straight = abs(jog) - abs(plan1.overhead_y) - plan2.overhead_x - mid_trans_dxy[0] - b2_trans_dxy[0]
|
|
||||||
|
|
||||||
if straight_mid.length_range[0] <= l2_straight < straight_mid.length_range[1]:
|
|
||||||
# Found a solution!
|
|
||||||
# For plan2, we assume l3_straight = 0.
|
|
||||||
# We need to verify if l3=0 is valid for plan2.straight.
|
|
||||||
l3_straight = 0
|
|
||||||
if plan2.straight.length_range[0] <= l3_straight < plan2.straight.length_range[1]:
|
|
||||||
ldata0 = self.LData(
|
|
||||||
l1_straight, plan1.straight, kwargs, ccw1, plan1.bend,
|
|
||||||
plan1.in_trans, plan1.b_trans, plan1.out_trans,
|
|
||||||
)
|
|
||||||
ldata1 = self.LData(
|
|
||||||
l3_straight, plan2.straight, kwargs, ccw2, plan2.bend,
|
|
||||||
b2_trans, None, plan2.out_trans,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = self.UData(ldata0, ldata1, straight_mid, l2_straight, mid_trans)
|
|
||||||
# out_port is at (length, jog) rot pi (for S-bend) or 0 (for U-turn) relative to input
|
|
||||||
out_rot = 0 if is_u else pi
|
|
||||||
out_port = Port((length, jog), rotation=out_rot, ptype=plan2.out_ptype)
|
|
||||||
return out_port, data
|
|
||||||
raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}")
|
|
||||||
|
|
||||||
straights: list[Straight]
|
straights: list[Straight]
|
||||||
""" List of straight-generators to choose from, in order of priority """
|
""" List of straight-generators to choose from, in order of priority """
|
||||||
|
|
||||||
|
|
@ -858,10 +543,9 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _bend2dxy(bend: Bend | None, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
|
def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
|
||||||
if ccw is None:
|
if ccw is None:
|
||||||
return numpy.zeros(2), pi
|
return numpy.zeros(2), pi
|
||||||
assert bend is not None
|
|
||||||
bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port)
|
bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port)
|
||||||
assert bend_angle is not None
|
assert bend_angle is not None
|
||||||
if bool(ccw):
|
if bool(ccw):
|
||||||
|
|
@ -905,23 +589,54 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> tuple[Port, LData]:
|
) -> tuple[Port, LData]:
|
||||||
|
|
||||||
for plan in self._iter_l_plans(ccw, in_ptype, out_ptype):
|
success = False
|
||||||
straight_length = length - plan.overhead_x
|
for straight in self.straights:
|
||||||
if plan.straight.length_range[0] <= straight_length < plan.straight.length_range[1]:
|
for bend in self.bends:
|
||||||
data = self.LData(
|
bend_dxy, bend_angle = self._bend2dxy(bend, ccw)
|
||||||
straight_length = straight_length,
|
|
||||||
straight = plan.straight,
|
|
||||||
straight_kwargs = kwargs,
|
|
||||||
ccw = ccw,
|
|
||||||
bend = plan.bend,
|
|
||||||
in_transition = plan.in_trans,
|
|
||||||
b_transition = plan.b_trans,
|
|
||||||
out_transition = plan.out_trans,
|
|
||||||
)
|
|
||||||
out_port = Port((length, plan.overhead_y), rotation=plan.bend_angle, ptype=plan.out_ptype)
|
|
||||||
return out_port, data
|
|
||||||
|
|
||||||
raise BuildError(f'Failed to find a valid L-path configuration for {length=:,g}, {ccw=}, {in_ptype=}, {out_ptype=}')
|
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
|
||||||
|
in_transition = self.transitions.get(in_ptype_pair, None)
|
||||||
|
itrans_dxy = self._itransition2dxy(in_transition)
|
||||||
|
|
||||||
|
out_ptype_pair = (
|
||||||
|
'unk' if out_ptype is None else out_ptype,
|
||||||
|
straight.ptype if ccw is None else bend.out_port.ptype
|
||||||
|
)
|
||||||
|
out_transition = self.transitions.get(out_ptype_pair, None)
|
||||||
|
otrans_dxy = self._otransition2dxy(out_transition, bend_angle)
|
||||||
|
|
||||||
|
b_transition = None
|
||||||
|
if ccw is not None and bend.in_port.ptype != straight.ptype:
|
||||||
|
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None)
|
||||||
|
btrans_dxy = self._itransition2dxy(b_transition)
|
||||||
|
|
||||||
|
straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0]
|
||||||
|
bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1]
|
||||||
|
success = straight.length_range[0] <= straight_length < straight.length_range[1]
|
||||||
|
if success:
|
||||||
|
break
|
||||||
|
if success:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Failed to break
|
||||||
|
raise BuildError(
|
||||||
|
f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n'
|
||||||
|
f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n'
|
||||||
|
f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if out_transition is not None:
|
||||||
|
out_ptype_actual = out_transition.their_port.ptype
|
||||||
|
elif ccw is not None:
|
||||||
|
out_ptype_actual = bend.out_port.ptype
|
||||||
|
elif not numpy.isclose(straight_length, 0):
|
||||||
|
out_ptype_actual = straight.ptype
|
||||||
|
else:
|
||||||
|
out_ptype_actual = self.default_out_ptype
|
||||||
|
|
||||||
|
data = self.LData(straight_length, straight, kwargs, ccw, bend, in_transition, b_transition, out_transition)
|
||||||
|
out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual)
|
||||||
|
return out_port, data
|
||||||
|
|
||||||
def _renderL(
|
def _renderL(
|
||||||
self,
|
self,
|
||||||
|
|
@ -958,7 +673,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
|
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
def traceL(
|
def path(
|
||||||
self,
|
self,
|
||||||
ccw: SupportsBool | None,
|
ccw: SupportsBool | None,
|
||||||
length: float,
|
length: float,
|
||||||
|
|
@ -975,7 +690,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
|
|
||||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
|
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
|
||||||
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
|
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
|
||||||
self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
|
self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
|
||||||
return tree
|
return tree
|
||||||
|
|
@ -1031,7 +746,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1]
|
jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1]
|
||||||
if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]:
|
if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]:
|
||||||
sbend_dxy = self._sbend2dxy(sbend, jog_remaining)
|
sbend_dxy = self._sbend2dxy(sbend, jog_remaining)
|
||||||
success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[0] + otrans_dxy[0])
|
success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1])
|
||||||
if success:
|
if success:
|
||||||
b_transition = None
|
b_transition = None
|
||||||
straight_length = 0
|
straight_length = 0
|
||||||
|
|
@ -1040,8 +755,26 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
break
|
break
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
ccw0 = jog > 0
|
try:
|
||||||
return self._solve_double_l(length, jog, ccw0, not ccw0, in_ptype, out_ptype, **kwargs)
|
ccw0 = jog > 0
|
||||||
|
p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype)
|
||||||
|
p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype)
|
||||||
|
|
||||||
|
dx = p_test1.x - length / 2
|
||||||
|
p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype)
|
||||||
|
p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype)
|
||||||
|
success = True
|
||||||
|
except BuildError as err:
|
||||||
|
l2_err: BuildError | None = err
|
||||||
|
else:
|
||||||
|
l2_err = None
|
||||||
|
raise NotImplementedError('TODO need to handle ldata below')
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
# Failed to break
|
||||||
|
raise BuildError(
|
||||||
|
f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}'
|
||||||
|
) from l2_err
|
||||||
|
|
||||||
if out_transition is not None:
|
if out_transition is not None:
|
||||||
out_ptype_actual = out_transition.their_port.ptype
|
out_ptype_actual = out_transition.their_port.ptype
|
||||||
|
|
@ -1096,7 +829,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
|
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
def traceS(
|
def pathS(
|
||||||
self,
|
self,
|
||||||
length: float,
|
length: float,
|
||||||
jog: float,
|
jog: float,
|
||||||
|
|
@ -1112,74 +845,9 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
in_ptype = in_ptype,
|
in_ptype = in_ptype,
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS')
|
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS')
|
||||||
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
|
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
|
||||||
if isinstance(data, self.UData):
|
self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
|
||||||
self._renderU(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
|
|
||||||
else:
|
|
||||||
self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
|
|
||||||
return tree
|
|
||||||
|
|
||||||
def planU(
|
|
||||||
self,
|
|
||||||
jog: float,
|
|
||||||
*,
|
|
||||||
length: float = 0,
|
|
||||||
in_ptype: str | None = None,
|
|
||||||
out_ptype: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> tuple[Port, UData]:
|
|
||||||
ccw = jog > 0
|
|
||||||
return self._solve_double_l(length, jog, ccw, ccw, in_ptype, out_ptype, **kwargs)
|
|
||||||
|
|
||||||
def _renderU(
|
|
||||||
self,
|
|
||||||
data: UData,
|
|
||||||
tree: ILibrary,
|
|
||||||
port_names: tuple[str, str],
|
|
||||||
gen_kwargs: dict[str, Any],
|
|
||||||
) -> ILibrary:
|
|
||||||
pat = tree.top_pattern()
|
|
||||||
# 1. First L-bend
|
|
||||||
self._renderL(data.ldata0, tree, port_names, gen_kwargs)
|
|
||||||
# 2. Connecting straight
|
|
||||||
if data.mid_transition:
|
|
||||||
pat.plug(data.mid_transition.abstract, {port_names[1]: data.mid_transition.their_port_name})
|
|
||||||
if not numpy.isclose(data.l2_length, 0):
|
|
||||||
s2_pat_or_tree = data.straight2.fn(data.l2_length, **(gen_kwargs | data.ldata0.straight_kwargs))
|
|
||||||
pmap = {port_names[1]: data.straight2.in_port_name}
|
|
||||||
if isinstance(s2_pat_or_tree, Pattern):
|
|
||||||
pat.plug(s2_pat_or_tree, pmap, append=True)
|
|
||||||
else:
|
|
||||||
s2_tree = s2_pat_or_tree
|
|
||||||
top = s2_tree.top()
|
|
||||||
s2_tree.flatten(top, dangling_ok=True)
|
|
||||||
pat.plug(s2_tree[top], pmap, append=True)
|
|
||||||
# 3. Second L-bend
|
|
||||||
self._renderL(data.ldata1, tree, port_names, gen_kwargs)
|
|
||||||
return tree
|
|
||||||
|
|
||||||
def traceU(
|
|
||||||
self,
|
|
||||||
jog: float,
|
|
||||||
*,
|
|
||||||
length: float = 0,
|
|
||||||
in_ptype: str | None = None,
|
|
||||||
out_ptype: str | None = None,
|
|
||||||
port_names: tuple[str, str] = ('A', 'B'),
|
|
||||||
**kwargs,
|
|
||||||
) -> Library:
|
|
||||||
_out_port, data = self.planU(
|
|
||||||
jog,
|
|
||||||
length = length,
|
|
||||||
in_ptype = in_ptype,
|
|
||||||
out_ptype = out_ptype,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceU')
|
|
||||||
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
|
|
||||||
self._renderU(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
|
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
def render(
|
def render(
|
||||||
|
|
@ -1190,7 +858,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> ILibrary:
|
) -> ILibrary:
|
||||||
|
|
||||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
|
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
|
||||||
pat.add_port_pair(names=(port_names[0], port_names[1]))
|
pat.add_port_pair(names=(port_names[0], port_names[1]))
|
||||||
|
|
||||||
for step in batch:
|
for step in batch:
|
||||||
|
|
@ -1198,12 +866,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
if step.opcode == 'L':
|
if step.opcode == 'L':
|
||||||
self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
|
self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
|
||||||
elif step.opcode == 'S':
|
elif step.opcode == 'S':
|
||||||
if isinstance(step.data, self.UData):
|
self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
|
||||||
self._renderU(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
|
|
||||||
else:
|
|
||||||
self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
|
|
||||||
elif step.opcode == 'U':
|
|
||||||
self._renderU(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
|
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1234,7 +897,7 @@ class PathTool(Tool, metaclass=ABCMeta):
|
||||||
# self.width = width
|
# self.width = width
|
||||||
# self.ptype: str
|
# self.ptype: str
|
||||||
|
|
||||||
def traceL(
|
def path(
|
||||||
self,
|
self,
|
||||||
ccw: SupportsBool | None,
|
ccw: SupportsBool | None,
|
||||||
length: float,
|
length: float,
|
||||||
|
|
@ -1244,20 +907,15 @@ class PathTool(Tool, metaclass=ABCMeta):
|
||||||
port_names: tuple[str, str] = ('A', 'B'),
|
port_names: tuple[str, str] = ('A', 'B'),
|
||||||
**kwargs, # noqa: ARG002 (unused)
|
**kwargs, # noqa: ARG002 (unused)
|
||||||
) -> Library:
|
) -> Library:
|
||||||
out_port, _data = self.planL(
|
out_port, dxy = self.planL(
|
||||||
ccw,
|
ccw,
|
||||||
length,
|
length,
|
||||||
in_ptype=in_ptype,
|
in_ptype=in_ptype,
|
||||||
out_ptype=out_ptype,
|
out_ptype=out_ptype,
|
||||||
)
|
)
|
||||||
|
|
||||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
|
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
|
||||||
vertices: list[tuple[float, float]]
|
pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)])
|
||||||
if ccw is None:
|
|
||||||
vertices = [(0.0, 0.0), (length, 0.0)]
|
|
||||||
else:
|
|
||||||
vertices = [(0.0, 0.0), (length, 0.0), tuple(out_port.offset)]
|
|
||||||
pat.path(layer=self.layer, width=self.width, vertices=vertices)
|
|
||||||
|
|
||||||
if ccw is None:
|
if ccw is None:
|
||||||
out_rot = pi
|
out_rot = pi
|
||||||
|
|
@ -1268,7 +926,7 @@ class PathTool(Tool, metaclass=ABCMeta):
|
||||||
|
|
||||||
pat.ports = {
|
pat.ports = {
|
||||||
port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype),
|
port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype),
|
||||||
port_names[1]: Port(out_port.offset, rotation=out_rot, ptype=self.ptype),
|
port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype),
|
||||||
}
|
}
|
||||||
|
|
||||||
return tree
|
return tree
|
||||||
|
|
@ -1317,44 +975,29 @@ class PathTool(Tool, metaclass=ABCMeta):
|
||||||
**kwargs, # noqa: ARG002 (unused)
|
**kwargs, # noqa: ARG002 (unused)
|
||||||
) -> ILibrary:
|
) -> ILibrary:
|
||||||
|
|
||||||
# Transform the batch so the first port is local (at 0,0) but retains its global rotation.
|
path_vertices = [batch[0].start_port.offset]
|
||||||
# This allows the path to be rendered with its original orientation, simplified by
|
for step in batch:
|
||||||
# translation to the origin. RenderPather.render will handle the final placement
|
|
||||||
# (including rotation alignment) via `pat.plug`.
|
|
||||||
first_port = batch[0].start_port
|
|
||||||
translation = -first_port.offset
|
|
||||||
rotation = 0
|
|
||||||
pivot = first_port.offset
|
|
||||||
|
|
||||||
# Localize the batch for rendering
|
|
||||||
local_batch = [step.transformed(translation, rotation, pivot) for step in batch]
|
|
||||||
|
|
||||||
path_vertices = [local_batch[0].start_port.offset]
|
|
||||||
for step in local_batch:
|
|
||||||
assert step.tool == self
|
assert step.tool == self
|
||||||
|
|
||||||
port_rot = step.start_port.rotation
|
port_rot = step.start_port.rotation
|
||||||
# Masque convention: Port rotation points INTO the device.
|
|
||||||
# So the direction of travel for the path is AWAY from the port, i.e., port_rot + pi.
|
|
||||||
assert port_rot is not None
|
assert port_rot is not None
|
||||||
|
|
||||||
if step.opcode == 'L':
|
if step.opcode == 'L':
|
||||||
|
length, bend_run = step.data
|
||||||
length, _ = step.data
|
|
||||||
dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0)
|
dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0)
|
||||||
|
#path_vertices.append(step.start_port.offset)
|
||||||
path_vertices.append(step.start_port.offset + dxy)
|
path_vertices.append(step.start_port.offset + dxy)
|
||||||
else:
|
else:
|
||||||
raise BuildError(f'Unrecognized opcode "{step.opcode}"')
|
raise BuildError(f'Unrecognized opcode "{step.opcode}"')
|
||||||
|
|
||||||
# Check if the last vertex added is already at the end port location
|
if (path_vertices[-1] != batch[-1].end_port.offset).any():
|
||||||
if not numpy.allclose(path_vertices[-1], local_batch[-1].end_port.offset):
|
|
||||||
# If the path ends in a bend, we need to add the final vertex
|
# If the path ends in a bend, we need to add the final vertex
|
||||||
path_vertices.append(local_batch[-1].end_port.offset)
|
path_vertices.append(batch[-1].end_port.offset)
|
||||||
|
|
||||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
|
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
|
||||||
pat.path(layer=self.layer, width=self.width, vertices=path_vertices)
|
pat.path(layer=self.layer, width=self.width, vertices=path_vertices)
|
||||||
pat.ports = {
|
pat.ports = {
|
||||||
port_names[0]: local_batch[0].start_port.copy().rotate(pi),
|
port_names[0]: batch[0].start_port.copy().rotate(pi),
|
||||||
port_names[1]: local_batch[-1].end_port.copy().rotate(pi),
|
port_names[1]: batch[-1].end_port.copy().rotate(pi),
|
||||||
}
|
}
|
||||||
return tree
|
return tree
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ def ell(
|
||||||
raise BuildError('Asked to find aggregation for ports that face in different directions:\n'
|
raise BuildError('Asked to find aggregation for ports that face in different directions:\n'
|
||||||
+ pformat(port_rotations))
|
+ pformat(port_rotations))
|
||||||
else:
|
else:
|
||||||
if set_rotation is None:
|
if set_rotation is not None:
|
||||||
raise BuildError('set_rotation must be specified if no ports have rotations!')
|
raise BuildError('set_rotation must be specified if no ports have rotations!')
|
||||||
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
|
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import gzip
|
||||||
import numpy
|
import numpy
|
||||||
import ezdxf
|
import ezdxf
|
||||||
from ezdxf.enums import TextEntityAlignment
|
from ezdxf.enums import TextEntityAlignment
|
||||||
from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace
|
from ezdxf.entities import LWPolyline, Polyline, Text, Insert
|
||||||
|
|
||||||
from .utils import is_gzipped, tmpfile
|
from .utils import is_gzipped, tmpfile
|
||||||
from .. import Pattern, Ref, PatternError, Label
|
from .. import Pattern, Ref, PatternError, Label
|
||||||
|
|
@ -55,7 +55,8 @@ def write(
|
||||||
tuple: (1, 2) -> '1.2'
|
tuple: (1, 2) -> '1.2'
|
||||||
str: '1.2' -> '1.2' (no change)
|
str: '1.2' -> '1.2' (no change)
|
||||||
|
|
||||||
Shape repetitions are expanded into individual DXF entities.
|
DXF does not support shape repetition (only block repeptition). Please call
|
||||||
|
library.wrap_repeated_shapes() before writing to file.
|
||||||
|
|
||||||
Other functions you may want to call:
|
Other functions you may want to call:
|
||||||
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
|
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
|
||||||
|
|
@ -212,60 +213,32 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
|
||||||
if isinstance(element, LWPolyline | Polyline):
|
if isinstance(element, LWPolyline | Polyline):
|
||||||
if isinstance(element, LWPolyline):
|
if isinstance(element, LWPolyline):
|
||||||
points = numpy.asarray(element.get_points())
|
points = numpy.asarray(element.get_points())
|
||||||
is_closed = element.closed
|
elif isinstance(element, Polyline):
|
||||||
else:
|
|
||||||
points = numpy.asarray([pp.xyz for pp in element.points()])
|
points = numpy.asarray([pp.xyz for pp in element.points()])
|
||||||
is_closed = element.is_closed
|
|
||||||
attr = element.dxfattribs()
|
attr = element.dxfattribs()
|
||||||
layer = attr.get('layer', DEFAULT_LAYER)
|
layer = attr.get('layer', DEFAULT_LAYER)
|
||||||
|
|
||||||
width = 0
|
if points.shape[1] == 2:
|
||||||
if isinstance(element, LWPolyline):
|
raise PatternError('Invalid or unimplemented polygon?')
|
||||||
# ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge)
|
|
||||||
if points.shape[1] >= 5:
|
|
||||||
if (points[:, 4] != 0).any():
|
|
||||||
raise PatternError('LWPolyline has bulge (not yet representable in masque!)')
|
|
||||||
if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any():
|
|
||||||
raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)')
|
|
||||||
width = points[0, 2]
|
|
||||||
elif points.shape[1] == 3:
|
|
||||||
# width used to be in column 2
|
|
||||||
width = points[0, 2]
|
|
||||||
|
|
||||||
if width == 0:
|
if points.shape[1] > 2:
|
||||||
width = attr.get('const_width', 0)
|
if (points[0, 2] != points[:, 2]).any():
|
||||||
|
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
|
||||||
|
if points.shape[1] == 4 and (points[:, 3] != 0).any():
|
||||||
|
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
|
||||||
|
|
||||||
verts = points[:, :2]
|
width = points[0, 2]
|
||||||
if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])):
|
if width == 0:
|
||||||
verts = numpy.vstack((verts, verts[0]))
|
width = attr.get('const_width', 0)
|
||||||
|
|
||||||
shape: Path | Polygon
|
shape: Path | Polygon
|
||||||
if width == 0 and is_closed:
|
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
|
||||||
# Use Polygon if it has at least 3 unique vertices
|
shape = Polygon(vertices=points[:-1, :2])
|
||||||
shape_verts = verts[:-1] if len(verts) > 1 else verts
|
|
||||||
if len(shape_verts) >= 3:
|
|
||||||
shape = Polygon(vertices=shape_verts)
|
|
||||||
else:
|
else:
|
||||||
shape = Path(width=width, vertices=verts)
|
shape = Path(width=width, vertices=points[:, :2])
|
||||||
else:
|
|
||||||
shape = Path(width=width, vertices=verts)
|
|
||||||
|
|
||||||
pat.shapes[layer].append(shape)
|
pat.shapes[layer].append(shape)
|
||||||
elif isinstance(element, Solid | Trace):
|
|
||||||
attr = element.dxfattribs()
|
|
||||||
layer = attr.get('layer', DEFAULT_LAYER)
|
|
||||||
points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4)
|
|
||||||
if element.has_dxf_attrib(f'vtx{i}')])
|
|
||||||
if len(points) >= 3:
|
|
||||||
# If vtx2 == vtx3, it's a triangle. ezdxf handles this.
|
|
||||||
if len(points) == 4 and numpy.allclose(points[2], points[3]):
|
|
||||||
verts = points[:3, :2]
|
|
||||||
# DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals!
|
|
||||||
elif len(points) == 4:
|
|
||||||
verts = points[[0, 1, 3, 2], :2]
|
|
||||||
else:
|
|
||||||
verts = points[:, :2]
|
|
||||||
pat.shapes[layer].append(Polygon(vertices=verts))
|
|
||||||
elif isinstance(element, Text):
|
elif isinstance(element, Text):
|
||||||
args = dict(
|
args = dict(
|
||||||
offset=numpy.asarray(element.get_placement()[1])[:2],
|
offset=numpy.asarray(element.get_placement()[1])[:2],
|
||||||
|
|
@ -330,23 +303,15 @@ def _mrefs_to_drefs(
|
||||||
elif isinstance(rep, Grid):
|
elif isinstance(rep, Grid):
|
||||||
a = rep.a_vector
|
a = rep.a_vector
|
||||||
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
||||||
# In masque, the grid basis vectors are NOT rotated by the reference's rotation.
|
rotated_a = rotation_matrix_2d(-ref.rotation) @ a
|
||||||
# In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing],
|
rotated_b = rotation_matrix_2d(-ref.rotation) @ b
|
||||||
# which ARE then rotated by the block reference's rotation.
|
if rotated_a[1] == 0 and rotated_b[0] == 0:
|
||||||
# Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90)
|
|
||||||
# AND the grid is already manhattan.
|
|
||||||
|
|
||||||
# Rotate basis vectors by the reference rotation to see where they end up in the DXF frame
|
|
||||||
rotated_a = rotation_matrix_2d(ref.rotation) @ a
|
|
||||||
rotated_b = rotation_matrix_2d(ref.rotation) @ b
|
|
||||||
|
|
||||||
if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8):
|
|
||||||
attribs['column_count'] = rep.a_count
|
attribs['column_count'] = rep.a_count
|
||||||
attribs['row_count'] = rep.b_count
|
attribs['row_count'] = rep.b_count
|
||||||
attribs['column_spacing'] = rotated_a[0]
|
attribs['column_spacing'] = rotated_a[0]
|
||||||
attribs['row_spacing'] = rotated_b[1]
|
attribs['row_spacing'] = rotated_b[1]
|
||||||
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
||||||
elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8):
|
elif rotated_a[0] == 0 and rotated_b[1] == 0:
|
||||||
attribs['column_count'] = rep.b_count
|
attribs['column_count'] = rep.b_count
|
||||||
attribs['row_count'] = rep.a_count
|
attribs['row_count'] = rep.a_count
|
||||||
attribs['column_spacing'] = rotated_b[0]
|
attribs['column_spacing'] = rotated_b[0]
|
||||||
|
|
@ -379,23 +344,16 @@ def _shapes_to_elements(
|
||||||
for layer, sseq in shapes.items():
|
for layer, sseq in shapes.items():
|
||||||
attribs = dict(layer=_mlayer2dxf(layer))
|
attribs = dict(layer=_mlayer2dxf(layer))
|
||||||
for shape in sseq:
|
for shape in sseq:
|
||||||
displacements = [numpy.zeros(2)]
|
|
||||||
if shape.repetition is not None:
|
if shape.repetition is not None:
|
||||||
displacements = shape.repetition.displacements
|
raise PatternError(
|
||||||
|
'Shape repetitions are not supported by DXF.'
|
||||||
|
' Please call library.wrap_repeated_shapes() before writing to file.'
|
||||||
|
)
|
||||||
|
|
||||||
for dd in displacements:
|
for polygon in shape.to_polygons():
|
||||||
if isinstance(shape, Path):
|
xy_open = polygon.vertices
|
||||||
# preserve path.
|
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
|
||||||
# Note: DXF paths don't support endcaps well, so this is still a bit limited.
|
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
|
||||||
xy = shape.vertices + dd
|
|
||||||
attribs_path = {**attribs}
|
|
||||||
if shape.width > 0:
|
|
||||||
attribs_path['const_width'] = shape.width
|
|
||||||
block.add_lwpolyline(xy, dxfattribs=attribs_path)
|
|
||||||
else:
|
|
||||||
for polygon in shape.to_polygons():
|
|
||||||
xy_open = polygon.vertices + dd
|
|
||||||
block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs)
|
|
||||||
|
|
||||||
|
|
||||||
def _labels_to_texts(
|
def _labels_to_texts(
|
||||||
|
|
@ -405,17 +363,11 @@ def _labels_to_texts(
|
||||||
for layer, lseq in labels.items():
|
for layer, lseq in labels.items():
|
||||||
attribs = dict(layer=_mlayer2dxf(layer))
|
attribs = dict(layer=_mlayer2dxf(layer))
|
||||||
for label in lseq:
|
for label in lseq:
|
||||||
if label.repetition is None:
|
xy = label.offset
|
||||||
block.add_text(
|
block.add_text(
|
||||||
label.string,
|
label.string,
|
||||||
dxfattribs=attribs
|
dxfattribs=attribs
|
||||||
).set_placement(label.offset, align=TextEntityAlignment.BOTTOM_LEFT)
|
).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)
|
||||||
else:
|
|
||||||
for dd in label.repetition.displacements:
|
|
||||||
block.add_text(
|
|
||||||
label.string,
|
|
||||||
dxfattribs=attribs
|
|
||||||
).set_placement(label.offset + dd, align=TextEntityAlignment.BOTTOM_LEFT)
|
|
||||||
|
|
||||||
|
|
||||||
def _mlayer2dxf(layer: layer_t) -> str:
|
def _mlayer2dxf(layer: layer_t) -> str:
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ def write(
|
||||||
datatype is chosen to be `shape.layer[1]` if available,
|
datatype is chosen to be `shape.layer[1]` if available,
|
||||||
otherwise `0`
|
otherwise `0`
|
||||||
|
|
||||||
GDS does not support shape repetition (only cell repetition). Please call
|
GDS does not support shape repetition (only cell repeptition). Please call
|
||||||
`library.wrap_repeated_shapes()` before writing to file.
|
`library.wrap_repeated_shapes()` before writing to file.
|
||||||
|
|
||||||
Other functions you may want to call:
|
Other functions you may want to call:
|
||||||
|
|
@ -453,7 +453,7 @@ def _shapes_to_elements(
|
||||||
|
|
||||||
extension: tuple[int, int]
|
extension: tuple[int, int]
|
||||||
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
|
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
|
||||||
extension = tuple(rint_cast(shape.cap_extensions))
|
extension = tuple(shape.cap_extensions) # type: ignore
|
||||||
else:
|
else:
|
||||||
extension = (0, 0)
|
extension = (0, 0)
|
||||||
|
|
||||||
|
|
@ -617,12 +617,7 @@ def load_libraryfile(
|
||||||
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
|
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
|
||||||
else:
|
else:
|
||||||
stream = path.open(mode='rb') # noqa: SIM115
|
stream = path.open(mode='rb') # noqa: SIM115
|
||||||
|
return load_library(stream, full_load=full_load, postprocess=postprocess)
|
||||||
try:
|
|
||||||
return load_library(stream, full_load=full_load, postprocess=postprocess)
|
|
||||||
finally:
|
|
||||||
if full_load:
|
|
||||||
stream.close()
|
|
||||||
|
|
||||||
|
|
||||||
def check_valid_names(
|
def check_valid_names(
|
||||||
|
|
@ -653,7 +648,7 @@ def check_valid_names(
|
||||||
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
|
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
|
||||||
|
|
||||||
if bad_lengths:
|
if bad_lengths:
|
||||||
logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths))
|
logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))
|
||||||
|
|
||||||
if bad_chars or bad_lengths:
|
if bad_chars or bad_lengths:
|
||||||
raise LibraryError('Library contains invalid names, see log above')
|
raise LibraryError('Library contains invalid names, see log above')
|
||||||
|
|
|
||||||
|
|
@ -120,10 +120,10 @@ def build(
|
||||||
layer, data_type = _mlayer2oas(layer_num)
|
layer, data_type = _mlayer2oas(layer_num)
|
||||||
lib.layers += [
|
lib.layers += [
|
||||||
fatrec.LayerName(
|
fatrec.LayerName(
|
||||||
nstring = name,
|
nstring=name,
|
||||||
layer_interval = (layer, layer),
|
layer_interval=(layer, layer),
|
||||||
type_interval = (data_type, data_type),
|
type_interval=(data_type, data_type),
|
||||||
is_textlayer = tt,
|
is_textlayer=tt,
|
||||||
)
|
)
|
||||||
for tt in (True, False)]
|
for tt in (True, False)]
|
||||||
|
|
||||||
|
|
@ -182,8 +182,8 @@ def writefile(
|
||||||
Args:
|
Args:
|
||||||
library: A {name: Pattern} mapping of patterns to write.
|
library: A {name: Pattern} mapping of patterns to write.
|
||||||
filename: Filename to save to.
|
filename: Filename to save to.
|
||||||
*args: passed to `oasis.build()`
|
*args: passed to `oasis.write`
|
||||||
**kwargs: passed to `oasis.build()`
|
**kwargs: passed to `oasis.write`
|
||||||
"""
|
"""
|
||||||
path = pathlib.Path(filename)
|
path = pathlib.Path(filename)
|
||||||
|
|
||||||
|
|
@ -213,9 +213,9 @@ def readfile(
|
||||||
Will automatically decompress gzipped files.
|
Will automatically decompress gzipped files.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename: Filename to load from.
|
filename: Filename to save to.
|
||||||
*args: passed to `oasis.read()`
|
*args: passed to `oasis.read`
|
||||||
**kwargs: passed to `oasis.read()`
|
**kwargs: passed to `oasis.read`
|
||||||
"""
|
"""
|
||||||
path = pathlib.Path(filename)
|
path = pathlib.Path(filename)
|
||||||
if is_gzipped(path):
|
if is_gzipped(path):
|
||||||
|
|
@ -286,11 +286,11 @@ def read(
|
||||||
|
|
||||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||||
pat.polygon(
|
pat.polygon(
|
||||||
vertices = vertices,
|
vertices=vertices,
|
||||||
layer = element.get_layer_tuple(),
|
layer=element.get_layer_tuple(),
|
||||||
offset = element.get_xy(),
|
offset=element.get_xy(),
|
||||||
annotations = annotations,
|
annotations=annotations,
|
||||||
repetition = repetition,
|
repetition=repetition,
|
||||||
)
|
)
|
||||||
elif isinstance(element, fatrec.Path):
|
elif isinstance(element, fatrec.Path):
|
||||||
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
|
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)
|
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||||
pat.path(
|
pat.path(
|
||||||
vertices = vertices,
|
vertices=vertices,
|
||||||
layer = element.get_layer_tuple(),
|
layer=element.get_layer_tuple(),
|
||||||
offset = element.get_xy(),
|
offset=element.get_xy(),
|
||||||
repetition = repetition,
|
repetition=repetition,
|
||||||
annotations = annotations,
|
annotations=annotations,
|
||||||
width = element.get_half_width() * 2,
|
width=element.get_half_width() * 2,
|
||||||
cap = cap,
|
cap=cap,
|
||||||
**path_args,
|
**path_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -325,11 +325,11 @@ def read(
|
||||||
height = element.get_height()
|
height = element.get_height()
|
||||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||||
pat.polygon(
|
pat.polygon(
|
||||||
layer = element.get_layer_tuple(),
|
layer=element.get_layer_tuple(),
|
||||||
offset = element.get_xy(),
|
offset=element.get_xy(),
|
||||||
repetition = repetition,
|
repetition=repetition,
|
||||||
vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
||||||
annotations = annotations,
|
annotations=annotations,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif isinstance(element, fatrec.Trapezoid):
|
elif isinstance(element, fatrec.Trapezoid):
|
||||||
|
|
@ -440,11 +440,11 @@ def read(
|
||||||
else:
|
else:
|
||||||
string = str_or_ref.string
|
string = str_or_ref.string
|
||||||
pat.label(
|
pat.label(
|
||||||
layer = element.get_layer_tuple(),
|
layer=element.get_layer_tuple(),
|
||||||
offset = element.get_xy(),
|
offset=element.get_xy(),
|
||||||
repetition = repetition,
|
repetition=repetition,
|
||||||
annotations = annotations,
|
annotations=annotations,
|
||||||
string = string,
|
string=string,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
@ -549,13 +549,13 @@ def _shapes_to_elements(
|
||||||
offset = rint_cast(shape.offset + rep_offset)
|
offset = rint_cast(shape.offset + rep_offset)
|
||||||
radius = rint_cast(shape.radius)
|
radius = rint_cast(shape.radius)
|
||||||
circle = fatrec.Circle(
|
circle = fatrec.Circle(
|
||||||
layer = layer,
|
layer=layer,
|
||||||
datatype = datatype,
|
datatype=datatype,
|
||||||
radius = cast('int', radius),
|
radius=cast('int', radius),
|
||||||
x = offset[0],
|
x=offset[0],
|
||||||
y = offset[1],
|
y=offset[1],
|
||||||
properties = properties,
|
properties=properties,
|
||||||
repetition = repetition,
|
repetition=repetition,
|
||||||
)
|
)
|
||||||
elements.append(circle)
|
elements.append(circle)
|
||||||
elif isinstance(shape, Path):
|
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_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)
|
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
|
||||||
path = fatrec.Path(
|
path = fatrec.Path(
|
||||||
layer = layer,
|
layer=layer,
|
||||||
datatype = datatype,
|
datatype=datatype,
|
||||||
point_list = cast('Sequence[Sequence[int]]', deltas),
|
point_list=cast('Sequence[Sequence[int]]', deltas),
|
||||||
half_width = cast('int', half_width),
|
half_width=cast('int', half_width),
|
||||||
x = xy[0],
|
x=xy[0],
|
||||||
y = xy[1],
|
y=xy[1],
|
||||||
extension_start = extension_start, # TODO implement multiple cap types?
|
extension_start=extension_start, # TODO implement multiple cap types?
|
||||||
extension_end = extension_end,
|
extension_end=extension_end,
|
||||||
properties = properties,
|
properties=properties,
|
||||||
repetition = repetition,
|
repetition=repetition,
|
||||||
)
|
)
|
||||||
elements.append(path)
|
elements.append(path)
|
||||||
else:
|
else:
|
||||||
|
|
@ -583,13 +583,13 @@ def _shapes_to_elements(
|
||||||
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
|
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
|
||||||
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
|
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
|
||||||
elements.append(fatrec.Polygon(
|
elements.append(fatrec.Polygon(
|
||||||
layer = layer,
|
layer=layer,
|
||||||
datatype = datatype,
|
datatype=datatype,
|
||||||
x = xy[0],
|
x=xy[0],
|
||||||
y = xy[1],
|
y=xy[1],
|
||||||
point_list = cast('list[list[int]]', points),
|
point_list=cast('list[list[int]]', points),
|
||||||
properties = properties,
|
properties=properties,
|
||||||
repetition = repetition,
|
repetition=repetition,
|
||||||
))
|
))
|
||||||
return elements
|
return elements
|
||||||
|
|
||||||
|
|
@ -606,13 +606,13 @@ def _labels_to_texts(
|
||||||
xy = rint_cast(label.offset + rep_offset)
|
xy = rint_cast(label.offset + rep_offset)
|
||||||
properties = annotations_to_properties(label.annotations)
|
properties = annotations_to_properties(label.annotations)
|
||||||
texts.append(fatrec.Text(
|
texts.append(fatrec.Text(
|
||||||
layer = layer,
|
layer=layer,
|
||||||
datatype = datatype,
|
datatype=datatype,
|
||||||
x = xy[0],
|
x=xy[0],
|
||||||
y = xy[1],
|
y=xy[1],
|
||||||
string = label.string,
|
string=label.string,
|
||||||
properties = properties,
|
properties=properties,
|
||||||
repetition = repetition,
|
repetition=repetition,
|
||||||
))
|
))
|
||||||
return texts
|
return texts
|
||||||
|
|
||||||
|
|
@ -622,12 +622,10 @@ def repetition_fata2masq(
|
||||||
) -> Repetition | None:
|
) -> Repetition | None:
|
||||||
mrep: Repetition | None
|
mrep: Repetition | None
|
||||||
if isinstance(rep, fatamorgana.GridRepetition):
|
if isinstance(rep, fatamorgana.GridRepetition):
|
||||||
mrep = Grid(
|
mrep = Grid(a_vector=rep.a_vector,
|
||||||
a_vector = rep.a_vector,
|
b_vector=rep.b_vector,
|
||||||
b_vector = rep.b_vector,
|
a_count=rep.a_count,
|
||||||
a_count = rep.a_count,
|
b_count=rep.b_count)
|
||||||
b_count = rep.b_count,
|
|
||||||
)
|
|
||||||
elif isinstance(rep, fatamorgana.ArbitraryRepetition):
|
elif isinstance(rep, fatamorgana.ArbitraryRepetition):
|
||||||
displacements = numpy.cumsum(numpy.column_stack((
|
displacements = numpy.cumsum(numpy.column_stack((
|
||||||
rep.x_displacements,
|
rep.x_displacements,
|
||||||
|
|
@ -649,19 +647,14 @@ def repetition_masq2fata(
|
||||||
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
|
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
|
||||||
if isinstance(rep, Grid):
|
if isinstance(rep, Grid):
|
||||||
a_vector = rint_cast(rep.a_vector)
|
a_vector = rint_cast(rep.a_vector)
|
||||||
a_count = int(rep.a_count)
|
b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None
|
||||||
if rep.b_count > 1:
|
a_count = rint_cast(rep.a_count)
|
||||||
b_vector = rint_cast(rep.b_vector)
|
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None
|
||||||
b_count = int(rep.b_count)
|
|
||||||
else:
|
|
||||||
b_vector = None
|
|
||||||
b_count = None
|
|
||||||
|
|
||||||
frep = fatamorgana.GridRepetition(
|
frep = fatamorgana.GridRepetition(
|
||||||
a_vector = a_vector,
|
a_vector=cast('list[int]', a_vector),
|
||||||
b_vector = b_vector,
|
b_vector=cast('list[int] | None', b_vector),
|
||||||
a_count = a_count,
|
a_count=cast('int', a_count),
|
||||||
b_count = b_count,
|
b_count=cast('int | None', b_count),
|
||||||
)
|
)
|
||||||
offset = (0, 0)
|
offset = (0, 0)
|
||||||
elif isinstance(rep, Arbitrary):
|
elif isinstance(rep, Arbitrary):
|
||||||
|
|
@ -717,6 +710,10 @@ def properties_to_annotations(
|
||||||
annotations[key] = values
|
annotations[key] = values
|
||||||
return annotations
|
return annotations
|
||||||
|
|
||||||
|
properties = [fatrec.Property(key, vals, is_standard=False)
|
||||||
|
for key, vals in annotations.items()]
|
||||||
|
return properties
|
||||||
|
|
||||||
|
|
||||||
def check_valid_names(
|
def check_valid_names(
|
||||||
names: Iterable[str],
|
names: Iterable[str],
|
||||||
|
|
|
||||||
|
|
@ -10,32 +10,16 @@ import svgwrite # type: ignore
|
||||||
|
|
||||||
from .utils import mangle_name
|
from .utils import mangle_name
|
||||||
from .. import Pattern
|
from .. import Pattern
|
||||||
from ..utils import rotation_matrix_2d
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _ref_to_svg_transform(ref) -> str:
|
|
||||||
linear = rotation_matrix_2d(ref.rotation) * ref.scale
|
|
||||||
if ref.mirrored:
|
|
||||||
linear = linear @ numpy.diag((1.0, -1.0))
|
|
||||||
|
|
||||||
a = linear[0, 0]
|
|
||||||
b = linear[1, 0]
|
|
||||||
c = linear[0, 1]
|
|
||||||
d = linear[1, 1]
|
|
||||||
e = ref.offset[0]
|
|
||||||
f = ref.offset[1]
|
|
||||||
return f'matrix({a:g} {b:g} {c:g} {d:g} {e:g} {f:g})'
|
|
||||||
|
|
||||||
|
|
||||||
def writefile(
|
def writefile(
|
||||||
library: Mapping[str, Pattern],
|
library: Mapping[str, Pattern],
|
||||||
top: str,
|
top: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
custom_attributes: bool = False,
|
custom_attributes: bool = False,
|
||||||
annotate_ports: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Write a Pattern to an SVG file, by first calling .polygonize() on it
|
Write a Pattern to an SVG file, by first calling .polygonize() on it
|
||||||
|
|
@ -60,8 +44,6 @@ def writefile(
|
||||||
filename: Filename to write to.
|
filename: Filename to write to.
|
||||||
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
||||||
SVG elements.
|
SVG elements.
|
||||||
annotate_ports: If True, draw an arrow for each port (similar to
|
|
||||||
`Pattern.visualize(..., ports=True)`).
|
|
||||||
"""
|
"""
|
||||||
pattern = library[top]
|
pattern = library[top]
|
||||||
|
|
||||||
|
|
@ -97,32 +79,11 @@ def writefile(
|
||||||
|
|
||||||
svg_group.add(path)
|
svg_group.add(path)
|
||||||
|
|
||||||
if annotate_ports:
|
|
||||||
# Draw arrows for the ports, pointing into the device (per port definition)
|
|
||||||
for port_name, port in pat.ports.items():
|
|
||||||
if port.rotation is not None:
|
|
||||||
p1 = port.offset
|
|
||||||
angle = port.rotation
|
|
||||||
size = 1.0 # arrow size
|
|
||||||
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
|
||||||
|
|
||||||
# head
|
|
||||||
head_angle = 0.5
|
|
||||||
h1 = p1 + 0.7 * size * numpy.array([numpy.cos(angle + head_angle), numpy.sin(angle + head_angle)])
|
|
||||||
h2 = p1 + 0.7 * size * numpy.array([numpy.cos(angle - head_angle), numpy.sin(angle - head_angle)])
|
|
||||||
|
|
||||||
line = svg.line(start=p1, end=p2, stroke='green', stroke_width=0.2)
|
|
||||||
head = svg.polyline(points=[h1, p1, h2], fill='none', stroke='green', stroke_width=0.2)
|
|
||||||
|
|
||||||
svg_group.add(line)
|
|
||||||
svg_group.add(head)
|
|
||||||
svg_group.add(svg.text(port_name, insert=p2, font_size=0.5, fill='green'))
|
|
||||||
|
|
||||||
for target, refs in pat.refs.items():
|
for target, refs in pat.refs.items():
|
||||||
if target is None:
|
if target is None:
|
||||||
continue
|
continue
|
||||||
for ref in refs:
|
for ref in refs:
|
||||||
transform = _ref_to_svg_transform(ref)
|
transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})'
|
||||||
use = svg.use(href='#' + mangle_name(target), transform=transform)
|
use = svg.use(href='#' + mangle_name(target), transform=transform)
|
||||||
svg_group.add(use)
|
svg_group.add(use)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,7 @@ def preflight(
|
||||||
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
|
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
|
||||||
|
|
||||||
if prune_empty_patterns:
|
if prune_empty_patterns:
|
||||||
prune_dangling = 'error' if allow_dangling_refs is False else 'ignore'
|
pruned = lib.prune_empty()
|
||||||
pruned = lib.prune_empty(dangling=prune_dangling)
|
|
||||||
if pruned:
|
if pruned:
|
||||||
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
|
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
|
||||||
logger.debug('Pruned: ' + pformat(pruned))
|
logger.debug('Pruned: ' + pformat(pruned))
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
from .repetition import Repetition
|
from .repetition import Repetition
|
||||||
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
|
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
|
||||||
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable
|
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded
|
||||||
from .traits import AnnotatableImpl
|
from .traits import AnnotatableImpl
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable):
|
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
|
||||||
"""
|
"""
|
||||||
A text annotation with a position (but no size; it is not drawn)
|
A text annotation with a position (but no size; it is not drawn)
|
||||||
"""
|
"""
|
||||||
|
|
@ -58,14 +58,12 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
||||||
string=self.string,
|
string=self.string,
|
||||||
offset=self.offset.copy(),
|
offset=self.offset.copy(),
|
||||||
repetition=self.repetition,
|
repetition=self.repetition,
|
||||||
annotations=copy.copy(self.annotations),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._offset = self._offset.copy()
|
new._offset = self._offset.copy()
|
||||||
new._annotations = copy.deepcopy(self._annotations, memo)
|
|
||||||
return new
|
return new
|
||||||
|
|
||||||
def __lt__(self, other: 'Label') -> bool:
|
def __lt__(self, other: 'Label') -> bool:
|
||||||
|
|
@ -98,34 +96,10 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
||||||
"""
|
"""
|
||||||
pivot = numpy.asarray(pivot, dtype=float)
|
pivot = numpy.asarray(pivot, dtype=float)
|
||||||
self.translate(-pivot)
|
self.translate(-pivot)
|
||||||
if self.repetition is not None:
|
|
||||||
self.repetition.rotate(rotation)
|
|
||||||
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
||||||
self.translate(+pivot)
|
self.translate(+pivot)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
|
|
||||||
"""
|
|
||||||
Extrinsic transformation: Flip the label across a line in the pattern's
|
|
||||||
coordinate system. This affects both the label's offset and its
|
|
||||||
repetition grid.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
axis: Axis to mirror across. 0: x-axis (flip y), 1: y-axis (flip x).
|
|
||||||
x: Vertical line x=val to mirror across.
|
|
||||||
y: Horizontal line y=val to mirror across.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
|
|
||||||
self.translate(-pivot)
|
|
||||||
if self.repetition is not None:
|
|
||||||
self.repetition.mirror(axis)
|
|
||||||
self.offset[1 - axis] *= -1
|
|
||||||
self.translate(+pivot)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||||
"""
|
"""
|
||||||
Return the bounds of the label.
|
Return the bounds of the label.
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,6 @@ TreeView: TypeAlias = Mapping[str, 'Pattern']
|
||||||
Tree: TypeAlias = MutableMapping[str, 'Pattern']
|
Tree: TypeAlias = MutableMapping[str, 'Pattern']
|
||||||
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
|
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
|
||||||
|
|
||||||
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
|
|
||||||
""" How helpers should handle refs whose targets are not present in the library. """
|
|
||||||
|
|
||||||
|
|
||||||
SINGLE_USE_PREFIX = '_'
|
SINGLE_USE_PREFIX = '_'
|
||||||
"""
|
"""
|
||||||
|
|
@ -189,9 +186,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
# Perform recursive lookups, but only once for each name
|
# Perform recursive lookups, but only once for each name
|
||||||
for target in targets - skip:
|
for target in targets - skip:
|
||||||
assert target is not None
|
assert target is not None
|
||||||
skip.add(target)
|
|
||||||
if target in self:
|
if target in self:
|
||||||
targets |= self.referenced_patterns(target, skip=skip)
|
targets |= self.referenced_patterns(target, skip=skip)
|
||||||
|
skip.add(target)
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
|
|
@ -294,9 +291,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
def flatten_single(name: str) -> None:
|
def flatten_single(name: str) -> None:
|
||||||
flattened[name] = None
|
flattened[name] = None
|
||||||
pat = self[name].deepcopy()
|
pat = self[name].deepcopy()
|
||||||
refs_by_target = tuple((target, tuple(refs)) for target, refs in pat.refs.items())
|
|
||||||
|
|
||||||
for target, refs in refs_by_target:
|
for target in pat.refs:
|
||||||
if target is None:
|
if target is None:
|
||||||
continue
|
continue
|
||||||
if dangling_ok and target not in self:
|
if dangling_ok and target not in self:
|
||||||
|
|
@ -307,16 +303,10 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
target_pat = flattened[target]
|
target_pat = flattened[target]
|
||||||
if target_pat is None:
|
if target_pat is None:
|
||||||
raise PatternError(f'Circular reference in {name} to {target}')
|
raise PatternError(f'Circular reference in {name} to {target}')
|
||||||
ports_only = flatten_ports and bool(target_pat.ports)
|
if target_pat.is_empty(): # avoid some extra allocations
|
||||||
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for ref in refs:
|
for ref in pat.refs[target]:
|
||||||
if flatten_ports and ref.repetition is not None and target_pat.ports:
|
|
||||||
raise PatternError(
|
|
||||||
f'Cannot flatten ports from repeated ref to {target!r}; '
|
|
||||||
'flatten with flatten_ports=False or expand/rename the ports manually first.'
|
|
||||||
)
|
|
||||||
p = ref.as_pattern(pattern=target_pat)
|
p = ref.as_pattern(pattern=target_pat)
|
||||||
if not flatten_ports:
|
if not flatten_ports:
|
||||||
p.ports.clear()
|
p.ports.clear()
|
||||||
|
|
@ -422,21 +412,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
return self[self.top()]
|
return self[self.top()]
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dangling_refs_error(dangling: set[str], context: str) -> LibraryError:
|
|
||||||
dangling_list = sorted(dangling)
|
|
||||||
return LibraryError(f'Dangling refs found while {context}: ' + pformat(dangling_list))
|
|
||||||
|
|
||||||
def _raw_child_graph(self) -> tuple[dict[str, set[str]], set[str]]:
|
|
||||||
existing = set(self.keys())
|
|
||||||
graph: dict[str, set[str]] = {}
|
|
||||||
dangling: set[str] = set()
|
|
||||||
for name, pat in self.items():
|
|
||||||
children = {child for child, refs in pat.refs.items() if child is not None and refs}
|
|
||||||
graph[name] = children
|
|
||||||
dangling |= children - existing
|
|
||||||
return graph, dangling
|
|
||||||
|
|
||||||
def dfs(
|
def dfs(
|
||||||
self,
|
self,
|
||||||
pattern: 'Pattern',
|
pattern: 'Pattern',
|
||||||
|
|
@ -491,11 +466,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
memo = {}
|
memo = {}
|
||||||
|
|
||||||
if transform is None or transform is True:
|
if transform is None or transform is True:
|
||||||
transform = numpy.array([0, 0, 0, 0, 1], dtype=float)
|
transform = numpy.zeros(4)
|
||||||
elif transform is not False:
|
elif transform is not False:
|
||||||
transform = numpy.asarray(transform, dtype=float)
|
transform = numpy.asarray(transform, dtype=float)
|
||||||
if transform.size == 4:
|
|
||||||
transform = numpy.append(transform, 1.0)
|
|
||||||
|
|
||||||
original_pattern = pattern
|
original_pattern = pattern
|
||||||
|
|
||||||
|
|
@ -542,88 +515,46 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def child_graph(
|
def child_graph(self) -> dict[str, set[str | None]]:
|
||||||
self,
|
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> dict[str, set[str]]:
|
|
||||||
"""
|
"""
|
||||||
Return a mapping from pattern name to a set of all child patterns
|
Return a mapping from pattern name to a set of all child patterns
|
||||||
(patterns it references).
|
(patterns it references).
|
||||||
|
|
||||||
Only non-empty ref lists with non-`None` targets are treated as graph edges.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dangling: How refs to missing targets are handled. `'error'` raises,
|
|
||||||
`'ignore'` drops those edges, and `'include'` exposes them as
|
|
||||||
synthetic leaf nodes.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Mapping from pattern name to a set of all pattern names it references.
|
Mapping from pattern name to a set of all pattern names it references.
|
||||||
"""
|
"""
|
||||||
graph, dangling_refs = self._raw_child_graph()
|
graph = {name: set(pat.refs.keys()) for name, pat in self.items()}
|
||||||
if dangling == 'error':
|
|
||||||
if dangling_refs:
|
|
||||||
raise self._dangling_refs_error(dangling_refs, 'building child graph')
|
|
||||||
return graph
|
|
||||||
if dangling == 'ignore':
|
|
||||||
existing = set(graph)
|
|
||||||
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
|
||||||
|
|
||||||
for target in dangling_refs:
|
|
||||||
graph.setdefault(target, set())
|
|
||||||
return graph
|
return graph
|
||||||
|
|
||||||
def parent_graph(
|
def parent_graph(self) -> dict[str, set[str]]:
|
||||||
self,
|
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> dict[str, set[str]]:
|
|
||||||
"""
|
"""
|
||||||
Return a mapping from pattern name to a set of all parent patterns
|
Return a mapping from pattern name to a set of all parent patterns
|
||||||
(patterns which reference it).
|
(patterns which reference it).
|
||||||
|
|
||||||
Args:
|
|
||||||
dangling: How refs to missing targets are handled. `'error'` raises,
|
|
||||||
`'ignore'` drops those targets, and `'include'` adds them as
|
|
||||||
synthetic keys whose values are their existing parents.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Mapping from pattern name to a set of all patterns which reference it.
|
Mapping from pattern name to a set of all patterns which reference it.
|
||||||
"""
|
"""
|
||||||
child_graph, dangling_refs = self._raw_child_graph()
|
igraph: dict[str, set[str]] = {name: set() for name in self}
|
||||||
if dangling == 'error' and dangling_refs:
|
for name, pat in self.items():
|
||||||
raise self._dangling_refs_error(dangling_refs, 'building parent graph')
|
for child, reflist in pat.refs.items():
|
||||||
|
if reflist and child is not None:
|
||||||
existing = set(child_graph)
|
igraph[child].add(name)
|
||||||
igraph: dict[str, set[str]] = {name: set() for name in existing}
|
|
||||||
for parent, children in child_graph.items():
|
|
||||||
for child in children:
|
|
||||||
if child in existing:
|
|
||||||
igraph[child].add(parent)
|
|
||||||
elif dangling == 'include':
|
|
||||||
igraph.setdefault(child, set()).add(parent)
|
|
||||||
return igraph
|
return igraph
|
||||||
|
|
||||||
def child_order(
|
def child_order(self) -> list[str]:
|
||||||
self,
|
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
"""
|
||||||
Return a topologically sorted list of graph node names.
|
Return a topologically sorted list of all contained pattern names.
|
||||||
Child (referenced) patterns will appear before their parents.
|
Child (referenced) patterns will appear before their parents.
|
||||||
|
|
||||||
Args:
|
|
||||||
dangling: Passed to `child_graph()`.
|
|
||||||
|
|
||||||
Return:
|
Return:
|
||||||
Topologically sorted list of pattern names.
|
Topologically sorted list of pattern names.
|
||||||
"""
|
"""
|
||||||
return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order()))
|
return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order()))
|
||||||
|
|
||||||
def find_refs_local(
|
def find_refs_local(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
parent_graph: dict[str, set[str]] | None = None,
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||||
"""
|
"""
|
||||||
Find the location and orientation of all refs pointing to `name`.
|
Find the location and orientation of all refs pointing to `name`.
|
||||||
|
|
@ -636,8 +567,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
The provided graph may be for a superset of `self` (i.e. it may
|
The provided graph may be for a superset of `self` (i.e. it may
|
||||||
contain additional patterns which are not present in self; they
|
contain additional patterns which are not present in self; they
|
||||||
will be ignored).
|
will be ignored).
|
||||||
dangling: How refs to missing targets are handled if `parent_graph`
|
|
||||||
is not provided. `'include'` also allows querying missing names.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Mapping of {parent_name: transform_list}, where transform_list
|
Mapping of {parent_name: transform_list}, where transform_list
|
||||||
|
|
@ -646,18 +575,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
instances = defaultdict(list)
|
instances = defaultdict(list)
|
||||||
if parent_graph is None:
|
if parent_graph is None:
|
||||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
parent_graph = self.parent_graph()
|
||||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
for parent in parent_graph[name]:
|
||||||
|
|
||||||
if name not in self:
|
|
||||||
if name not in parent_graph:
|
|
||||||
return instances
|
|
||||||
if dangling == 'error':
|
|
||||||
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
|
||||||
if dangling == 'ignore':
|
|
||||||
return instances
|
|
||||||
|
|
||||||
for parent in parent_graph.get(name, set()):
|
|
||||||
if parent not in self: # parent_graph may be a for a superset of self
|
if parent not in self: # parent_graph may be a for a superset of self
|
||||||
continue
|
continue
|
||||||
for ref in self[parent].refs[name]:
|
for ref in self[parent].refs[name]:
|
||||||
|
|
@ -670,7 +589,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
name: str,
|
name: str,
|
||||||
order: list[str] | None = None,
|
order: list[str] | None = None,
|
||||||
parent_graph: dict[str, set[str]] | None = None,
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
"""
|
"""
|
||||||
Find the absolute (top-level) location and orientation of all refs (including
|
Find the absolute (top-level) location and orientation of all refs (including
|
||||||
|
|
@ -687,28 +605,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
The provided graph may be for a superset of `self` (i.e. it may
|
The provided graph may be for a superset of `self` (i.e. it may
|
||||||
contain additional patterns which are not present in self; they
|
contain additional patterns which are not present in self; they
|
||||||
will be ignored).
|
will be ignored).
|
||||||
dangling: How refs to missing targets are handled if `order` or
|
|
||||||
`parent_graph` are not provided. `'include'` also allows
|
|
||||||
querying missing names.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form
|
Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form
|
||||||
`(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray
|
`(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray
|
||||||
with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
|
with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
|
||||||
"""
|
"""
|
||||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
|
||||||
if order is None:
|
|
||||||
order = self.child_order(dangling=graph_mode)
|
|
||||||
if parent_graph is None:
|
|
||||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
|
||||||
|
|
||||||
if name not in self:
|
if name not in self:
|
||||||
if name not in parent_graph:
|
return {}
|
||||||
return {}
|
if order is None:
|
||||||
if dangling == 'error':
|
order = self.child_order()
|
||||||
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
if parent_graph is None:
|
||||||
if dangling == 'ignore':
|
parent_graph = self.parent_graph()
|
||||||
return {}
|
|
||||||
|
|
||||||
self_keys = set(self.keys())
|
self_keys = set(self.keys())
|
||||||
|
|
||||||
|
|
@ -717,16 +625,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
NDArray[numpy.float64]
|
NDArray[numpy.float64]
|
||||||
]]]
|
]]]
|
||||||
transforms = defaultdict(list)
|
transforms = defaultdict(list)
|
||||||
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items():
|
||||||
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||||
|
|
||||||
for next_name in order:
|
for next_name in order:
|
||||||
if next_name not in transforms:
|
if next_name not in transforms:
|
||||||
continue
|
continue
|
||||||
if not parent_graph.get(next_name, set()) & self_keys:
|
if not parent_graph[next_name] & self_keys:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
outers = self.find_refs_local(next_name, parent_graph=parent_graph)
|
||||||
inners = transforms.pop(next_name)
|
inners = transforms.pop(next_name)
|
||||||
for parent, outer in outers.items():
|
for parent, outer in outers.items():
|
||||||
for path, inner in inners:
|
for path, inner in inners:
|
||||||
|
|
@ -774,33 +682,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
|
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def resolve(
|
|
||||||
self,
|
|
||||||
other: 'Abstract | str | Pattern | TreeView',
|
|
||||||
append: bool = False,
|
|
||||||
) -> 'Abstract | Pattern':
|
|
||||||
"""
|
|
||||||
Resolve another device (name, Abstract, Pattern, or TreeView) into an Abstract or Pattern.
|
|
||||||
If it is a TreeView, it is first added into this library.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
other: The device to resolve.
|
|
||||||
append: If True and `other` is an `Abstract`, returns the full `Pattern` from the library.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An `Abstract` or `Pattern` object.
|
|
||||||
"""
|
|
||||||
from .pattern import Pattern #noqa: PLC0415
|
|
||||||
if not isinstance(other, (str, Abstract, Pattern)):
|
|
||||||
# We got a TreeView; add it into self and grab its topcell as an Abstract
|
|
||||||
other = self << other
|
|
||||||
|
|
||||||
if isinstance(other, str):
|
|
||||||
other = self.abstract(other)
|
|
||||||
if append and isinstance(other, Abstract):
|
|
||||||
other = self[other.name]
|
|
||||||
return other
|
|
||||||
|
|
||||||
def rename(
|
def rename(
|
||||||
self,
|
self,
|
||||||
old_name: str,
|
old_name: str,
|
||||||
|
|
@ -882,7 +763,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
(name, pattern) tuple
|
(name, pattern) tuple
|
||||||
"""
|
"""
|
||||||
from .pattern import Pattern #noqa: PLC0415
|
from .pattern import Pattern
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
self[name] = pat
|
self[name] = pat
|
||||||
return name, pat
|
return name, pat
|
||||||
|
|
@ -922,7 +803,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
Raises:
|
Raises:
|
||||||
`LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`.
|
`LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`.
|
||||||
"""
|
"""
|
||||||
from .pattern import map_targets #noqa: PLC0415
|
from .pattern import map_targets
|
||||||
duplicates = set(self.keys()) & set(other.keys())
|
duplicates = set(self.keys()) & set(other.keys())
|
||||||
|
|
||||||
if not duplicates:
|
if not duplicates:
|
||||||
|
|
@ -1028,7 +909,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
# This currently simplifies globally (same shape in different patterns is
|
# This currently simplifies globally (same shape in different patterns is
|
||||||
# merged into the same ref target).
|
# merged into the same ref target).
|
||||||
|
|
||||||
from .pattern import Pattern #noqa: PLC0415
|
from .pattern import Pattern
|
||||||
|
|
||||||
if exclude_types is None:
|
if exclude_types is None:
|
||||||
exclude_types = ()
|
exclude_types = ()
|
||||||
|
|
@ -1121,7 +1002,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
from .pattern import Pattern #noqa: PLC0415
|
from .pattern import Pattern
|
||||||
|
|
||||||
if name_func is None:
|
if name_func is None:
|
||||||
def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
|
def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
|
||||||
|
|
@ -1155,25 +1036,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def resolve_repeated_refs(self, name: str | None = None) -> Self:
|
|
||||||
"""
|
|
||||||
Expand all repeated references into multiple individual references.
|
|
||||||
Alters the library in-place.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: If specified, only resolve repeated refs in this pattern.
|
|
||||||
Otherwise, resolve in all patterns.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
if name is not None:
|
|
||||||
self[name].resolve_repeated_refs()
|
|
||||||
else:
|
|
||||||
for pat in self.values():
|
|
||||||
pat.resolve_repeated_refs()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def subtree(
|
def subtree(
|
||||||
self,
|
self,
|
||||||
tops: str | Sequence[str],
|
tops: str | Sequence[str],
|
||||||
|
|
@ -1203,19 +1065,17 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
def prune_empty(
|
def prune_empty(
|
||||||
self,
|
self,
|
||||||
repeat: bool = True,
|
repeat: bool = True,
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
|
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
|
repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
|
||||||
dangling: Passed to `parent_graph()`.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A set containing the names of all deleted patterns
|
A set containing the names of all deleted patterns
|
||||||
"""
|
"""
|
||||||
parent_graph = self.parent_graph(dangling=dangling)
|
parent_graph = self.parent_graph()
|
||||||
empty = {name for name, pat in self.items() if pat.is_empty()}
|
empty = {name for name, pat in self.items() if pat.is_empty()}
|
||||||
trimmed = set()
|
trimmed = set()
|
||||||
while empty:
|
while empty:
|
||||||
|
|
@ -1345,7 +1205,7 @@ class Library(ILibrary):
|
||||||
Returns:
|
Returns:
|
||||||
The newly created `Library` and the newly created `Pattern`
|
The newly created `Library` and the newly created `Pattern`
|
||||||
"""
|
"""
|
||||||
from .pattern import Pattern #noqa: PLC0415
|
from .pattern import Pattern
|
||||||
tree = cls()
|
tree = cls()
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
tree[name] = pat
|
tree[name] = pat
|
||||||
|
|
@ -1361,12 +1221,12 @@ class LazyLibrary(ILibrary):
|
||||||
"""
|
"""
|
||||||
mapping: dict[str, Callable[[], 'Pattern']]
|
mapping: dict[str, Callable[[], 'Pattern']]
|
||||||
cache: dict[str, 'Pattern']
|
cache: dict[str, 'Pattern']
|
||||||
_lookups_in_progress: list[str]
|
_lookups_in_progress: set[str]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.mapping = {}
|
self.mapping = {}
|
||||||
self.cache = {}
|
self.cache = {}
|
||||||
self._lookups_in_progress = []
|
self._lookups_in_progress = set()
|
||||||
|
|
||||||
def __setitem__(
|
def __setitem__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1397,20 +1257,16 @@ class LazyLibrary(ILibrary):
|
||||||
return self.cache[key]
|
return self.cache[key]
|
||||||
|
|
||||||
if key in self._lookups_in_progress:
|
if key in self._lookups_in_progress:
|
||||||
chain = ' -> '.join(self._lookups_in_progress + [key])
|
|
||||||
raise LibraryError(
|
raise LibraryError(
|
||||||
f'Detected circular reference or recursive lookup of "{key}".\n'
|
f'Detected multiple simultaneous lookups of "{key}".\n'
|
||||||
f'Lookup chain: {chain}\n'
|
|
||||||
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
|
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
|
||||||
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
|
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' # TODO give advice on finding cycles
|
||||||
)
|
)
|
||||||
|
|
||||||
self._lookups_in_progress.append(key)
|
self._lookups_in_progress.add(key)
|
||||||
try:
|
func = self.mapping[key]
|
||||||
func = self.mapping[key]
|
pat = func()
|
||||||
pat = func()
|
self._lookups_in_progress.remove(key)
|
||||||
finally:
|
|
||||||
self._lookups_in_progress.pop()
|
|
||||||
self.cache[key] = pat
|
self.cache[key] = pat
|
||||||
return pat
|
return pat
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab
|
||||||
from .ports import Port, PortList
|
from .ports import Port, PortList
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -172,8 +171,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
return s
|
return s
|
||||||
|
|
||||||
def __copy__(self) -> 'Pattern':
|
def __copy__(self) -> 'Pattern':
|
||||||
logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! '
|
logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!')
|
||||||
'Consider using .deepcopy() if this was not intended.')
|
|
||||||
new = Pattern(
|
new = Pattern(
|
||||||
annotations=copy.deepcopy(self.annotations),
|
annotations=copy.deepcopy(self.annotations),
|
||||||
ports=copy.deepcopy(self.ports),
|
ports=copy.deepcopy(self.ports),
|
||||||
|
|
@ -200,7 +198,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
def __lt__(self, other: 'Pattern') -> bool:
|
def __lt__(self, other: 'Pattern') -> bool:
|
||||||
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||||
other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist]
|
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||||
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
|
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
|
||||||
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
|
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
|
||||||
|
|
||||||
|
|
@ -214,7 +212,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
return refs_ours < refs_theirs
|
return refs_ours < refs_theirs
|
||||||
|
|
||||||
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||||
other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems]
|
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||||
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
||||||
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
||||||
|
|
||||||
|
|
@ -223,21 +221,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
for _, _, layer in self_layerkeys:
|
for _, _, layer in self_layerkeys:
|
||||||
shapes_ours = tuple(sorted(self.shapes[layer]))
|
shapes_ours = tuple(sorted(self.shapes[layer]))
|
||||||
shapes_theirs = tuple(sorted(other.shapes[layer]))
|
shapes_theirs = tuple(sorted(self.shapes[layer]))
|
||||||
if shapes_ours != shapes_theirs:
|
if shapes_ours != shapes_theirs:
|
||||||
return shapes_ours < shapes_theirs
|
return shapes_ours < shapes_theirs
|
||||||
|
|
||||||
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||||
other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems]
|
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||||
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
||||||
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
||||||
|
|
||||||
if self_txtlayerkeys != other_txtlayerkeys:
|
if self_txtlayerkeys != other_txtlayerkeys:
|
||||||
return self_txtlayerkeys < other_txtlayerkeys
|
return self_txtlayerkeys < other_txtlayerkeys
|
||||||
|
|
||||||
for _, _, layer in self_txtlayerkeys:
|
for _, _, layer in self_layerkeys:
|
||||||
labels_ours = tuple(sorted(self.labels[layer]))
|
labels_ours = tuple(sorted(self.labels[layer]))
|
||||||
labels_theirs = tuple(sorted(other.labels[layer]))
|
labels_theirs = tuple(sorted(self.labels[layer]))
|
||||||
if labels_ours != labels_theirs:
|
if labels_ours != labels_theirs:
|
||||||
return labels_ours < labels_theirs
|
return labels_ours < labels_theirs
|
||||||
|
|
||||||
|
|
@ -254,7 +252,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||||
other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist]
|
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||||
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
|
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
|
||||||
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
|
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
|
||||||
|
|
||||||
|
|
@ -268,7 +266,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||||
other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems]
|
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||||
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
||||||
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
||||||
|
|
||||||
|
|
@ -277,21 +275,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
for _, _, layer in self_layerkeys:
|
for _, _, layer in self_layerkeys:
|
||||||
shapes_ours = tuple(sorted(self.shapes[layer]))
|
shapes_ours = tuple(sorted(self.shapes[layer]))
|
||||||
shapes_theirs = tuple(sorted(other.shapes[layer]))
|
shapes_theirs = tuple(sorted(self.shapes[layer]))
|
||||||
if shapes_ours != shapes_theirs:
|
if shapes_ours != shapes_theirs:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||||
other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems]
|
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||||
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
||||||
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
||||||
|
|
||||||
if self_txtlayerkeys != other_txtlayerkeys:
|
if self_txtlayerkeys != other_txtlayerkeys:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for _, _, layer in self_txtlayerkeys:
|
for _, _, layer in self_layerkeys:
|
||||||
labels_ours = tuple(sorted(self.labels[layer]))
|
labels_ours = tuple(sorted(self.labels[layer]))
|
||||||
labels_theirs = tuple(sorted(other.labels[layer]))
|
labels_theirs = tuple(sorted(self.labels[layer]))
|
||||||
if labels_ours != labels_theirs:
|
if labels_ours != labels_theirs:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -501,61 +499,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
]
|
]
|
||||||
return polys
|
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]:
|
def referenced_patterns(self) -> set[str | None]:
|
||||||
"""
|
"""
|
||||||
Get all pattern namers referenced by this pattern. Non-recursive.
|
Get all pattern namers referenced by this pattern. Non-recursive.
|
||||||
|
|
@ -692,7 +635,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
"""
|
"""
|
||||||
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
|
||||||
cast('Positionable', entry).translate(offset)
|
cast('Positionable', entry).translate(offset)
|
||||||
self._log_bulk_update(f"translate({offset!r})")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_elements(self, c: float) -> Self:
|
def scale_elements(self, c: float) -> Self:
|
||||||
|
|
@ -746,9 +688,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Extrinsic transformation: Rotate the Pattern around the a location in the
|
Rotate the Pattern around the a location.
|
||||||
container's coordinate system. This affects all elements' offsets and
|
|
||||||
their repetition grids.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pivot: (x, y) location to rotate around
|
pivot: (x, y) location to rotate around
|
||||||
|
|
@ -762,14 +702,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
self.rotate_elements(rotation)
|
self.rotate_elements(rotation)
|
||||||
self.rotate_element_centers(rotation)
|
self.rotate_element_centers(rotation)
|
||||||
self.translate_elements(+pivot)
|
self.translate_elements(+pivot)
|
||||||
self._log_bulk_update(f"rotate_around({pivot}, {rotation})")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def rotate_element_centers(self, rotation: float) -> Self:
|
def rotate_element_centers(self, rotation: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Extrinsic transformation part: Rotate the offsets and repetition grids of all
|
Rotate the offsets of all shapes, labels, refs, and ports around (0, 0)
|
||||||
shapes, labels, refs, and ports around (0, 0) in the container's
|
|
||||||
coordinate system.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||||
|
|
@ -780,15 +717,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||||
old_offset = cast('Positionable', entry).offset
|
old_offset = cast('Positionable', entry).offset
|
||||||
cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset)
|
cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset)
|
||||||
if isinstance(entry, Repeatable) and entry.repetition is not None:
|
|
||||||
entry.repetition.rotate(rotation)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def rotate_elements(self, rotation: float) -> Self:
|
def rotate_elements(self, rotation: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Intrinsic transformation part: Rotate each shape, ref, label, and port around its
|
Rotate each shape, ref, and port around its origin (offset)
|
||||||
origin (offset) in the container's coordinate system. This does NOT
|
|
||||||
affect their repetition grids.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||||
|
|
@ -796,61 +729,54 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
|
||||||
if isinstance(entry, Rotatable):
|
cast('Rotatable', entry).rotate(rotation)
|
||||||
entry.rotate(rotation)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror_element_centers(self, axis: int = 0) -> Self:
|
def mirror_element_centers(self, across_axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Extrinsic transformation part: Mirror the offsets and repetition grids of all
|
Mirror the offsets of all shapes, labels, and refs across an axis
|
||||||
shapes, labels, refs, and ports relative to the container's origin.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
axis: Axis to mirror across (0: x-axis, 1: y-axis)
|
across_axis: Axis to mirror across
|
||||||
|
(0: mirror across x axis, 1: mirror across y axis)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||||
cast('Positionable', entry).offset[1 - axis] *= -1
|
cast('Positionable', entry).offset[1 - across_axis] *= -1
|
||||||
if isinstance(entry, Repeatable) and entry.repetition is not None:
|
|
||||||
entry.repetition.mirror(axis)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror_elements(self, axis: int = 0) -> Self:
|
def mirror_elements(self, across_axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Intrinsic transformation part: Mirror each shape, ref, label, and port relative
|
Mirror each shape, ref, and pattern across an axis, relative
|
||||||
to its offset. This does NOT affect their repetition grids.
|
to its offset
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
axis: Axis to mirror across
|
across_axis: Axis to mirror across
|
||||||
0: mirror across x axis (flip y),
|
(0: mirror across x axis, 1: mirror across y axis)
|
||||||
1: mirror across y axis (flip x)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
|
||||||
if isinstance(entry, Mirrorable):
|
cast('Mirrorable', entry).mirror(across_axis)
|
||||||
entry.mirror(axis=axis)
|
|
||||||
self._log_bulk_update(f"mirror_elements({axis})")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror(self, across_axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Extrinsic transformation: Mirror the Pattern across an axis through its origin.
|
Mirror the Pattern across an axis
|
||||||
This affects all elements' offsets and their internal orientations.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
across_axis: Axis to mirror across
|
||||||
|
(0: mirror across x axis, 1: mirror across y axis)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
self.mirror_elements(axis=axis)
|
self.mirror_elements(across_axis)
|
||||||
self.mirror_element_centers(axis=axis)
|
self.mirror_element_centers(across_axis)
|
||||||
self._log_bulk_update(f"mirror({axis})")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def copy(self) -> Self:
|
def copy(self) -> Self:
|
||||||
|
|
@ -861,7 +787,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
Returns:
|
Returns:
|
||||||
A deep copy of the current Pattern.
|
A deep copy of the current Pattern.
|
||||||
"""
|
"""
|
||||||
return self.deepcopy()
|
return copy.deepcopy(self)
|
||||||
|
|
||||||
def deepcopy(self) -> Self:
|
def deepcopy(self) -> Self:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1004,28 +930,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
del self.labels[layer]
|
del self.labels[layer]
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def resolve_repeated_refs(self) -> Self:
|
|
||||||
"""
|
|
||||||
Expand all repeated references into multiple individual references.
|
|
||||||
Alters the current pattern in-place.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
new_refs: defaultdict[str | None, list[Ref]] = defaultdict(list)
|
|
||||||
for target, rseq in self.refs.items():
|
|
||||||
for ref in rseq:
|
|
||||||
if ref.repetition is None:
|
|
||||||
new_refs[target].append(ref)
|
|
||||||
else:
|
|
||||||
for dd in ref.repetition.displacements:
|
|
||||||
new_ref = ref.deepcopy()
|
|
||||||
new_ref.offset = ref.offset + dd
|
|
||||||
new_ref.repetition = None
|
|
||||||
new_refs[target].append(new_ref)
|
|
||||||
self.refs = new_refs
|
|
||||||
return self
|
|
||||||
|
|
||||||
def prune_refs(self) -> Self:
|
def prune_refs(self) -> Self:
|
||||||
"""
|
"""
|
||||||
Remove empty ref lists in `self.refs`.
|
Remove empty ref lists in `self.refs`.
|
||||||
|
|
@ -1077,16 +981,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
if target_pat is None:
|
if target_pat is None:
|
||||||
raise PatternError(f'Circular reference in {name} to {target}')
|
raise PatternError(f'Circular reference in {name} to {target}')
|
||||||
ports_only = flatten_ports and bool(target_pat.ports)
|
if target_pat.is_empty(): # avoid some extra allocations
|
||||||
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for ref in refs:
|
for ref in refs:
|
||||||
if flatten_ports and ref.repetition is not None and target_pat.ports:
|
|
||||||
raise PatternError(
|
|
||||||
f'Cannot flatten ports from repeated ref to {target!r}; '
|
|
||||||
'flatten with flatten_ports=False or expand/rename the ports manually first.'
|
|
||||||
)
|
|
||||||
p = ref.as_pattern(pattern=target_pat)
|
p = ref.as_pattern(pattern=target_pat)
|
||||||
if not flatten_ports:
|
if not flatten_ports:
|
||||||
p.ports.clear()
|
p.ports.clear()
|
||||||
|
|
@ -1105,8 +1003,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
line_color: str = 'k',
|
line_color: str = 'k',
|
||||||
fill_color: str = 'none',
|
fill_color: str = 'none',
|
||||||
overdraw: bool = False,
|
overdraw: bool = False,
|
||||||
filename: str | None = None,
|
|
||||||
ports: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Draw a picture of the Pattern and wait for the user to inspect it
|
Draw a picture of the Pattern and wait for the user to inspect it
|
||||||
|
|
@ -1117,18 +1013,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
klayout or a different GDS viewer!
|
klayout or a different GDS viewer!
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
library: Mapping of {name: Pattern} for resolving references. Required if `self.has_refs()`.
|
offset: Coordinates to offset by before drawing
|
||||||
offset: Coordinates to offset by before drawing.
|
line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
|
||||||
line_color: Outlines are drawn with this color.
|
fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
|
||||||
fill_color: Interiors are drawn with this color.
|
overdraw: Whether to create a new figure or draw on a pre-existing one
|
||||||
overdraw: Whether to create a new figure or draw on a pre-existing one.
|
|
||||||
filename: If provided, save the figure to this file instead of showing it.
|
|
||||||
ports: If True, annotate the plot with arrows representing the ports.
|
|
||||||
"""
|
"""
|
||||||
# TODO: add text labels to visualize()
|
# TODO: add text labels to visualize()
|
||||||
try:
|
try:
|
||||||
from matplotlib import pyplot # type: ignore #noqa: PLC0415
|
from matplotlib import pyplot # type: ignore
|
||||||
import matplotlib.collections # type: ignore #noqa: PLC0415
|
import matplotlib.collections # type: ignore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.exception('Pattern.visualize() depends on matplotlib!\n'
|
logger.exception('Pattern.visualize() depends on matplotlib!\n'
|
||||||
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
|
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
|
||||||
|
|
@ -1137,155 +1030,48 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
if self.has_refs() and library is None:
|
if self.has_refs() and library is None:
|
||||||
raise PatternError('Must provide a library when visualizing a pattern with refs')
|
raise PatternError('Must provide a library when visualizing a pattern with refs')
|
||||||
|
|
||||||
# Cache for {Pattern object ID: List of local polygon vertex arrays}
|
offset = numpy.asarray(offset, dtype=float)
|
||||||
# Polygons are stored relative to the pattern's origin (offset included)
|
|
||||||
poly_cache: dict[int, list[NDArray[numpy.float64]]] = {}
|
|
||||||
|
|
||||||
def get_local_polys(pat: 'Pattern') -> list[NDArray[numpy.float64]]:
|
|
||||||
pid = id(pat)
|
|
||||||
if pid not in poly_cache:
|
|
||||||
polys = []
|
|
||||||
for shape in chain.from_iterable(pat.shapes.values()):
|
|
||||||
for ss in shape.to_polygons():
|
|
||||||
# Shape.to_polygons() returns Polygons with their own offsets and vertices.
|
|
||||||
# We need to expand any shape-level repetition here.
|
|
||||||
v_base = ss.vertices + ss.offset
|
|
||||||
if ss.repetition is not None:
|
|
||||||
for disp in ss.repetition.displacements:
|
|
||||||
polys.append(v_base + disp)
|
|
||||||
else:
|
|
||||||
polys.append(v_base)
|
|
||||||
poly_cache[pid] = polys
|
|
||||||
return poly_cache[pid]
|
|
||||||
|
|
||||||
all_polygons: list[NDArray[numpy.float64]] = []
|
|
||||||
port_info: list[tuple[str, NDArray[numpy.float64], float]] = []
|
|
||||||
|
|
||||||
def collect_polys_recursive(
|
|
||||||
pat: 'Pattern',
|
|
||||||
c_offset: NDArray[numpy.float64],
|
|
||||||
c_rotation: float,
|
|
||||||
c_mirrored: bool,
|
|
||||||
c_scale: float,
|
|
||||||
) -> None:
|
|
||||||
# Current transform: T(c_offset) * R(c_rotation) * M(c_mirrored) * S(c_scale)
|
|
||||||
|
|
||||||
# 1. Transform and collect local polygons
|
|
||||||
local_polys = get_local_polys(pat)
|
|
||||||
if local_polys:
|
|
||||||
rot_mat = rotation_matrix_2d(c_rotation)
|
|
||||||
for v in local_polys:
|
|
||||||
vt = v * c_scale
|
|
||||||
if c_mirrored:
|
|
||||||
vt = vt.copy()
|
|
||||||
vt[:, 1] *= -1
|
|
||||||
vt = (rot_mat @ vt.T).T + c_offset
|
|
||||||
all_polygons.append(vt)
|
|
||||||
|
|
||||||
# 2. Collect ports if requested
|
|
||||||
if ports:
|
|
||||||
for name, p in pat.ports.items():
|
|
||||||
pt_v = p.offset * c_scale
|
|
||||||
if c_mirrored:
|
|
||||||
pt_v = pt_v.copy()
|
|
||||||
pt_v[1] *= -1
|
|
||||||
pt_v = rotation_matrix_2d(c_rotation) @ pt_v + c_offset
|
|
||||||
|
|
||||||
if p.rotation is not None:
|
|
||||||
pt_rot = p.rotation
|
|
||||||
if c_mirrored:
|
|
||||||
pt_rot = -pt_rot
|
|
||||||
pt_rot += c_rotation
|
|
||||||
port_info.append((name, pt_v, pt_rot))
|
|
||||||
|
|
||||||
# 3. Recurse into refs
|
|
||||||
for target, refs in pat.refs.items():
|
|
||||||
if target is None:
|
|
||||||
continue
|
|
||||||
assert library is not None
|
|
||||||
target_pat = library[target]
|
|
||||||
for ref in refs:
|
|
||||||
# Ref order of operations: mirror, rotate, scale, translate, repeat
|
|
||||||
|
|
||||||
# Combined scale and mirror
|
|
||||||
r_scale = c_scale * ref.scale
|
|
||||||
r_mirrored = c_mirrored ^ ref.mirrored
|
|
||||||
|
|
||||||
# Combined rotation: push c_mirrored and c_rotation through ref.rotation
|
|
||||||
r_rot_relative = -ref.rotation if c_mirrored else ref.rotation
|
|
||||||
r_rotation = c_rotation + r_rot_relative
|
|
||||||
|
|
||||||
# Offset composition helper
|
|
||||||
def get_full_offset(rel_offset: NDArray[numpy.float64]) -> NDArray[numpy.float64]:
|
|
||||||
o = rel_offset * c_scale
|
|
||||||
if c_mirrored:
|
|
||||||
o = o.copy()
|
|
||||||
o[1] *= -1
|
|
||||||
return rotation_matrix_2d(c_rotation) @ o + c_offset
|
|
||||||
|
|
||||||
if ref.repetition is not None:
|
|
||||||
for disp in ref.repetition.displacements:
|
|
||||||
collect_polys_recursive(
|
|
||||||
target_pat,
|
|
||||||
get_full_offset(ref.offset + disp),
|
|
||||||
r_rotation,
|
|
||||||
r_mirrored,
|
|
||||||
r_scale
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
collect_polys_recursive(
|
|
||||||
target_pat,
|
|
||||||
get_full_offset(ref.offset),
|
|
||||||
r_rotation,
|
|
||||||
r_mirrored,
|
|
||||||
r_scale
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start recursive collection
|
|
||||||
collect_polys_recursive(self, numpy.asarray(offset, dtype=float), 0.0, False, 1.0)
|
|
||||||
|
|
||||||
# Plotting
|
|
||||||
if not overdraw:
|
if not overdraw:
|
||||||
figure = pyplot.figure()
|
figure = pyplot.figure()
|
||||||
|
pyplot.axis('equal')
|
||||||
else:
|
else:
|
||||||
figure = pyplot.gcf()
|
figure = pyplot.gcf()
|
||||||
|
|
||||||
axes = figure.gca()
|
axes = figure.gca()
|
||||||
|
|
||||||
if all_polygons:
|
polygons = []
|
||||||
mpl_poly_collection = matplotlib.collections.PolyCollection(
|
for shape in chain.from_iterable(self.shapes.values()):
|
||||||
all_polygons,
|
polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()]
|
||||||
facecolors = fill_color,
|
|
||||||
edgecolors = line_color,
|
|
||||||
)
|
|
||||||
axes.add_collection(mpl_poly_collection)
|
|
||||||
|
|
||||||
if ports:
|
mpl_poly_collection = matplotlib.collections.PolyCollection(
|
||||||
for port_name, pt_v, pt_rot in port_info:
|
polygons,
|
||||||
p1 = pt_v
|
facecolors=fill_color,
|
||||||
angle = pt_rot
|
edgecolors=line_color,
|
||||||
size = 1.0 # arrow size
|
)
|
||||||
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
axes.add_collection(mpl_poly_collection)
|
||||||
|
pyplot.axis('equal')
|
||||||
|
|
||||||
axes.annotate(
|
for target, refs in self.refs.items():
|
||||||
port_name,
|
if target is None:
|
||||||
xy = tuple(p1),
|
continue
|
||||||
xytext = tuple(p2),
|
if not refs:
|
||||||
arrowprops = dict(arrowstyle="->", color='g', linewidth=1),
|
continue
|
||||||
color = 'g',
|
assert library is not None
|
||||||
fontsize = 8,
|
target_pat = library[target]
|
||||||
|
for ref in refs:
|
||||||
|
ref.as_pattern(target_pat).visualize(
|
||||||
|
library=library,
|
||||||
|
offset=offset,
|
||||||
|
overdraw=True,
|
||||||
|
line_color=line_color,
|
||||||
|
fill_color=fill_color,
|
||||||
)
|
)
|
||||||
|
|
||||||
axes.autoscale_view()
|
|
||||||
axes.set_aspect('equal')
|
|
||||||
|
|
||||||
if not overdraw:
|
if not overdraw:
|
||||||
axes.set_xlabel('x')
|
pyplot.xlabel('x')
|
||||||
axes.set_ylabel('y')
|
pyplot.ylabel('y')
|
||||||
if filename:
|
pyplot.show()
|
||||||
figure.savefig(filename)
|
|
||||||
else:
|
|
||||||
figure.show()
|
|
||||||
|
|
||||||
# @overload
|
# @overload
|
||||||
# def place(
|
# def place(
|
||||||
|
|
@ -1328,7 +1114,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
port_map: dict[str, str | None] | None = None,
|
port_map: dict[str, str | None] | None = None,
|
||||||
skip_port_check: bool = False,
|
skip_port_check: bool = False,
|
||||||
append: bool = False,
|
append: bool = False,
|
||||||
skip_geometry: bool = False,
|
|
||||||
) -> Self:
|
) -> Self:
|
||||||
"""
|
"""
|
||||||
Instantiate or append the pattern `other` into the current pattern, adding its
|
Instantiate or append the pattern `other` into the current pattern, adding its
|
||||||
|
|
@ -1360,10 +1145,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
append: If `True`, `other` is appended instead of being referenced.
|
append: If `True`, `other` is appended instead of being referenced.
|
||||||
Note that this does not flatten `other`, so its refs will still
|
Note that this does not flatten `other`, so its refs will still
|
||||||
be refs (now inside `self`).
|
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:
|
Returns:
|
||||||
self
|
self
|
||||||
|
|
@ -1395,10 +1176,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
pp.rotate_around(pivot, rotation)
|
pp.rotate_around(pivot, rotation)
|
||||||
pp.translate(offset)
|
pp.translate(offset)
|
||||||
self.ports[name] = pp
|
self.ports[name] = pp
|
||||||
self._log_port_update(name)
|
|
||||||
|
|
||||||
if skip_geometry:
|
|
||||||
return self
|
|
||||||
|
|
||||||
if append:
|
if append:
|
||||||
if isinstance(other, Abstract):
|
if isinstance(other, Abstract):
|
||||||
|
|
@ -1411,9 +1188,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
other_copy.translate_elements(offset)
|
other_copy.translate_elements(offset)
|
||||||
self.append(other_copy)
|
self.append(other_copy)
|
||||||
else:
|
else:
|
||||||
if isinstance(other, Pattern):
|
assert not isinstance(other, Pattern)
|
||||||
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
|
|
||||||
'Use `append=True` if you intended to append the full geometry.')
|
|
||||||
ref = Ref(mirrored=mirrored)
|
ref = Ref(mirrored=mirrored)
|
||||||
ref.rotate_around(pivot, rotation)
|
ref.rotate_around(pivot, rotation)
|
||||||
ref.translate(offset)
|
ref.translate(offset)
|
||||||
|
|
@ -1459,7 +1234,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
set_rotation: bool | None = None,
|
set_rotation: bool | None = None,
|
||||||
append: bool = False,
|
append: bool = False,
|
||||||
ok_connections: Iterable[tuple[str, str]] = (),
|
ok_connections: Iterable[tuple[str, str]] = (),
|
||||||
skip_geometry: bool = False,
|
|
||||||
) -> Self:
|
) -> Self:
|
||||||
"""
|
"""
|
||||||
Instantiate or append a pattern into the current pattern, connecting
|
Instantiate or append a pattern into the current pattern, connecting
|
||||||
|
|
@ -1514,11 +1288,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
any other ptypte. Non-allowed ptype connections will emit a
|
any other ptypte. Non-allowed ptype connections will emit a
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||||
`(b, a)`.
|
`(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:
|
Returns:
|
||||||
self
|
self
|
||||||
|
|
@ -1551,42 +1320,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
map_out = {out_port_name: next(iter(map_in.keys()))}
|
map_out = {out_port_name: next(iter(map_in.keys()))}
|
||||||
|
|
||||||
self.check_ports(other.ports.keys(), map_in, map_out)
|
self.check_ports(other.ports.keys(), map_in, map_out)
|
||||||
try:
|
translation, rotation, pivot = self.find_transform(
|
||||||
translation, rotation, pivot = self.find_transform(
|
other,
|
||||||
other,
|
map_in,
|
||||||
map_in,
|
mirrored = mirrored,
|
||||||
mirrored = mirrored,
|
set_rotation = set_rotation,
|
||||||
set_rotation = set_rotation,
|
ok_connections = ok_connections,
|
||||||
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
|
# get rid of plugged ports
|
||||||
for ki, vi in map_in.items():
|
for ki, vi in map_in.items():
|
||||||
del self.ports[ki]
|
del self.ports[ki]
|
||||||
self._log_port_removal(ki)
|
|
||||||
map_out[vi] = None
|
map_out[vi] = None
|
||||||
|
|
||||||
if isinstance(other, Pattern) and not (append or skip_geometry):
|
if isinstance(other, Pattern):
|
||||||
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
|
assert append, 'Got a name (not an abstract) but was asked to reference (not append)'
|
||||||
'Use `append=True` if you intended to append the full geometry.')
|
|
||||||
|
|
||||||
self.place(
|
self.place(
|
||||||
other,
|
other,
|
||||||
|
|
@ -1597,7 +1345,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
port_map = map_out,
|
port_map = map_out,
|
||||||
skip_port_check = True,
|
skip_port_check = True,
|
||||||
append = append,
|
append = append,
|
||||||
skip_geometry = skip_geometry,
|
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
|
||||||
103
masque/ports.py
103
masque/ports.py
|
|
@ -2,7 +2,6 @@ from typing import overload, Self, NoReturn, Any
|
||||||
from collections.abc import Iterable, KeysView, ValuesView, Mapping
|
from collections.abc import Iterable, KeysView, ValuesView, Mapping
|
||||||
import logging
|
import logging
|
||||||
import functools
|
import functools
|
||||||
import copy
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
@ -11,17 +10,16 @@ import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
|
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
|
||||||
from .utils import rotate_offsets_around, rotation_matrix_2d
|
from .utils import rotate_offsets_around, rotation_matrix_2d
|
||||||
from .error import PortError, format_stacktrace
|
from .error import PortError, format_stacktrace
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
port_logger = logging.getLogger('masque.ports')
|
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
||||||
"""
|
"""
|
||||||
A point at which a `Device` can be snapped to another `Device`.
|
A point at which a `Device` can be snapped to another `Device`.
|
||||||
|
|
||||||
|
|
@ -93,12 +91,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
||||||
def copy(self) -> Self:
|
def copy(self) -> Self:
|
||||||
return self.deepcopy()
|
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]:
|
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||||
return numpy.vstack((self.offset, self.offset))
|
return numpy.vstack((self.offset, self.offset))
|
||||||
|
|
||||||
|
|
@ -107,27 +99,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
||||||
self.ptype = ptype
|
self.ptype = ptype
|
||||||
return self
|
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:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
if self.rotation is not None:
|
if self.rotation is not None:
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
|
|
@ -143,34 +114,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def describe(self) -> str:
|
|
||||||
"""
|
|
||||||
Returns a human-readable description of the port's state including cardinal directions.
|
|
||||||
"""
|
|
||||||
deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None
|
|
||||||
|
|
||||||
cardinal = ""
|
|
||||||
travel_dir = ""
|
|
||||||
|
|
||||||
if self.rotation is not None:
|
|
||||||
dirs = {0: "East (+x)", 90: "North (+y)", 180: "West (-x)", 270: "South (-y)"}
|
|
||||||
# normalize to [0, 360)
|
|
||||||
deg_norm = deg % 360
|
|
||||||
|
|
||||||
# Find closest cardinal
|
|
||||||
closest = min(dirs.keys(), key=lambda x: abs((deg_norm - x + 180) % 360 - 180))
|
|
||||||
if numpy.isclose((deg_norm - closest + 180) % 360 - 180, 0, atol=1e-3):
|
|
||||||
cardinal = f" ({dirs[closest]})"
|
|
||||||
|
|
||||||
# Travel direction (rotation + 180)
|
|
||||||
t_deg = (deg_norm + 180) % 360
|
|
||||||
closest_t = min(dirs.keys(), key=lambda x: abs((t_deg - x + 180) % 360 - 180))
|
|
||||||
if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3):
|
|
||||||
travel_dir = f" (Travel -> {dirs[closest_t]})"
|
|
||||||
|
|
||||||
deg_text = 'any' if deg is None else f'{deg:g}'
|
|
||||||
return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}"
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
if self.rotation is None:
|
if self.rotation is None:
|
||||||
rot = 'any'
|
rot = 'any'
|
||||||
|
|
@ -236,19 +179,6 @@ class PortList(metaclass=ABCMeta):
|
||||||
def ports(self, value: dict[str, Port]) -> None:
|
def ports(self, value: dict[str, Port]) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _log_port_update(self, name: str) -> None:
|
|
||||||
""" Log the current state of the named port """
|
|
||||||
port_logger.debug("Port %s: %s", name, self.ports[name].describe())
|
|
||||||
|
|
||||||
def _log_port_removal(self, name: str) -> None:
|
|
||||||
""" Log that the named port has been removed """
|
|
||||||
port_logger.debug("Port %s: removed", name)
|
|
||||||
|
|
||||||
def _log_bulk_update(self, label: str) -> None:
|
|
||||||
""" Log all current ports at DEBUG level """
|
|
||||||
for name, port in self.ports.items():
|
|
||||||
port_logger.debug("%s: Port %s: %s", label, name, port)
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def __getitem__(self, key: str) -> Port:
|
def __getitem__(self, key: str) -> Port:
|
||||||
pass
|
pass
|
||||||
|
|
@ -302,7 +232,6 @@ class PortList(metaclass=ABCMeta):
|
||||||
raise PortError(f'Port {name} already exists.')
|
raise PortError(f'Port {name} already exists.')
|
||||||
assert name not in self.ports
|
assert name not in self.ports
|
||||||
self.ports[name] = value
|
self.ports[name] = value
|
||||||
self._log_port_update(name)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def rename_ports(
|
def rename_ports(
|
||||||
|
|
@ -328,24 +257,12 @@ class PortList(metaclass=ABCMeta):
|
||||||
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
|
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
|
||||||
if duplicates:
|
if duplicates:
|
||||||
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
|
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
|
||||||
missing = set(mapping) - set(self.ports)
|
|
||||||
if missing:
|
|
||||||
raise PortError(f'Ports to rename were not found: {missing}')
|
|
||||||
|
|
||||||
for kk, vv in mapping.items():
|
|
||||||
if vv is None or vv != kk:
|
|
||||||
self._log_port_removal(kk)
|
|
||||||
|
|
||||||
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
|
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
|
||||||
if None in renamed:
|
if None in renamed:
|
||||||
del renamed[None]
|
del renamed[None]
|
||||||
|
|
||||||
self.ports.update(renamed) # type: ignore
|
self.ports.update(renamed) # type: ignore
|
||||||
|
|
||||||
for vv in mapping.values():
|
|
||||||
if vv is not None:
|
|
||||||
self._log_port_update(vv)
|
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def add_port_pair(
|
def add_port_pair(
|
||||||
|
|
@ -368,16 +285,12 @@ class PortList(metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
if names[0] == names[1]:
|
|
||||||
raise PortError(f'Port names must be distinct: {names[0]!r}')
|
|
||||||
new_ports = {
|
new_ports = {
|
||||||
names[0]: Port(offset, rotation=rotation, ptype=ptype),
|
names[0]: Port(offset, rotation=rotation, ptype=ptype),
|
||||||
names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
|
names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
|
||||||
}
|
}
|
||||||
self.check_ports(names)
|
self.check_ports(names)
|
||||||
self.ports.update(new_ports)
|
self.ports.update(new_ports)
|
||||||
self._log_port_update(names[0])
|
|
||||||
self._log_port_update(names[1])
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def plugged(
|
def plugged(
|
||||||
|
|
@ -400,14 +313,6 @@ class PortList(metaclass=ABCMeta):
|
||||||
Raises:
|
Raises:
|
||||||
`PortError` if the ports are not properly aligned.
|
`PortError` if the ports are not properly aligned.
|
||||||
"""
|
"""
|
||||||
if not connections:
|
|
||||||
raise PortError('Must provide at least one port connection')
|
|
||||||
missing_a = set(connections) - set(self.ports)
|
|
||||||
if missing_a:
|
|
||||||
raise PortError(f'Connection source ports were not found: {missing_a}')
|
|
||||||
missing_b = set(connections.values()) - set(self.ports)
|
|
||||||
if missing_b:
|
|
||||||
raise PortError(f'Connection destination ports were not found: {missing_b}')
|
|
||||||
a_names, b_names = list(zip(*connections.items(), strict=True))
|
a_names, b_names = list(zip(*connections.items(), strict=True))
|
||||||
a_ports = [self.ports[pp] for pp in a_names]
|
a_ports = [self.ports[pp] for pp in a_names]
|
||||||
b_ports = [self.ports[pp] for pp in b_names]
|
b_ports = [self.ports[pp] for pp in b_names]
|
||||||
|
|
@ -455,7 +360,6 @@ class PortList(metaclass=ABCMeta):
|
||||||
|
|
||||||
for pp in chain(a_names, b_names):
|
for pp in chain(a_names, b_names):
|
||||||
del self.ports[pp]
|
del self.ports[pp]
|
||||||
self._log_port_removal(pp)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def check_ports(
|
def check_ports(
|
||||||
|
|
@ -644,7 +548,7 @@ class PortList(metaclass=ABCMeta):
|
||||||
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
|
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
|
||||||
if not has_rot.any():
|
if not has_rot.any():
|
||||||
if set_rotation is None:
|
if set_rotation is None:
|
||||||
raise PortError('Must provide set_rotation if rotation is indeterminate')
|
PortError('Must provide set_rotation if rotation is indeterminate')
|
||||||
rotations[:] = set_rotation
|
rotations[:] = set_rotation
|
||||||
else:
|
else:
|
||||||
rotations[~has_rot] = rotations[has_rot][0]
|
rotations[~has_rot] = rotations[has_rot][0]
|
||||||
|
|
@ -669,3 +573,4 @@ class PortList(metaclass=ABCMeta):
|
||||||
raise PortError(msg)
|
raise PortError(msg)
|
||||||
|
|
||||||
return translations[0], rotations[0], o_offsets[0]
|
return translations[0], rotations[0], o_offsets[0]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation
|
||||||
from .repetition import Repetition
|
from .repetition import Repetition
|
||||||
from .traits import (
|
from .traits import (
|
||||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||||
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||||
FlippableImpl,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,9 +25,8 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
class Ref(
|
class Ref(
|
||||||
FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
|
||||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||||
Copyable,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
`Ref` provides basic support for nesting Pattern objects within each other.
|
`Ref` provides basic support for nesting Pattern objects within each other.
|
||||||
|
|
@ -44,7 +42,7 @@ class Ref(
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'_mirrored',
|
'_mirrored',
|
||||||
# inherited
|
# inherited
|
||||||
'_offset', '_rotation', '_scale', '_repetition', '_annotations',
|
'_offset', '_rotation', 'scale', '_repetition', '_annotations',
|
||||||
)
|
)
|
||||||
|
|
||||||
_mirrored: bool
|
_mirrored: bool
|
||||||
|
|
@ -92,22 +90,18 @@ class Ref(
|
||||||
rotation=self.rotation,
|
rotation=self.rotation,
|
||||||
scale=self.scale,
|
scale=self.scale,
|
||||||
mirrored=self.mirrored,
|
mirrored=self.mirrored,
|
||||||
repetition=self.repetition,
|
repetition=copy.deepcopy(self.repetition),
|
||||||
annotations=self.annotations,
|
annotations=copy.deepcopy(self.annotations),
|
||||||
)
|
)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._offset = self._offset.copy()
|
#new.repetition = copy.deepcopy(self.repetition, memo)
|
||||||
new.repetition = copy.deepcopy(self.repetition, memo)
|
#new.annotations = copy.deepcopy(self.annotations, memo)
|
||||||
new.annotations = copy.deepcopy(self.annotations, memo)
|
|
||||||
return new
|
return new
|
||||||
|
|
||||||
def copy(self) -> 'Ref':
|
|
||||||
return self.deepcopy()
|
|
||||||
|
|
||||||
def __lt__(self, other: 'Ref') -> bool:
|
def __lt__(self, other: 'Ref') -> bool:
|
||||||
if (self.offset != other.offset).any():
|
if (self.offset != other.offset).any():
|
||||||
return tuple(self.offset) < tuple(other.offset)
|
return tuple(self.offset) < tuple(other.offset)
|
||||||
|
|
@ -166,16 +160,16 @@ class Ref(
|
||||||
return pattern
|
return pattern
|
||||||
|
|
||||||
def rotate(self, rotation: float) -> Self:
|
def rotate(self, rotation: float) -> Self:
|
||||||
"""
|
|
||||||
Intrinsic transformation: Rotate the target pattern relative to this Ref's
|
|
||||||
origin. This does NOT affect the repetition grid.
|
|
||||||
"""
|
|
||||||
self.rotation += rotation
|
self.rotation += rotation
|
||||||
|
if self.repetition is not None:
|
||||||
|
self.repetition.rotate(rotation)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
self.mirror_target(axis)
|
self.mirror_target(axis)
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
|
if self.repetition is not None:
|
||||||
|
self.repetition.mirror(axis)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror_target(self, axis: int = 0) -> Self:
|
def mirror_target(self, axis: int = 0) -> Self:
|
||||||
|
|
@ -193,11 +187,10 @@ class Ref(
|
||||||
xys = self.offset[None, :]
|
xys = self.offset[None, :]
|
||||||
if self.repetition is not None:
|
if self.repetition is not None:
|
||||||
xys = xys + self.repetition.displacements
|
xys = xys + self.repetition.displacements
|
||||||
transforms = numpy.empty((xys.shape[0], 5))
|
transforms = numpy.empty((xys.shape[0], 4))
|
||||||
transforms[:, :2] = xys
|
transforms[:, :2] = xys
|
||||||
transforms[:, 2] = self.rotation
|
transforms[:, 2] = self.rotation
|
||||||
transforms[:, 3] = self.mirrored
|
transforms[:, 3] = self.mirrored
|
||||||
transforms[:, 4] = self.scale
|
|
||||||
return transforms
|
return transforms
|
||||||
|
|
||||||
def get_bounds_single(
|
def get_bounds_single(
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class Grid(Repetition):
|
||||||
_a_count: int
|
_a_count: int
|
||||||
""" Number of instances along the direction specified by the `a_vector` """
|
""" Number of instances along the direction specified by the `a_vector` """
|
||||||
|
|
||||||
_b_vector: NDArray[numpy.float64]
|
_b_vector: NDArray[numpy.float64] | None
|
||||||
""" Vector `[x, y]` specifying a second lattice vector for the grid.
|
""" Vector `[x, y]` specifying a second lattice vector for the grid.
|
||||||
Specifies center-to-center spacing between adjacent elements.
|
Specifies center-to-center spacing between adjacent elements.
|
||||||
Can be `None` for a 1D array.
|
Can be `None` for a 1D array.
|
||||||
|
|
@ -199,6 +199,9 @@ class Grid(Repetition):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def displacements(self) -> NDArray[numpy.float64]:
|
def displacements(self) -> NDArray[numpy.float64]:
|
||||||
|
if self.b_vector is None:
|
||||||
|
return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :]
|
||||||
|
|
||||||
aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
|
aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
|
||||||
return (aa.flatten()[:, None] * self.a_vector[None, :]
|
return (aa.flatten()[:, None] * self.a_vector[None, :]
|
||||||
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
|
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
|
||||||
|
|
@ -298,8 +301,12 @@ class Grid(Repetition):
|
||||||
return self.b_count < other.b_count
|
return self.b_count < other.b_count
|
||||||
if not numpy.array_equal(self.a_vector, other.a_vector):
|
if not numpy.array_equal(self.a_vector, other.a_vector):
|
||||||
return tuple(self.a_vector) < tuple(other.a_vector)
|
return tuple(self.a_vector) < tuple(other.a_vector)
|
||||||
|
if self.b_vector is None:
|
||||||
|
return other.b_vector is not None
|
||||||
|
if other.b_vector is None:
|
||||||
|
return False
|
||||||
if not numpy.array_equal(self.b_vector, other.b_vector):
|
if not numpy.array_equal(self.b_vector, other.b_vector):
|
||||||
return tuple(self.b_vector) < tuple(other.b_vector)
|
return tuple(self.a_vector) < tuple(other.a_vector)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -343,7 +350,7 @@ class Arbitrary(Repetition):
|
||||||
return (f'<Arbitrary {len(self.displacements)}pts >')
|
return (f'<Arbitrary {len(self.displacements)}pts >')
|
||||||
|
|
||||||
def __eq__(self, other: Any) -> bool:
|
def __eq__(self, other: Any) -> bool:
|
||||||
if type(other) is not type(self):
|
if not type(other) is not type(self):
|
||||||
return False
|
return False
|
||||||
return numpy.array_equal(self.displacements, other.displacements)
|
return numpy.array_equal(self.displacements, other.displacements)
|
||||||
|
|
||||||
|
|
@ -384,9 +391,7 @@ class Arbitrary(Repetition):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
new_displacements = self.displacements.copy()
|
self.displacements[1 - axis] *= -1
|
||||||
new_displacements[:, 1 - axis] *= -1
|
|
||||||
self.displacements = new_displacements
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def get_bounds(self) -> NDArray[numpy.float64] | None:
|
def get_bounds(self) -> NDArray[numpy.float64] | None:
|
||||||
|
|
@ -397,8 +402,6 @@ class Arbitrary(Repetition):
|
||||||
Returns:
|
Returns:
|
||||||
`[[x_min, y_min], [x_max, y_max]]` or `None`
|
`[[x_min, y_min], [x_max, y_max]]` or `None`
|
||||||
"""
|
"""
|
||||||
if self.displacements.size == 0:
|
|
||||||
return None
|
|
||||||
xy_min = numpy.min(self.displacements, axis=0)
|
xy_min = numpy.min(self.displacements, axis=0)
|
||||||
xy_max = numpy.max(self.displacements, axis=0)
|
xy_max = numpy.max(self.displacements, axis=0)
|
||||||
return numpy.array((xy_min, xy_max))
|
return numpy.array((xy_min, xy_max))
|
||||||
|
|
@ -413,6 +416,6 @@ class Arbitrary(Repetition):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
self.displacements = self.displacements * c
|
self.displacements *= c
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,16 +272,13 @@ class Arc(PositionableImpl, Shape):
|
||||||
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
|
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
|
||||||
|
|
||||||
keep = [0]
|
keep = [0]
|
||||||
start = 0
|
removable = (numpy.cumsum(arc_lengths) <= max_arclen)
|
||||||
|
start = 1
|
||||||
while start < arc_lengths.size:
|
while start < arc_lengths.size:
|
||||||
removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
|
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
|
||||||
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)
|
keep.append(next_to_keep)
|
||||||
start = next_to_keep
|
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
|
||||||
|
start = next_to_keep + 1
|
||||||
if keep[-1] != thetas.size - 1:
|
if keep[-1] != thetas.size - 1:
|
||||||
keep.append(thetas.size - 1)
|
keep.append(thetas.size - 1)
|
||||||
|
|
||||||
|
|
@ -365,20 +362,17 @@ class Arc(PositionableImpl, Shape):
|
||||||
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
|
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 our arc subtends a coordinate axis, use the extremum along that axis
|
||||||
if abs(a1 - a0) >= 2 * pi:
|
if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
|
||||||
xn, xp, yn, yp = -xr, xr, -yr, yr
|
xp = xr
|
||||||
else:
|
|
||||||
if a0 <= xpt <= a1 or a0 <= xpt + 2 * pi <= a1:
|
|
||||||
xp = xr
|
|
||||||
|
|
||||||
if a0 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1:
|
if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
|
||||||
xn = -xr
|
xn = -xr
|
||||||
|
|
||||||
if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1:
|
if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
|
||||||
yp = yr
|
yp = yr
|
||||||
|
|
||||||
if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1:
|
if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
|
||||||
yn = -yr
|
yn = -yr
|
||||||
|
|
||||||
mins.append([xn, yn])
|
mins.append([xn, yn])
|
||||||
maxs.append([xp, yp])
|
maxs.append([xp, yp])
|
||||||
|
|
@ -390,6 +384,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Arc':
|
def mirror(self, axis: int = 0) -> 'Arc':
|
||||||
|
self.offset[axis - 1] *= -1
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
self.rotation += axis * pi
|
self.rotation += axis * pi
|
||||||
self.angles *= -1
|
self.angles *= -1
|
||||||
|
|
@ -469,18 +464,13 @@ class Arc(PositionableImpl, Shape):
|
||||||
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
||||||
"""
|
"""
|
||||||
aa = []
|
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):
|
for sgn in (-1, +1):
|
||||||
wh = sgn * self.width / 2.0
|
wh = sgn * self.width / 2.0
|
||||||
rx = self.radius_x + wh
|
rx = self.radius_x + wh
|
||||||
ry = self.radius_y + wh
|
ry = self.radius_y + wh
|
||||||
|
|
||||||
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
|
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
|
||||||
sign = numpy.sign(d_angle)
|
sign = numpy.sign(self.angles[1] - self.angles[0])
|
||||||
if sign != numpy.sign(a1 - a0):
|
if sign != numpy.sign(a1 - a0):
|
||||||
a1 += sign * 2 * pi
|
a1 += sign * 2 * pi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ class Circle(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
|
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
|
||||||
|
self.offset[axis - 1] *= -1
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_by(self, c: float) -> 'Circle':
|
def scale_by(self, c: float) -> 'Circle':
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,7 @@ class Ellipse(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
|
self.offset[axis - 1] *= -1
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
self.rotation += axis * pi
|
self.rotation += axis * pi
|
||||||
return self
|
return self
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,7 @@ class PathCap(Enum):
|
||||||
# # defined by path.cap_extensions
|
# # defined by path.cap_extensions
|
||||||
|
|
||||||
def __lt__(self, other: Any) -> bool:
|
def __lt__(self, other: Any) -> bool:
|
||||||
if self.__class__ is not other.__class__:
|
return self.value == other.value
|
||||||
return self.__class__.__name__ < other.__class__.__name__
|
|
||||||
# Order: Flush, Square, Circle, SquareCustom
|
|
||||||
order = {
|
|
||||||
PathCap.Flush: 0,
|
|
||||||
PathCap.Square: 1,
|
|
||||||
PathCap.Circle: 2,
|
|
||||||
PathCap.SquareCustom: 3,
|
|
||||||
}
|
|
||||||
return order[self] < order[other]
|
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
|
|
@ -88,10 +79,10 @@ class Path(Shape):
|
||||||
def cap(self, val: PathCap) -> None:
|
def cap(self, val: PathCap) -> None:
|
||||||
self._cap = PathCap(val)
|
self._cap = PathCap(val)
|
||||||
if self.cap != PathCap.SquareCustom:
|
if self.cap != PathCap.SquareCustom:
|
||||||
self._cap_extensions = None
|
self.cap_extensions = None
|
||||||
elif self._cap_extensions is None:
|
elif self.cap_extensions is None:
|
||||||
# just got set to SquareCustom
|
# just got set to SquareCustom
|
||||||
self._cap_extensions = numpy.zeros(2)
|
self.cap_extensions = numpy.zeros(2)
|
||||||
|
|
||||||
# cap_extensions property
|
# cap_extensions property
|
||||||
@property
|
@property
|
||||||
|
|
@ -218,12 +209,9 @@ class Path(Shape):
|
||||||
self.vertices = vertices
|
self.vertices = vertices
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
self._cap = cap
|
|
||||||
if cap == PathCap.SquareCustom and cap_extensions is None:
|
|
||||||
self._cap_extensions = numpy.zeros(2)
|
|
||||||
else:
|
|
||||||
self.cap_extensions = cap_extensions
|
|
||||||
self.width = width
|
self.width = width
|
||||||
|
self.cap = cap
|
||||||
|
self.cap_extensions = cap_extensions
|
||||||
if rotation:
|
if rotation:
|
||||||
self.rotate(rotation)
|
self.rotate(rotation)
|
||||||
if numpy.any(offset):
|
if numpy.any(offset):
|
||||||
|
|
@ -265,14 +253,6 @@ class Path(Shape):
|
||||||
if self.cap_extensions is None:
|
if self.cap_extensions is None:
|
||||||
return True
|
return True
|
||||||
return tuple(self.cap_extensions) < tuple(other.cap_extensions)
|
return tuple(self.cap_extensions) < tuple(other.cap_extensions)
|
||||||
if not numpy.array_equal(self.vertices, other.vertices):
|
|
||||||
min_len = min(self.vertices.shape[0], other.vertices.shape[0])
|
|
||||||
eq_mask = self.vertices[:min_len] != other.vertices[:min_len]
|
|
||||||
eq_lt = self.vertices[:min_len] < other.vertices[:min_len]
|
|
||||||
eq_lt_masked = eq_lt[eq_mask]
|
|
||||||
if eq_lt_masked.size > 0:
|
|
||||||
return eq_lt_masked.flat[0]
|
|
||||||
return self.vertices.shape[0] < other.vertices.shape[0]
|
|
||||||
if self.repetition != other.repetition:
|
if self.repetition != other.repetition:
|
||||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||||
return annotations_lt(self.annotations, other.annotations)
|
return annotations_lt(self.annotations, other.annotations)
|
||||||
|
|
@ -323,30 +303,9 @@ class Path(Shape):
|
||||||
) -> list['Polygon']:
|
) -> list['Polygon']:
|
||||||
extensions = self._calculate_cap_extensions()
|
extensions = self._calculate_cap_extensions()
|
||||||
|
|
||||||
v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
|
v = remove_colinear_vertices(self.vertices, closed_path=False)
|
||||||
dv = numpy.diff(v, axis=0)
|
dv = numpy.diff(v, axis=0)
|
||||||
norms = numpy.sqrt((dv * dv).sum(axis=1))
|
dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None]
|
||||||
|
|
||||||
# Filter out zero-length segments if any remained after remove_colinear_vertices
|
|
||||||
valid = (norms > 1e-18)
|
|
||||||
if not numpy.all(valid):
|
|
||||||
# This shouldn't happen much if remove_colinear_vertices is working
|
|
||||||
v = v[numpy.append(valid, True)]
|
|
||||||
dv = numpy.diff(v, axis=0)
|
|
||||||
norms = norms[valid]
|
|
||||||
|
|
||||||
if dv.shape[0] == 0:
|
|
||||||
# All vertices were the same. It's a point.
|
|
||||||
if self.width == 0:
|
|
||||||
return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate
|
|
||||||
if self.cap == PathCap.Circle:
|
|
||||||
return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)
|
|
||||||
if self.cap == PathCap.Square:
|
|
||||||
return [Polygon.square(side_length=self.width, offset=v[0])]
|
|
||||||
# Flush or CustomSquare
|
|
||||||
return [Polygon(vertices=numpy.zeros((3, 2)))]
|
|
||||||
|
|
||||||
dvdir = dv / norms[:, None]
|
|
||||||
|
|
||||||
if self.width == 0:
|
if self.width == 0:
|
||||||
verts = numpy.vstack((v, v[::-1]))
|
verts = numpy.vstack((v, v[::-1]))
|
||||||
|
|
@ -365,21 +324,11 @@ class Path(Shape):
|
||||||
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
|
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
|
||||||
ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]
|
ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]
|
||||||
|
|
||||||
try:
|
rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0]
|
||||||
# Vectorized solve for all intersections
|
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0]
|
||||||
# solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1)
|
|
||||||
rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0]
|
|
||||||
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0]
|
|
||||||
except numpy.linalg.LinAlgError:
|
|
||||||
# Fallback to slower lstsq if some segments are parallel (singular matrix)
|
|
||||||
rp = numpy.zeros(As.shape[0])
|
|
||||||
rn = numpy.zeros(As.shape[0])
|
|
||||||
for ii in range(As.shape[0]):
|
|
||||||
rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0]
|
|
||||||
rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0]
|
|
||||||
|
|
||||||
intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1]
|
intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1]
|
||||||
intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1]
|
intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1]
|
||||||
|
|
||||||
towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp?
|
towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp?
|
||||||
# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight
|
# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight
|
||||||
|
|
@ -447,14 +396,12 @@ class Path(Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Path':
|
def mirror(self, axis: int = 0) -> 'Path':
|
||||||
self.vertices[:, 1 - axis] *= -1
|
self.vertices[:, axis - 1] *= -1
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_by(self, c: float) -> 'Path':
|
def scale_by(self, c: float) -> 'Path':
|
||||||
self.vertices *= c
|
self.vertices *= c
|
||||||
self.width *= c
|
self.width *= c
|
||||||
if self.cap_extensions is not None:
|
|
||||||
self.cap_extensions *= c
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||||
|
|
@ -471,22 +418,21 @@ class Path(Shape):
|
||||||
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
|
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
|
||||||
for v in normed_vertices])
|
for v in normed_vertices])
|
||||||
|
|
||||||
# Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller
|
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
||||||
if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat):
|
x_min = rotated_vertices[:, 0].argmin()
|
||||||
reordered_vertices = rotated_vertices[::-1]
|
if not is_scalar(x_min):
|
||||||
else:
|
y_min = rotated_vertices[x_min, 1].argmin()
|
||||||
reordered_vertices = rotated_vertices
|
x_min = cast('Sequence', x_min)[y_min]
|
||||||
|
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
||||||
|
|
||||||
width0 = self.width / norm_value
|
width0 = self.width / norm_value
|
||||||
cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions)
|
|
||||||
|
|
||||||
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0),
|
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap),
|
||||||
(offset, scale / norm_value, rotation, False),
|
(offset, scale / norm_value, rotation, False),
|
||||||
lambda: Path(
|
lambda: Path(
|
||||||
reordered_vertices * norm_value,
|
reordered_vertices * norm_value,
|
||||||
width=width0 * norm_value,
|
width=self.width * norm_value,
|
||||||
cap=self.cap,
|
cap=self.cap,
|
||||||
cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0),
|
|
||||||
))
|
))
|
||||||
|
|
||||||
def clean_vertices(self) -> 'Path':
|
def clean_vertices(self) -> 'Path':
|
||||||
|
|
@ -516,7 +462,7 @@ class Path(Shape):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
|
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:
|
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,9 @@ class PolyCollection(Shape):
|
||||||
"""
|
"""
|
||||||
Iterator which provides slices which index vertex_lists
|
Iterator which provides slices which index vertex_lists
|
||||||
"""
|
"""
|
||||||
if self._vertex_offsets.size == 0:
|
|
||||||
return
|
|
||||||
for ii, ff in zip(
|
for ii, ff in zip(
|
||||||
self._vertex_offsets,
|
self._vertex_offsets,
|
||||||
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
|
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)),
|
||||||
strict=True,
|
strict=True,
|
||||||
):
|
):
|
||||||
yield slice(ii, ff)
|
yield slice(ii, ff)
|
||||||
|
|
@ -84,7 +82,7 @@ class PolyCollection(Shape):
|
||||||
|
|
||||||
def set_offset(self, val: ArrayLike) -> Self:
|
def set_offset(self, val: ArrayLike) -> Self:
|
||||||
if numpy.any(val):
|
if numpy.any(val):
|
||||||
raise PatternError('PolyCollection offset is forced to (0, 0)')
|
raise PatternError('Path offset is forced to (0, 0)')
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def translate(self, offset: ArrayLike) -> Self:
|
def translate(self, offset: ArrayLike) -> Self:
|
||||||
|
|
@ -170,9 +168,7 @@ class PolyCollection(Shape):
|
||||||
annotations = copy.deepcopy(self.annotations),
|
annotations = copy.deepcopy(self.annotations),
|
||||||
) for vv in self.polygon_vertices]
|
) for vv in self.polygon_vertices]
|
||||||
|
|
||||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
|
def get_bounds_single(self) -> NDArray[numpy.float64]: # 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),
|
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
|
||||||
numpy.max(self._vertex_lists, axis=0)))
|
numpy.max(self._vertex_lists, axis=0)))
|
||||||
|
|
||||||
|
|
@ -183,7 +179,7 @@ class PolyCollection(Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
self._vertex_lists[:, 1 - axis] *= -1
|
self._vertex_lists[:, axis - 1] *= -1
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_by(self, c: float) -> Self:
|
def scale_by(self, c: float) -> Self:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Any, cast, TYPE_CHECKING, Self, Literal
|
from typing import Any, cast, TYPE_CHECKING, Self
|
||||||
import copy
|
import copy
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
|
|
@ -96,11 +96,11 @@ class Polygon(Shape):
|
||||||
@offset.setter
|
@offset.setter
|
||||||
def offset(self, val: ArrayLike) -> None:
|
def offset(self, val: ArrayLike) -> None:
|
||||||
if numpy.any(val):
|
if numpy.any(val):
|
||||||
raise PatternError('Polygon offset is forced to (0, 0)')
|
raise PatternError('Path offset is forced to (0, 0)')
|
||||||
|
|
||||||
def set_offset(self, val: ArrayLike) -> Self:
|
def set_offset(self, val: ArrayLike) -> Self:
|
||||||
if numpy.any(val):
|
if numpy.any(val):
|
||||||
raise PatternError('Polygon offset is forced to (0, 0)')
|
raise PatternError('Path offset is forced to (0, 0)')
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def translate(self, offset: ArrayLike) -> Self:
|
def translate(self, offset: ArrayLike) -> Self:
|
||||||
|
|
@ -321,7 +321,7 @@ class Polygon(Shape):
|
||||||
else:
|
else:
|
||||||
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
|
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
|
||||||
|
|
||||||
poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition)
|
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition)
|
||||||
return poly
|
return poly
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -394,7 +394,7 @@ class Polygon(Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Polygon':
|
def mirror(self, axis: int = 0) -> 'Polygon':
|
||||||
self.vertices[:, 1 - axis] *= -1
|
self.vertices[:, axis - 1] *= -1
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_by(self, c: float) -> 'Polygon':
|
def scale_by(self, c: float) -> 'Polygon':
|
||||||
|
|
@ -417,15 +417,11 @@ class Polygon(Shape):
|
||||||
for v in normed_vertices])
|
for v in normed_vertices])
|
||||||
|
|
||||||
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
||||||
x_min_val = rotated_vertices[:, 0].min()
|
x_min = rotated_vertices[:, 0].argmin()
|
||||||
x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0]
|
if not is_scalar(x_min):
|
||||||
if x_min_inds.size > 1:
|
y_min = rotated_vertices[x_min, 1].argmin()
|
||||||
y_min_val = rotated_vertices[x_min_inds, 1].min()
|
x_min = cast('Sequence', x_min)[y_min]
|
||||||
tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0]
|
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
||||||
start_ind = x_min_inds[tie_breaker]
|
|
||||||
else:
|
|
||||||
start_ind = x_min_inds[0]
|
|
||||||
reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0)
|
|
||||||
|
|
||||||
# TODO: normalize mirroring?
|
# TODO: normalize mirroring?
|
||||||
|
|
||||||
|
|
@ -466,23 +462,3 @@ class Polygon(Shape):
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
centroid = self.vertices.mean(axis=0)
|
centroid = self.vertices.mean(axis=0)
|
||||||
return f'<Polygon centroid {centroid} v{len(self.vertices)}>'
|
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 #noqa: PLC0415
|
|
||||||
return boolean([self], other, operation=operation, scale=scale)
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import numpy
|
||||||
from numpy.typing import NDArray, ArrayLike
|
from numpy.typing import NDArray, ArrayLike
|
||||||
|
|
||||||
from ..traits import (
|
from ..traits import (
|
||||||
Copyable, Scalable, FlippableImpl,
|
Rotatable, Mirrorable, Copyable, Scalable,
|
||||||
PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -26,9 +26,8 @@ normalized_shape_tuple = tuple[
|
||||||
DEFAULT_POLY_NUM_VERTICES = 24
|
DEFAULT_POLY_NUM_VERTICES = 24
|
||||||
|
|
||||||
|
|
||||||
class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
||||||
Copyable, Scalable,
|
PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
|
||||||
metaclass=ABCMeta):
|
|
||||||
"""
|
"""
|
||||||
Class specifying functions common to all shapes.
|
Class specifying functions common to all shapes.
|
||||||
"""
|
"""
|
||||||
|
|
@ -74,7 +73,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
def normalized_form(self, norm_value: int) -> normalized_shape_tuple:
|
||||||
"""
|
"""
|
||||||
Writes the shape in a standardized notation, with offset, scale, and rotation
|
Writes the shape in a standardized notation, with offset, scale, and rotation
|
||||||
information separated out from the remaining values.
|
information separated out from the remaining values.
|
||||||
|
|
@ -121,7 +120,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||||
Returns:
|
Returns:
|
||||||
List of `Polygon` objects with grid-aligned edges.
|
List of `Polygon` objects with grid-aligned edges.
|
||||||
"""
|
"""
|
||||||
from . import Polygon #noqa: PLC0415
|
from . import Polygon
|
||||||
|
|
||||||
gx = numpy.unique(grid_x)
|
gx = numpy.unique(grid_x)
|
||||||
gy = numpy.unique(grid_y)
|
gy = numpy.unique(grid_y)
|
||||||
|
|
@ -139,24 +138,22 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||||
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
|
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
|
||||||
dv = v_next - v
|
dv = v_next - v
|
||||||
|
|
||||||
# Find x-index bounds for the line
|
# Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape
|
||||||
gxi_range = numpy.digitize([v[0], v_next[0]], gx)
|
gxi_range = numpy.digitize([v[0], v_next[0]], gx)
|
||||||
gxi_min = int(numpy.min(gxi_range - 1).clip(0, len(gx) - 1))
|
gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1)
|
||||||
gxi_max = int(numpy.max(gxi_range).clip(0, len(gx)))
|
gxi_max = numpy.max(gxi_range).clip(0, len(gx))
|
||||||
|
|
||||||
if gxi_min < len(gx) - 1:
|
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
|
||||||
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
|
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
|
||||||
if err_xmin >= 0.5:
|
|
||||||
gxi_min += 1
|
|
||||||
|
|
||||||
if gxi_max > 0 and gxi_max < len(gx):
|
if err_xmin >= 0.5:
|
||||||
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
|
gxi_min += 1
|
||||||
if err_xmax >= 0.5:
|
if err_xmax >= 0.5:
|
||||||
gxi_max += 1
|
gxi_max += 1
|
||||||
|
|
||||||
if abs(dv[0]) < 1e-20:
|
if abs(dv[0]) < 1e-20:
|
||||||
# Vertical line, don't calculate slope
|
# Vertical line, don't calculate slope
|
||||||
xi = [gxi_min, max(gxi_min, gxi_max - 1)]
|
xi = [gxi_min, gxi_max - 1]
|
||||||
ys = numpy.array([v[1], v_next[1]])
|
ys = numpy.array([v[1], v_next[1]])
|
||||||
yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1)
|
yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1)
|
||||||
err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1])
|
err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1])
|
||||||
|
|
@ -252,9 +249,9 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||||
Returns:
|
Returns:
|
||||||
List of `Polygon` objects with grid-aligned edges.
|
List of `Polygon` objects with grid-aligned edges.
|
||||||
"""
|
"""
|
||||||
from . import Polygon #noqa: PLC0415
|
from . import Polygon
|
||||||
import skimage.measure #noqa: PLC0415
|
import skimage.measure # type: ignore
|
||||||
import float_raster #noqa: PLC0415
|
import float_raster
|
||||||
|
|
||||||
grx = numpy.unique(grid_x)
|
grx = numpy.unique(grid_x)
|
||||||
gry = numpy.unique(grid_y)
|
gry = numpy.unique(grid_y)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
*,
|
*,
|
||||||
offset: ArrayLike = (0.0, 0.0),
|
offset: ArrayLike = (0.0, 0.0),
|
||||||
rotation: float = 0.0,
|
rotation: float = 0.0,
|
||||||
mirrored: bool = False,
|
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
raw: bool = False,
|
||||||
|
|
@ -81,7 +80,6 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
self._string = string
|
self._string = string
|
||||||
self._height = height
|
self._height = height
|
||||||
self._rotation = rotation
|
self._rotation = rotation
|
||||||
self._mirrored = mirrored
|
|
||||||
self._repetition = repetition
|
self._repetition = repetition
|
||||||
self._annotations = annotations
|
self._annotations = annotations
|
||||||
else:
|
else:
|
||||||
|
|
@ -89,7 +87,6 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
self.string = string
|
self.string = string
|
||||||
self.height = height
|
self.height = height
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
self.mirrored = mirrored
|
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
self.font_path = font_path
|
self.font_path = font_path
|
||||||
|
|
@ -149,7 +146,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
if self.mirrored:
|
if self.mirrored:
|
||||||
poly.mirror()
|
poly.mirror()
|
||||||
poly.scale_by(self.height)
|
poly.scale_by(self.height)
|
||||||
poly.translate(self.offset + [total_advance, 0])
|
poly.offset = self.offset + [total_advance, 0]
|
||||||
poly.rotate_around(self.offset, self.rotation)
|
poly.rotate_around(self.offset, self.rotation)
|
||||||
all_polygons += [poly]
|
all_polygons += [poly]
|
||||||
|
|
||||||
|
|
@ -205,8 +202,8 @@ def get_char_as_polygons(
|
||||||
char: str,
|
char: str,
|
||||||
resolution: float = 48 * 64,
|
resolution: float = 48 * 64,
|
||||||
) -> tuple[list[NDArray[numpy.float64]], float]:
|
) -> tuple[list[NDArray[numpy.float64]], float]:
|
||||||
from freetype import Face # type: ignore #noqa: PLC0415
|
from freetype import Face # type: ignore
|
||||||
from matplotlib.path import Path # type: ignore #noqa: PLC0415
|
from matplotlib.path import Path # type: ignore
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Get a list of polygons representing a single character.
|
Get a list of polygons representing a single character.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""
|
|
||||||
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
|
|
||||||
"""
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
"""
|
|
||||||
|
|
||||||
Test fixtures
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ruff: noqa: ARG001
|
|
||||||
from typing import Any
|
|
||||||
import numpy
|
|
||||||
|
|
||||||
|
|
||||||
FixtureRequest = Any
|
|
||||||
PRNG = numpy.random.RandomState(12345)
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..abstract import Abstract
|
|
||||||
from ..ports import Port
|
|
||||||
from ..ref import Ref
|
|
||||||
|
|
||||||
|
|
||||||
def test_abstract_init() -> None:
|
|
||||||
ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)}
|
|
||||||
abs_obj = Abstract("test", ports)
|
|
||||||
assert abs_obj.name == "test"
|
|
||||||
assert len(abs_obj.ports) == 2
|
|
||||||
assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied
|
|
||||||
|
|
||||||
|
|
||||||
def test_abstract_transform() -> None:
|
|
||||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
|
||||||
# Rotate 90 deg around (0,0)
|
|
||||||
abs_obj.rotate_around((0, 0), pi / 2)
|
|
||||||
# (10, 0) rot 0 -> (0, 10) rot pi/2
|
|
||||||
assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10)
|
|
||||||
assert abs_obj.ports["A"].rotation is not None
|
|
||||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
# Mirror across x axis (axis 0): flips y-offset
|
|
||||||
abs_obj.mirror(0)
|
|
||||||
# (0, 10) mirrored(0) -> (0, -10)
|
|
||||||
# rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2
|
|
||||||
assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10)
|
|
||||||
assert abs_obj.ports["A"].rotation is not None
|
|
||||||
assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_abstract_ref_transform() -> None:
|
|
||||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
|
||||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
|
||||||
|
|
||||||
# Apply ref transform
|
|
||||||
abs_obj.apply_ref_transform(ref)
|
|
||||||
# Ref order: mirror, rotate, scale, translate
|
|
||||||
|
|
||||||
# 1. mirror (across x: y -> -y)
|
|
||||||
# (10, 0) rot 0 -> (10, 0) rot 0
|
|
||||||
|
|
||||||
# 2. rotate pi/2 around (0,0)
|
|
||||||
# (10, 0) rot 0 -> (0, 10) rot pi/2
|
|
||||||
|
|
||||||
# 3. translate (100, 100)
|
|
||||||
# (0, 10) -> (100, 110)
|
|
||||||
|
|
||||||
assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10)
|
|
||||||
assert abs_obj.ports["A"].rotation is not None
|
|
||||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_abstract_ref_transform_scales_offsets() -> None:
|
|
||||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
|
||||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
|
|
||||||
|
|
||||||
abs_obj.apply_ref_transform(ref)
|
|
||||||
|
|
||||||
assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10)
|
|
||||||
assert abs_obj.ports["A"].rotation is not None
|
|
||||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_abstract_undo_transform() -> None:
|
|
||||||
abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)})
|
|
||||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
|
||||||
|
|
||||||
abs_obj.undo_ref_transform(ref)
|
|
||||||
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
|
||||||
assert abs_obj.ports["A"].rotation is not None
|
|
||||||
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_abstract_undo_transform_scales_offsets() -> None:
|
|
||||||
abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)})
|
|
||||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
|
|
||||||
|
|
||||||
abs_obj.undo_ref_transform(ref)
|
|
||||||
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
|
||||||
assert abs_obj.ports["A"].rotation is not None
|
|
||||||
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_equal
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import Pather
|
|
||||||
from ..builder.tools import PathTool
|
|
||||||
from ..library import Library
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def advanced_pather() -> tuple[Pather, PathTool, Library]:
|
|
||||||
lib = Library()
|
|
||||||
# Simple PathTool: 2um width on layer (1,0)
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False)
|
|
||||||
return p, tool, lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = advanced_pather
|
|
||||||
# Facing ports
|
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device)
|
|
||||||
# Forward (+pi relative to port) is West (-x).
|
|
||||||
# Put destination at (-20, 0) pointing East (pi).
|
|
||||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
|
||||||
|
|
||||||
p.trace_into("src", "dst")
|
|
||||||
|
|
||||||
assert "src" not in p.ports
|
|
||||||
assert "dst" not in p.ports
|
|
||||||
# Pather._traceL adds a Reference to the generated pattern
|
|
||||||
assert len(p.pattern.refs) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = advanced_pather
|
|
||||||
# Source at (0,0) rot 0 (facing East). Forward is West (-x).
|
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
|
||||||
# Destination at (-20, -20) rot pi (facing West). Forward is East (+x).
|
|
||||||
# Wait, src forward is -x. dst is at -20, -20.
|
|
||||||
# To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South).
|
|
||||||
# Forward for South is North (+y).
|
|
||||||
p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire")
|
|
||||||
|
|
||||||
p.trace_into("src", "dst")
|
|
||||||
|
|
||||||
assert "src" not in p.ports
|
|
||||||
assert "dst" not in p.ports
|
|
||||||
# Single bend should result in 2 segments (one for x move, one for y move)
|
|
||||||
assert len(p.pattern.refs) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = advanced_pather
|
|
||||||
# Facing but offset ports
|
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Forward is West (-x)
|
|
||||||
p.ports["dst"] = Port((-20, -10), pi, ptype="wire") # Facing East (rot pi)
|
|
||||||
|
|
||||||
p.trace_into("src", "dst")
|
|
||||||
|
|
||||||
assert "src" not in p.ports
|
|
||||||
assert "dst" not in p.ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = advanced_pather
|
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
|
||||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
|
||||||
p.ports["other"] = Port((10, 10), 0)
|
|
||||||
|
|
||||||
p.trace_into("src", "dst", thru="other")
|
|
||||||
|
|
||||||
assert "src" in p.ports
|
|
||||||
assert_equal(p.ports["src"].offset, [10, 10])
|
|
||||||
assert "other" not in p.ports
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import Pather
|
|
||||||
from ..builder.tools import AutoTool
|
|
||||||
from ..library import Library
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
|
|
||||||
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
|
||||||
pat.ports["in"] = Port((0, 0), 0, ptype=ptype)
|
|
||||||
pat.ports["out"] = Port((length, 0), pi, ptype=ptype)
|
|
||||||
return pat
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def autotool_setup() -> tuple[Pather, AutoTool, Library]:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
# Define a simple bend
|
|
||||||
bend_pat = Pattern()
|
|
||||||
# 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise)
|
|
||||||
bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire")
|
|
||||||
bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire")
|
|
||||||
lib["bend"] = bend_pat
|
|
||||||
lib.abstract("bend")
|
|
||||||
|
|
||||||
# Define a transition (e.g., via)
|
|
||||||
via_pat = Pattern()
|
|
||||||
via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1")
|
|
||||||
via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2")
|
|
||||||
lib["via"] = via_pat
|
|
||||||
via_abs = lib.abstract("via")
|
|
||||||
|
|
||||||
tool_m1 = AutoTool(
|
|
||||||
straights=[
|
|
||||||
AutoTool.Straight(ptype="wire_m1", fn=lambda length: make_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out")
|
|
||||||
],
|
|
||||||
bends=[],
|
|
||||||
sbends=[],
|
|
||||||
transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")},
|
|
||||||
default_out_ptype="wire_m1",
|
|
||||||
)
|
|
||||||
|
|
||||||
p = Pather(lib, tools=tool_m1)
|
|
||||||
# Start with an m2 port
|
|
||||||
p.ports["start"] = Port((0, 0), pi, ptype="wire_m2")
|
|
||||||
|
|
||||||
return p, tool_m1, lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = autotool_setup
|
|
||||||
|
|
||||||
# Route m1 from an m2 port. Should trigger via.
|
|
||||||
# length 10. Via length is 1. So straight m1 should be 9.
|
|
||||||
p.straight("start", 10)
|
|
||||||
|
|
||||||
# Start at (0,0) rot pi (facing West).
|
|
||||||
# Forward (+pi relative to port) is East (+x).
|
|
||||||
# Via: m2(1,0)pi -> m1(0,0)0.
|
|
||||||
# Plug via m2 into start(0,0)pi: transformation rot=mod(pi-pi-pi, 2pi)=pi.
|
|
||||||
# rotate via by pi: m2 at (0,0), m1 at (-1, 0) rot pi.
|
|
||||||
# Then straight m1 of length 9 from (-1, 0) rot pi -> ends at (8, 0) rot pi.
|
|
||||||
# Wait, (length, 0) relative to (-1, 0) rot pi:
|
|
||||||
# transform (9, 0) by pi: (-9, 0).
|
|
||||||
# (-1, 0) + (-9, 0) = (-10, 0)? No.
|
|
||||||
# Let's re-calculate.
|
|
||||||
# start (0,0) rot pi. Direction East.
|
|
||||||
# via m2 is at (0,0), m1 is at (1,0).
|
|
||||||
# When via is plugged into start: m2 goes to (0,0).
|
|
||||||
# since start is pi and m2 is pi, rotation is 0.
|
|
||||||
# so via m1 is at (1,0) rot 0.
|
|
||||||
# then straight m1 length 9 from (1,0) rot 0: ends at (10, 0) rot 0.
|
|
||||||
|
|
||||||
assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10)
|
|
||||||
assert p.ports["start"].ptype == "wire_m1"
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from masque.builder.tools import AutoTool
|
|
||||||
from masque.pattern import Pattern
|
|
||||||
from masque.ports import Port
|
|
||||||
from masque.library import Library
|
|
||||||
from masque.builder.pather import Pather, RenderPather
|
|
||||||
|
|
||||||
def make_straight(length, width=2, ptype="wire"):
|
|
||||||
pat = Pattern()
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
|
||||||
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
|
|
||||||
return pat
|
|
||||||
|
|
||||||
def make_bend(R, width=2, ptype="wire", clockwise=True):
|
|
||||||
pat = Pattern()
|
|
||||||
# 90 degree arc approximation (just two rects for start and end)
|
|
||||||
if clockwise:
|
|
||||||
# (0,0) rot 0 to (R, -R) rot pi/2
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
|
|
||||||
pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0)
|
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
|
||||||
pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype)
|
|
||||||
else:
|
|
||||||
# (0,0) rot 0 to (R, R) rot -pi/2
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
|
|
||||||
pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R)
|
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
|
||||||
pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype)
|
|
||||||
return pat
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def multi_bend_tool():
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
# Bend 1: R=2
|
|
||||||
lib["b1"] = make_bend(2, ptype="wire")
|
|
||||||
b1_abs = lib.abstract("b1")
|
|
||||||
# Bend 2: R=5
|
|
||||||
lib["b2"] = make_bend(5, ptype="wire")
|
|
||||||
b2_abs = lib.abstract("b2")
|
|
||||||
|
|
||||||
tool = AutoTool(
|
|
||||||
straights=[
|
|
||||||
# Straight 1: only for length < 10
|
|
||||||
AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)),
|
|
||||||
# Straight 2: for length >= 10
|
|
||||||
AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8))
|
|
||||||
],
|
|
||||||
bends=[
|
|
||||||
AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True),
|
|
||||||
AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True)
|
|
||||||
],
|
|
||||||
sbends=[],
|
|
||||||
transitions={},
|
|
||||||
default_out_ptype="wire"
|
|
||||||
)
|
|
||||||
return tool, lib
|
|
||||||
|
|
||||||
def test_autotool_planL_selection(multi_bend_tool) -> None:
|
|
||||||
tool, _ = multi_bend_tool
|
|
||||||
|
|
||||||
# Small length: should pick straight 1 and bend 1 (R=2)
|
|
||||||
# L = straight + R. If L=5, straight=3.
|
|
||||||
p, data = tool.planL(True, 5)
|
|
||||||
assert data.straight.length_range == (0, 10)
|
|
||||||
assert data.straight_length == 3
|
|
||||||
assert data.bend.abstract.name == "b1"
|
|
||||||
assert_allclose(p.offset, [5, 2])
|
|
||||||
|
|
||||||
# Large length: should pick straight 2 and bend 1 (R=2)
|
|
||||||
# If L=15, straight=13.
|
|
||||||
p, data = tool.planL(True, 15)
|
|
||||||
assert data.straight.length_range == (10, 1e8)
|
|
||||||
assert data.straight_length == 13
|
|
||||||
assert_allclose(p.offset, [15, 2])
|
|
||||||
|
|
||||||
def test_autotool_planU_consistency(multi_bend_tool) -> None:
|
|
||||||
tool, lib = multi_bend_tool
|
|
||||||
|
|
||||||
# length=10, jog=20.
|
|
||||||
# U-turn: Straight1 -> Bend1 -> Straight_mid -> Straight3(0) -> Bend2
|
|
||||||
# X = L1_total - R2 = length
|
|
||||||
# Y = R1 + L2_mid + R2 = jog
|
|
||||||
|
|
||||||
p, data = tool.planU(20, length=10)
|
|
||||||
assert data.ldata0.straight_length == 7
|
|
||||||
assert data.ldata0.bend.abstract.name == "b2"
|
|
||||||
assert data.l2_length == 13
|
|
||||||
assert data.ldata1.straight_length == 0
|
|
||||||
assert data.ldata1.bend.abstract.name == "b1"
|
|
||||||
|
|
||||||
def test_autotool_planS_double_L(multi_bend_tool) -> None:
|
|
||||||
tool, lib = multi_bend_tool
|
|
||||||
|
|
||||||
# length=20, jog=10. S-bend (ccw1, cw2)
|
|
||||||
# X = L1_total + R2 = length
|
|
||||||
# Y = R1 + L2_mid + R2 = jog
|
|
||||||
|
|
||||||
p, data = tool.planS(20, 10)
|
|
||||||
assert_allclose(p.offset, [20, 10])
|
|
||||||
assert_allclose(p.rotation, pi)
|
|
||||||
|
|
||||||
assert data.ldata0.straight_length == 16
|
|
||||||
assert data.ldata1.straight_length == 0
|
|
||||||
assert data.l2_length == 6
|
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_planS_pure_sbend_with_transition_dx() -> None:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
def make_straight(length: float) -> Pattern:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype="core")
|
|
||||||
pat.ports["B"] = Port((length, 0), pi, ptype="core")
|
|
||||||
return pat
|
|
||||||
|
|
||||||
def make_sbend(jog: float) -> Pattern:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype="core")
|
|
||||||
pat.ports["B"] = Port((10, jog), pi, ptype="core")
|
|
||||||
return pat
|
|
||||||
|
|
||||||
trans_pat = Pattern()
|
|
||||||
trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext")
|
|
||||||
trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core")
|
|
||||||
lib["xin"] = trans_pat
|
|
||||||
|
|
||||||
tool = AutoTool(
|
|
||||||
straights=[
|
|
||||||
AutoTool.Straight(
|
|
||||||
ptype="core",
|
|
||||||
fn=make_straight,
|
|
||||||
in_port_name="A",
|
|
||||||
out_port_name="B",
|
|
||||||
length_range=(1, 1e8),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
bends=[],
|
|
||||||
sbends=[
|
|
||||||
AutoTool.SBend(
|
|
||||||
ptype="core",
|
|
||||||
fn=make_sbend,
|
|
||||||
in_port_name="A",
|
|
||||||
out_port_name="B",
|
|
||||||
jog_range=(0, 1e8),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
transitions={
|
|
||||||
("ext", "core"): AutoTool.Transition(lib.abstract("xin"), "EXT", "CORE"),
|
|
||||||
},
|
|
||||||
default_out_ptype="core",
|
|
||||||
)
|
|
||||||
|
|
||||||
p, data = tool.planS(15, 4, in_ptype="ext")
|
|
||||||
|
|
||||||
assert_allclose(p.offset, [15, 4])
|
|
||||||
assert_allclose(p.rotation, pi)
|
|
||||||
assert data.straight_length == 0
|
|
||||||
assert data.jog_remaining == 4
|
|
||||||
assert data.in_transition is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_autotool_double_L(multi_bend_tool) -> None:
|
|
||||||
tool, lib = multi_bend_tool
|
|
||||||
rp = RenderPather(lib, tools=tool)
|
|
||||||
rp.ports["A"] = Port((0,0), 0, ptype="wire")
|
|
||||||
|
|
||||||
# This should trigger double-L fallback in planS
|
|
||||||
rp.jog("A", 10, length=20)
|
|
||||||
|
|
||||||
# port_rot=0 -> forward is -x. jog=10 (left) is -y.
|
|
||||||
assert_allclose(rp.ports["A"].offset, [-20, -10])
|
|
||||||
assert_allclose(rp.ports["A"].rotation, 0) # jog rot is pi relative to input, input rot is pi relative to port.
|
|
||||||
# Wait, planS returns out_port at (length, jog) rot pi relative to input (0,0) rot 0.
|
|
||||||
# Input rot relative to port is pi.
|
|
||||||
# Rotate (length, jog) rot pi by pi: (-length, -jog) rot 0. Correct.
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
assert len(rp.pattern.refs) > 0
|
|
||||||
|
|
||||||
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None:
|
|
||||||
tool, lib = multi_bend_tool
|
|
||||||
|
|
||||||
class BasicTool(AutoTool):
|
|
||||||
def planU(self, *args, **kwargs):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
tool_basic = BasicTool(
|
|
||||||
straights=tool.straights,
|
|
||||||
bends=tool.bends,
|
|
||||||
sbends=tool.sbends,
|
|
||||||
transitions=tool.transitions,
|
|
||||||
default_out_ptype=tool.default_out_ptype
|
|
||||||
)
|
|
||||||
|
|
||||||
p = Pather(lib, tools=tool_basic)
|
|
||||||
p.ports["A"] = Port((0,0), 0, ptype="wire") # facing West (Actually East points Inwards, West is Extension)
|
|
||||||
|
|
||||||
# uturn jog=10, length=5.
|
|
||||||
# R=2. L1 = 5+2=7. L2 = 10-2=8.
|
|
||||||
p.uturn("A", 10, length=5)
|
|
||||||
|
|
||||||
# port_rot=0 -> forward is -x. jog=10 (left) is -y.
|
|
||||||
# L1=7 along -x -> (-7, 0). Bend1 (ccw) -> rot -pi/2 (South).
|
|
||||||
# L2=8 along -y -> (-7, -8). Bend2 (ccw) -> rot 0 (East).
|
|
||||||
# wait. CCW turn from facing South (-y): turn towards East (+x).
|
|
||||||
# Wait.
|
|
||||||
# Input facing -x. CCW turn -> face -y.
|
|
||||||
# Input facing -y. CCW turn -> face +x.
|
|
||||||
# So final rotation is 0.
|
|
||||||
# Bend1 (ccw) relative to -x: global offset is (-7, -2)?
|
|
||||||
# Let's re-run my manual calculation.
|
|
||||||
# Port rot 0. Wire input rot pi. Wire output relative to input:
|
|
||||||
# L1=7, R1=2, CCW=True. Output (7, 2) rot pi/2.
|
|
||||||
# Rotate wire by pi: output (-7, -2) rot 3pi/2.
|
|
||||||
# Second turn relative to (-7, -2) rot 3pi/2:
|
|
||||||
# local output (8, 2) rot pi/2.
|
|
||||||
# global: (-7, -2) + 8*rot(3pi/2)*x + 2*rot(3pi/2)*y
|
|
||||||
# = (-7, -2) + 8*(0, -1) + 2*(1, 0) = (-7, -2) + (0, -8) + (2, 0) = (-5, -10).
|
|
||||||
# YES! ACTUAL result was (-5, -10).
|
|
||||||
assert_allclose(p.ports["A"].offset, [-5, -10])
|
|
||||||
assert_allclose(p.ports["A"].rotation, pi)
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
# ruff: noqa: PLC0415
|
|
||||||
import pytest
|
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from masque.pattern import Pattern
|
|
||||||
from masque.shapes.polygon import Polygon
|
|
||||||
from masque.repetition import Grid
|
|
||||||
from masque.library import Library
|
|
||||||
|
|
||||||
def test_layer_as_polygons_basic() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
||||||
|
|
||||||
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
|
||||||
assert len(polys) == 1
|
|
||||||
assert isinstance(polys[0], Polygon)
|
|
||||||
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
||||||
|
|
||||||
def test_layer_as_polygons_repetition() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
rep = Grid(a_vector=(2, 0), a_count=2)
|
|
||||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep)
|
|
||||||
|
|
||||||
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
|
||||||
assert len(polys) == 2
|
|
||||||
# First polygon at (0,0)
|
|
||||||
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
||||||
# Second polygon at (2,0)
|
|
||||||
assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]])
|
|
||||||
|
|
||||||
def test_layer_as_polygons_flatten() -> None:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
child = Pattern()
|
|
||||||
child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]])
|
|
||||||
lib['child'] = child
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref('child', offset=(10, 10), rotation=numpy.pi/2)
|
|
||||||
|
|
||||||
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
|
|
||||||
assert len(polys) == 1
|
|
||||||
# Original child at (0,0) with rot pi/2 is still at (0,0) in its own space?
|
|
||||||
# No, ref.as_pattern(child) will apply the transform.
|
|
||||||
# Child (0,0), (1,0), (1,1) rotated pi/2 around (0,0) -> (0,0), (0,1), (-1,1)
|
|
||||||
# Then offset by (10,10) -> (10,10), (10,11), (9,11)
|
|
||||||
|
|
||||||
# Let's verify the vertices
|
|
||||||
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
|
|
||||||
assert_allclose(polys[0].vertices, expected, atol=1e-10)
|
|
||||||
|
|
||||||
def test_boolean_import_error() -> None:
|
|
||||||
from masque import boolean
|
|
||||||
# If pyclipper is not installed, this should raise ImportError
|
|
||||||
try:
|
|
||||||
import pyclipper # noqa: F401
|
|
||||||
pytest.skip("pyclipper is installed, cannot test ImportError")
|
|
||||||
except ImportError:
|
|
||||||
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
|
||||||
boolean([], [], operation='union')
|
|
||||||
|
|
||||||
def test_polygon_boolean_shortcut() -> None:
|
|
||||||
poly = Polygon([[0, 0], [1, 0], [1, 1]])
|
|
||||||
# This should also raise ImportError if pyclipper is missing
|
|
||||||
try:
|
|
||||||
import pyclipper # noqa: F401
|
|
||||||
pytest.skip("pyclipper is installed")
|
|
||||||
except ImportError:
|
|
||||||
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
|
||||||
poly.boolean(poly)
|
|
||||||
|
|
||||||
def test_bridge_holes() -> None:
|
|
||||||
from masque.utils.boolean import _bridge_holes
|
|
||||||
|
|
||||||
# Outer: 10x10 square
|
|
||||||
outer = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
||||||
# Hole: 2x2 square in the middle
|
|
||||||
hole = numpy.array([[4, 4], [6, 4], [6, 6], [4, 6]])
|
|
||||||
|
|
||||||
bridged = _bridge_holes(outer, [hole])
|
|
||||||
|
|
||||||
# We expect more vertices than outer + hole
|
|
||||||
# Original outer has 4, hole has 4. Bridge adds 2 (to hole) and 2 (back to outer) + 1 to close hole loop?
|
|
||||||
# Our implementation:
|
|
||||||
# 1. outer up to bridge edge (best_edge_idx)
|
|
||||||
# 2. bridge point on outer
|
|
||||||
# 3. hole reordered starting at max X
|
|
||||||
# 4. close hole loop (repeat max X)
|
|
||||||
# 5. bridge point on outer again
|
|
||||||
# 6. rest of outer
|
|
||||||
|
|
||||||
# max X of hole is 6 at (6,4) or (6,6). argmax will pick first one.
|
|
||||||
# hole vertices: [4,4], [6,4], [6,6], [4,6]. argmax(x) is index 1: (6,4)
|
|
||||||
# roll hole to start at (6,4): [6,4], [6,6], [4,6], [4,4]
|
|
||||||
|
|
||||||
# intersection of ray from (6,4) to right:
|
|
||||||
# edges of outer: (0,0)-(10,0), (10,0)-(10,10), (10,10)-(0,10), (0,10)-(0,0)
|
|
||||||
# edge (10,0)-(10,10) spans y=4.
|
|
||||||
# intersection at (10,4). best_edge_idx = 1 (edge from index 1 to 2)
|
|
||||||
|
|
||||||
# vertices added:
|
|
||||||
# outer[0:2]: (0,0), (10,0)
|
|
||||||
# bridge pt: (10,4)
|
|
||||||
# hole: (6,4), (6,6), (4,6), (4,4)
|
|
||||||
# hole close: (6,4)
|
|
||||||
# bridge pt back: (10,4)
|
|
||||||
# outer[2:]: (10,10), (0,10)
|
|
||||||
|
|
||||||
expected_len = 11
|
|
||||||
assert len(bridged) == expected_len
|
|
||||||
|
|
||||||
# verify it wraps around the hole and back
|
|
||||||
# index 2 is bridge_pt
|
|
||||||
assert_allclose(bridged[2], [10, 4])
|
|
||||||
# index 3 is hole reordered max X
|
|
||||||
assert_allclose(bridged[3], [6, 4])
|
|
||||||
# index 7 is hole closed at max X
|
|
||||||
assert_allclose(bridged[7], [6, 4])
|
|
||||||
# index 8 is bridge_pt back
|
|
||||||
assert_allclose(bridged[8], [10, 4])
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import Builder
|
|
||||||
from ..library import Library
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_init() -> None:
|
|
||||||
lib = Library()
|
|
||||||
b = Builder(lib, name="mypat")
|
|
||||||
assert b.pattern is lib["mypat"]
|
|
||||||
assert b.library is lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_place() -> None:
|
|
||||||
lib = Library()
|
|
||||||
child = Pattern()
|
|
||||||
child.ports["A"] = Port((0, 0), 0)
|
|
||||||
lib["child"] = child
|
|
||||||
|
|
||||||
b = Builder(lib)
|
|
||||||
b.place("child", offset=(10, 20), port_map={"A": "child_A"})
|
|
||||||
|
|
||||||
assert "child_A" in b.ports
|
|
||||||
assert_equal(b.ports["child_A"].offset, [10, 20])
|
|
||||||
assert "child" in b.pattern.refs
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_plug() -> None:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
wire = Pattern()
|
|
||||||
wire.ports["in"] = Port((0, 0), 0)
|
|
||||||
wire.ports["out"] = Port((10, 0), pi)
|
|
||||||
lib["wire"] = wire
|
|
||||||
|
|
||||||
b = Builder(lib)
|
|
||||||
b.ports["start"] = Port((100, 100), 0)
|
|
||||||
|
|
||||||
# Plug wire's "in" port into builder's "start" port
|
|
||||||
# Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports
|
|
||||||
# builder start: (100, 100) rotation 0
|
|
||||||
# wire in: (0, 0) rotation 0
|
|
||||||
# wire out: (10, 0) rotation pi
|
|
||||||
# Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg)
|
|
||||||
# so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100)
|
|
||||||
b.plug("wire", map_in={"start": "in"})
|
|
||||||
|
|
||||||
assert "start" in b.ports
|
|
||||||
assert_equal(b.ports["start"].offset, [90, 100])
|
|
||||||
assert b.ports["start"].rotation is not None
|
|
||||||
assert_allclose(b.ports["start"].rotation, 0, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_interface() -> None:
|
|
||||||
lib = Library()
|
|
||||||
source = Pattern()
|
|
||||||
source.ports["P1"] = Port((0, 0), 0)
|
|
||||||
lib["source"] = source
|
|
||||||
|
|
||||||
b = Builder.interface("source", library=lib, name="iface")
|
|
||||||
assert "in_P1" in b.ports
|
|
||||||
assert "P1" in b.ports
|
|
||||||
assert b.pattern is lib["iface"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_set_dead() -> None:
|
|
||||||
lib = Library()
|
|
||||||
lib["sub"] = Pattern()
|
|
||||||
b = Builder(lib)
|
|
||||||
b.set_dead()
|
|
||||||
|
|
||||||
b.place("sub")
|
|
||||||
assert not b.pattern.has_refs()
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_dead_ports() -> None:
|
|
||||||
lib = Library()
|
|
||||||
pat = Pattern()
|
|
||||||
pat.ports['A'] = Port((0, 0), 0)
|
|
||||||
b = Builder(lib, pattern=pat)
|
|
||||||
b.set_dead()
|
|
||||||
|
|
||||||
# Attempt to plug a device where ports don't line up
|
|
||||||
# A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference).
|
|
||||||
other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)})
|
|
||||||
|
|
||||||
# This should NOT raise PortError because b is dead
|
|
||||||
b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'})
|
|
||||||
|
|
||||||
# Port A should be removed, and Port B (renamed from D) should be added
|
|
||||||
assert 'A' not in b.ports
|
|
||||||
assert 'B' in b.ports
|
|
||||||
|
|
||||||
# Verify geometry was not added
|
|
||||||
assert not b.pattern.has_refs()
|
|
||||||
assert not b.pattern.has_shapes()
|
|
||||||
|
|
||||||
|
|
||||||
def test_dead_plug_best_effort() -> None:
|
|
||||||
lib = Library()
|
|
||||||
pat = Pattern()
|
|
||||||
pat.ports['A'] = Port((0, 0), 0)
|
|
||||||
b = Builder(lib, pattern=pat)
|
|
||||||
b.set_dead()
|
|
||||||
|
|
||||||
# Device with multiple ports, none of which line up correctly
|
|
||||||
other = Pattern(ports={
|
|
||||||
'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi)
|
|
||||||
'P2': Port((20, 20), pi) # Correct rotation but wrong offset
|
|
||||||
})
|
|
||||||
|
|
||||||
# Try to plug. find_transform will fail.
|
|
||||||
# It should fall back to aligning the first pair ('A' and 'P1').
|
|
||||||
b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'})
|
|
||||||
|
|
||||||
assert 'A' not in b.ports
|
|
||||||
assert 'B' in b.ports
|
|
||||||
|
|
||||||
# Dummy transform aligns A (0,0) with P1 (10,10)
|
|
||||||
# A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi
|
|
||||||
# P2 (20,20) rotation pi:
|
|
||||||
# 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10)
|
|
||||||
# 2. Rotate (10,10) by -pi: (-10,-10)
|
|
||||||
# 3. Translate by s_port.offset (0,0): (-10,-10)
|
|
||||||
assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10)
|
|
||||||
# P2 rot pi + transform rot -pi = 0
|
|
||||||
assert b.ports['B'].rotation is not None
|
|
||||||
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10)
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
import io
|
|
||||||
import numpy
|
|
||||||
import ezdxf
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..library import Library
|
|
||||||
from ..shapes import Path as MPath, Polygon
|
|
||||||
from ..repetition import Grid
|
|
||||||
from ..file import dxf
|
|
||||||
|
|
||||||
def test_dxf_roundtrip(tmp_path: Path):
|
|
||||||
lib = Library()
|
|
||||||
pat = Pattern()
|
|
||||||
|
|
||||||
# 1. Polygon (closed)
|
|
||||||
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
||||||
pat.polygon("1", vertices=poly_verts)
|
|
||||||
|
|
||||||
# 2. Path (open, 3 points)
|
|
||||||
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
|
|
||||||
pat.path("2", vertices=path_verts, width=2)
|
|
||||||
|
|
||||||
# 3. Path (open, 2 points) - Testing the fix for 2-point polylines
|
|
||||||
path2_verts = numpy.array([[40, 0], [50, 10]])
|
|
||||||
pat.path("3", vertices=path2_verts, width=0) # width 0 to be sure it's not a polygonized path if we're not careful
|
|
||||||
|
|
||||||
# 4. Ref with Grid repetition (Manhattan)
|
|
||||||
subpat = Pattern()
|
|
||||||
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
|
|
||||||
lib["sub"] = subpat
|
|
||||||
|
|
||||||
pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3))
|
|
||||||
|
|
||||||
lib["top"] = pat
|
|
||||||
|
|
||||||
dxf_file = tmp_path / "test.dxf"
|
|
||||||
dxf.writefile(lib, "top", dxf_file)
|
|
||||||
|
|
||||||
read_lib, _ = dxf.readfile(dxf_file)
|
|
||||||
|
|
||||||
# In DXF read, the top level is usually called "Model"
|
|
||||||
top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
|
||||||
|
|
||||||
# Verify Polygon
|
|
||||||
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
|
|
||||||
assert len(polys) >= 1
|
|
||||||
poly_read = polys[0]
|
|
||||||
# DXF polyline might be shifted or vertices reordered, but here they should be simple
|
|
||||||
assert_allclose(poly_read.vertices, poly_verts)
|
|
||||||
|
|
||||||
# Verify 3-point Path
|
|
||||||
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
|
|
||||||
assert len(paths) >= 1
|
|
||||||
path_read = paths[0]
|
|
||||||
assert_allclose(path_read.vertices, path_verts)
|
|
||||||
assert path_read.width == 2
|
|
||||||
|
|
||||||
# Verify 2-point Path
|
|
||||||
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
|
|
||||||
assert len(paths2) >= 1
|
|
||||||
path2_read = paths2[0]
|
|
||||||
assert_allclose(path2_read.vertices, path2_verts)
|
|
||||||
assert path2_read.width == 0
|
|
||||||
|
|
||||||
# Verify Ref with Grid
|
|
||||||
# Finding the sub pattern name might be tricky because of how DXF stores blocks
|
|
||||||
# but "sub" should be in read_lib
|
|
||||||
assert "sub" in read_lib
|
|
||||||
|
|
||||||
# Check refs in the top pattern
|
|
||||||
found_grid = False
|
|
||||||
for target, reflist in top_pat.refs.items():
|
|
||||||
# DXF names might be case-insensitive or modified, but ezdxf usually preserves them
|
|
||||||
if target.upper() == "SUB":
|
|
||||||
for ref in reflist:
|
|
||||||
if isinstance(ref.repetition, Grid):
|
|
||||||
assert ref.repetition.a_count == 2
|
|
||||||
assert ref.repetition.b_count == 3
|
|
||||||
assert_allclose(ref.repetition.a_vector, (10, 0))
|
|
||||||
assert_allclose(ref.repetition.b_vector, (0, 10))
|
|
||||||
found_grid = True
|
|
||||||
assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}"
|
|
||||||
|
|
||||||
def test_dxf_manhattan_precision(tmp_path: Path):
|
|
||||||
# Test that float precision doesn't break Manhattan grid detection
|
|
||||||
lib = Library()
|
|
||||||
sub = Pattern()
|
|
||||||
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
|
||||||
lib["sub"] = sub
|
|
||||||
|
|
||||||
top = Pattern()
|
|
||||||
# 90 degree rotation: in masque the grid is NOT rotated, so it stays [[10,0],[0,10]]
|
|
||||||
# In DXF, an array with rotation 90 has basis vectors [[0,10],[-10,0]].
|
|
||||||
# So a masque grid [[10,0],[0,10]] with ref rotation 90 matches a DXF array.
|
|
||||||
angle = numpy.pi / 2 # 90 degrees
|
|
||||||
top.ref("sub", offset=(0, 0), rotation=angle,
|
|
||||||
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
|
|
||||||
|
|
||||||
lib["top"] = top
|
|
||||||
|
|
||||||
dxf_file = tmp_path / "precision.dxf"
|
|
||||||
dxf.writefile(lib, "top", dxf_file)
|
|
||||||
|
|
||||||
# If the isclose() fix works, this should still be a Grid when read back
|
|
||||||
read_lib, _ = dxf.readfile(dxf_file)
|
|
||||||
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
|
||||||
|
|
||||||
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
|
|
||||||
ref = read_top.refs[target_name][0]
|
|
||||||
assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation"
|
|
||||||
|
|
||||||
|
|
||||||
def test_dxf_read_legacy_polyline() -> None:
|
|
||||||
doc = ezdxf.new()
|
|
||||||
msp = doc.modelspace()
|
|
||||||
msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True)
|
|
||||||
|
|
||||||
stream = io.StringIO()
|
|
||||||
doc.write(stream)
|
|
||||||
stream.seek(0)
|
|
||||||
|
|
||||||
read_lib, _ = dxf.read(stream)
|
|
||||||
top_pat = read_lib.get("Model") or list(read_lib.values())[0]
|
|
||||||
|
|
||||||
polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)]
|
|
||||||
assert len(polys) == 1
|
|
||||||
assert_allclose(polys[0].vertices, [[0, 0], [10, 0], [10, 10]])
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# ruff: noqa
|
|
||||||
# ruff: noqa: ARG001
|
|
||||||
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import pytest # type: ignore
|
|
||||||
import numpy
|
|
||||||
from numpy import pi
|
|
||||||
from numpy.typing import NDArray
|
|
||||||
# from numpy.testing import assert_allclose, assert_array_equal
|
|
||||||
|
|
||||||
from .. import Pattern, Arc, Circle
|
|
||||||
|
|
||||||
|
|
||||||
def test_circle_mirror():
|
|
||||||
cc = Circle(radius=4, offset=(10, 20))
|
|
||||||
cc.flip_across(axis=0) # flip across y=0
|
|
||||||
assert cc.offset[0] == 10
|
|
||||||
assert cc.offset[1] == -20
|
|
||||||
assert cc.radius == 4
|
|
||||||
cc.flip_across(axis=1) # flip across x=0
|
|
||||||
assert cc.offset[0] == -10
|
|
||||||
assert cc.offset[1] == -20
|
|
||||||
assert cc.radius == 4
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
from typing import cast
|
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..library import Library
|
|
||||||
from ..shapes import Path as MPath, Circle, Polygon
|
|
||||||
from ..repetition import Grid, Arbitrary
|
|
||||||
|
|
||||||
def create_test_library(for_gds: bool = False) -> Library:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
# 1. Polygons
|
|
||||||
pat_poly = Pattern()
|
|
||||||
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
|
|
||||||
lib["polygons"] = pat_poly
|
|
||||||
|
|
||||||
# 2. Paths with different endcaps
|
|
||||||
pat_paths = Pattern()
|
|
||||||
# Flush
|
|
||||||
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
|
|
||||||
# Square
|
|
||||||
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
|
|
||||||
# Circle (Only for GDS)
|
|
||||||
if for_gds:
|
|
||||||
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
|
|
||||||
# SquareCustom
|
|
||||||
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
|
|
||||||
lib["paths"] = pat_paths
|
|
||||||
|
|
||||||
# 3. Circles (only for OASIS or polygonized for GDS)
|
|
||||||
pat_circles = Pattern()
|
|
||||||
if for_gds:
|
|
||||||
# GDS writer calls to_polygons() for non-supported shapes,
|
|
||||||
# but we can also pre-polygonize
|
|
||||||
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
|
|
||||||
else:
|
|
||||||
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
|
|
||||||
lib["circles"] = pat_circles
|
|
||||||
|
|
||||||
# 4. Refs with repetitions
|
|
||||||
pat_refs = Pattern()
|
|
||||||
# Simple Ref
|
|
||||||
pat_refs.ref("polygons", offset=(0, 0))
|
|
||||||
# Ref with Grid repetition
|
|
||||||
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
|
|
||||||
# Ref with Arbitrary repetition
|
|
||||||
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
|
|
||||||
lib["refs"] = pat_refs
|
|
||||||
|
|
||||||
# 5. Shapes with repetitions (OASIS only, must be wrapped for GDS)
|
|
||||||
pat_rep_shapes = Pattern()
|
|
||||||
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
|
|
||||||
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
|
|
||||||
lib["rep_shapes"] = pat_rep_shapes
|
|
||||||
|
|
||||||
if for_gds:
|
|
||||||
lib.wrap_repeated_shapes()
|
|
||||||
|
|
||||||
return lib
|
|
||||||
|
|
||||||
def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
|
|
||||||
from ..file import gdsii
|
|
||||||
lib = create_test_library(for_gds=True)
|
|
||||||
gds_file = tmp_path / "full_test.gds"
|
|
||||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
|
||||||
|
|
||||||
read_lib, _ = gdsii.readfile(gds_file)
|
|
||||||
|
|
||||||
# Check existence
|
|
||||||
for name in lib:
|
|
||||||
assert name in read_lib
|
|
||||||
|
|
||||||
# Check Paths
|
|
||||||
read_paths = read_lib["paths"]
|
|
||||||
# Check caps (GDS stores them as path_type)
|
|
||||||
# Order might be different depending on how they were written,
|
|
||||||
# but here they should match the order they were added if dict order is preserved.
|
|
||||||
# Actually, they are grouped by layer.
|
|
||||||
p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
|
|
||||||
assert p_flush.cap == MPath.Cap.Flush
|
|
||||||
|
|
||||||
p_square = cast("MPath", read_paths.shapes[(2, 1)][0])
|
|
||||||
assert p_square.cap == MPath.Cap.Square
|
|
||||||
|
|
||||||
p_circle = cast("MPath", read_paths.shapes[(2, 2)][0])
|
|
||||||
assert p_circle.cap == MPath.Cap.Circle
|
|
||||||
|
|
||||||
p_custom = cast("MPath", read_paths.shapes[(2, 3)][0])
|
|
||||||
assert p_custom.cap == MPath.Cap.SquareCustom
|
|
||||||
assert p_custom.cap_extensions is not None
|
|
||||||
assert_allclose(p_custom.cap_extensions, (1, 5))
|
|
||||||
|
|
||||||
# Check Refs with repetitions
|
|
||||||
read_refs = read_lib["refs"]
|
|
||||||
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
|
|
||||||
|
|
||||||
# AREF check
|
|
||||||
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
|
|
||||||
assert len(arefs) == 1
|
|
||||||
assert isinstance(arefs[0].repetition, Grid)
|
|
||||||
assert arefs[0].repetition.a_count == 3
|
|
||||||
assert arefs[0].repetition.b_count == 2
|
|
||||||
|
|
||||||
# Check wrapped shapes
|
|
||||||
# lib.wrap_repeated_shapes() created new patterns
|
|
||||||
# Original pattern "rep_shapes" now should have a Ref
|
|
||||||
assert len(read_lib["rep_shapes"].refs) > 0
|
|
||||||
|
|
||||||
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
|
||||||
pytest.importorskip("fatamorgana")
|
|
||||||
from ..file import oasis
|
|
||||||
lib = create_test_library(for_gds=False)
|
|
||||||
oas_file = tmp_path / "full_test.oas"
|
|
||||||
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
|
||||||
|
|
||||||
read_lib, _ = oasis.readfile(oas_file)
|
|
||||||
|
|
||||||
# Check existence
|
|
||||||
for name in lib:
|
|
||||||
assert name in read_lib
|
|
||||||
|
|
||||||
# Check Circle
|
|
||||||
read_circles = read_lib["circles"]
|
|
||||||
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
|
|
||||||
assert read_circles.shapes[(3, 0)][0].radius == 5
|
|
||||||
|
|
||||||
# Check Path caps
|
|
||||||
read_paths = read_lib["paths"]
|
|
||||||
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
|
|
||||||
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
|
|
||||||
# OASIS HalfWidth is Square. masque's Square is also HalfWidth extension.
|
|
||||||
# Wait, Circle cap in OASIS?
|
|
||||||
# masque/file/oasis.py:
|
|
||||||
# path_cap_map = {
|
|
||||||
# PathExtensionScheme.Flush: Path.Cap.Flush,
|
|
||||||
# PathExtensionScheme.HalfWidth: Path.Cap.Square,
|
|
||||||
# PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom,
|
|
||||||
# }
|
|
||||||
# It seems Circle cap is NOT supported in OASIS by masque currently.
|
|
||||||
# Let's verify what happens with Circle cap in OASIS write.
|
|
||||||
# _shapes_to_elements in oasis.py:
|
|
||||||
# path_type = next(k for k, v in path_cap_map.items() if v == shape.cap)
|
|
||||||
# This will raise StopIteration if Circle is not in path_cap_map.
|
|
||||||
|
|
||||||
# Check Shape repetition
|
|
||||||
read_rep_shapes = read_lib["rep_shapes"]
|
|
||||||
poly = read_rep_shapes.shapes[(4, 0)][0]
|
|
||||||
assert poly.repetition is not None
|
|
||||||
assert isinstance(poly.repetition, Grid)
|
|
||||||
assert poly.repetition.a_count == 5
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
from typing import cast
|
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..library import Library
|
|
||||||
from ..file import gdsii
|
|
||||||
from ..shapes import Path as MPath, Polygon
|
|
||||||
|
|
||||||
|
|
||||||
def test_gdsii_roundtrip(tmp_path: Path) -> None:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
# Simple polygon cell
|
|
||||||
pat1 = Pattern()
|
|
||||||
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
||||||
lib["poly_cell"] = pat1
|
|
||||||
|
|
||||||
# Path cell
|
|
||||||
pat2 = Pattern()
|
|
||||||
pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10)
|
|
||||||
lib["path_cell"] = pat2
|
|
||||||
|
|
||||||
# Cell with Ref
|
|
||||||
pat3 = Pattern()
|
|
||||||
pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2)
|
|
||||||
lib["ref_cell"] = pat3
|
|
||||||
|
|
||||||
gds_file = tmp_path / "test.gds"
|
|
||||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
|
||||||
|
|
||||||
read_lib, info = gdsii.readfile(gds_file)
|
|
||||||
|
|
||||||
assert "poly_cell" in read_lib
|
|
||||||
assert "path_cell" in read_lib
|
|
||||||
assert "ref_cell" in read_lib
|
|
||||||
|
|
||||||
# Check polygon
|
|
||||||
read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0])
|
|
||||||
# GDSII closes polygons, so it might have an extra vertex or different order
|
|
||||||
assert len(read_poly.vertices) >= 4
|
|
||||||
# Check bounds as a proxy for geometry correctness
|
|
||||||
assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]])
|
|
||||||
|
|
||||||
# Check path
|
|
||||||
read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0])
|
|
||||||
assert isinstance(read_path, MPath)
|
|
||||||
assert read_path.width == 10
|
|
||||||
assert_equal(read_path.vertices, [[0, 0], [100, 0]])
|
|
||||||
|
|
||||||
# Check Ref
|
|
||||||
read_ref = read_lib["ref_cell"].refs["poly_cell"][0]
|
|
||||||
assert_equal(read_ref.offset, [50, 50])
|
|
||||||
assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5)
|
|
||||||
|
|
||||||
|
|
||||||
def test_gdsii_annotations(tmp_path: Path) -> None:
|
|
||||||
lib = Library()
|
|
||||||
pat = Pattern()
|
|
||||||
# GDS only supports integer keys in range [1, 126] for properties
|
|
||||||
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]})
|
|
||||||
lib["cell"] = pat
|
|
||||||
|
|
||||||
gds_file = tmp_path / "test_ann.gds"
|
|
||||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
|
||||||
|
|
||||||
read_lib, _ = gdsii.readfile(gds_file)
|
|
||||||
read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations
|
|
||||||
assert read_ann is not None
|
|
||||||
assert read_ann["1"] == ["hello"]
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import copy
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..label import Label
|
|
||||||
from ..repetition import Grid
|
|
||||||
from ..utils import annotations_eq
|
|
||||||
|
|
||||||
|
|
||||||
def test_label_init() -> None:
|
|
||||||
lbl = Label("test", offset=(10, 20))
|
|
||||||
assert lbl.string == "test"
|
|
||||||
assert_equal(lbl.offset, [10, 20])
|
|
||||||
|
|
||||||
|
|
||||||
def test_label_transform() -> None:
|
|
||||||
lbl = Label("test", offset=(10, 0))
|
|
||||||
# Rotate 90 deg CCW around (0,0)
|
|
||||||
lbl.rotate_around((0, 0), pi / 2)
|
|
||||||
assert_allclose(lbl.offset, [0, 10], atol=1e-10)
|
|
||||||
|
|
||||||
# Translate
|
|
||||||
lbl.translate((5, 5))
|
|
||||||
assert_allclose(lbl.offset, [5, 15], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_label_repetition() -> None:
|
|
||||||
rep = Grid(a_vector=(10, 0), a_count=3)
|
|
||||||
lbl = Label("rep", offset=(0, 0), repetition=rep)
|
|
||||||
assert lbl.repetition is rep
|
|
||||||
assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]])
|
|
||||||
# Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't
|
|
||||||
# seem to automatically include repetition bounds in label.py itself,
|
|
||||||
# it's handled during pattern bounding.
|
|
||||||
|
|
||||||
|
|
||||||
def test_label_copy() -> None:
|
|
||||||
l1 = Label("test", offset=(1, 2), annotations={"a": [1]})
|
|
||||||
l2 = copy.deepcopy(l1)
|
|
||||||
|
|
||||||
print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}")
|
|
||||||
print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}")
|
|
||||||
print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}")
|
|
||||||
|
|
||||||
assert l1 == l2
|
|
||||||
assert l1 is not l2
|
|
||||||
l2.offset[0] = 100
|
|
||||||
assert l1.offset[0] == 1
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
import pytest
|
|
||||||
from typing import cast, TYPE_CHECKING
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from ..library import Library, LazyLibrary
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..error import LibraryError, PatternError
|
|
||||||
from ..ports import Port
|
|
||||||
from ..repetition import Grid
|
|
||||||
from ..shapes import Path
|
|
||||||
from ..file.utils import preflight
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..shapes import Polygon
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_basic() -> None:
|
|
||||||
lib = Library()
|
|
||||||
pat = Pattern()
|
|
||||||
lib["cell1"] = pat
|
|
||||||
|
|
||||||
assert "cell1" in lib
|
|
||||||
assert lib["cell1"] is pat
|
|
||||||
assert len(lib) == 1
|
|
||||||
|
|
||||||
with pytest.raises(LibraryError):
|
|
||||||
lib["cell1"] = Pattern() # Overwriting not allowed
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_tops() -> None:
|
|
||||||
lib = Library()
|
|
||||||
lib["child"] = Pattern()
|
|
||||||
lib["parent"] = Pattern()
|
|
||||||
lib["parent"].ref("child")
|
|
||||||
|
|
||||||
assert set(lib.tops()) == {"parent"}
|
|
||||||
assert lib.top() == "parent"
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_dangling() -> None:
|
|
||||||
lib = Library()
|
|
||||||
lib["parent"] = Pattern()
|
|
||||||
lib["parent"].ref("missing")
|
|
||||||
|
|
||||||
assert lib.dangling_refs() == {"missing"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_dangling_graph_modes() -> None:
|
|
||||||
lib = Library()
|
|
||||||
lib["parent"] = Pattern()
|
|
||||||
lib["parent"].ref("missing")
|
|
||||||
|
|
||||||
with pytest.raises(LibraryError, match="Dangling refs found"):
|
|
||||||
lib.child_graph()
|
|
||||||
with pytest.raises(LibraryError, match="Dangling refs found"):
|
|
||||||
lib.parent_graph()
|
|
||||||
with pytest.raises(LibraryError, match="Dangling refs found"):
|
|
||||||
lib.child_order()
|
|
||||||
|
|
||||||
assert lib.child_graph(dangling="ignore") == {"parent": set()}
|
|
||||||
assert lib.parent_graph(dangling="ignore") == {"parent": set()}
|
|
||||||
assert lib.child_order(dangling="ignore") == ["parent"]
|
|
||||||
|
|
||||||
assert lib.child_graph(dangling="include") == {"parent": {"missing"}, "missing": set()}
|
|
||||||
assert lib.parent_graph(dangling="include") == {"parent": set(), "missing": {"parent"}}
|
|
||||||
assert lib.child_order(dangling="include") == ["missing", "parent"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_refs_with_dangling_modes() -> None:
|
|
||||||
lib = Library()
|
|
||||||
lib["target"] = Pattern()
|
|
||||||
|
|
||||||
mid = Pattern()
|
|
||||||
mid.ref("target", offset=(2, 0))
|
|
||||||
lib["mid"] = mid
|
|
||||||
|
|
||||||
top = Pattern()
|
|
||||||
top.ref("mid", offset=(5, 0))
|
|
||||||
top.ref("missing", offset=(9, 0))
|
|
||||||
lib["top"] = top
|
|
||||||
|
|
||||||
assert lib.find_refs_local("missing", dangling="ignore") == {}
|
|
||||||
assert lib.find_refs_global("missing", dangling="ignore") == {}
|
|
||||||
|
|
||||||
local_missing = lib.find_refs_local("missing", dangling="include")
|
|
||||||
assert set(local_missing) == {"top"}
|
|
||||||
assert_allclose(local_missing["top"][0], [[9, 0, 0, 0, 1]])
|
|
||||||
|
|
||||||
global_missing = lib.find_refs_global("missing", dangling="include")
|
|
||||||
assert_allclose(global_missing[("top", "missing")], [[9, 0, 0, 0, 1]])
|
|
||||||
|
|
||||||
with pytest.raises(LibraryError, match="missing"):
|
|
||||||
lib.find_refs_local("missing")
|
|
||||||
with pytest.raises(LibraryError, match="missing"):
|
|
||||||
lib.find_refs_global("missing")
|
|
||||||
|
|
||||||
global_target = lib.find_refs_global("target")
|
|
||||||
assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None:
|
|
||||||
def make_lib() -> Library:
|
|
||||||
lib = Library()
|
|
||||||
lib["empty"] = Pattern()
|
|
||||||
lib["top"] = Pattern()
|
|
||||||
lib["top"].ref("missing")
|
|
||||||
return lib
|
|
||||||
|
|
||||||
caplog.set_level("WARNING")
|
|
||||||
warned = preflight(make_lib(), allow_dangling_refs=None, prune_empty_patterns=True)
|
|
||||||
assert "empty" not in warned
|
|
||||||
assert any("Dangling refs found" in record.message for record in caplog.records)
|
|
||||||
|
|
||||||
allowed = preflight(make_lib(), allow_dangling_refs=True, prune_empty_patterns=True)
|
|
||||||
assert "empty" not in allowed
|
|
||||||
|
|
||||||
with pytest.raises(LibraryError, match="Dangling refs found"):
|
|
||||||
preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_flatten() -> None:
|
|
||||||
lib = Library()
|
|
||||||
child = Pattern()
|
|
||||||
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
|
||||||
lib["child"] = child
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref("child", offset=(10, 10))
|
|
||||||
lib["parent"] = parent
|
|
||||||
|
|
||||||
flat_lib = lib.flatten("parent")
|
|
||||||
flat_parent = flat_lib["parent"]
|
|
||||||
|
|
||||||
assert not flat_parent.has_refs()
|
|
||||||
assert len(flat_parent.shapes[(1, 0)]) == 1
|
|
||||||
# Transformations are baked into vertices for Polygon
|
|
||||||
assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices
|
|
||||||
assert tuple(assert_vertices[0]) == (10.0, 10.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_flatten_preserves_ports_only_child() -> None:
|
|
||||||
lib = Library()
|
|
||||||
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
|
||||||
lib["child"] = child
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref("child", offset=(10, 10))
|
|
||||||
lib["parent"] = parent
|
|
||||||
|
|
||||||
flat_parent = lib.flatten("parent", flatten_ports=True)["parent"]
|
|
||||||
|
|
||||||
assert set(flat_parent.ports) == {"P1"}
|
|
||||||
assert cast("Port", flat_parent.ports["P1"]).rotation == 0
|
|
||||||
assert tuple(flat_parent.ports["P1"].offset) == (11.0, 12.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_flatten_repeated_ref_with_ports_raises() -> None:
|
|
||||||
lib = Library()
|
|
||||||
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
|
||||||
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
|
||||||
lib["child"] = child
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
|
|
||||||
lib["parent"] = parent
|
|
||||||
|
|
||||||
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
|
|
||||||
lib.flatten("parent", flatten_ports=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_flatten_dangling_ok_nested_preserves_dangling_refs() -> None:
|
|
||||||
lib = Library()
|
|
||||||
child = Pattern()
|
|
||||||
child.ref("missing")
|
|
||||||
lib["child"] = child
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref("child")
|
|
||||||
lib["parent"] = parent
|
|
||||||
|
|
||||||
flat = lib.flatten("parent", dangling_ok=True)
|
|
||||||
|
|
||||||
assert set(flat["child"].refs) == {"missing"}
|
|
||||||
assert flat["child"].has_refs()
|
|
||||||
assert set(flat["parent"].refs) == {"missing"}
|
|
||||||
assert flat["parent"].has_refs()
|
|
||||||
|
|
||||||
|
|
||||||
def test_lazy_library() -> None:
|
|
||||||
lib = LazyLibrary()
|
|
||||||
called = 0
|
|
||||||
|
|
||||||
def make_pat() -> Pattern:
|
|
||||||
nonlocal called
|
|
||||||
called += 1
|
|
||||||
return Pattern()
|
|
||||||
|
|
||||||
lib["lazy"] = make_pat
|
|
||||||
assert called == 0
|
|
||||||
|
|
||||||
pat = lib["lazy"]
|
|
||||||
assert called == 1
|
|
||||||
assert isinstance(pat, Pattern)
|
|
||||||
|
|
||||||
# Second access should be cached
|
|
||||||
pat2 = lib["lazy"]
|
|
||||||
assert called == 1
|
|
||||||
assert pat is pat2
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_rename() -> None:
|
|
||||||
lib = Library()
|
|
||||||
lib["old"] = Pattern()
|
|
||||||
lib["parent"] = Pattern()
|
|
||||||
lib["parent"].ref("old")
|
|
||||||
|
|
||||||
lib.rename("old", "new", move_references=True)
|
|
||||||
|
|
||||||
assert "old" not in lib
|
|
||||||
assert "new" in lib
|
|
||||||
assert "new" in lib["parent"].refs
|
|
||||||
assert "old" not in lib["parent"].refs
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_subtree() -> None:
|
|
||||||
lib = Library()
|
|
||||||
lib["a"] = Pattern()
|
|
||||||
lib["b"] = Pattern()
|
|
||||||
lib["c"] = Pattern()
|
|
||||||
lib["a"].ref("b")
|
|
||||||
|
|
||||||
sub = lib.subtree("a")
|
|
||||||
assert "a" in sub
|
|
||||||
assert "b" in sub
|
|
||||||
assert "c" not in sub
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_get_name() -> None:
|
|
||||||
lib = Library()
|
|
||||||
lib["cell"] = Pattern()
|
|
||||||
|
|
||||||
name1 = lib.get_name("cell")
|
|
||||||
assert name1 != "cell"
|
|
||||||
assert name1.startswith("cell")
|
|
||||||
|
|
||||||
name2 = lib.get_name("other")
|
|
||||||
assert name2 == "other"
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None:
|
|
||||||
lib = Library()
|
|
||||||
pat = Pattern()
|
|
||||||
pat.shapes[(1, 0)] += [
|
|
||||||
Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)),
|
|
||||||
Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)),
|
|
||||||
]
|
|
||||||
lib["top"] = pat
|
|
||||||
|
|
||||||
lib.dedup(norm_value=1, threshold=2)
|
|
||||||
|
|
||||||
assert not lib["top"].refs
|
|
||||||
assert len(lib["top"].shapes[(1, 0)]) == 2
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_equal
|
|
||||||
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..library import Library
|
|
||||||
def test_oasis_roundtrip(tmp_path: Path) -> None:
|
|
||||||
# Skip if fatamorgana is not installed
|
|
||||||
pytest.importorskip("fatamorgana")
|
|
||||||
from ..file import oasis
|
|
||||||
|
|
||||||
lib = Library()
|
|
||||||
pat1 = Pattern()
|
|
||||||
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
||||||
lib["cell1"] = pat1
|
|
||||||
|
|
||||||
oas_file = tmp_path / "test.oas"
|
|
||||||
# OASIS needs units_per_micron
|
|
||||||
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
|
||||||
|
|
||||||
read_lib, info = oasis.readfile(oas_file)
|
|
||||||
assert "cell1" in read_lib
|
|
||||||
|
|
||||||
# Check bounds
|
|
||||||
assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]])
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns
|
|
||||||
from ..library import Library
|
|
||||||
from ..pattern import Pattern
|
|
||||||
|
|
||||||
|
|
||||||
def test_maxrects_bssf_simple() -> None:
|
|
||||||
# Pack two 10x10 squares into one 20x10 container
|
|
||||||
rects = [[10, 10], [10, 10]]
|
|
||||||
containers = [[0, 0, 20, 10]]
|
|
||||||
|
|
||||||
locs, rejects = maxrects_bssf(rects, containers)
|
|
||||||
|
|
||||||
assert not rejects
|
|
||||||
# They should be at (0,0) and (10,0)
|
|
||||||
assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)}
|
|
||||||
|
|
||||||
|
|
||||||
def test_maxrects_bssf_reject() -> None:
|
|
||||||
# Try to pack a too-large rectangle
|
|
||||||
rects = [[10, 10], [30, 30]]
|
|
||||||
containers = [[0, 0, 20, 20]]
|
|
||||||
|
|
||||||
locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True)
|
|
||||||
assert 1 in rejects # Second rect rejected
|
|
||||||
assert 0 not in rejects
|
|
||||||
|
|
||||||
|
|
||||||
def test_maxrects_bssf_exact_fill_rejects_remaining() -> None:
|
|
||||||
rects = [[20, 20], [1, 1]]
|
|
||||||
containers = [[0, 0, 20, 20]]
|
|
||||||
|
|
||||||
locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True)
|
|
||||||
|
|
||||||
assert tuple(locs[0]) == (0.0, 0.0)
|
|
||||||
assert rejects == {1}
|
|
||||||
|
|
||||||
|
|
||||||
def test_maxrects_bssf_presort_reject_mapping() -> None:
|
|
||||||
rects = [[10, 12], [19, 14], [13, 11]]
|
|
||||||
containers = [[0, 0, 20, 20]]
|
|
||||||
|
|
||||||
_locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True)
|
|
||||||
|
|
||||||
assert rejects == {0, 2}
|
|
||||||
|
|
||||||
|
|
||||||
def test_guillotine_bssf_sas_presort_reject_mapping() -> None:
|
|
||||||
rects = [[2, 1], [17, 15], [16, 11]]
|
|
||||||
containers = [[0, 0, 20, 20]]
|
|
||||||
|
|
||||||
_locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True)
|
|
||||||
|
|
||||||
assert rejects == {2}
|
|
||||||
|
|
||||||
|
|
||||||
def test_pack_patterns() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p1 = Pattern()
|
|
||||||
p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
||||||
lib["p1"] = p1
|
|
||||||
|
|
||||||
p2 = Pattern()
|
|
||||||
p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
|
||||||
lib["p2"] = p2
|
|
||||||
|
|
||||||
# Containers: one 20x20
|
|
||||||
containers = [[0, 0, 20, 20]]
|
|
||||||
# 2um spacing
|
|
||||||
pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2))
|
|
||||||
|
|
||||||
assert not rejects
|
|
||||||
assert len(pat.refs) == 2
|
|
||||||
assert "p1" in pat.refs
|
|
||||||
assert "p2" in pat.refs
|
|
||||||
|
|
||||||
# Check that they don't overlap (simple check via bounds)
|
|
||||||
# p1 size 10x10, effectively 12x12
|
|
||||||
# p2 size 5x5, effectively 7x7
|
|
||||||
# Both should fit in 20x20
|
|
||||||
|
|
||||||
|
|
||||||
def test_pack_patterns_reject_names_match_original_patterns() -> None:
|
|
||||||
lib = Library()
|
|
||||||
for name, (lx, ly) in {
|
|
||||||
"p0": (10, 12),
|
|
||||||
"p1": (19, 14),
|
|
||||||
"p2": (13, 11),
|
|
||||||
}.items():
|
|
||||||
pat = Pattern()
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly)
|
|
||||||
lib[name] = pat
|
|
||||||
|
|
||||||
pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0))
|
|
||||||
|
|
||||||
assert set(rejects) == {"p0", "p2"}
|
|
||||||
assert set(pat.refs) == {"p1"}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
|
|
||||||
from ..shapes import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_init() -> None:
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
|
||||||
assert_equal(p.vertices, [[0, 0], [10, 0]])
|
|
||||||
assert p.width == 2
|
|
||||||
assert p.cap == Path.Cap.Flush
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_to_polygons_flush() -> None:
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
|
||||||
polys = p.to_polygons()
|
|
||||||
assert len(polys) == 1
|
|
||||||
# Rectangle from (0, -1) to (10, 1)
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
assert_equal(bounds, [[0, -1], [10, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_to_polygons_square() -> None:
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
|
|
||||||
polys = p.to_polygons()
|
|
||||||
assert len(polys) == 1
|
|
||||||
# Square cap adds width/2 = 1 to each end
|
|
||||||
# Rectangle from (-1, -1) to (11, 1)
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_to_polygons_circle() -> None:
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
|
|
||||||
polys = p.to_polygons(num_vertices=32)
|
|
||||||
# Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps
|
|
||||||
assert len(polys) >= 3
|
|
||||||
|
|
||||||
# Combined bounds should be from (-1, -1) to (11, 1)
|
|
||||||
# But wait, Path.get_bounds_single() handles this more directly
|
|
||||||
bounds = p.get_bounds_single()
|
|
||||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_custom_cap() -> None:
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
|
|
||||||
polys = p.to_polygons()
|
|
||||||
assert len(polys) == 1
|
|
||||||
# Extends 5 units at start, 10 at end
|
|
||||||
# Starts at -5, ends at 20
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
assert_equal(bounds, [[-5, -1], [20, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_bend() -> None:
|
|
||||||
# L-shaped path
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
|
|
||||||
polys = p.to_polygons()
|
|
||||||
assert len(polys) == 1
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
# Outer corner at (11, -1) is not right.
|
|
||||||
# Segments: (0,0)-(10,0) and (10,0)-(10,10)
|
|
||||||
# Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1)
|
|
||||||
# Corners of segment 2: (9,0), (9,10), (11,10), (11,0)
|
|
||||||
# Bounds should be [[-1 (if start is square), -1], [11, 11]]?
|
|
||||||
# Flush cap start at (0,0) with width 2 means y from -1 to 1.
|
|
||||||
# Vertical segment end at (10,10) with width 2 means x from 9 to 11.
|
|
||||||
# So bounds should be x: [0, 11], y: [-1, 10]
|
|
||||||
assert_equal(bounds, [[0, -1], [11, 10]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_mirror() -> None:
|
|
||||||
p = Path(vertices=[[10, 5], [20, 10]], width=2)
|
|
||||||
p.mirror(0) # Mirror across x axis (y -> -y)
|
|
||||||
assert_equal(p.vertices, [[10, -5], [20, -10]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_scale() -> None:
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2)
|
|
||||||
p.scale_by(2)
|
|
||||||
assert_equal(p.vertices, [[0, 0], [20, 0]])
|
|
||||||
assert p.width == 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_scale_custom_cap_extensions() -> None:
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
|
||||||
p.scale_by(3)
|
|
||||||
|
|
||||||
assert_equal(p.vertices, [[0, 0], [30, 0]])
|
|
||||||
assert p.width == 6
|
|
||||||
assert p.cap_extensions is not None
|
|
||||||
assert_allclose(p.cap_extensions, [3, 6])
|
|
||||||
assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None:
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
|
||||||
|
|
||||||
intrinsic, _extrinsic, ctor = p.normalized_form(5)
|
|
||||||
q = ctor()
|
|
||||||
|
|
||||||
assert intrinsic[-1] == (0.2, 0.4)
|
|
||||||
assert q.width == 2
|
|
||||||
assert q.cap_extensions is not None
|
|
||||||
assert_allclose(q.cap_extensions, [1, 2])
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_normalized_form_distinguishes_custom_caps() -> None:
|
|
||||||
p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
|
||||||
p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4))
|
|
||||||
|
|
||||||
assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import Pather
|
|
||||||
from ..builder.tools import PathTool
|
|
||||||
from ..library import Library
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pather_setup() -> tuple[Pather, PathTool, Library]:
|
|
||||||
lib = Library()
|
|
||||||
# Simple PathTool: 2um width on layer (1,0)
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
# Add an initial port facing North (pi/2)
|
|
||||||
# Port rotation points INTO device. So "North" rotation means device is North of port.
|
|
||||||
# Pathing "forward" moves South.
|
|
||||||
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
|
||||||
return p, tool, lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
# Route 10um "forward"
|
|
||||||
p.straight("start", 10)
|
|
||||||
|
|
||||||
# port rot pi/2 (North). Travel +pi relative to port -> South.
|
|
||||||
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
|
|
||||||
assert p.ports["start"].rotation is not None
|
|
||||||
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
# Start (0,0) rot pi/2 (North).
|
|
||||||
# Path 10um "forward" (South), then turn Clockwise (ccw=False).
|
|
||||||
# Facing South, turn Right -> West.
|
|
||||||
p.cw("start", 10)
|
|
||||||
|
|
||||||
# PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0.
|
|
||||||
# Transformed by port rot pi/2 (North) + pi (to move "forward" away from device):
|
|
||||||
# Transformation rot = pi/2 + pi = 3pi/2.
|
|
||||||
# (10, -1) rotated 3pi/2: (x,y) -> (y, -x) -> (-1, -10).
|
|
||||||
|
|
||||||
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
|
|
||||||
# North (pi/2) + CW (90 deg) -> West (pi)?
|
|
||||||
# Actual behavior results in 0 (East) - apparently rotation is flipped.
|
|
||||||
assert p.ports["start"].rotation is not None
|
|
||||||
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
# start at (0,0) rot pi/2 (North)
|
|
||||||
# path "forward" (South) to y=-50
|
|
||||||
p.straight("start", y=-50)
|
|
||||||
assert_equal(p.ports["start"].offset, [0, -50])
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
p.ports["A"] = Port((0, 0), pi / 2, ptype="wire")
|
|
||||||
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
|
|
||||||
|
|
||||||
# Path both "forward" (South) to y=-20
|
|
||||||
p.straight(["A", "B"], ymin=-20)
|
|
||||||
assert_equal(p.ports["A"].offset, [0, -20])
|
|
||||||
assert_equal(p.ports["B"].offset, [10, -20])
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
# Fluent API test
|
|
||||||
p.at("start").straight(10).ccw(10)
|
|
||||||
# 10um South -> (0, -10) rot pi/2
|
|
||||||
# then 10um South and turn CCW (Facing South, CCW is East)
|
|
||||||
# PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0
|
|
||||||
# Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10)
|
|
||||||
# (0, -10) + (1, -10) = (1, -20)
|
|
||||||
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
|
|
||||||
# pi/2 (North) + CCW (90 deg) -> 0 (East)?
|
|
||||||
# Actual behavior results in pi (West).
|
|
||||||
assert p.ports["start"].rotation is not None
|
|
||||||
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_dead_ports() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer=(1, 0), width=1)
|
|
||||||
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
|
|
||||||
p.set_dead()
|
|
||||||
|
|
||||||
# Path with negative length (impossible for PathTool, would normally raise BuildError)
|
|
||||||
p.straight("in", -10)
|
|
||||||
|
|
||||||
# Port 'in' should be updated by dummy extension despite tool failure
|
|
||||||
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
|
|
||||||
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
|
|
||||||
|
|
||||||
# Downstream path should work correctly using the dummy port location
|
|
||||||
p.straight("in", 20)
|
|
||||||
# 10 + (-20) = -10
|
|
||||||
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)
|
|
||||||
|
|
||||||
# Verify no geometry
|
|
||||||
assert not p.pattern.has_shapes()
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
import pytest
|
|
||||||
import numpy
|
|
||||||
from numpy import pi
|
|
||||||
from masque import Pather, RenderPather, Library, Pattern, Port
|
|
||||||
from masque.builder.tools import PathTool
|
|
||||||
from masque.error import BuildError
|
|
||||||
|
|
||||||
def test_pather_trace_basic() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
|
|
||||||
# Port rotation 0 points in +x (INTO device).
|
|
||||||
# To extend it, we move in -x direction.
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
# Trace single port
|
|
||||||
p.at('A').trace(None, 5000)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
|
|
||||||
|
|
||||||
# Trace with bend
|
|
||||||
p.at('A').trace(True, 5000) # CCW bend
|
|
||||||
# Port was at (-5000, 0) rot 0.
|
|
||||||
# New wire starts at (-5000, 0) rot 0.
|
|
||||||
# Output port of wire before rotation: (5000, 500) rot -pi/2
|
|
||||||
# Rotate by pi (since dev port rot is 0 and tool port rot is 0):
|
|
||||||
# (-5000, -500) rot pi - pi/2 = pi/2
|
|
||||||
# Add to start: (-10000, -500) rot pi/2
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500))
|
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2)
|
|
||||||
|
|
||||||
def test_pather_trace_to() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
# Trace to x=-10000
|
|
||||||
p.at('A').trace_to(None, x=-10000)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
|
||||||
|
|
||||||
# Trace to position=-20000
|
|
||||||
p.at('A').trace_to(None, p=-20000)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0))
|
|
||||||
|
|
||||||
def test_pather_bundle_trace() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
|
||||||
|
|
||||||
# Straight bundle - all should align to same x
|
|
||||||
p.at(['A', 'B']).straight(xmin=-10000)
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000)
|
|
||||||
assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000)
|
|
||||||
|
|
||||||
# Bundle with bend
|
|
||||||
p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000)
|
|
||||||
# Traveling in -x direction. CCW turn turns towards -y.
|
|
||||||
# A is at y=0, B is at y=2000.
|
|
||||||
# Rotation center is at y = -R.
|
|
||||||
# A is closer to center than B. So A is inner, B is outer.
|
|
||||||
# xmin is coordinate of innermost bend (A).
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000)
|
|
||||||
# B's bend is further out (more negative x)
|
|
||||||
assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000)
|
|
||||||
|
|
||||||
def test_pather_each_bound() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['B'] = Port((-1000, 2000), rotation=0)
|
|
||||||
|
|
||||||
# Each should move by 5000 (towards -x)
|
|
||||||
p.at(['A', 'B']).trace(None, each=5000)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
|
|
||||||
assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000))
|
|
||||||
|
|
||||||
def test_selection_management() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
pp = p.at('A')
|
|
||||||
assert pp.ports == ['A']
|
|
||||||
|
|
||||||
pp.select('B')
|
|
||||||
assert pp.ports == ['A', 'B']
|
|
||||||
|
|
||||||
pp.deselect('A')
|
|
||||||
assert pp.ports == ['B']
|
|
||||||
|
|
||||||
pp.select(['A'])
|
|
||||||
assert pp.ports == ['B', 'A']
|
|
||||||
|
|
||||||
pp.drop()
|
|
||||||
assert 'A' not in p.pattern.ports
|
|
||||||
assert 'B' not in p.pattern.ports
|
|
||||||
assert pp.ports == []
|
|
||||||
|
|
||||||
def test_mark_fork() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib)
|
|
||||||
p.pattern.ports['A'] = Port((100, 200), rotation=1)
|
|
||||||
|
|
||||||
pp = p.at('A')
|
|
||||||
pp.mark('B')
|
|
||||||
assert 'B' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200))
|
|
||||||
assert p.pattern.ports['B'].rotation == 1
|
|
||||||
assert pp.ports == ['A'] # mark keeps current selection
|
|
||||||
|
|
||||||
pp.fork('C')
|
|
||||||
assert 'C' in p.pattern.ports
|
|
||||||
assert pp.ports == ['C'] # fork switches to new name
|
|
||||||
|
|
||||||
def test_rename() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
p.at('A').rename('B')
|
|
||||||
assert 'A' not in p.pattern.ports
|
|
||||||
assert 'B' in p.pattern.ports
|
|
||||||
|
|
||||||
p.pattern.ports['C'] = Port((0, 0), rotation=0)
|
|
||||||
pp = p.at(['B', 'C'])
|
|
||||||
pp.rename({'B': 'D', 'C': 'E'})
|
|
||||||
assert 'B' not in p.pattern.ports
|
|
||||||
assert 'C' not in p.pattern.ports
|
|
||||||
assert 'D' in p.pattern.ports
|
|
||||||
assert 'E' in p.pattern.ports
|
|
||||||
assert set(pp.ports) == {'D', 'E'}
|
|
||||||
|
|
||||||
def test_renderpather_uturn_fallback() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
rp = RenderPather(lib, tools=tool)
|
|
||||||
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
# PathTool doesn't implement planU, so it should fall back to two planL calls
|
|
||||||
rp.at('A').uturn(offset=10000, length=5000)
|
|
||||||
|
|
||||||
# Two steps should be added
|
|
||||||
assert len(rp.paths['A']) == 2
|
|
||||||
assert rp.paths['A'][0].opcode == 'L'
|
|
||||||
assert rp.paths['A'][1].opcode == 'L'
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
assert rp.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(rp.pattern.ports['A'].rotation, pi)
|
|
||||||
|
|
||||||
def test_autotool_uturn() -> None:
|
|
||||||
from masque.builder.tools import AutoTool
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
# Setup AutoTool with a simple straight and a bend
|
|
||||||
def make_straight(length: float) -> Pattern:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000)
|
|
||||||
pat.ports['in'] = Port((0, 0), 0)
|
|
||||||
pat.ports['out'] = Port((length, 0), pi)
|
|
||||||
return pat
|
|
||||||
|
|
||||||
bend_pat = Pattern()
|
|
||||||
bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)])
|
|
||||||
bend_pat.ports['in'] = Port((0, 0), 0)
|
|
||||||
bend_pat.ports['out'] = Port((500, -500), pi/2)
|
|
||||||
lib['bend'] = bend_pat
|
|
||||||
|
|
||||||
tool = AutoTool(
|
|
||||||
straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')],
|
|
||||||
bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)],
|
|
||||||
sbends=[],
|
|
||||||
transitions={},
|
|
||||||
default_out_ptype='wire'
|
|
||||||
)
|
|
||||||
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), 0)
|
|
||||||
|
|
||||||
# CW U-turn (jog < 0)
|
|
||||||
# R = 500. jog = -2000. length = 1000.
|
|
||||||
# p0 = planL(length=1000) -> out at (1000, -500) rot pi/2
|
|
||||||
# R2 = 500.
|
|
||||||
# l2_length = abs(-2000) - abs(-500) - 500 = 1000.
|
|
||||||
p.at('A').uturn(offset=-2000, length=1000)
|
|
||||||
|
|
||||||
# Final port should be at (-1000, 2000) rot pi
|
|
||||||
# Start: (0,0) rot 0. Wire direction is rot + pi = pi (West, -x).
|
|
||||||
# Tool planU returns (length, jog) = (1000, -2000) relative to (0,0) rot 0.
|
|
||||||
# Rotation of pi transforms (1000, -2000) to (-1000, 2000).
|
|
||||||
# Final rotation: 0 + pi = pi.
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000))
|
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
|
||||||
|
|
||||||
def test_pather_trace_into() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
|
|
||||||
# 1. Straight connector
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi)
|
|
||||||
p.at('A').trace_into('B', plug_destination=False)
|
|
||||||
assert 'B' in p.pattern.ports
|
|
||||||
assert 'A' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
|
||||||
|
|
||||||
# 2. Single bend
|
|
||||||
p.pattern.ports['C'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2)
|
|
||||||
p.at('C').trace_into('D', plug_destination=False)
|
|
||||||
assert 'D' in p.pattern.ports
|
|
||||||
assert 'C' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000))
|
|
||||||
|
|
||||||
# 3. Jog (S-bend)
|
|
||||||
p.pattern.ports['E'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi)
|
|
||||||
p.at('E').trace_into('F', plug_destination=False)
|
|
||||||
assert 'F' in p.pattern.ports
|
|
||||||
assert 'E' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000))
|
|
||||||
|
|
||||||
# 4. U-bend (0 deg angle)
|
|
||||||
p.pattern.ports['G'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['H'] = Port((-10000, 2000), rotation=0)
|
|
||||||
p.at('G').trace_into('H', plug_destination=False)
|
|
||||||
assert 'H' in p.pattern.ports
|
|
||||||
assert 'G' in p.pattern.ports
|
|
||||||
# A U-bend with length=-travel=10000 and jog=-2000 from (0,0) rot 0
|
|
||||||
# ends up at (-10000, 2000) rot pi.
|
|
||||||
assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000))
|
|
||||||
assert p.pattern.ports['G'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['G'].rotation, pi)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_jog_failed_fallback_is_atomic() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='shorter than required bend'):
|
|
||||||
p.jog('A', 1.5, length=5)
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
|
||||||
assert p.pattern.ports['A'].rotation == 0
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_uturn_failed_fallback_is_atomic() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='shorter than required bend'):
|
|
||||||
p.uturn('A', 1.5, length=0)
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
|
||||||
assert p.pattern.ports['A'].rotation == 0
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import pytest
|
|
||||||
from typing import cast
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..error import PatternError
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..shapes import Polygon
|
|
||||||
from ..ref import Ref
|
|
||||||
from ..ports import Port
|
|
||||||
from ..label import Label
|
|
||||||
from ..repetition import Grid
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_init() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
assert pat.is_empty()
|
|
||||||
assert not pat.has_shapes()
|
|
||||||
assert not pat.has_refs()
|
|
||||||
assert not pat.has_labels()
|
|
||||||
assert not pat.has_ports()
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_with_elements() -> None:
|
|
||||||
poly = Polygon.square(10)
|
|
||||||
label = Label("test", offset=(5, 5))
|
|
||||||
ref = Ref(offset=(100, 100))
|
|
||||||
port = Port((0, 0), 0)
|
|
||||||
|
|
||||||
pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port})
|
|
||||||
|
|
||||||
assert pat.has_shapes()
|
|
||||||
assert pat.has_labels()
|
|
||||||
assert pat.has_refs()
|
|
||||||
assert pat.has_ports()
|
|
||||||
assert not pat.is_empty()
|
|
||||||
assert pat.shapes[(1, 0)] == [poly]
|
|
||||||
assert pat.labels[(1, 2)] == [label]
|
|
||||||
assert pat.refs["sub"] == [ref]
|
|
||||||
assert pat.ports["P1"] == port
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_append() -> None:
|
|
||||||
pat1 = Pattern()
|
|
||||||
pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
|
|
||||||
|
|
||||||
pat2 = Pattern()
|
|
||||||
pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]])
|
|
||||||
|
|
||||||
pat1.append(pat2)
|
|
||||||
assert len(pat1.shapes[(1, 0)]) == 1
|
|
||||||
assert len(pat1.shapes[(2, 0)]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_translate() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
|
|
||||||
pat.ports["P1"] = Port((5, 5), 0)
|
|
||||||
|
|
||||||
pat.translate_elements((10, 20))
|
|
||||||
|
|
||||||
# Polygon.translate adds to vertices, and offset is always (0,0)
|
|
||||||
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20])
|
|
||||||
assert_equal(pat.ports["P1"].offset, [15, 25])
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_scale() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
# Polygon.rect sets an offset in its constructor which is immediately translated into vertices
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1)
|
|
||||||
pat.scale_by(2)
|
|
||||||
|
|
||||||
# Vertices should be scaled
|
|
||||||
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_rotate() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]])
|
|
||||||
# Rotate 90 degrees CCW around (0,0)
|
|
||||||
pat.rotate_around((0, 0), pi / 2)
|
|
||||||
|
|
||||||
# [10, 0] rotated 90 deg around (0,0) is [0, 10]
|
|
||||||
assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_mirror() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]])
|
|
||||||
# Mirror across X axis (y -> -y)
|
|
||||||
pat.mirror(0)
|
|
||||||
|
|
||||||
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5])
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_get_bounds() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]])
|
|
||||||
pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]])
|
|
||||||
|
|
||||||
bounds = pat.get_bounds()
|
|
||||||
assert_equal(bounds, [[-5, -5], [10, 10]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_flatten_preserves_ports_only_child() -> None:
|
|
||||||
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref("child", offset=(10, 10))
|
|
||||||
|
|
||||||
parent.flatten({"child": child}, flatten_ports=True)
|
|
||||||
|
|
||||||
assert set(parent.ports) == {"P1"}
|
|
||||||
assert parent.ports["P1"].rotation == 0
|
|
||||||
assert tuple(parent.ports["P1"].offset) == (11.0, 12.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_flatten_repeated_ref_with_ports_raises() -> None:
|
|
||||||
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
|
||||||
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
|
|
||||||
|
|
||||||
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
|
|
||||||
parent.flatten({"child": child}, flatten_ports=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_place_requires_abstract_for_reference() -> None:
|
|
||||||
parent = Pattern()
|
|
||||||
child = Pattern()
|
|
||||||
|
|
||||||
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
|
|
||||||
parent.place(child)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pattern_interface() -> None:
|
|
||||||
source = Pattern()
|
|
||||||
source.ports["A"] = Port((10, 20), 0, ptype="test")
|
|
||||||
|
|
||||||
iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_")
|
|
||||||
|
|
||||||
assert "in_A" in iface.ports
|
|
||||||
assert "out_A" in iface.ports
|
|
||||||
assert iface.ports["in_A"].rotation is not None
|
|
||||||
assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10)
|
|
||||||
assert iface.ports["out_A"].rotation is not None
|
|
||||||
assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10)
|
|
||||||
assert iface.ports["in_A"].ptype == "test"
|
|
||||||
assert iface.ports["out_A"].ptype == "test"
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
import pytest
|
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_equal
|
|
||||||
|
|
||||||
|
|
||||||
from ..shapes import Polygon
|
|
||||||
from ..utils import R90
|
|
||||||
from ..error import PatternError
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def polygon() -> Polygon:
|
|
||||||
return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_vertices(polygon: Polygon) -> None:
|
|
||||||
assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_xs(polygon: Polygon) -> None:
|
|
||||||
assert_equal(polygon.xs, [0, 1, 1, 0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_ys(polygon: Polygon) -> None:
|
|
||||||
assert_equal(polygon.ys, [0, 0, 1, 1])
|
|
||||||
|
|
||||||
|
|
||||||
def test_offset(polygon: Polygon) -> None:
|
|
||||||
assert_equal(polygon.offset, [0, 0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_square() -> None:
|
|
||||||
square = Polygon.square(1)
|
|
||||||
assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_rectangle() -> None:
|
|
||||||
rectangle = Polygon.rectangle(1, 2)
|
|
||||||
assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_rect() -> None:
|
|
||||||
rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1)
|
|
||||||
assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
|
|
||||||
|
|
||||||
rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2)
|
|
||||||
assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
|
|
||||||
|
|
||||||
rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2)
|
|
||||||
assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]])
|
|
||||||
|
|
||||||
rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0)
|
|
||||||
assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]])
|
|
||||||
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
Polygon.rect(xctr=0, yctr=-2, ymax=0)
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
Polygon.rect(xmin=0, yctr=-2, ymax=0)
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
Polygon.rect(xmax=0, yctr=-2, ymax=0)
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
Polygon.rect(lx=0, yctr=-2, ymax=0)
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
Polygon.rect(yctr=0, xctr=-2, xmax=0)
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
Polygon.rect(ymin=0, xctr=-2, xmax=0)
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
Polygon.rect(ymax=0, xctr=-2, xmax=0)
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
Polygon.rect(ly=0, xctr=-2, xmax=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_octagon() -> None:
|
|
||||||
octagon = Polygon.octagon(side_length=1) # regular=True
|
|
||||||
assert_equal(octagon.vertices.shape, (8, 2))
|
|
||||||
diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0)
|
|
||||||
side_len = numpy.sqrt((diff * diff).sum(axis=1))
|
|
||||||
assert numpy.allclose(side_len, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_polygons(polygon: Polygon) -> None:
|
|
||||||
assert polygon.to_polygons() == [polygon]
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_bounds_single(polygon: Polygon) -> None:
|
|
||||||
assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotate(polygon: Polygon) -> None:
|
|
||||||
rotated_polygon = polygon.rotate(R90)
|
|
||||||
assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_mirror(polygon: Polygon) -> None:
|
|
||||||
mirrored_by_y = polygon.deepcopy().mirror(1)
|
|
||||||
assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]])
|
|
||||||
print(polygon.vertices)
|
|
||||||
mirrored_by_x = polygon.deepcopy().mirror(0)
|
|
||||||
assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_scale_by(polygon: Polygon) -> None:
|
|
||||||
scaled_polygon = polygon.scale_by(2)
|
|
||||||
assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_vertices(polygon: Polygon) -> None:
|
|
||||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices()
|
|
||||||
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_duplicate_vertices() -> None:
|
|
||||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices()
|
|
||||||
assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_colinear_vertices() -> None:
|
|
||||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices()
|
|
||||||
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_vertices_dtype() -> None:
|
|
||||||
polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32))
|
|
||||||
polygon.scale_by(0.5)
|
|
||||||
assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]])
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..ports import Port, PortList
|
|
||||||
from ..error import PortError
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_init() -> None:
|
|
||||||
p = Port(offset=(10, 20), rotation=pi / 2, ptype="test")
|
|
||||||
assert_equal(p.offset, [10, 20])
|
|
||||||
assert p.rotation == pi / 2
|
|
||||||
assert p.ptype == "test"
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_transform() -> None:
|
|
||||||
p = Port(offset=(10, 0), rotation=0)
|
|
||||||
p.rotate_around((0, 0), pi / 2)
|
|
||||||
assert_allclose(p.offset, [0, 10], atol=1e-10)
|
|
||||||
assert p.rotation is not None
|
|
||||||
assert_allclose(p.rotation, pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
|
|
||||||
assert_allclose(p.offset, [0, 10], atol=1e-10)
|
|
||||||
# rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2
|
|
||||||
assert p.rotation is not None
|
|
||||||
assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_flip_across() -> None:
|
|
||||||
p = Port(offset=(10, 0), rotation=0)
|
|
||||||
p.flip_across(axis=1) # Mirror across x=0: flips x-offset
|
|
||||||
assert_equal(p.offset, [-10, 0])
|
|
||||||
# rotation was 0, mirrored(1) -> pi
|
|
||||||
assert p.rotation is not None
|
|
||||||
assert_allclose(p.rotation, pi, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_measure_travel() -> None:
|
|
||||||
p1 = Port((0, 0), 0)
|
|
||||||
p2 = Port((10, 5), pi) # Facing each other
|
|
||||||
|
|
||||||
(travel, jog), rotation = p1.measure_travel(p2)
|
|
||||||
assert travel == 10
|
|
||||||
assert jog == 5
|
|
||||||
assert rotation == pi
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_describe_any_rotation() -> None:
|
|
||||||
p = Port((0, 0), None)
|
|
||||||
assert p.describe() == "pos=(0, 0), rot=any"
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_rename() -> None:
|
|
||||||
class MyPorts(PortList):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._ports = {"A": Port((0, 0), 0)}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self._ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, val: dict[str, Port]) -> None:
|
|
||||||
self._ports = val
|
|
||||||
|
|
||||||
pl = MyPorts()
|
|
||||||
pl.rename_ports({"A": "B"})
|
|
||||||
assert "A" not in pl.ports
|
|
||||||
assert "B" in pl.ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_rename_missing_port_raises() -> None:
|
|
||||||
class MyPorts(PortList):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._ports = {"A": Port((0, 0), 0)}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self._ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, val: dict[str, Port]) -> None:
|
|
||||||
self._ports = val
|
|
||||||
|
|
||||||
pl = MyPorts()
|
|
||||||
with pytest.raises(PortError, match="Ports to rename were not found"):
|
|
||||||
pl.rename_ports({"missing": "B"})
|
|
||||||
assert set(pl.ports) == {"A"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_add_port_pair_requires_distinct_names() -> None:
|
|
||||||
class MyPorts(PortList):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._ports: dict[str, Port] = {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self._ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, val: dict[str, Port]) -> None:
|
|
||||||
self._ports = val
|
|
||||||
|
|
||||||
pl = MyPorts()
|
|
||||||
with pytest.raises(PortError, match="Port names must be distinct"):
|
|
||||||
pl.add_port_pair(names=("A", "A"))
|
|
||||||
assert not pl.ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_plugged() -> None:
|
|
||||||
class MyPorts(PortList):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self._ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, val: dict[str, Port]) -> None:
|
|
||||||
self._ports = val
|
|
||||||
|
|
||||||
pl = MyPorts()
|
|
||||||
pl.plugged({"A": "B"})
|
|
||||||
assert not pl.ports # Both should be removed
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_plugged_empty_raises() -> None:
|
|
||||||
class MyPorts(PortList):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self._ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, val: dict[str, Port]) -> None:
|
|
||||||
self._ports = val
|
|
||||||
|
|
||||||
pl = MyPorts()
|
|
||||||
with pytest.raises(PortError, match="Must provide at least one port connection"):
|
|
||||||
pl.plugged({})
|
|
||||||
assert set(pl.ports) == {"A", "B"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_plugged_missing_port_raises() -> None:
|
|
||||||
class MyPorts(PortList):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self._ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, val: dict[str, Port]) -> None:
|
|
||||||
self._ports = val
|
|
||||||
|
|
||||||
pl = MyPorts()
|
|
||||||
with pytest.raises(PortError, match="Connection source ports were not found"):
|
|
||||||
pl.plugged({"missing": "B"})
|
|
||||||
assert set(pl.ports) == {"A", "B"}
|
|
||||||
|
|
||||||
with pytest.raises(PortError, match="Connection destination ports were not found"):
|
|
||||||
pl.plugged({"A": "missing"})
|
|
||||||
assert set(pl.ports) == {"A", "B"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_list_plugged_mismatch() -> None:
|
|
||||||
class MyPorts(PortList):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._ports = {
|
|
||||||
"A": Port((10, 10), 0),
|
|
||||||
"B": Port((11, 10), pi), # Offset mismatch
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self._ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, val: dict[str, Port]) -> None:
|
|
||||||
self._ports = val
|
|
||||||
|
|
||||||
pl = MyPorts()
|
|
||||||
with pytest.raises(PortError):
|
|
||||||
pl.plugged({"A": "B"})
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
|
|
||||||
from ..utils.ports2data import ports_to_data, data_to_ports
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..ports import Port
|
|
||||||
from ..library import Library
|
|
||||||
|
|
||||||
|
|
||||||
def test_ports2data_roundtrip() -> None:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.ports["P1"] = Port((10, 20), numpy.pi / 2, ptype="test")
|
|
||||||
|
|
||||||
layer = (10, 0)
|
|
||||||
ports_to_data(pat, layer)
|
|
||||||
|
|
||||||
assert len(pat.labels[layer]) == 1
|
|
||||||
assert pat.labels[layer][0].string == "P1:test 90"
|
|
||||||
assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0)
|
|
||||||
|
|
||||||
# New pattern, read ports back
|
|
||||||
pat2 = Pattern()
|
|
||||||
pat2.labels[layer] = pat.labels[layer]
|
|
||||||
data_to_ports([layer], {}, pat2)
|
|
||||||
|
|
||||||
assert "P1" in pat2.ports
|
|
||||||
assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10)
|
|
||||||
assert pat2.ports["P1"].rotation is not None
|
|
||||||
assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10)
|
|
||||||
assert pat2.ports["P1"].ptype == "test"
|
|
||||||
|
|
||||||
|
|
||||||
def test_data_to_ports_hierarchical() -> None:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
# Child has port data in labels
|
|
||||||
child = Pattern()
|
|
||||||
layer = (10, 0)
|
|
||||||
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
|
|
||||||
lib["child"] = child
|
|
||||||
|
|
||||||
# Parent references child
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2)
|
|
||||||
|
|
||||||
# Read ports hierarchically (max_depth > 0)
|
|
||||||
data_to_ports([layer], lib, parent, max_depth=1)
|
|
||||||
|
|
||||||
# child port A (5,0) rot 0
|
|
||||||
# transformed by parent ref: rot pi/2, trans (100, 100)
|
|
||||||
# (5,0) rot pi/2 -> (0, 5)
|
|
||||||
# (0, 5) + (100, 100) = (100, 105)
|
|
||||||
# rot 0 + pi/2 = pi/2
|
|
||||||
assert "A" in parent.ports
|
|
||||||
assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10)
|
|
||||||
assert parent.ports["A"].rotation is not None
|
|
||||||
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_data_to_ports_hierarchical_scaled_ref() -> None:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
child = Pattern()
|
|
||||||
layer = (10, 0)
|
|
||||||
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
|
|
||||||
lib["child"] = child
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2, scale=2)
|
|
||||||
|
|
||||||
data_to_ports([layer], lib, parent, max_depth=1)
|
|
||||||
|
|
||||||
assert "A" in parent.ports
|
|
||||||
assert_allclose(parent.ports["A"].offset, [100, 110], atol=1e-10)
|
|
||||||
assert parent.ports["A"].rotation is not None
|
|
||||||
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
from typing import cast, TYPE_CHECKING
|
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..error import MasqueError
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..ref import Ref
|
|
||||||
from ..repetition import Grid
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..shapes import Polygon
|
|
||||||
|
|
||||||
|
|
||||||
def test_ref_init() -> None:
|
|
||||||
ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0)
|
|
||||||
assert_equal(ref.offset, [10, 20])
|
|
||||||
assert ref.rotation == pi / 4
|
|
||||||
assert ref.mirrored is True
|
|
||||||
assert ref.scale == 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_ref_as_pattern() -> None:
|
|
||||||
sub_pat = Pattern()
|
|
||||||
sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
|
||||||
|
|
||||||
ref = Ref(offset=(10, 10), rotation=pi / 2, scale=2.0)
|
|
||||||
transformed_pat = ref.as_pattern(sub_pat)
|
|
||||||
|
|
||||||
# Check transformed shape
|
|
||||||
shape = cast("Polygon", transformed_pat.shapes[(1, 0)][0])
|
|
||||||
# ref.as_pattern deepcopies sub_pat then applies transformations:
|
|
||||||
# 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]]
|
|
||||||
# 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]]
|
|
||||||
# 3. pattern.translate_elements((10,10)) -> vertices [[10,10], [10,12], [8,10]]
|
|
||||||
|
|
||||||
assert_allclose(shape.vertices, [[10, 10], [10, 12], [8, 10]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ref_with_repetition() -> None:
|
|
||||||
sub_pat = Pattern()
|
|
||||||
sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
|
||||||
|
|
||||||
rep = Grid(a_vector=(10, 0), b_vector=(0, 10), a_count=2, b_count=2)
|
|
||||||
ref = Ref(repetition=rep)
|
|
||||||
|
|
||||||
repeated_pat = ref.as_pattern(sub_pat)
|
|
||||||
# Should have 4 shapes
|
|
||||||
assert len(repeated_pat.shapes[(1, 0)]) == 4
|
|
||||||
|
|
||||||
first_verts = sorted([tuple(cast("Polygon", s).vertices[0]) for s in repeated_pat.shapes[(1, 0)]])
|
|
||||||
assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_ref_get_bounds() -> None:
|
|
||||||
sub_pat = Pattern()
|
|
||||||
sub_pat.polygon((1, 0), vertices=[[0, 0], [5, 0], [0, 5]])
|
|
||||||
|
|
||||||
ref = Ref(offset=(10, 10), scale=2.0)
|
|
||||||
bounds = ref.get_bounds_single(sub_pat)
|
|
||||||
# sub_pat bounds [[0,0], [5,5]]
|
|
||||||
# scaled [[0,0], [10,10]]
|
|
||||||
# translated [[10,10], [20,20]]
|
|
||||||
assert_equal(bounds, [[10, 10], [20, 20]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_ref_copy() -> None:
|
|
||||||
ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]})
|
|
||||||
ref2 = ref1.copy()
|
|
||||||
assert ref1 == ref2
|
|
||||||
assert ref1 is not ref2
|
|
||||||
|
|
||||||
ref2.offset[0] = 100
|
|
||||||
assert ref1.offset[0] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_ref_rejects_nonpositive_scale() -> None:
|
|
||||||
with pytest.raises(MasqueError, match='Scale must be positive'):
|
|
||||||
Ref(scale=0)
|
|
||||||
|
|
||||||
with pytest.raises(MasqueError, match='Scale must be positive'):
|
|
||||||
Ref(scale=-1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ref_scale_by_rejects_nonpositive_scale() -> None:
|
|
||||||
ref = Ref(scale=2.0)
|
|
||||||
|
|
||||||
with pytest.raises(MasqueError, match='Scale must be positive'):
|
|
||||||
ref.scale_by(-1)
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import pytest
|
|
||||||
from typing import cast, TYPE_CHECKING
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import RenderPather
|
|
||||||
from ..builder.tools import PathTool
|
|
||||||
from ..library import Library
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..shapes import Path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def rpather_setup() -> tuple[RenderPather, PathTool, Library]:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
rp = RenderPather(lib, tools=tool)
|
|
||||||
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
|
||||||
return rp, tool, lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
# Plan two segments
|
|
||||||
rp.at("start").straight(10).straight(10)
|
|
||||||
|
|
||||||
# Before rendering, no shapes in pattern
|
|
||||||
assert not rp.pattern.has_shapes()
|
|
||||||
assert len(rp.paths["start"]) == 2
|
|
||||||
|
|
||||||
# Render
|
|
||||||
rp.render()
|
|
||||||
assert rp.pattern.has_shapes()
|
|
||||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
|
||||||
|
|
||||||
# Path vertices should be (0,0), (0,-10), (0,-20)
|
|
||||||
# transformed by start port (rot pi/2 -> 270 deg transform)
|
|
||||||
# wait, PathTool.render for opcode L uses rotation_matrix_2d(port_rot + pi)
|
|
||||||
# start_port rot pi/2. pi/2 + pi = 3pi/2.
|
|
||||||
# (10, 0) rotated 3pi/2 -> (0, -10)
|
|
||||||
# So vertices: (0,0), (0,-10), (0,-20)
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
|
||||||
assert len(path_shape.vertices) == 3
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
# Plan straight then bend
|
|
||||||
rp.at("start").straight(10).cw(10)
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
|
||||||
# Path vertices:
|
|
||||||
# 1. Start (0,0)
|
|
||||||
# 2. Straight end: (0, -10)
|
|
||||||
# 3. Bend end: (-1, -20)
|
|
||||||
# PathTool.planL(ccw=False, length=10) returns data=[10, -1]
|
|
||||||
# start_port for 2nd segment is at (0, -10) with rotation pi/2
|
|
||||||
# dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20).
|
|
||||||
# and final end_port.offset is (-1, -20).
|
|
||||||
assert len(path_shape.vertices) == 4
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
|
||||||
rp, tool1, lib = rpather_setup
|
|
||||||
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
|
|
||||||
|
|
||||||
rp.at("start").straight(10)
|
|
||||||
rp.retool(tool2, keys=["start"])
|
|
||||||
rp.at("start").straight(10)
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
# Different tools should cause different batches/shapes
|
|
||||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
|
||||||
assert len(rp.pattern.shapes[(2, 0)]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_dead_ports() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer=(1, 0), width=1)
|
|
||||||
rp = RenderPather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
|
|
||||||
rp.set_dead()
|
|
||||||
|
|
||||||
# Impossible path
|
|
||||||
rp.straight("in", -10)
|
|
||||||
|
|
||||||
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
|
|
||||||
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
|
|
||||||
|
|
||||||
# Verify no render steps were added
|
|
||||||
assert len(rp.paths["in"]) == 0
|
|
||||||
|
|
||||||
# Verify no geometry
|
|
||||||
rp.render()
|
|
||||||
assert not rp.pattern.has_shapes()
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_rename_port(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
rp.at("start").straight(10)
|
|
||||||
# Rename port while path is planned
|
|
||||||
rp.rename_ports({"start": "new_start"})
|
|
||||||
# Continue path on new name
|
|
||||||
rp.at("new_start").straight(10)
|
|
||||||
|
|
||||||
assert "start" not in rp.paths
|
|
||||||
assert len(rp.paths["new_start"]) == 2
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
assert rp.pattern.has_shapes()
|
|
||||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
|
||||||
# Total length 20. start_port rot pi/2 -> 270 deg transform.
|
|
||||||
# Vertices (0,0), (0,-10), (0,-20)
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
|
||||||
assert "new_start" in rp.ports
|
|
||||||
assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pathtool_traceL_bend_geometry_matches_ports() -> None:
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
|
|
||||||
tree = tool.traceL(True, 10)
|
|
||||||
pat = tree.top_pattern()
|
|
||||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
|
||||||
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10)
|
|
||||||
assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10)
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..repetition import Grid, Arbitrary
|
|
||||||
|
|
||||||
|
|
||||||
def test_grid_displacements() -> None:
|
|
||||||
# 2x2 grid
|
|
||||||
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
|
|
||||||
disps = sorted([tuple(d) for d in grid.displacements])
|
|
||||||
assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_grid_1d() -> None:
|
|
||||||
grid = Grid(a_vector=(10, 0), a_count=3)
|
|
||||||
disps = sorted([tuple(d) for d in grid.displacements])
|
|
||||||
assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_grid_rotate() -> None:
|
|
||||||
grid = Grid(a_vector=(10, 0), a_count=2)
|
|
||||||
grid.rotate(pi / 2)
|
|
||||||
assert_allclose(grid.a_vector, [0, 10], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_grid_get_bounds() -> None:
|
|
||||||
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
|
|
||||||
bounds = grid.get_bounds()
|
|
||||||
assert_equal(bounds, [[0, 0], [10, 5]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_arbitrary_displacements() -> None:
|
|
||||||
pts = [[0, 0], [10, 20], [-5, 30]]
|
|
||||||
arb = Arbitrary(pts)
|
|
||||||
# They should be sorted by displacements.setter
|
|
||||||
disps = arb.displacements
|
|
||||||
assert len(disps) == 3
|
|
||||||
assert any((disps == [0, 0]).all(axis=1))
|
|
||||||
assert any((disps == [10, 20]).all(axis=1))
|
|
||||||
assert any((disps == [-5, 30]).all(axis=1))
|
|
||||||
|
|
||||||
|
|
||||||
def test_arbitrary_transform() -> None:
|
|
||||||
arb = Arbitrary([[10, 0]])
|
|
||||||
arb.rotate(pi / 2)
|
|
||||||
assert_allclose(arb.displacements, [[0, 10]], atol=1e-10)
|
|
||||||
|
|
||||||
arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is:
|
|
||||||
# self.displacements[:, 1 - axis] *= -1
|
|
||||||
# if axis=0, 1-axis=1, so y *= -1
|
|
||||||
assert_allclose(arb.displacements, [[0, -10]], atol=1e-10)
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
|
|
||||||
from typing import cast
|
|
||||||
import numpy as np
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..ref import Ref
|
|
||||||
from ..label import Label
|
|
||||||
from ..repetition import Grid
|
|
||||||
|
|
||||||
def test_ref_rotate_intrinsic() -> None:
|
|
||||||
# Intrinsic rotate() should NOT affect repetition
|
|
||||||
rep = Grid(a_vector=(10, 0), a_count=2)
|
|
||||||
ref = Ref(repetition=rep)
|
|
||||||
|
|
||||||
ref.rotate(np.pi/2)
|
|
||||||
|
|
||||||
assert_allclose(ref.rotation, np.pi/2, atol=1e-10)
|
|
||||||
# Grid vector should still be (10, 0)
|
|
||||||
assert ref.repetition is not None
|
|
||||||
assert_allclose(cast('Grid', ref.repetition).a_vector, [10, 0], atol=1e-10)
|
|
||||||
|
|
||||||
def test_ref_rotate_around_extrinsic() -> None:
|
|
||||||
# Extrinsic rotate_around() SHOULD affect repetition
|
|
||||||
rep = Grid(a_vector=(10, 0), a_count=2)
|
|
||||||
ref = Ref(repetition=rep)
|
|
||||||
|
|
||||||
ref.rotate_around((0, 0), np.pi/2)
|
|
||||||
|
|
||||||
assert_allclose(ref.rotation, np.pi/2, atol=1e-10)
|
|
||||||
# Grid vector should be rotated to (0, 10)
|
|
||||||
assert ref.repetition is not None
|
|
||||||
assert_allclose(cast('Grid', ref.repetition).a_vector, [0, 10], atol=1e-10)
|
|
||||||
|
|
||||||
def test_pattern_rotate_around_extrinsic() -> None:
|
|
||||||
# Pattern.rotate_around() SHOULD affect repetition of its elements
|
|
||||||
rep = Grid(a_vector=(10, 0), a_count=2)
|
|
||||||
ref = Ref(repetition=rep)
|
|
||||||
|
|
||||||
pat = Pattern()
|
|
||||||
pat.refs['cell'].append(ref)
|
|
||||||
|
|
||||||
pat.rotate_around((0, 0), np.pi/2)
|
|
||||||
|
|
||||||
# Check the ref inside the pattern
|
|
||||||
ref_in_pat = pat.refs['cell'][0]
|
|
||||||
assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10)
|
|
||||||
# Grid vector should be rotated to (0, 10)
|
|
||||||
assert ref_in_pat.repetition is not None
|
|
||||||
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10)
|
|
||||||
|
|
||||||
def test_label_rotate_around_extrinsic() -> None:
|
|
||||||
# Extrinsic rotate_around() SHOULD affect repetition of labels
|
|
||||||
rep = Grid(a_vector=(10, 0), a_count=2)
|
|
||||||
lbl = Label("test", repetition=rep, offset=(5, 0))
|
|
||||||
|
|
||||||
lbl.rotate_around((0, 0), np.pi/2)
|
|
||||||
|
|
||||||
# Label offset should be (0, 5)
|
|
||||||
assert_allclose(lbl.offset, [0, 5], atol=1e-10)
|
|
||||||
# Grid vector should be rotated to (0, 10)
|
|
||||||
assert lbl.repetition is not None
|
|
||||||
assert_allclose(cast('Grid', lbl.repetition).a_vector, [0, 10], atol=1e-10)
|
|
||||||
|
|
||||||
def test_pattern_rotate_elements_intrinsic() -> None:
|
|
||||||
# rotate_elements() should NOT affect repetition
|
|
||||||
rep = Grid(a_vector=(10, 0), a_count=2)
|
|
||||||
ref = Ref(repetition=rep)
|
|
||||||
|
|
||||||
pat = Pattern()
|
|
||||||
pat.refs['cell'].append(ref)
|
|
||||||
|
|
||||||
pat.rotate_elements(np.pi/2)
|
|
||||||
|
|
||||||
ref_in_pat = pat.refs['cell'][0]
|
|
||||||
assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10)
|
|
||||||
# Grid vector should still be (10, 0)
|
|
||||||
assert ref_in_pat.repetition is not None
|
|
||||||
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 0], atol=1e-10)
|
|
||||||
|
|
||||||
def test_pattern_rotate_element_centers_extrinsic() -> None:
|
|
||||||
# rotate_element_centers() SHOULD affect repetition and offset
|
|
||||||
rep = Grid(a_vector=(10, 0), a_count=2)
|
|
||||||
ref = Ref(repetition=rep, offset=(5, 0))
|
|
||||||
|
|
||||||
pat = Pattern()
|
|
||||||
pat.refs['cell'].append(ref)
|
|
||||||
|
|
||||||
pat.rotate_element_centers(np.pi/2)
|
|
||||||
|
|
||||||
ref_in_pat = pat.refs['cell'][0]
|
|
||||||
# Offset should be (0, 5)
|
|
||||||
assert_allclose(ref_in_pat.offset, [0, 5], atol=1e-10)
|
|
||||||
# Grid vector should be rotated to (0, 10)
|
|
||||||
assert ref_in_pat.repetition is not None
|
|
||||||
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10)
|
|
||||||
# Ref rotation should NOT be changed
|
|
||||||
assert_allclose(ref_in_pat.rotation, 0, atol=1e-10)
|
|
||||||
|
|
||||||
def test_pattern_mirror_elements_intrinsic() -> None:
|
|
||||||
# mirror_elements() should NOT affect repetition or offset
|
|
||||||
rep = Grid(a_vector=(10, 5), a_count=2)
|
|
||||||
ref = Ref(repetition=rep, offset=(5, 2))
|
|
||||||
|
|
||||||
pat = Pattern()
|
|
||||||
pat.refs['cell'].append(ref)
|
|
||||||
|
|
||||||
pat.mirror_elements(axis=0) # Mirror across x (flip y)
|
|
||||||
|
|
||||||
ref_in_pat = pat.refs['cell'][0]
|
|
||||||
assert ref_in_pat.mirrored is True
|
|
||||||
# Repetition and offset should be unchanged
|
|
||||||
assert ref_in_pat.repetition is not None
|
|
||||||
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 5], atol=1e-10)
|
|
||||||
assert_allclose(ref_in_pat.offset, [5, 2], atol=1e-10)
|
|
||||||
|
|
||||||
def test_pattern_mirror_element_centers_extrinsic() -> None:
|
|
||||||
# mirror_element_centers() SHOULD affect repetition and offset
|
|
||||||
rep = Grid(a_vector=(10, 5), a_count=2)
|
|
||||||
ref = Ref(repetition=rep, offset=(5, 2))
|
|
||||||
|
|
||||||
pat = Pattern()
|
|
||||||
pat.refs['cell'].append(ref)
|
|
||||||
|
|
||||||
pat.mirror_element_centers(axis=0) # Mirror across x (flip y)
|
|
||||||
|
|
||||||
ref_in_pat = pat.refs['cell'][0]
|
|
||||||
# Offset should be (5, -2)
|
|
||||||
assert_allclose(ref_in_pat.offset, [5, -2], atol=1e-10)
|
|
||||||
# Grid vector should be (10, -5)
|
|
||||||
assert ref_in_pat.repetition is not None
|
|
||||||
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, -5], atol=1e-10)
|
|
||||||
# Ref mirrored state should NOT be changed
|
|
||||||
assert ref_in_pat.mirrored is False
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
import pytest
|
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection
|
|
||||||
from ..error import PatternError
|
|
||||||
|
|
||||||
|
|
||||||
# 1. Text shape tests
|
|
||||||
def test_text_to_polygons() -> None:
|
|
||||||
pytest.importorskip("freetype")
|
|
||||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
|
|
||||||
if not Path(font_path).exists():
|
|
||||||
pytest.skip("Font file not found")
|
|
||||||
|
|
||||||
t = Text("Hi", height=10, font_path=font_path)
|
|
||||||
polys = t.to_polygons()
|
|
||||||
assert len(polys) > 0
|
|
||||||
assert all(isinstance(p, Polygon) for p in polys)
|
|
||||||
|
|
||||||
# Check that it advances
|
|
||||||
# Character 'H' and 'i' should have different vertices
|
|
||||||
# Each character is a set of polygons. We check the mean x of vertices for each character.
|
|
||||||
char_x_means = [p.vertices[:, 0].mean() for p in polys]
|
|
||||||
assert len(set(char_x_means)) >= 2
|
|
||||||
|
|
||||||
|
|
||||||
# 2. Manhattanization tests
|
|
||||||
def test_manhattanize() -> None:
|
|
||||||
pytest.importorskip("float_raster")
|
|
||||||
pytest.importorskip("skimage.measure")
|
|
||||||
# Diamond shape
|
|
||||||
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
|
|
||||||
grid = numpy.arange(0, 11, 1)
|
|
||||||
|
|
||||||
manhattan_polys = poly.manhattanize(grid, grid)
|
|
||||||
assert len(manhattan_polys) >= 1
|
|
||||||
for mp in manhattan_polys:
|
|
||||||
# Check that all edges are axis-aligned
|
|
||||||
dv = numpy.diff(mp.vertices, axis=0)
|
|
||||||
# For each segment, either dx or dy must be zero
|
|
||||||
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
|
|
||||||
|
|
||||||
|
|
||||||
# 3. Comparison and Sorting tests
|
|
||||||
def test_shape_comparisons() -> None:
|
|
||||||
c1 = Circle(radius=10)
|
|
||||||
c2 = Circle(radius=20)
|
|
||||||
assert c1 < c2
|
|
||||||
assert not (c2 < c1)
|
|
||||||
|
|
||||||
p1 = Polygon([[0, 0], [10, 0], [10, 10]])
|
|
||||||
p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex
|
|
||||||
assert p1 < p2
|
|
||||||
|
|
||||||
# Different types
|
|
||||||
assert c1 < p1 or p1 < c1
|
|
||||||
assert (c1 < p1) != (p1 < c1)
|
|
||||||
|
|
||||||
|
|
||||||
# 4. Arc/Path Edge Cases
|
|
||||||
def test_arc_edge_cases() -> None:
|
|
||||||
# Wrapped arc (> 360 deg)
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
|
|
||||||
a.to_polygons(num_vertices=64)
|
|
||||||
# Should basically be a ring
|
|
||||||
bounds = a.get_bounds_single()
|
|
||||||
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_edge_cases() -> None:
|
|
||||||
# Zero-length segments
|
|
||||||
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
|
|
||||||
polys = p.to_polygons()
|
|
||||||
assert len(polys) == 1
|
|
||||||
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
# 5. PolyCollection with holes
|
|
||||||
def test_poly_collection_holes() -> None:
|
|
||||||
# Outer square, inner square hole
|
|
||||||
# PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do?
|
|
||||||
# wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple
|
|
||||||
# polygons or using specific winding rules.
|
|
||||||
# masque.shapes.Polygon doc says "specify an implicitly-closed boundary".
|
|
||||||
# Pyclipper is used in connectivity.py for holes.
|
|
||||||
|
|
||||||
# Let's test PolyCollection with multiple polygons
|
|
||||||
verts = [
|
|
||||||
[0, 0],
|
|
||||||
[10, 0],
|
|
||||||
[10, 10],
|
|
||||||
[0, 10], # Poly 1
|
|
||||||
[2, 2],
|
|
||||||
[2, 8],
|
|
||||||
[8, 8],
|
|
||||||
[8, 2], # Poly 2
|
|
||||||
]
|
|
||||||
offsets = [0, 4]
|
|
||||||
pc = PolyCollection(verts, offsets)
|
|
||||||
polys = pc.to_polygons()
|
|
||||||
assert len(polys) == 2
|
|
||||||
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
||||||
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_constituent_empty() -> None:
|
|
||||||
# One real triangle, one "empty" polygon (0 vertices), one real square
|
|
||||||
# Note: Polygon requires 3 vertices, so "empty" here might mean just some junk
|
|
||||||
# that to_polygons should handle.
|
|
||||||
# Actually PolyCollection doesn't check vertex count per polygon.
|
|
||||||
verts = [
|
|
||||||
[0, 0],
|
|
||||||
[1, 0],
|
|
||||||
[0, 1], # Tri
|
|
||||||
# Empty space
|
|
||||||
[10, 10],
|
|
||||||
[11, 10],
|
|
||||||
[11, 11],
|
|
||||||
[10, 11], # Square
|
|
||||||
]
|
|
||||||
offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square?
|
|
||||||
# No, offsets should be strictly increasing or handle 0-length slices.
|
|
||||||
# vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)]))
|
|
||||||
# if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7]
|
|
||||||
offsets = [0, 3, 3]
|
|
||||||
pc = PolyCollection(verts, offsets)
|
|
||||||
# Polygon(vertices=[]) will fail because of the setter check.
|
|
||||||
# Let's see if pc.to_polygons() handles it.
|
|
||||||
# It calls Polygon(vertices=vv) for each slice.
|
|
||||||
# slice [3:3] gives empty vv.
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
pc.to_polygons()
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_valid() -> None:
|
|
||||||
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
|
||||||
offsets = [0, 3]
|
|
||||||
pc = PolyCollection(verts, offsets)
|
|
||||||
assert len(pc.to_polygons()) == 2
|
|
||||||
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
|
|
||||||
sorted_shapes = sorted(shapes)
|
|
||||||
assert len(sorted_shapes) == 4
|
|
||||||
# Just verify it doesn't crash and is stable
|
|
||||||
assert sorted(sorted_shapes) == sorted_shapes
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_init() -> None:
|
|
||||||
# Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]]
|
|
||||||
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
|
||||||
offsets = [0, 4]
|
|
||||||
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
|
||||||
assert len(list(pc.polygon_vertices)) == 2
|
|
||||||
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_to_polygons() -> None:
|
|
||||||
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
|
||||||
offsets = [0, 4]
|
|
||||||
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
|
||||||
polys = pc.to_polygons()
|
|
||||||
assert len(polys) == 2
|
|
||||||
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
||||||
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_circle_init() -> None:
|
|
||||||
c = Circle(radius=10, offset=(5, 5))
|
|
||||||
assert c.radius == 10
|
|
||||||
assert_equal(c.offset, [5, 5])
|
|
||||||
|
|
||||||
|
|
||||||
def test_circle_to_polygons() -> None:
|
|
||||||
c = Circle(radius=10)
|
|
||||||
polys = c.to_polygons(num_vertices=32)
|
|
||||||
assert len(polys) == 1
|
|
||||||
assert isinstance(polys[0], Polygon)
|
|
||||||
# A circle with 32 vertices should have vertices distributed around (0,0)
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ellipse_init() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
|
|
||||||
assert_equal(e.radii, [10, 5])
|
|
||||||
assert_equal(e.offset, [1, 2])
|
|
||||||
assert e.rotation == pi / 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_ellipse_to_polygons() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5))
|
|
||||||
polys = e.to_polygons(num_vertices=64)
|
|
||||||
assert len(polys) == 1
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_arc_init() -> None:
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
|
|
||||||
assert_equal(a.radii, [10, 10])
|
|
||||||
assert_equal(a.angles, [0, pi / 2])
|
|
||||||
assert a.width == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_arc_to_polygons() -> None:
|
|
||||||
# Quarter circle arc
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
|
||||||
polys = a.to_polygons(num_vertices=32)
|
|
||||||
assert len(polys) == 1
|
|
||||||
# Outer radius 11, inner radius 9
|
|
||||||
# Quarter circle from 0 to 90 deg
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
# Min x should be 0 (inner edge start/stop or center if width is large)
|
|
||||||
# But wait, the arc is centered at 0,0.
|
|
||||||
# Outer edge goes from (11, 0) to (0, 11)
|
|
||||||
# Inner edge goes from (9, 0) to (0, 9)
|
|
||||||
# So x ranges from 0 to 11, y ranges from 0 to 11.
|
|
||||||
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_mirror() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
|
||||||
e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
|
|
||||||
assert_equal(e.offset, [10, 20])
|
|
||||||
# rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi)
|
|
||||||
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
|
|
||||||
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20))
|
|
||||||
a.mirror(0)
|
|
||||||
assert_equal(a.offset, [10, 20])
|
|
||||||
# For Arc, mirror(0) negates rotation and angles
|
|
||||||
assert_allclose(a.angles, [0, -pi / 4], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_flip_across() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
|
||||||
e.flip_across(axis=0) # Mirror across y=0: flips y-offset
|
|
||||||
assert_equal(e.offset, [10, -20])
|
|
||||||
# rotation also flips: -pi/4 == 3pi/4 (mod pi)
|
|
||||||
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
|
|
||||||
# Mirror across specific y
|
|
||||||
e = Ellipse(radii=(10, 5), offset=(10, 20))
|
|
||||||
e.flip_across(y=10) # Mirror across y=10
|
|
||||||
# y=20 mirrored across y=10 -> y=0
|
|
||||||
assert_equal(e.offset, [10, 0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_scale() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5))
|
|
||||||
e.scale_by(2)
|
|
||||||
assert_equal(e.radii, [20, 10])
|
|
||||||
|
|
||||||
a = Arc(radii=(10, 5), angles=(0, pi), width=2)
|
|
||||||
a.scale_by(0.5)
|
|
||||||
assert_equal(a.radii, [5, 2.5])
|
|
||||||
assert a.width == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_arclen() -> None:
|
|
||||||
# Test that max_arclen correctly limits segment lengths
|
|
||||||
|
|
||||||
# Ellipse
|
|
||||||
e = Ellipse(radii=(10, 5))
|
|
||||||
# Approximate perimeter is ~48.4
|
|
||||||
# With max_arclen=5, should have > 10 segments
|
|
||||||
polys = e.to_polygons(max_arclen=5)
|
|
||||||
v = polys[0].vertices
|
|
||||||
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
|
|
||||||
assert numpy.all(dist <= 5.000001)
|
|
||||||
assert len(v) > 10
|
|
||||||
|
|
||||||
# Arc
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
|
||||||
# Outer perimeter is 11 * pi/2 ~ 17.27
|
|
||||||
# Inner perimeter is 9 * pi/2 ~ 14.14
|
|
||||||
# With max_arclen=2, should have > 8 segments on outer edge
|
|
||||||
polys = a.to_polygons(max_arclen=2)
|
|
||||||
v = polys[0].vertices
|
|
||||||
# Arc polygons are closed, but contain both inner and outer edges and caps
|
|
||||||
# Let's just check that all segment lengths are within limit
|
|
||||||
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
|
|
||||||
assert numpy.all(dist <= 2.000001)
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
|
|
||||||
pytest.importorskip("svgwrite")
|
|
||||||
|
|
||||||
from ..library import Library
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..file import svg
|
|
||||||
|
|
||||||
|
|
||||||
SVG_NS = "{http://www.w3.org/2000/svg}"
|
|
||||||
XLINK_HREF = "{http://www.w3.org/1999/xlink}href"
|
|
||||||
|
|
||||||
|
|
||||||
def _child_transform(svg_path: Path) -> tuple[float, ...]:
|
|
||||||
root = ET.fromstring(svg_path.read_text())
|
|
||||||
for use in root.iter(f"{SVG_NS}use"):
|
|
||||||
if use.attrib.get(XLINK_HREF) == "#child":
|
|
||||||
raw = use.attrib["transform"]
|
|
||||||
assert raw.startswith("matrix(") and raw.endswith(")")
|
|
||||||
return tuple(float(value) for value in raw[7:-1].split())
|
|
||||||
raise AssertionError("No child reference found in SVG output")
|
|
||||||
|
|
||||||
|
|
||||||
def test_svg_ref_rotation_uses_correct_affine_transform(tmp_path: Path) -> None:
|
|
||||||
lib = Library()
|
|
||||||
child = Pattern()
|
|
||||||
child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]])
|
|
||||||
lib["child"] = child
|
|
||||||
|
|
||||||
top = Pattern()
|
|
||||||
top.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2)
|
|
||||||
lib["top"] = top
|
|
||||||
|
|
||||||
svg_path = tmp_path / "rotation.svg"
|
|
||||||
svg.writefile(lib, "top", str(svg_path))
|
|
||||||
|
|
||||||
assert_allclose(_child_transform(svg_path), (0, 2, -2, 0, 3, 4), atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_svg_ref_mirroring_changes_affine_transform(tmp_path: Path) -> None:
|
|
||||||
base = Library()
|
|
||||||
child = Pattern()
|
|
||||||
child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]])
|
|
||||||
base["child"] = child
|
|
||||||
|
|
||||||
top_plain = Pattern()
|
|
||||||
top_plain.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=False)
|
|
||||||
base["plain"] = top_plain
|
|
||||||
|
|
||||||
plain_path = tmp_path / "plain.svg"
|
|
||||||
svg.writefile(base, "plain", str(plain_path))
|
|
||||||
plain_transform = _child_transform(plain_path)
|
|
||||||
|
|
||||||
mirrored = Library()
|
|
||||||
mirrored["child"] = child.deepcopy()
|
|
||||||
top_mirrored = Pattern()
|
|
||||||
top_mirrored.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=True)
|
|
||||||
mirrored["mirrored"] = top_mirrored
|
|
||||||
|
|
||||||
mirrored_path = tmp_path / "mirrored.svg"
|
|
||||||
svg.writefile(mirrored, "mirrored", str(mirrored_path))
|
|
||||||
mirrored_transform = _child_transform(mirrored_path)
|
|
||||||
|
|
||||||
assert_allclose(plain_transform, (0, 2, -2, 0, 3, 4), atol=1e-10)
|
|
||||||
assert_allclose(mirrored_transform, (0, 2, 2, 0, 3, 4), atol=1e-10)
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms, DeferredDict
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_duplicate_vertices() -> None:
|
|
||||||
# Closed path (default)
|
|
||||||
v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]]
|
|
||||||
v_clean = remove_duplicate_vertices(v, closed_path=True)
|
|
||||||
# The last [0,0] is a duplicate of the first [0,0] if closed_path=True
|
|
||||||
assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]])
|
|
||||||
|
|
||||||
# Open path
|
|
||||||
v_clean_open = remove_duplicate_vertices(v, closed_path=False)
|
|
||||||
assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_colinear_vertices() -> None:
|
|
||||||
v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]]
|
|
||||||
v_clean = remove_colinear_vertices(v, closed_path=True)
|
|
||||||
# [1, 0] is between [0, 0] and [2, 0]
|
|
||||||
# [2, 1] is between [2, 0] and [2, 2]
|
|
||||||
# [1, 1] is between [2, 2] and [0, 0]
|
|
||||||
assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_colinear_vertices_exhaustive() -> None:
|
|
||||||
# U-turn
|
|
||||||
v = [[0, 0], [10, 0], [0, 0]]
|
|
||||||
v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True)
|
|
||||||
# Open path should keep ends. [10,0] is between [0,0] and [0,0]?
|
|
||||||
# They are colinear, but it's a 180 degree turn.
|
|
||||||
# We preserve 180 degree turns if preserve_uturns is True.
|
|
||||||
assert len(v_clean) == 3
|
|
||||||
|
|
||||||
v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False)
|
|
||||||
# If not preserving u-turns, it should collapse to just the endpoints
|
|
||||||
assert len(v_collapsed) == 2
|
|
||||||
|
|
||||||
# 180 degree U-turn in closed path
|
|
||||||
v = [[0, 0], [10, 0], [5, 0]]
|
|
||||||
v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False)
|
|
||||||
assert len(v_clean) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_contains_points() -> None:
|
|
||||||
v = [[0, 0], [10, 0], [10, 10], [0, 10]]
|
|
||||||
pts = [[5, 5], [-1, -1], [10, 10], [11, 5]]
|
|
||||||
inside = poly_contains_points(v, pts)
|
|
||||||
assert_equal(inside, [True, False, True, False])
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotation_matrix_2d() -> None:
|
|
||||||
m = rotation_matrix_2d(pi / 2)
|
|
||||||
assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotation_matrix_non_manhattan() -> None:
|
|
||||||
# 45 degrees
|
|
||||||
m = rotation_matrix_2d(pi / 4)
|
|
||||||
s = numpy.sqrt(2) / 2
|
|
||||||
assert_allclose(m, [[s, -s], [s, s]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_transforms() -> None:
|
|
||||||
# cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)]
|
|
||||||
t1 = [10, 20, 0, 0]
|
|
||||||
t2 = [[5, 0, 0, 0], [0, 5, 0, 0]]
|
|
||||||
combined = apply_transforms(t1, t2)
|
|
||||||
assert_equal(combined, [[15, 20, 0, 0, 1], [10, 25, 0, 0, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_transforms_advanced() -> None:
|
|
||||||
# Ox4: (x, y, rot, mir)
|
|
||||||
# Outer: mirror x (axis 0), then rotate 90 deg CCW
|
|
||||||
# apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror)
|
|
||||||
outer = [0, 0, pi / 2, 1]
|
|
||||||
|
|
||||||
# Inner: (10, 0, 0, 0)
|
|
||||||
inner = [10, 0, 0, 0]
|
|
||||||
|
|
||||||
combined = apply_transforms(outer, inner)
|
|
||||||
# 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0)
|
|
||||||
# 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10)
|
|
||||||
# 3. add outer offset (0, 0) -> (0, 10)
|
|
||||||
assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_deferred_dict_accessors_resolve_values_once() -> None:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
def make_value() -> int:
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
return 7
|
|
||||||
|
|
||||||
deferred = DeferredDict[str, int]()
|
|
||||||
deferred["x"] = make_value
|
|
||||||
|
|
||||||
assert deferred.get("missing", 9) == 9
|
|
||||||
assert deferred.get("x") == 7
|
|
||||||
assert list(deferred.values()) == [7]
|
|
||||||
assert list(deferred.items()) == [("x", 7)]
|
|
||||||
assert calls == 1
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from masque.pattern import Pattern
|
|
||||||
from masque.ports import Port
|
|
||||||
from masque.repetition import Grid
|
|
||||||
|
|
||||||
try:
|
|
||||||
import matplotlib
|
|
||||||
HAS_MATPLOTLIB = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_MATPLOTLIB = False
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
|
|
||||||
def test_visualize_noninteractive(tmp_path) -> None:
|
|
||||||
"""
|
|
||||||
Test that visualize() runs and saves a file without error.
|
|
||||||
This covers the recursive transformation and collection logic.
|
|
||||||
"""
|
|
||||||
# Create a hierarchy
|
|
||||||
child = Pattern()
|
|
||||||
child.polygon('L1', [[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
||||||
child.ports['P1'] = Port((0.5, 0.5), 0)
|
|
||||||
|
|
||||||
parent = Pattern()
|
|
||||||
# Add some refs with various transforms
|
|
||||||
parent.ref('child', offset=(10, 0), rotation=np.pi/4, mirrored=True, scale=2.0)
|
|
||||||
|
|
||||||
# Add a repetition
|
|
||||||
rep = Grid(a_vector=(5, 5), a_count=2)
|
|
||||||
parent.ref('child', offset=(0, 10), repetition=rep)
|
|
||||||
|
|
||||||
library = {'child': child}
|
|
||||||
|
|
||||||
output_file = tmp_path / "test_plot.png"
|
|
||||||
|
|
||||||
# Run visualize with filename to avoid showing window
|
|
||||||
parent.visualize(library=library, filename=str(output_file), ports=True)
|
|
||||||
|
|
||||||
assert output_file.exists()
|
|
||||||
assert output_file.stat().st_size > 0
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
|
|
||||||
def test_visualize_empty() -> None:
|
|
||||||
""" Test visualizing an empty pattern. """
|
|
||||||
pat = Pattern()
|
|
||||||
# Should not raise
|
|
||||||
pat.visualize(overdraw=True)
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
|
|
||||||
def test_visualize_no_refs() -> None:
|
|
||||||
""" Test visualizing a pattern with only local shapes (no library needed). """
|
|
||||||
pat = Pattern()
|
|
||||||
pat.polygon('L1', [[0, 0], [1, 0], [0, 1]])
|
|
||||||
# Should not raise even if library is None
|
|
||||||
pat.visualize(overdraw=True)
|
|
||||||
|
|
@ -26,11 +26,7 @@ from .scalable import (
|
||||||
Scalable as Scalable,
|
Scalable as Scalable,
|
||||||
ScalableImpl as ScalableImpl,
|
ScalableImpl as ScalableImpl,
|
||||||
)
|
)
|
||||||
from .mirrorable import (
|
from .mirrorable import Mirrorable as Mirrorable
|
||||||
Mirrorable as Mirrorable,
|
|
||||||
Flippable as Flippable,
|
|
||||||
FlippableImpl as FlippableImpl,
|
|
||||||
)
|
|
||||||
from .copyable import Copyable as Copyable
|
from .copyable import Copyable as Copyable
|
||||||
from .annotatable import (
|
from .annotatable import (
|
||||||
Annotatable as Annotatable,
|
Annotatable as Annotatable,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
from typing import Self
|
from typing import Self
|
||||||
from abc import ABCMeta, abstractmethod
|
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):
|
class Mirrorable(metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
|
|
@ -18,17 +11,11 @@ class Mirrorable(metaclass=ABCMeta):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Intrinsic transformation: Mirror the entity across an axis through its origin.
|
Mirror the entity across an axis.
|
||||||
This does NOT affect the object's repetition grid.
|
|
||||||
|
|
||||||
This operation is performed relative to the object's internal origin (ignoring
|
|
||||||
its offset). For objects like `Polygon` and `Path` where the offset is forced
|
|
||||||
to (0, 0), this is equivalent to mirroring in the container's coordinate system.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
axis: Axis to mirror across:
|
axis: Axis to mirror across.
|
||||||
0: X-axis (flip y coords),
|
|
||||||
1: Y-axis (flip x coords)
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
|
|
@ -36,11 +23,10 @@ class Mirrorable(metaclass=ABCMeta):
|
||||||
|
|
||||||
def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self:
|
def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self:
|
||||||
"""
|
"""
|
||||||
Optionally mirror the entity across both axes through its origin.
|
Optionally mirror the entity across both axes
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
across_x: Mirror across the horizontal X-axis (flip Y coordinates).
|
axes: (mirror_across_x, mirror_across_y)
|
||||||
across_y: Mirror across the vertical Y-axis (flip X coordinates).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
|
|
@ -52,61 +38,30 @@ class Mirrorable(metaclass=ABCMeta):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class Flippable(Positionable, metaclass=ABCMeta):
|
#class MirrorableImpl(Mirrorable, metaclass=ABCMeta):
|
||||||
"""
|
# """
|
||||||
Trait class for entities which can be mirrored relative to an external line.
|
# Simple implementation of `Mirrorable`
|
||||||
"""
|
# """
|
||||||
__slots__ = ()
|
# __slots__ = ()
|
||||||
|
#
|
||||||
@staticmethod
|
# _mirrored: NDArray[numpy.bool]
|
||||||
def _check_flip_args(axis: int | None = None, *, x: float | None = None, y: float | None = None) -> tuple[int, NDArray[numpy.float64]]:
|
# """ Whether to mirror the instance across the x and/or y axes. """
|
||||||
pivot = numpy.zeros(2)
|
#
|
||||||
if axis is not None:
|
# #
|
||||||
if x is not None or y is not None:
|
# # Properties
|
||||||
raise MasqueError('Cannot specify both axis and x or y')
|
# #
|
||||||
return axis, pivot
|
# # Mirrored property
|
||||||
if x is not None:
|
# @property
|
||||||
if y is not None:
|
# def mirrored(self) -> NDArray[numpy.bool]:
|
||||||
raise MasqueError('Cannot specify both x and y')
|
# """ Whether to mirror across the [x, y] axes, respectively """
|
||||||
return 1, pivot + (x, 0)
|
# return self._mirrored
|
||||||
if y is not None:
|
#
|
||||||
return 0, pivot + (0, y)
|
# @mirrored.setter
|
||||||
raise MasqueError('Must specify one of axis, x, or y')
|
# def mirrored(self, val: Sequence[bool]) -> None:
|
||||||
|
# if is_scalar(val):
|
||||||
@abstractmethod
|
# raise MasqueError('Mirrored must be a 2-element list of booleans')
|
||||||
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
|
# self._mirrored = numpy.array(val, dtype=bool)
|
||||||
"""
|
#
|
||||||
Extrinsic transformation: Mirror the object across a line in the container's
|
# #
|
||||||
coordinate system. This affects both the object's offset and its repetition grid.
|
# # Methods
|
||||||
|
# #
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta):
|
||||||
|
|
||||||
@repetition.setter
|
@repetition.setter
|
||||||
def repetition(self, repetition: 'Repetition | None') -> None:
|
def repetition(self, repetition: 'Repetition | None') -> None:
|
||||||
from ..repetition import Repetition #noqa: PLC0415
|
from ..repetition import Repetition
|
||||||
if repetition is not None and not isinstance(repetition, Repetition):
|
if repetition is not None and not isinstance(repetition, Repetition):
|
||||||
raise MasqueError(f'{repetition} is not a valid Repetition object!')
|
raise MasqueError(f'{repetition} is not a valid Repetition object!')
|
||||||
self._repetition = repetition
|
self._repetition = repetition
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Self
|
from typing import Self, cast, Any, TYPE_CHECKING
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
|
|
@ -8,7 +8,8 @@ from numpy.typing import ArrayLike
|
||||||
from ..error import MasqueError
|
from ..error import MasqueError
|
||||||
from ..utils import rotation_matrix_2d
|
from ..utils import rotation_matrix_2d
|
||||||
|
|
||||||
from .positionable import Positionable
|
if TYPE_CHECKING:
|
||||||
|
from .positionable import Positionable
|
||||||
|
|
||||||
_empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass
|
_empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass
|
||||||
|
|
||||||
|
|
@ -25,8 +26,7 @@ class Rotatable(metaclass=ABCMeta):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def rotate(self, val: float) -> Self:
|
def rotate(self, val: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset.
|
Rotate the shape around its origin (0, 0), ignoring its offset.
|
||||||
This does NOT affect the object's repetition grid.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
val: Angle to rotate by (counterclockwise, radians)
|
val: Angle to rotate by (counterclockwise, radians)
|
||||||
|
|
@ -64,10 +64,6 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta):
|
||||||
# Methods
|
# Methods
|
||||||
#
|
#
|
||||||
def rotate(self, rotation: float) -> Self:
|
def rotate(self, rotation: float) -> Self:
|
||||||
"""
|
|
||||||
Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset.
|
|
||||||
This does NOT affect the object's repetition grid.
|
|
||||||
"""
|
|
||||||
self.rotation += rotation
|
self.rotation += rotation
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -85,9 +81,9 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class Pivotable(Positionable, metaclass=ABCMeta):
|
class Pivotable(metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
Trait class for entities which can be rotated around a point.
|
Trait class for entites which can be rotated around a point.
|
||||||
This requires that they are `Positionable` but not necessarily `Rotatable` themselves.
|
This requires that they are `Positionable` but not necessarily `Rotatable` themselves.
|
||||||
"""
|
"""
|
||||||
__slots__ = ()
|
__slots__ = ()
|
||||||
|
|
@ -95,11 +91,7 @@ class Pivotable(Positionable, metaclass=ABCMeta):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Extrinsic transformation: Rotate the object around a point in the container's
|
Rotate the object around a point.
|
||||||
coordinate system. This affects both the object's offset and its repetition grid.
|
|
||||||
|
|
||||||
For objects that are also `Rotatable`, this also performs an intrinsic
|
|
||||||
rotation of the object.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pivot: Point (x, y) to rotate around
|
pivot: Point (x, y) to rotate around
|
||||||
|
|
@ -111,21 +103,20 @@ class Pivotable(Positionable, metaclass=ABCMeta):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta):
|
class PivotableImpl(Pivotable, metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
Implementation of `Pivotable` for objects which are `Rotatable`
|
Implementation of `Pivotable` for objects which are `Rotatable`
|
||||||
and `Positionable`.
|
|
||||||
"""
|
"""
|
||||||
__slots__ = ()
|
__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:
|
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
||||||
from .repeatable import Repeatable #noqa: PLC0415
|
|
||||||
pivot = numpy.asarray(pivot, dtype=float)
|
pivot = numpy.asarray(pivot, dtype=float)
|
||||||
self.translate(-pivot)
|
cast('Positionable', self).translate(-pivot)
|
||||||
self.rotate(rotation)
|
cast('Rotatable', self).rotate(rotation)
|
||||||
if isinstance(self, Repeatable) and self.repetition is not None:
|
|
||||||
self.repetition.rotate(rotation)
|
|
||||||
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
||||||
self.translate(+pivot)
|
cast('Positionable', self).translate(+pivot)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,11 @@ class AutoSlots(ABCMeta):
|
||||||
for base in bases:
|
for base in bases:
|
||||||
parents |= set(base.mro())
|
parents |= set(base.mro())
|
||||||
|
|
||||||
slots = list(dctn.get('__slots__', ()))
|
slots = tuple(dctn.get('__slots__', ()))
|
||||||
for parent in parents:
|
for parent in parents:
|
||||||
if not hasattr(parent, '__annotations__'):
|
if not hasattr(parent, '__annotations__'):
|
||||||
continue
|
continue
|
||||||
slots.extend(parent.__annotations__.keys())
|
slots += tuple(parent.__annotations__.keys())
|
||||||
|
|
||||||
# Deduplicate (dict to preserve order)
|
dctn['__slots__'] = slots
|
||||||
dctn['__slots__'] = tuple(dict.fromkeys(slots))
|
|
||||||
return super().__new__(cls, name, bases, dctn)
|
return super().__new__(cls, name, bases, dctn)
|
||||||
|
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
from typing import Any, Literal
|
|
||||||
from collections.abc import Iterable
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
from numpy.typing import NDArray
|
|
||||||
|
|
||||||
from ..shapes.polygon import Polygon
|
|
||||||
from ..error import PatternError
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]:
|
|
||||||
"""
|
|
||||||
Bridge multiple holes into an outer boundary using zero-width slits.
|
|
||||||
"""
|
|
||||||
current_outer = outer_path
|
|
||||||
|
|
||||||
# Sort holes by max X to potentially minimize bridge lengths or complexity
|
|
||||||
# (though not strictly necessary for correctness)
|
|
||||||
holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True)
|
|
||||||
|
|
||||||
for hole in holes:
|
|
||||||
# Find max X vertex of hole
|
|
||||||
max_idx = numpy.argmax(hole[:, 0])
|
|
||||||
m = hole[max_idx]
|
|
||||||
|
|
||||||
# Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges
|
|
||||||
best_t = numpy.inf
|
|
||||||
best_pt = None
|
|
||||||
best_edge_idx = -1
|
|
||||||
|
|
||||||
n = len(current_outer)
|
|
||||||
for i in range(n):
|
|
||||||
p1 = current_outer[i]
|
|
||||||
p2 = current_outer[(i + 1) % n]
|
|
||||||
|
|
||||||
# Check if edge (p1, p2) spans m.y
|
|
||||||
if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]):
|
|
||||||
# Intersection x:
|
|
||||||
# x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y)
|
|
||||||
t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0]
|
|
||||||
if 0 <= t < best_t:
|
|
||||||
best_t = t
|
|
||||||
best_pt = numpy.array([m[0] + t, m[1]])
|
|
||||||
best_edge_idx = i
|
|
||||||
|
|
||||||
if best_edge_idx == -1:
|
|
||||||
# Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole)
|
|
||||||
dists = numpy.linalg.norm(current_outer - m, axis=1)
|
|
||||||
best_edge_idx = int(numpy.argmin(dists))
|
|
||||||
best_pt = current_outer[best_edge_idx]
|
|
||||||
# Adjust best_edge_idx to insert AFTER this vertex
|
|
||||||
# (treating it as a degenerate edge)
|
|
||||||
|
|
||||||
assert best_pt is not None
|
|
||||||
|
|
||||||
# Reorder hole vertices to start at m
|
|
||||||
hole_reordered = numpy.roll(hole, -max_idx, axis=0)
|
|
||||||
|
|
||||||
# Construct new outer:
|
|
||||||
# 1. Start of outer up to best_edge_idx
|
|
||||||
# 2. Intersection point
|
|
||||||
# 3. Hole vertices (starting and ending at m)
|
|
||||||
# 4. Intersection point (to close slit)
|
|
||||||
# 5. Rest of outer
|
|
||||||
|
|
||||||
new_outer: list[NDArray[numpy.float64]] = []
|
|
||||||
new_outer.extend(current_outer[:best_edge_idx + 1])
|
|
||||||
new_outer.append(best_pt)
|
|
||||||
new_outer.extend(hole_reordered)
|
|
||||||
new_outer.append(hole_reordered[0]) # close hole loop at m
|
|
||||||
new_outer.append(best_pt) # back to outer
|
|
||||||
new_outer.extend(current_outer[best_edge_idx + 1:])
|
|
||||||
|
|
||||||
current_outer = numpy.array(new_outer)
|
|
||||||
|
|
||||||
return current_outer
|
|
||||||
|
|
||||||
def boolean(
|
|
||||||
subjects: Iterable[Any],
|
|
||||||
clips: Iterable[Any] | None = None,
|
|
||||||
operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union',
|
|
||||||
scale: float = 1e6,
|
|
||||||
) -> list[Polygon]:
|
|
||||||
"""
|
|
||||||
Perform a boolean operation on two sets of polygons.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
subjects: List of subjects (Polygons or vertex arrays).
|
|
||||||
clips: List of clips (Polygons or vertex arrays).
|
|
||||||
operation: The boolean operation to perform.
|
|
||||||
scale: Scaling factor for integer conversion (pyclipper uses integers).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of result Polygons.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import pyclipper #noqa: PLC0415
|
|
||||||
except ImportError:
|
|
||||||
raise ImportError(
|
|
||||||
"Boolean operations require 'pyclipper'. "
|
|
||||||
"Install it with 'pip install pyclipper' or 'pip install masque[boolean]'."
|
|
||||||
) from None
|
|
||||||
|
|
||||||
op_map = {
|
|
||||||
'union': pyclipper.PT_UNION,
|
|
||||||
'intersection': pyclipper.PT_INTERSECTION,
|
|
||||||
'difference': pyclipper.PT_DIFFERENCE,
|
|
||||||
'xor': pyclipper.PT_XOR,
|
|
||||||
}
|
|
||||||
|
|
||||||
def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]:
|
|
||||||
if objs is None:
|
|
||||||
return []
|
|
||||||
verts = []
|
|
||||||
for obj in objs:
|
|
||||||
if hasattr(obj, 'to_polygons'):
|
|
||||||
for p in obj.to_polygons():
|
|
||||||
verts.append(p.vertices)
|
|
||||||
elif isinstance(obj, numpy.ndarray):
|
|
||||||
verts.append(obj)
|
|
||||||
elif isinstance(obj, Polygon):
|
|
||||||
verts.append(obj.vertices)
|
|
||||||
else:
|
|
||||||
# Try to iterate if it's an iterable of shapes
|
|
||||||
try:
|
|
||||||
for sub in obj:
|
|
||||||
if hasattr(sub, 'to_polygons'):
|
|
||||||
for p in sub.to_polygons():
|
|
||||||
verts.append(p.vertices)
|
|
||||||
elif isinstance(sub, Polygon):
|
|
||||||
verts.append(sub.vertices)
|
|
||||||
except TypeError:
|
|
||||||
raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None
|
|
||||||
return verts
|
|
||||||
|
|
||||||
subject_verts = to_vertices(subjects)
|
|
||||||
clip_verts = to_vertices(clips)
|
|
||||||
|
|
||||||
pc = pyclipper.Pyclipper()
|
|
||||||
pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True)
|
|
||||||
if clip_verts:
|
|
||||||
pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True)
|
|
||||||
|
|
||||||
# Use GetPolyTree to distinguish between outers and holes
|
|
||||||
polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
|
|
||||||
|
|
||||||
result_polygons = []
|
|
||||||
|
|
||||||
def process_node(node: Any) -> None:
|
|
||||||
if not node.IsHole:
|
|
||||||
# This is an outer boundary
|
|
||||||
outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale))
|
|
||||||
|
|
||||||
# Find immediate holes
|
|
||||||
holes = []
|
|
||||||
for child in node.Childs:
|
|
||||||
if child.IsHole:
|
|
||||||
holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale)))
|
|
||||||
|
|
||||||
if holes:
|
|
||||||
combined_vertices = _bridge_holes(outer_path, holes)
|
|
||||||
result_polygons.append(Polygon(combined_vertices))
|
|
||||||
else:
|
|
||||||
result_polygons.append(Polygon(outer_path))
|
|
||||||
|
|
||||||
# Recursively process children of holes (which are nested outers)
|
|
||||||
for child in node.Childs:
|
|
||||||
if child.IsHole:
|
|
||||||
for grandchild in child.Childs:
|
|
||||||
process_node(grandchild)
|
|
||||||
else:
|
|
||||||
# Holes are processed as children of outers
|
|
||||||
pass
|
|
||||||
|
|
||||||
for top_node in polytree.Childs:
|
|
||||||
process_node(top_node)
|
|
||||||
|
|
||||||
return result_polygons
|
|
||||||
|
|
@ -47,7 +47,7 @@ def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool:
|
||||||
keys_a = tuple(sorted(aa.keys()))
|
keys_a = tuple(sorted(aa.keys()))
|
||||||
keys_b = tuple(sorted(bb.keys()))
|
keys_b = tuple(sorted(bb.keys()))
|
||||||
if keys_a != keys_b:
|
if keys_a != keys_b:
|
||||||
return False
|
return keys_a < keys_b
|
||||||
|
|
||||||
for key in keys_a:
|
for key in keys_a:
|
||||||
va = aa[key]
|
va = aa[key]
|
||||||
|
|
|
||||||
|
|
@ -69,25 +69,14 @@ def euler_bend(
|
||||||
num_points_arc = num_points - 2 * num_points_spiral
|
num_points_arc = num_points - 2 * num_points_spiral
|
||||||
|
|
||||||
def gen_spiral(ll_max: float) -> NDArray[numpy.float64]:
|
def gen_spiral(ll_max: float) -> NDArray[numpy.float64]:
|
||||||
if ll_max == 0:
|
xx = []
|
||||||
return numpy.zeros((num_points_spiral, 2))
|
yy = []
|
||||||
|
for ll in numpy.linspace(0, ll_max, num_points_spiral):
|
||||||
resolution = 100000
|
qq = numpy.linspace(0, ll, 1000) # integrate to current arclength
|
||||||
qq = numpy.linspace(0, ll_max, resolution)
|
xx.append(trapezoid( numpy.cos(qq * qq / 2), qq))
|
||||||
dx = numpy.cos(qq * qq / 2)
|
yy.append(trapezoid(-numpy.sin(qq * qq / 2), qq))
|
||||||
dy = -numpy.sin(qq * qq / 2)
|
xy_part = numpy.stack((xx, yy), axis=1)
|
||||||
|
return xy_part
|
||||||
dq = ll_max / (resolution - 1)
|
|
||||||
ix = numpy.zeros(resolution)
|
|
||||||
iy = numpy.zeros(resolution)
|
|
||||||
ix[1:] = numpy.cumsum((dx[:-1] + dx[1:]) / 2) * dq
|
|
||||||
iy[1:] = numpy.cumsum((dy[:-1] + dy[1:]) / 2) * dq
|
|
||||||
|
|
||||||
ll_target = numpy.linspace(0, ll_max, num_points_spiral)
|
|
||||||
x_target = numpy.interp(ll_target, qq, ix)
|
|
||||||
y_target = numpy.interp(ll_target, qq, iy)
|
|
||||||
|
|
||||||
return numpy.stack((x_target, y_target), axis=1)
|
|
||||||
|
|
||||||
xy_spiral = gen_spiral(ll_max)
|
xy_spiral = gen_spiral(ll_max)
|
||||||
xy_parts = [xy_spiral]
|
xy_parts = [xy_spiral]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from typing import TypeVar, Generic
|
from typing import TypeVar, Generic
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,45 +25,18 @@ class DeferredDict(dict, Generic[Key, Value]):
|
||||||
"""
|
"""
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
dict.__init__(self)
|
dict.__init__(self)
|
||||||
if args or kwargs:
|
self.update(*args, **kwargs)
|
||||||
self.update(*args, **kwargs)
|
|
||||||
|
|
||||||
def __setitem__(self, key: Key, value: Callable[[], Value]) -> None:
|
def __setitem__(self, key: Key, value: Callable[[], Value]) -> None:
|
||||||
"""
|
|
||||||
Set a value, which must be a callable that returns the actual value.
|
|
||||||
The result of the callable is cached after the first access.
|
|
||||||
"""
|
|
||||||
if not callable(value):
|
|
||||||
raise TypeError(f"DeferredDict value must be callable, got {type(value)}")
|
|
||||||
cached_fn = lru_cache(maxsize=1)(value)
|
cached_fn = lru_cache(maxsize=1)(value)
|
||||||
dict.__setitem__(self, key, cached_fn)
|
dict.__setitem__(self, key, cached_fn)
|
||||||
|
|
||||||
def __getitem__(self, key: Key) -> Value:
|
def __getitem__(self, key: Key) -> Value:
|
||||||
return dict.__getitem__(self, key)()
|
return dict.__getitem__(self, key)()
|
||||||
|
|
||||||
def get(self, key: Key, default: Value | None = None) -> Value | None:
|
|
||||||
if key not in self:
|
|
||||||
return default
|
|
||||||
return self[key]
|
|
||||||
|
|
||||||
def items(self) -> Iterator[tuple[Key, Value]]:
|
|
||||||
for key in self.keys():
|
|
||||||
yield key, self[key]
|
|
||||||
|
|
||||||
def values(self) -> Iterator[Value]:
|
|
||||||
for key in self.keys():
|
|
||||||
yield self[key]
|
|
||||||
|
|
||||||
def update(self, *args, **kwargs) -> None:
|
def update(self, *args, **kwargs) -> None:
|
||||||
"""
|
|
||||||
Update the DeferredDict. If a value is callable, it is used as a generator.
|
|
||||||
Otherwise, it is wrapped as a constant.
|
|
||||||
"""
|
|
||||||
for k, v in dict(*args, **kwargs).items():
|
for k, v in dict(*args, **kwargs).items():
|
||||||
if callable(v):
|
self[k] = v
|
||||||
self[k] = v
|
|
||||||
else:
|
|
||||||
self.set_const(k, v)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return '<DeferredDict with keys ' + repr(set(self.keys())) + '>'
|
return '<DeferredDict with keys ' + repr(set(self.keys())) + '>'
|
||||||
|
|
@ -73,4 +46,4 @@ class DeferredDict(dict, Generic[Key, Value]):
|
||||||
Convenience function to avoid having to manually wrap
|
Convenience function to avoid having to manually wrap
|
||||||
constant values into callables.
|
constant values into callables.
|
||||||
"""
|
"""
|
||||||
self[key] = lambda v=value: v
|
self[key] = lambda: value
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,6 @@ def maxrects_bssf(
|
||||||
degenerate = (min_more & max_less).any(axis=0)
|
degenerate = (min_more & max_less).any(axis=0)
|
||||||
regions = regions[~degenerate]
|
regions = regions[~degenerate]
|
||||||
|
|
||||||
if regions.shape[0] == 0:
|
|
||||||
if allow_rejects:
|
|
||||||
rejected_inds.add(rect_ind)
|
|
||||||
continue
|
|
||||||
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
|
|
||||||
|
|
||||||
''' Place the rect '''
|
''' Place the rect '''
|
||||||
# Best short-side fit (bssf) to pick a region
|
# Best short-side fit (bssf) to pick a region
|
||||||
region_sizes = regions[:, 2:] - regions[:, :2]
|
region_sizes = regions[:, 2:] - regions[:, :2]
|
||||||
|
|
@ -108,7 +102,7 @@ def maxrects_bssf(
|
||||||
if presort:
|
if presort:
|
||||||
unsort_order = rect_order.argsort()
|
unsort_order = rect_order.argsort()
|
||||||
rect_locs = rect_locs[unsort_order]
|
rect_locs = rect_locs[unsort_order]
|
||||||
rejected_inds = {int(rect_order[ii]) for ii in rejected_inds}
|
rejected_inds = set(unsort_order[list(rejected_inds)])
|
||||||
|
|
||||||
return rect_locs, rejected_inds
|
return rect_locs, rejected_inds
|
||||||
|
|
||||||
|
|
@ -193,7 +187,7 @@ def guillotine_bssf_sas(
|
||||||
if presort:
|
if presort:
|
||||||
unsort_order = rect_order.argsort()
|
unsort_order = rect_order.argsort()
|
||||||
rect_locs = rect_locs[unsort_order]
|
rect_locs = rect_locs[unsort_order]
|
||||||
rejected_inds = {int(rect_order[ii]) for ii in rejected_inds}
|
rejected_inds = set(unsort_order[list(rejected_inds)])
|
||||||
|
|
||||||
return rect_locs, rejected_inds
|
return rect_locs, rejected_inds
|
||||||
|
|
||||||
|
|
@ -242,9 +236,7 @@ def pack_patterns(
|
||||||
locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects)
|
locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects)
|
||||||
|
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
for ii, (pp, oo, loc) in enumerate(zip(patterns, offsets, locations, strict=True)):
|
for pp, oo, loc in zip(patterns, offsets, locations, strict=True):
|
||||||
if ii in reject_inds:
|
|
||||||
continue
|
|
||||||
pat.ref(pp, offset=oo + loc)
|
pat.ref(pp, offset=oo + loc)
|
||||||
|
|
||||||
rejects = [patterns[ii] for ii in reject_inds]
|
rejects = [patterns[ii] for ii in reject_inds]
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,11 @@ def data_to_ports(
|
||||||
name: str | None = None, # Note: name optional, but arg order different from read(postprocess=)
|
name: str | None = None, # Note: name optional, but arg order different from read(postprocess=)
|
||||||
max_depth: int = 0,
|
max_depth: int = 0,
|
||||||
skip_subcells: bool = True,
|
skip_subcells: bool = True,
|
||||||
visited: set[int] | None = None,
|
# TODO missing ok?
|
||||||
) -> Pattern:
|
) -> Pattern:
|
||||||
"""
|
"""
|
||||||
|
# TODO fixup documentation in ports2data
|
||||||
|
# TODO move to utils.file?
|
||||||
Examine `pattern` for labels specifying port info, and use that info
|
Examine `pattern` for labels specifying port info, and use that info
|
||||||
to fill out its `ports` attribute.
|
to fill out its `ports` attribute.
|
||||||
|
|
||||||
|
|
@ -68,30 +70,18 @@ def data_to_ports(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
layers: Search for labels on all the given layers.
|
layers: Search for labels on all the given layers.
|
||||||
library: Mapping from pattern names to patterns.
|
|
||||||
pattern: Pattern object to scan for labels.
|
pattern: Pattern object to scan for labels.
|
||||||
name: Name of the pattern object.
|
max_depth: Maximum hierarcy depth to search. Default 999_999.
|
||||||
max_depth: Maximum hierarcy depth to search. Default 0.
|
|
||||||
Reduce this to 0 to avoid ever searching subcells.
|
Reduce this to 0 to avoid ever searching subcells.
|
||||||
skip_subcells: If port labels are found at a given hierarcy level,
|
skip_subcells: If port labels are found at a given hierarcy level,
|
||||||
do not continue searching at deeper levels. This allows subcells
|
do not continue searching at deeper levels. This allows subcells
|
||||||
to contain their own port info without interfering with supercells'
|
to contain their own port info without interfering with supercells'
|
||||||
port data.
|
port data.
|
||||||
Default True.
|
Default True.
|
||||||
visited: Set of object IDs which have already been processed.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The updated `pattern`. Port labels are not removed.
|
The updated `pattern`. Port labels are not removed.
|
||||||
"""
|
"""
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
|
|
||||||
# Note: visited uses id(pattern) to detect cycles and avoid redundant processing.
|
|
||||||
# This may not catch identical patterns if they are loaded as separate object instances.
|
|
||||||
if id(pattern) in visited:
|
|
||||||
return pattern
|
|
||||||
visited.add(id(pattern))
|
|
||||||
|
|
||||||
if pattern.ports:
|
if pattern.ports:
|
||||||
logger.warning(f'Pattern {name if name else pattern} already had ports, skipping data_to_ports')
|
logger.warning(f'Pattern {name if name else pattern} already had ports, skipping data_to_ports')
|
||||||
return pattern
|
return pattern
|
||||||
|
|
@ -109,13 +99,12 @@ def data_to_ports(
|
||||||
if target is None:
|
if target is None:
|
||||||
continue
|
continue
|
||||||
pp = data_to_ports(
|
pp = data_to_ports(
|
||||||
layers = layers,
|
layers=layers,
|
||||||
library = library,
|
library=library,
|
||||||
pattern = library[target],
|
pattern=library[target],
|
||||||
name = target,
|
name=target,
|
||||||
max_depth = max_depth - 1,
|
max_depth=max_depth - 1,
|
||||||
skip_subcells = skip_subcells,
|
skip_subcells=skip_subcells,
|
||||||
visited = visited,
|
|
||||||
)
|
)
|
||||||
found_ports |= bool(pp.ports)
|
found_ports |= bool(pp.ports)
|
||||||
|
|
||||||
|
|
@ -171,17 +160,13 @@ def data_to_ports_flat(
|
||||||
|
|
||||||
local_ports = {}
|
local_ports = {}
|
||||||
for label in labels:
|
for label in labels:
|
||||||
if ':' not in label.string:
|
name, property_string = label.string.split(':')
|
||||||
logger.warning(f'Invalid port label "{label.string}" in pattern "{pstr}" (missing ":")')
|
properties = property_string.split(' ')
|
||||||
continue
|
ptype = properties[0]
|
||||||
|
angle_deg = float(properties[1]) if len(ptype) else 0
|
||||||
name, property_string = label.string.split(':', 1)
|
|
||||||
properties = property_string.split()
|
|
||||||
ptype = properties[0] if len(properties) > 0 else 'unk'
|
|
||||||
angle_deg = float(properties[1]) if len(properties) > 1 else numpy.inf
|
|
||||||
|
|
||||||
xy = label.offset
|
xy = label.offset
|
||||||
angle = numpy.deg2rad(angle_deg) if numpy.isfinite(angle_deg) else None
|
angle = numpy.deg2rad(angle_deg)
|
||||||
|
|
||||||
if name in local_ports:
|
if name in local_ports:
|
||||||
logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"')
|
logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"')
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,8 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
|
||||||
arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
|
arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
|
||||||
[numpy.sin(theta), +numpy.cos(theta)]])
|
[numpy.sin(theta), +numpy.cos(theta)]])
|
||||||
|
|
||||||
# If this was a manhattan rotation, round to remove some inaccuracies in sin & cos
|
# If this was a manhattan rotation, round to remove some inacuraccies in sin & cos
|
||||||
# cos(4*theta) is 1 for any multiple of pi/2.
|
if numpy.isclose(theta % (pi / 2), 0):
|
||||||
if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12):
|
|
||||||
arr = numpy.round(arr)
|
arr = numpy.round(arr)
|
||||||
|
|
||||||
arr.flags.writeable = False
|
arr.flags.writeable = False
|
||||||
|
|
@ -87,50 +86,37 @@ def apply_transforms(
|
||||||
Apply a set of transforms (`outer`) to a second set (`inner`).
|
Apply a set of transforms (`outer`) to a second set (`inner`).
|
||||||
This is used to find the "absolute" transform for nested `Ref`s.
|
This is used to find the "absolute" transform for nested `Ref`s.
|
||||||
|
|
||||||
The two transforms should be of shape Ox5 and Ix5.
|
The two transforms should be of shape Ox4 and Ix4.
|
||||||
Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x, scale)`.
|
Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
|
||||||
The output will be of the form (O*I)x5 (if `tensor=False`) or OxIx5 (`tensor=True`).
|
The output will be of the form (O*I)x4 (if `tensor=False`) or OxIx4 (`tensor=True`).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
outer: Transforms for the container refs. Shape Ox5.
|
outer: Transforms for the container refs. Shape Ox4.
|
||||||
inner: Transforms for the contained refs. Shape Ix5.
|
inner: Transforms for the contained refs. Shape Ix4.
|
||||||
tensor: If `True`, an OxIx5 array is returned, with `result[oo, ii, :]` corresponding
|
tensor: If `True`, an OxIx4 array is returned, with `result[oo, ii, :]` corresponding
|
||||||
to the `oo`th `outer` transform applied to the `ii`th inner transform.
|
to the `oo`th `outer` transform applied to the `ii`th inner transform.
|
||||||
If `False` (default), this is concatenated into `(O*I)x5` to allow simple
|
If `False` (default), this is concatenated into `(O*I)x4` to allow simple
|
||||||
chaining into additional `apply_transforms()` calls.
|
chaining into additional `apply_transforms()` calls.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OxIx5 or (O*I)x5 array. Final dimension is
|
OxIx4 or (O*I)x4 array. Final dimension is
|
||||||
`(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x, total_scale)`.
|
`(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`.
|
||||||
"""
|
"""
|
||||||
outer = numpy.atleast_2d(outer).astype(float, copy=False)
|
outer = numpy.atleast_2d(outer).astype(float, copy=False)
|
||||||
inner = numpy.atleast_2d(inner).astype(float, copy=False)
|
inner = numpy.atleast_2d(inner).astype(float, copy=False)
|
||||||
|
|
||||||
if outer.shape[1] == 4:
|
|
||||||
outer = numpy.pad(outer, ((0, 0), (0, 1)), constant_values=1.0)
|
|
||||||
if inner.shape[1] == 4:
|
|
||||||
inner = numpy.pad(inner, ((0, 0), (0, 1)), constant_values=1.0)
|
|
||||||
|
|
||||||
# If mirrored, flip y's
|
# If mirrored, flip y's
|
||||||
xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm
|
xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm
|
||||||
xy_mir[outer[:, 3].astype(bool), :, 1] *= -1
|
xy_mir[outer[:, 3].astype(bool), :, 1] *= -1
|
||||||
|
|
||||||
# Apply outer scale to inner offset
|
|
||||||
xy_mir *= outer[:, None, 4, None]
|
|
||||||
|
|
||||||
rot_mats = [rotation_matrix_2d(angle) for angle in outer[:, 2]]
|
rot_mats = [rotation_matrix_2d(angle) for angle in outer[:, 2]]
|
||||||
xy = numpy.einsum('ort,oit->oir', rot_mats, xy_mir)
|
xy = numpy.einsum('ort,oit->oir', rot_mats, xy_mir)
|
||||||
|
|
||||||
tot = numpy.empty((outer.shape[0], inner.shape[0], 5))
|
tot = numpy.empty((outer.shape[0], inner.shape[0], 4))
|
||||||
tot[:, :, :2] = outer[:, None, :2] + xy
|
tot[:, :, :2] = outer[:, None, :2] + xy
|
||||||
|
tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored
|
||||||
# If mirrored, flip inner rotation
|
tot[:, :, 2] %= 2 * pi # clamp rot
|
||||||
mirrored_outer = outer[:, None, 3].astype(bool)
|
tot[:, :, 3] %= 2 # clamp mirrored
|
||||||
rotations = outer[:, None, 2] + numpy.where(mirrored_outer, -inner[None, :, 2], inner[None, :, 2])
|
|
||||||
|
|
||||||
tot[:, :, 2] = rotations % (2 * pi)
|
|
||||||
tot[:, :, 3] = (outer[:, None, 3] + inner[None, :, 3]) % 2 # net mirrored
|
|
||||||
tot[:, :, 4] = outer[:, None, 4] * inner[None, :, 4] # net scale
|
|
||||||
|
|
||||||
if tensor:
|
if tensor:
|
||||||
return tot
|
return tot
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,13 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) ->
|
||||||
`vertices` with no consecutive duplicates. This may be a view into the original array.
|
`vertices` with no consecutive duplicates. This may be a view into the original array.
|
||||||
"""
|
"""
|
||||||
vertices = numpy.asarray(vertices)
|
vertices = numpy.asarray(vertices)
|
||||||
if vertices.shape[0] <= 1:
|
|
||||||
return vertices
|
|
||||||
duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1)
|
duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1)
|
||||||
if not closed_path:
|
if not closed_path:
|
||||||
duplicates[-1] = False
|
duplicates[-1] = False
|
||||||
|
return vertices[~duplicates]
|
||||||
result = vertices[~duplicates]
|
|
||||||
if result.shape[0] == 0 and vertices.shape[0] > 0:
|
|
||||||
return vertices[:1]
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def remove_colinear_vertices(
|
def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]:
|
||||||
vertices: ArrayLike,
|
|
||||||
closed_path: bool = True,
|
|
||||||
preserve_uturns: bool = False,
|
|
||||||
) -> NDArray[numpy.float64]:
|
|
||||||
"""
|
"""
|
||||||
Given a list of vertices, remove any superflous vertices (i.e.
|
Given a list of vertices, remove any superflous vertices (i.e.
|
||||||
those which lie along the line formed by their neighbors)
|
those which lie along the line formed by their neighbors)
|
||||||
|
|
@ -43,40 +33,24 @@ def remove_colinear_vertices(
|
||||||
vertices: Nx2 ndarray of vertices
|
vertices: Nx2 ndarray of vertices
|
||||||
closed_path: If `True`, the vertices are assumed to represent an implicitly
|
closed_path: If `True`, the vertices are assumed to represent an implicitly
|
||||||
closed path. If `False`, the path is assumed to be open. Default `True`.
|
closed path. If `False`, the path is assumed to be open. Default `True`.
|
||||||
preserve_uturns: If `True`, colinear vertices that correspond to a 180 degree
|
|
||||||
turn (a "spike") are preserved. Default `False`.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
`vertices` with colinear (superflous) vertices removed. May be a view into the original array.
|
`vertices` with colinear (superflous) vertices removed. May be a view into the original array.
|
||||||
"""
|
"""
|
||||||
vertices = remove_duplicate_vertices(vertices, closed_path=closed_path)
|
vertices = remove_duplicate_vertices(vertices)
|
||||||
|
|
||||||
# Check for dx0/dy0 == dx1/dy1
|
# Check for dx0/dy0 == dx1/dy1
|
||||||
dv = numpy.roll(vertices, -1, axis=0) - vertices
|
|
||||||
if not closed_path:
|
|
||||||
dv[-1] = 0
|
|
||||||
|
|
||||||
# dxdy[i] is based on dv[i] and dv[i-1]
|
dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...]
|
||||||
# slopes_equal[i] refers to vertex i
|
dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]]
|
||||||
dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1]
|
|
||||||
|
|
||||||
dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0]
|
dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0]
|
||||||
err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40
|
err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40
|
||||||
|
|
||||||
slopes_equal = (dxdy_diff / err_mult) < 1e-15
|
slopes_equal = (dxdy_diff / err_mult) < 1e-15
|
||||||
|
|
||||||
if preserve_uturns:
|
|
||||||
# Only merge if segments are in the same direction (avoid collapsing u-turns)
|
|
||||||
dot_prod = (dv * numpy.roll(dv, 1, axis=0)).sum(axis=1)
|
|
||||||
slopes_equal &= (dot_prod > 0)
|
|
||||||
|
|
||||||
if not closed_path:
|
if not closed_path:
|
||||||
slopes_equal[[0, -1]] = False
|
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]
|
return vertices[~slopes_equal]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,7 +58,7 @@ def poly_contains_points(
|
||||||
vertices: ArrayLike,
|
vertices: ArrayLike,
|
||||||
points: ArrayLike,
|
points: ArrayLike,
|
||||||
include_boundary: bool = True,
|
include_boundary: bool = True,
|
||||||
) -> NDArray[numpy.bool_]:
|
) -> NDArray[numpy.int_]:
|
||||||
"""
|
"""
|
||||||
Tests whether the provided points are inside the implicitly closed polygon
|
Tests whether the provided points are inside the implicitly closed polygon
|
||||||
described by the provided list of vertices.
|
described by the provided list of vertices.
|
||||||
|
|
@ -103,13 +77,13 @@ def poly_contains_points(
|
||||||
vertices = numpy.asarray(vertices, dtype=float)
|
vertices = numpy.asarray(vertices, dtype=float)
|
||||||
|
|
||||||
if points.size == 0:
|
if points.size == 0:
|
||||||
return numpy.zeros(0, dtype=bool)
|
return numpy.zeros(0, dtype=numpy.int8)
|
||||||
|
|
||||||
min_bounds = numpy.min(vertices, axis=0)[None, :]
|
min_bounds = numpy.min(vertices, axis=0)[None, :]
|
||||||
max_bounds = numpy.max(vertices, axis=0)[None, :]
|
max_bounds = numpy.max(vertices, axis=0)[None, :]
|
||||||
|
|
||||||
trivially_outside = ((points < min_bounds).any(axis=1)
|
trivially_outside = ((points < min_bounds).any(axis=1)
|
||||||
| (points > max_bounds).any(axis=1))
|
| (points > max_bounds).any(axis=1)) # noqa: E128
|
||||||
|
|
||||||
nontrivial = ~trivially_outside
|
nontrivial = ~trivially_outside
|
||||||
if trivially_outside.all():
|
if trivially_outside.all():
|
||||||
|
|
@ -127,10 +101,10 @@ def poly_contains_points(
|
||||||
|
|
||||||
dv = numpy.roll(verts, -1, axis=0) - verts
|
dv = numpy.roll(verts, -1, axis=0) - verts
|
||||||
is_left = (dv[:, 0] * (ntpts[..., 1] - verts[:, 1]) # >0 if left of dv, <0 if right, 0 if on the line
|
is_left = (dv[:, 0] * (ntpts[..., 1] - verts[:, 1]) # >0 if left of dv, <0 if right, 0 if on the line
|
||||||
- dv[:, 1] * (ntpts[..., 0] - verts[:, 0]))
|
- dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) # noqa: E128
|
||||||
|
|
||||||
winding_number = ((upward & (is_left > 0)).sum(axis=0)
|
winding_number = ((upward & (is_left > 0)).sum(axis=0)
|
||||||
- (downward & (is_left < 0)).sum(axis=0))
|
- (downward & (is_left < 0)).sum(axis=0)) # noqa: E128
|
||||||
|
|
||||||
nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number
|
nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number
|
||||||
if include_boundary:
|
if include_boundary:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "masque"
|
name = "masque"
|
||||||
description = "Lithography mask library"
|
description = "Lithography mask library"
|
||||||
|
|
@ -42,38 +46,17 @@ dependencies = [
|
||||||
"klamath~=1.4",
|
"klamath~=1.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
|
||||||
dev = [
|
|
||||||
"pytest",
|
|
||||||
"masque[oasis]",
|
|
||||||
"masque[dxf]",
|
|
||||||
"masque[svg]",
|
|
||||||
"masque[visualize]",
|
|
||||||
"masque[text]",
|
|
||||||
"masque[manhattanize]",
|
|
||||||
"masque[manhattanize_slow]",
|
|
||||||
"matplotlib>=3.10.8",
|
|
||||||
"pytest>=9.0.2",
|
|
||||||
"ruff>=0.15.5",
|
|
||||||
"mypy>=1.19.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
path = "masque/__init__.py"
|
path = "masque/__init__.py"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
oasis = ["fatamorgana~=0.11"]
|
oasis = ["fatamorgana~=0.11"]
|
||||||
dxf = ["ezdxf~=1.4"]
|
dxf = ["ezdxf~=1.0.2"]
|
||||||
svg = ["svgwrite"]
|
svg = ["svgwrite"]
|
||||||
visualize = ["matplotlib"]
|
visualize = ["matplotlib"]
|
||||||
text = ["matplotlib", "freetype-py"]
|
text = ["matplotlib", "freetype-py"]
|
||||||
manhattanize = ["scikit-image"]
|
manhatanize_slow = ["float_raster"]
|
||||||
manhattanize_slow = ["float_raster"]
|
|
||||||
boolean = ["pyclipper"]
|
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|
@ -104,21 +87,10 @@ lint.ignore = [
|
||||||
"PLR09", # Too many xxx
|
"PLR09", # Too many xxx
|
||||||
"PLR2004", # magic number
|
"PLR2004", # magic number
|
||||||
"PLC0414", # import x as x
|
"PLC0414", # import x as x
|
||||||
# "PLC0415", # non-top-level import
|
|
||||||
"PLW1641", # missing __hash__ with total_ordering
|
|
||||||
"TRY003", # Long exception message
|
"TRY003", # Long exception message
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-rsXx"
|
addopts = "-rsXx"
|
||||||
testpaths = ["masque"]
|
testpaths = ["masque"]
|
||||||
filterwarnings = [
|
|
||||||
"ignore::DeprecationWarning:ezdxf.*",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
mypy_path = "stubs"
|
|
||||||
python_version = "3.11"
|
|
||||||
strict = false
|
|
||||||
check_untyped_defs = true
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
from typing import Any, TextIO
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from .layouts import Modelspace, BlockRecords
|
|
||||||
|
|
||||||
class Drawing:
|
|
||||||
blocks: BlockRecords
|
|
||||||
@property
|
|
||||||
def layers(self) -> Iterable[Any]: ...
|
|
||||||
def modelspace(self) -> Modelspace: ...
|
|
||||||
def write(self, stream: TextIO) -> None: ...
|
|
||||||
|
|
||||||
def new(version: str = ..., setup: bool = ...) -> Drawing: ...
|
|
||||||
def read(stream: TextIO) -> Drawing: ...
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
from typing import Any
|
|
||||||
from collections.abc import Iterable, Sequence
|
|
||||||
|
|
||||||
class DXFEntity:
|
|
||||||
def dxfattribs(self) -> dict[str, Any]: ...
|
|
||||||
def dxftype(self) -> str: ...
|
|
||||||
|
|
||||||
class LWPolyline(DXFEntity):
|
|
||||||
def get_points(self) -> Iterable[tuple[float, ...]]: ...
|
|
||||||
|
|
||||||
class Polyline(DXFEntity):
|
|
||||||
def points(self) -> Iterable[Any]: ... # has .xyz
|
|
||||||
|
|
||||||
class Text(DXFEntity):
|
|
||||||
def get_placement(self) -> tuple[int, tuple[float, float, float]]: ...
|
|
||||||
def set_placement(self, p: Sequence[float], align: int = ...) -> Text: ...
|
|
||||||
|
|
||||||
class Insert(DXFEntity): ...
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
from enum import IntEnum
|
|
||||||
|
|
||||||
class TextEntityAlignment(IntEnum):
|
|
||||||
BOTTOM_LEFT = ...
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
from typing import Any
|
|
||||||
from collections.abc import Iterator, Sequence, Iterable
|
|
||||||
from .entities import DXFEntity
|
|
||||||
|
|
||||||
class BaseLayout:
|
|
||||||
def __iter__(self) -> Iterator[DXFEntity]: ...
|
|
||||||
def add_lwpolyline(self, points: Iterable[Sequence[float]], dxfattribs: dict[str, Any] = ...) -> Any: ...
|
|
||||||
def add_text(self, text: str, dxfattribs: dict[str, Any] = ...) -> Any: ...
|
|
||||||
def add_blockref(self, name: str, insert: Any, dxfattribs: dict[str, Any] = ...) -> Any: ...
|
|
||||||
|
|
||||||
class Modelspace(BaseLayout):
|
|
||||||
@property
|
|
||||||
def name(self) -> str: ...
|
|
||||||
|
|
||||||
class BlockLayout(BaseLayout):
|
|
||||||
@property
|
|
||||||
def name(self) -> str: ...
|
|
||||||
|
|
||||||
class BlockRecords:
|
|
||||||
def new(self, name: str) -> BlockLayout: ...
|
|
||||||
def __iter__(self) -> Iterator[BlockLayout]: ...
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
from typing import Any
|
|
||||||
from collections.abc import Iterable, Sequence
|
|
||||||
import numpy
|
|
||||||
from numpy.typing import NDArray
|
|
||||||
|
|
||||||
|
|
||||||
# Basic types for Clipper integer coordinates
|
|
||||||
Path = Sequence[tuple[int, int]]
|
|
||||||
Paths = Sequence[Path]
|
|
||||||
|
|
||||||
# Types for input/output floating point coordinates
|
|
||||||
FloatPoint = tuple[float, float] | NDArray[numpy.floating]
|
|
||||||
FloatPath = Sequence[FloatPoint] | NDArray[numpy.floating]
|
|
||||||
FloatPaths = Iterable[FloatPath]
|
|
||||||
|
|
||||||
# Constants
|
|
||||||
PT_SUBJECT: int
|
|
||||||
PT_CLIP: int
|
|
||||||
|
|
||||||
PT_UNION: int
|
|
||||||
PT_INTERSECTION: int
|
|
||||||
PT_DIFFERENCE: int
|
|
||||||
PT_XOR: int
|
|
||||||
|
|
||||||
PFT_EVENODD: int
|
|
||||||
PFT_NONZERO: int
|
|
||||||
PFT_POSITIVE: int
|
|
||||||
PFT_NEGATIVE: int
|
|
||||||
|
|
||||||
# Scaling functions
|
|
||||||
def scale_to_clipper(paths: FloatPaths, scale: float = ...) -> Paths: ...
|
|
||||||
def scale_from_clipper(paths: Path | Paths, scale: float = ...) -> Any: ...
|
|
||||||
|
|
||||||
class PolyNode:
|
|
||||||
Contour: Path
|
|
||||||
Childs: list[PolyNode]
|
|
||||||
Parent: PolyNode
|
|
||||||
IsHole: bool
|
|
||||||
|
|
||||||
class Pyclipper:
|
|
||||||
def __init__(self) -> None: ...
|
|
||||||
def AddPath(self, path: Path, poly_type: int, closed: bool) -> None: ...
|
|
||||||
def AddPaths(self, paths: Paths, poly_type: int, closed: bool) -> None: ...
|
|
||||||
def Execute(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> Paths: ...
|
|
||||||
def Execute2(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> PolyNode: ...
|
|
||||||
def Clear(self) -> None: ...
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue