Compare commits
10 commits
2b7ad00204
...
1cce6c1f70
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cce6c1f70 | |||
| d9adb4e1b9 | |||
| 1de76bff47 | |||
| 9bb0d5190d | |||
| ad49276345 | |||
| fe70d0574b | |||
| 36fed84249 | |||
| 278f0783da | |||
| 72f462d077 | |||
| 66d6fae2bd |
27 changed files with 1828 additions and 22 deletions
|
|
@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir
|
|||
|
||||
# TODO document all tools
|
||||
"""
|
||||
from typing import Literal, Any, Self
|
||||
from typing import Literal, Any, Self, cast, TYPE_CHECKING
|
||||
from collections.abc import Sequence, Callable
|
||||
from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method?
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -543,9 +543,10 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
|||
return self
|
||||
|
||||
@staticmethod
|
||||
def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
|
||||
def _bend2dxy(bend: Bend | None, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
|
||||
if ccw is None:
|
||||
return numpy.zeros(2), pi
|
||||
assert bend is not None
|
||||
bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port)
|
||||
assert bend_angle is not None
|
||||
if bool(ccw):
|
||||
|
|
@ -590,8 +591,20 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
|||
) -> tuple[Port, LData]:
|
||||
|
||||
success = False
|
||||
# If ccw is None, we don't need a bend, but we still loop to reuse the logic.
|
||||
# We'll use a dummy loop if bends is empty and ccw is None.
|
||||
bends = cast(list[AutoTool.Bend | None], self.bends)
|
||||
if ccw is None and not bends:
|
||||
bends += [None]
|
||||
|
||||
# Initialize these to avoid UnboundLocalError in the error message
|
||||
bend_dxy, bend_angle = numpy.zeros(2), pi
|
||||
itrans_dxy = numpy.zeros(2)
|
||||
otrans_dxy = numpy.zeros(2)
|
||||
btrans_dxy = numpy.zeros(2)
|
||||
|
||||
for straight in self.straights:
|
||||
for bend in self.bends:
|
||||
for bend in bends:
|
||||
bend_dxy, bend_angle = self._bend2dxy(bend, ccw)
|
||||
|
||||
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
|
||||
|
|
@ -600,13 +613,15 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
|||
|
||||
out_ptype_pair = (
|
||||
'unk' if out_ptype is None else out_ptype,
|
||||
straight.ptype if ccw is None else bend.out_port.ptype
|
||||
straight.ptype if ccw is None else cast(AutoTool.Bend, bend).out_port.ptype
|
||||
)
|
||||
out_transition = self.transitions.get(out_ptype_pair, None)
|
||||
otrans_dxy = self._otransition2dxy(out_transition, bend_angle)
|
||||
|
||||
b_transition = None
|
||||
if ccw is not None and bend.in_port.ptype != straight.ptype:
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -272,13 +272,16 @@ class Arc(PositionableImpl, Shape):
|
|||
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
|
||||
|
||||
keep = [0]
|
||||
removable = (numpy.cumsum(arc_lengths) <= max_arclen)
|
||||
start = 1
|
||||
start = 0
|
||||
while start < arc_lengths.size:
|
||||
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
|
||||
removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
|
||||
if not removable.any():
|
||||
next_to_keep = start + 1
|
||||
else:
|
||||
next_to_keep = start + numpy.where(removable)[0][-1] + 1
|
||||
keep.append(next_to_keep)
|
||||
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
|
||||
start = next_to_keep + 1
|
||||
start = next_to_keep
|
||||
|
||||
if keep[-1] != thetas.size - 1:
|
||||
keep.append(thetas.size - 1)
|
||||
|
||||
|
|
@ -362,16 +365,19 @@ class Arc(PositionableImpl, Shape):
|
|||
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
|
||||
|
||||
# If our arc subtends a coordinate axis, use the extremum along that axis
|
||||
if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
|
||||
if abs(a1 - a0) >= 2 * pi:
|
||||
xn, xp, yn, yp = -xr, xr, -yr, yr
|
||||
else:
|
||||
if a0 <= xpt <= a1 or a0 <= xpt + 2 * pi <= a1:
|
||||
xp = xr
|
||||
|
||||
if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
|
||||
if a0 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1:
|
||||
xn = -xr
|
||||
|
||||
if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
|
||||
if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1:
|
||||
yp = yr
|
||||
|
||||
if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
|
||||
if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1:
|
||||
yn = -yr
|
||||
|
||||
mins.append([xn, yn])
|
||||
|
|
@ -463,13 +469,18 @@ class Arc(PositionableImpl, Shape):
|
|||
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
||||
"""
|
||||
aa = []
|
||||
d_angle = self.angles[1] - self.angles[0]
|
||||
if abs(d_angle) >= 2 * pi:
|
||||
# Full ring
|
||||
return numpy.tile([0, 2 * pi], (2, 1)).astype(float)
|
||||
|
||||
for sgn in (-1, +1):
|
||||
wh = sgn * self.width / 2.0
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
|
||||
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
|
||||
sign = numpy.sign(self.angles[1] - self.angles[0])
|
||||
sign = numpy.sign(d_angle)
|
||||
if sign != numpy.sign(a1 - a0):
|
||||
a1 += sign * 2 * pi
|
||||
|
||||
|
|
|
|||
|
|
@ -56,9 +56,11 @@ class PolyCollection(Shape):
|
|||
"""
|
||||
Iterator which provides slices which index vertex_lists
|
||||
"""
|
||||
if self._vertex_offsets.size == 0:
|
||||
return
|
||||
for ii, ff in zip(
|
||||
self._vertex_offsets,
|
||||
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)),
|
||||
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
|
||||
strict=True,
|
||||
):
|
||||
yield slice(ii, ff)
|
||||
|
|
@ -168,7 +170,9 @@ class PolyCollection(Shape):
|
|||
annotations = copy.deepcopy(self.annotations),
|
||||
) for vv in self.polygon_vertices]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
|
||||
if self._vertex_lists.size == 0:
|
||||
return None
|
||||
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
|
||||
numpy.max(self._vertex_lists, axis=0)))
|
||||
|
||||
|
|
|
|||
3
masque/test/__init__.py
Normal file
3
masque/test/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
|
||||
"""
|
||||
13
masque/test/conftest.py
Normal file
13
masque/test/conftest.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""
|
||||
|
||||
Test fixtures
|
||||
|
||||
"""
|
||||
|
||||
# ruff: noqa: ARG001
|
||||
from typing import Any
|
||||
import numpy
|
||||
|
||||
|
||||
FixtureRequest = Any
|
||||
PRNG = numpy.random.RandomState(12345)
|
||||
60
masque/test/test_abstract.py
Normal file
60
masque/test/test_abstract.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
# Mirror across x axis (axis 0): flips y-offset
|
||||
abs_obj.mirror(0)
|
||||
# (0, 10) mirrored(0) -> (0, -10)
|
||||
# rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2
|
||||
assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10)
|
||||
assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_ref_transform() -> 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_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_undo_transform() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
||||
|
||||
abs_obj.undo_ref_transform(ref)
|
||||
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
||||
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
||||
87
masque/test/test_advanced_routing.py
Normal file
87
masque/test/test_advanced_routing.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import pytest
|
||||
from numpy.testing import assert_equal
|
||||
from numpy import pi
|
||||
|
||||
from ..builder import Pather
|
||||
from ..builder.tools import PathTool
|
||||
from ..library import Library
|
||||
from ..ports import Port
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def advanced_pather() -> tuple[Pather, PathTool, Library]:
|
||||
lib = Library()
|
||||
# Simple PathTool: 2um width on layer (1,0)
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
p = Pather(lib, tools=tool)
|
||||
return p, tool, lib
|
||||
|
||||
|
||||
def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = advanced_pather
|
||||
# Facing ports
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device)
|
||||
# Forward (+pi relative to port) is West (-x).
|
||||
# Put destination at (-20, 0) pointing East (pi).
|
||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
||||
|
||||
p.path_into("src", "dst")
|
||||
|
||||
assert "src" not in p.ports
|
||||
assert "dst" not in p.ports
|
||||
# Pather.path adds a Reference to the generated pattern
|
||||
assert len(p.pattern.refs) == 1
|
||||
|
||||
|
||||
def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = advanced_pather
|
||||
# Source at (0,0) rot 0 (facing East). Forward is West (-x).
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||
# Destination at (-20, -20) rot pi (facing West). Forward is East (+x).
|
||||
# Wait, src forward is -x. dst is at -20, -20.
|
||||
# To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South).
|
||||
# Forward for South is North (+y).
|
||||
p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire")
|
||||
|
||||
p.path_into("src", "dst")
|
||||
|
||||
assert "src" not in p.ports
|
||||
assert "dst" not in p.ports
|
||||
# Single bend should result in 2 segments (one for x move, one for y move)
|
||||
assert len(p.pattern.refs) == 2
|
||||
|
||||
|
||||
def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = advanced_pather
|
||||
# Facing but offset ports
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Forward is West (-x)
|
||||
p.ports["dst"] = Port((-20, -10), pi, ptype="wire") # Facing East (rot pi)
|
||||
|
||||
p.path_into("src", "dst")
|
||||
|
||||
assert "src" not in p.ports
|
||||
assert "dst" not in p.ports
|
||||
|
||||
|
||||
def test_path_from(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = advanced_pather
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
||||
|
||||
p.at("dst").path_from("src")
|
||||
|
||||
assert "src" not in p.ports
|
||||
assert "dst" not in p.ports
|
||||
|
||||
|
||||
def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = advanced_pather
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
||||
p.ports["other"] = Port((10, 10), 0)
|
||||
|
||||
p.path_into("src", "dst", thru="other")
|
||||
|
||||
assert "src" in p.ports
|
||||
assert_equal(p.ports["src"].offset, [10, 10])
|
||||
assert "other" not in p.ports
|
||||
81
masque/test/test_autotool.py
Normal file
81
masque/test/test_autotool.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import pytest
|
||||
from numpy.testing import assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..builder import Pather
|
||||
from ..builder.tools import AutoTool
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
|
||||
|
||||
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
|
||||
pat = Pattern()
|
||||
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
||||
pat.ports["in"] = Port((0, 0), 0, ptype=ptype)
|
||||
pat.ports["out"] = Port((length, 0), pi, ptype=ptype)
|
||||
return pat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def autotool_setup() -> tuple[Pather, AutoTool, Library]:
|
||||
lib = Library()
|
||||
|
||||
# Define a simple bend
|
||||
bend_pat = Pattern()
|
||||
# 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise)
|
||||
bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire")
|
||||
bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire")
|
||||
lib["bend"] = bend_pat
|
||||
lib.abstract("bend")
|
||||
|
||||
# Define a transition (e.g., via)
|
||||
via_pat = Pattern()
|
||||
via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1")
|
||||
via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2")
|
||||
lib["via"] = via_pat
|
||||
via_abs = lib.abstract("via")
|
||||
|
||||
tool_m1 = AutoTool(
|
||||
straights=[
|
||||
AutoTool.Straight(ptype="wire_m1", fn=lambda length: make_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out")
|
||||
],
|
||||
bends=[],
|
||||
sbends=[],
|
||||
transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")},
|
||||
default_out_ptype="wire_m1",
|
||||
)
|
||||
|
||||
p = Pather(lib, tools=tool_m1)
|
||||
# Start with an m2 port
|
||||
p.ports["start"] = Port((0, 0), pi, ptype="wire_m2")
|
||||
|
||||
return p, tool_m1, lib
|
||||
|
||||
|
||||
def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None:
|
||||
p, tool, lib = autotool_setup
|
||||
|
||||
# Route m1 from an m2 port. Should trigger via.
|
||||
# length 10. Via length is 1. So straight m1 should be 9.
|
||||
p.path("start", ccw=None, length=10)
|
||||
|
||||
# Start at (0,0) rot pi (facing West).
|
||||
# Forward (+pi relative to port) is East (+x).
|
||||
# Via: m2(1,0)pi -> m1(0,0)0.
|
||||
# Plug via m2 into start(0,0)pi: transformation rot=mod(pi-pi-pi, 2pi)=pi.
|
||||
# rotate via by pi: m2 at (0,0), m1 at (-1, 0) rot pi.
|
||||
# Then straight m1 of length 9 from (-1, 0) rot pi -> ends at (8, 0) rot pi.
|
||||
# Wait, (length, 0) relative to (-1, 0) rot pi:
|
||||
# transform (9, 0) by pi: (-9, 0).
|
||||
# (-1, 0) + (-9, 0) = (-10, 0)? No.
|
||||
# Let's re-calculate.
|
||||
# start (0,0) rot pi. Direction East.
|
||||
# via m2 is at (0,0), m1 is at (1,0).
|
||||
# When via is plugged into start: m2 goes to (0,0).
|
||||
# since start is pi and m2 is pi, rotation is 0.
|
||||
# so via m1 is at (1,0) rot 0.
|
||||
# then straight m1 length 9 from (1,0) rot 0: ends at (10, 0) rot 0.
|
||||
|
||||
assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10)
|
||||
assert p.ports["start"].ptype == "wire_m1"
|
||||
75
masque/test/test_builder.py
Normal file
75
masque/test/test_builder.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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_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()
|
||||
24
masque/test/test_fdfd.py
Normal file
24
masque/test/test_fdfd.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# ruff: noqa
|
||||
# ruff: noqa: ARG001
|
||||
|
||||
|
||||
import dataclasses
|
||||
import pytest # type: ignore
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray
|
||||
# from numpy.testing import assert_allclose, assert_array_equal
|
||||
|
||||
from .. import Pattern, Arc, Circle
|
||||
|
||||
|
||||
def test_circle_mirror():
|
||||
cc = Circle(radius=4, offset=(10, 20))
|
||||
cc.flip_across(axis=0) # flip across y=0
|
||||
assert cc.offset[0] == 10
|
||||
assert cc.offset[1] == -20
|
||||
assert cc.radius == 4
|
||||
cc.flip_across(axis=1) # flip across x=0
|
||||
assert cc.offset[0] == -10
|
||||
assert cc.offset[1] == -20
|
||||
assert cc.radius == 4
|
||||
69
masque/test/test_gdsii.py
Normal file
69
masque/test/test_gdsii.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from pathlib import Path
|
||||
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
|
||||
|
||||
|
||||
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 = read_lib["poly_cell"].shapes[(1, 0)][0]
|
||||
# GDSII closes polygons, so it might have an extra vertex or different order
|
||||
assert len(read_poly.vertices) >= 4
|
||||
# Check bounds as a proxy for geometry correctness
|
||||
assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]])
|
||||
|
||||
# Check path
|
||||
read_path = read_lib["path_cell"].shapes[(2, 5)][0]
|
||||
assert isinstance(read_path, MPath)
|
||||
assert read_path.width == 10
|
||||
assert_equal(read_path.vertices, [[0, 0], [100, 0]])
|
||||
|
||||
# Check Ref
|
||||
read_ref = read_lib["ref_cell"].refs["poly_cell"][0]
|
||||
assert_equal(read_ref.offset, [50, 50])
|
||||
assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5)
|
||||
|
||||
|
||||
def test_gdsii_annotations(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
# GDS only supports integer keys in range [1, 126] for properties
|
||||
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]})
|
||||
lib["cell"] = pat
|
||||
|
||||
gds_file = tmp_path / "test_ann.gds"
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, _ = gdsii.readfile(gds_file)
|
||||
read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations
|
||||
assert read_ann["1"] == ["hello"]
|
||||
50
masque/test/test_label.py
Normal file
50
masque/test/test_label.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import copy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..label import Label
|
||||
from ..repetition import Grid
|
||||
|
||||
|
||||
def test_label_init() -> None:
|
||||
lbl = Label("test", offset=(10, 20))
|
||||
assert lbl.string == "test"
|
||||
assert_equal(lbl.offset, [10, 20])
|
||||
|
||||
|
||||
def test_label_transform() -> None:
|
||||
lbl = Label("test", offset=(10, 0))
|
||||
# Rotate 90 deg CCW around (0,0)
|
||||
lbl.rotate_around((0, 0), pi / 2)
|
||||
assert_allclose(lbl.offset, [0, 10], atol=1e-10)
|
||||
|
||||
# Translate
|
||||
lbl.translate((5, 5))
|
||||
assert_allclose(lbl.offset, [5, 15], atol=1e-10)
|
||||
|
||||
|
||||
def test_label_repetition() -> None:
|
||||
rep = Grid(a_vector=(10, 0), a_count=3)
|
||||
lbl = Label("rep", offset=(0, 0), repetition=rep)
|
||||
assert lbl.repetition is rep
|
||||
assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]])
|
||||
# Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't
|
||||
# seem to automatically include repetition bounds in label.py itself,
|
||||
# it's handled during pattern bounding.
|
||||
|
||||
|
||||
def test_label_copy() -> None:
|
||||
l1 = Label("test", offset=(1, 2), annotations={"a": [1]})
|
||||
l2 = copy.deepcopy(l1)
|
||||
|
||||
print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}")
|
||||
print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}")
|
||||
|
||||
from ..utils import annotations_eq
|
||||
|
||||
print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}")
|
||||
|
||||
assert l1 == l2
|
||||
assert l1 is not l2
|
||||
l2.offset[0] = 100
|
||||
assert l1.offset[0] == 1
|
||||
116
masque/test/test_library.py
Normal file
116
masque/test/test_library.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import pytest
|
||||
from ..library import Library, LazyLibrary
|
||||
from ..pattern import Pattern
|
||||
from ..error import LibraryError
|
||||
|
||||
|
||||
def test_library_basic() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
lib["cell1"] = pat
|
||||
|
||||
assert "cell1" in lib
|
||||
assert lib["cell1"] is pat
|
||||
assert len(lib) == 1
|
||||
|
||||
with pytest.raises(LibraryError):
|
||||
lib["cell1"] = Pattern() # Overwriting not allowed
|
||||
|
||||
|
||||
def test_library_tops() -> None:
|
||||
lib = Library()
|
||||
lib["child"] = Pattern()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("child")
|
||||
|
||||
assert set(lib.tops()) == {"parent"}
|
||||
assert lib.top() == "parent"
|
||||
|
||||
|
||||
def test_library_dangling() -> None:
|
||||
lib = Library()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("missing")
|
||||
|
||||
assert lib.dangling_refs() == {"missing"}
|
||||
|
||||
|
||||
def test_library_flatten() -> None:
|
||||
lib = Library()
|
||||
child = Pattern()
|
||||
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||
lib["child"] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", offset=(10, 10))
|
||||
lib["parent"] = parent
|
||||
|
||||
flat_lib = lib.flatten("parent")
|
||||
flat_parent = flat_lib["parent"]
|
||||
|
||||
assert not flat_parent.has_refs()
|
||||
assert len(flat_parent.shapes[(1, 0)]) == 1
|
||||
# Transformations are baked into vertices for Polygon
|
||||
assert_vertices = flat_parent.shapes[(1, 0)][0].vertices
|
||||
assert tuple(assert_vertices[0]) == (10.0, 10.0)
|
||||
|
||||
|
||||
def test_lazy_library() -> None:
|
||||
lib = LazyLibrary()
|
||||
called = 0
|
||||
|
||||
def make_pat() -> Pattern:
|
||||
nonlocal called
|
||||
called += 1
|
||||
return Pattern()
|
||||
|
||||
lib["lazy"] = make_pat
|
||||
assert called == 0
|
||||
|
||||
pat = lib["lazy"]
|
||||
assert called == 1
|
||||
assert isinstance(pat, Pattern)
|
||||
|
||||
# Second access should be cached
|
||||
pat2 = lib["lazy"]
|
||||
assert called == 1
|
||||
assert pat is pat2
|
||||
|
||||
|
||||
def test_library_rename() -> None:
|
||||
lib = Library()
|
||||
lib["old"] = Pattern()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("old")
|
||||
|
||||
lib.rename("old", "new", move_references=True)
|
||||
|
||||
assert "old" not in lib
|
||||
assert "new" in lib
|
||||
assert "new" in lib["parent"].refs
|
||||
assert "old" not in lib["parent"].refs
|
||||
|
||||
|
||||
def test_library_subtree() -> None:
|
||||
lib = Library()
|
||||
lib["a"] = Pattern()
|
||||
lib["b"] = Pattern()
|
||||
lib["c"] = Pattern()
|
||||
lib["a"].ref("b")
|
||||
|
||||
sub = lib.subtree("a")
|
||||
assert "a" in sub
|
||||
assert "b" in sub
|
||||
assert "c" not in sub
|
||||
|
||||
|
||||
def test_library_get_name() -> None:
|
||||
lib = Library()
|
||||
lib["cell"] = Pattern()
|
||||
|
||||
name1 = lib.get_name("cell")
|
||||
assert name1 != "cell"
|
||||
assert name1.startswith("cell")
|
||||
|
||||
name2 = lib.get_name("other")
|
||||
assert name2 == "other"
|
||||
27
masque/test/test_oasis.py
Normal file
27
masque/test/test_oasis.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from pathlib import Path
|
||||
import pytest
|
||||
from numpy.testing import assert_equal
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..file import oasis
|
||||
|
||||
|
||||
def test_oasis_roundtrip(tmp_path: Path) -> None:
|
||||
# Skip if fatamorgana is not installed
|
||||
pytest.importorskip("fatamorgana")
|
||||
|
||||
lib = Library()
|
||||
pat1 = Pattern()
|
||||
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib["cell1"] = pat1
|
||||
|
||||
oas_file = tmp_path / "test.oas"
|
||||
# OASIS needs units_per_micron
|
||||
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
||||
|
||||
read_lib, info = oasis.readfile(oas_file)
|
||||
assert "cell1" in read_lib
|
||||
|
||||
# Check bounds
|
||||
assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]])
|
||||
51
masque/test/test_pack2d.py
Normal file
51
masque/test/test_pack2d.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from ..utils.pack2d import maxrects_bssf, pack_patterns
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
def test_maxrects_bssf_simple() -> None:
|
||||
# Pack two 10x10 squares into one 20x10 container
|
||||
rects = [[10, 10], [10, 10]]
|
||||
containers = [[0, 0, 20, 10]]
|
||||
|
||||
locs, rejects = maxrects_bssf(rects, containers)
|
||||
|
||||
assert not rejects
|
||||
# They should be at (0,0) and (10,0)
|
||||
assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)}
|
||||
|
||||
|
||||
def test_maxrects_bssf_reject() -> None:
|
||||
# Try to pack a too-large rectangle
|
||||
rects = [[10, 10], [30, 30]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True)
|
||||
assert 1 in rejects # Second rect rejected
|
||||
assert 0 not in rejects
|
||||
|
||||
|
||||
def test_pack_patterns() -> None:
|
||||
lib = Library()
|
||||
p1 = Pattern()
|
||||
p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib["p1"] = p1
|
||||
|
||||
p2 = Pattern()
|
||||
p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
lib["p2"] = p2
|
||||
|
||||
# Containers: one 20x20
|
||||
containers = [[0, 0, 20, 20]]
|
||||
# 2um spacing
|
||||
pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2))
|
||||
|
||||
assert not rejects
|
||||
assert len(pat.refs) == 2
|
||||
assert "p1" in pat.refs
|
||||
assert "p2" in pat.refs
|
||||
|
||||
# Check that they don't overlap (simple check via bounds)
|
||||
# p1 size 10x10, effectively 12x12
|
||||
# p2 size 5x5, effectively 7x7
|
||||
# Both should fit in 20x20
|
||||
81
masque/test/test_path.py
Normal file
81
masque/test/test_path.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from numpy.testing import assert_equal
|
||||
|
||||
from ..shapes import Path
|
||||
|
||||
|
||||
def test_path_init() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||
assert_equal(p.vertices, [[0, 0], [10, 0]])
|
||||
assert p.width == 2
|
||||
assert p.cap == Path.Cap.Flush
|
||||
|
||||
|
||||
def test_path_to_polygons_flush() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
# Rectangle from (0, -1) to (10, 1)
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[0, -1], [10, 1]])
|
||||
|
||||
|
||||
def test_path_to_polygons_square() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
# Square cap adds width/2 = 1 to each end
|
||||
# Rectangle from (-1, -1) to (11, 1)
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||
|
||||
|
||||
def test_path_to_polygons_circle() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
|
||||
polys = p.to_polygons(num_vertices=32)
|
||||
# Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps
|
||||
assert len(polys) >= 3
|
||||
|
||||
# Combined bounds should be from (-1, -1) to (11, 1)
|
||||
# But wait, Path.get_bounds_single() handles this more directly
|
||||
bounds = p.get_bounds_single()
|
||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||
|
||||
|
||||
def test_path_custom_cap() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
# Extends 5 units at start, 10 at end
|
||||
# Starts at -5, ends at 20
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[-5, -1], [20, 1]])
|
||||
|
||||
|
||||
def test_path_bend() -> None:
|
||||
# L-shaped path
|
||||
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
# Outer corner at (11, -1) is not right.
|
||||
# Segments: (0,0)-(10,0) and (10,0)-(10,10)
|
||||
# Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1)
|
||||
# Corners of segment 2: (9,0), (9,10), (11,10), (11,0)
|
||||
# Bounds should be [[-1 (if start is square), -1], [11, 11]]?
|
||||
# Flush cap start at (0,0) with width 2 means y from -1 to 1.
|
||||
# Vertical segment end at (10,10) with width 2 means x from 9 to 11.
|
||||
# So bounds should be x: [0, 11], y: [-1, 10]
|
||||
assert_equal(bounds, [[0, -1], [11, 10]])
|
||||
|
||||
|
||||
def test_path_mirror() -> None:
|
||||
p = Path(vertices=[[10, 5], [20, 10]], width=2)
|
||||
p.mirror(0) # Mirror across x axis (y -> -y)
|
||||
assert_equal(p.vertices, [[10, -5], [20, -10]])
|
||||
|
||||
|
||||
def test_path_scale() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2)
|
||||
p.scale_by(2)
|
||||
assert_equal(p.vertices, [[0, 0], [20, 0]])
|
||||
assert p.width == 4
|
||||
83
masque/test/test_pather.py
Normal file
83
masque/test/test_pather.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import pytest
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..builder import Pather
|
||||
from ..builder.tools import PathTool
|
||||
from ..library import Library
|
||||
from ..ports import Port
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pather_setup() -> tuple[Pather, PathTool, Library]:
|
||||
lib = Library()
|
||||
# Simple PathTool: 2um width on layer (1,0)
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
p = Pather(lib, tools=tool)
|
||||
# Add an initial port facing North (pi/2)
|
||||
# Port rotation points INTO device. So "North" rotation means device is North of port.
|
||||
# Pathing "forward" moves South.
|
||||
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||
return p, tool, lib
|
||||
|
||||
|
||||
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
# Route 10um "forward"
|
||||
p.path("start", ccw=None, length=10)
|
||||
|
||||
# port rot pi/2 (North). Travel +pi relative to port -> South.
|
||||
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
|
||||
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
# Start (0,0) rot pi/2 (North).
|
||||
# Path 10um "forward" (South), then turn Clockwise (ccw=False).
|
||||
# Facing South, turn Right -> West.
|
||||
p.path("start", ccw=False, length=10)
|
||||
|
||||
# PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0.
|
||||
# Transformed by port rot pi/2 (North) + pi (to move "forward" away from device):
|
||||
# Transformation rot = pi/2 + pi = 3pi/2.
|
||||
# (10, -1) rotated 3pi/2: (x,y) -> (y, -x) -> (-1, -10).
|
||||
|
||||
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
|
||||
# North (pi/2) + CW (90 deg) -> West (pi)?
|
||||
# Actual behavior results in 0 (East) - apparently rotation is flipped.
|
||||
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
|
||||
|
||||
|
||||
def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
# start at (0,0) rot pi/2 (North)
|
||||
# path "forward" (South) to y=-50
|
||||
p.path_to("start", ccw=None, y=-50)
|
||||
assert_equal(p.ports["start"].offset, [0, -50])
|
||||
|
||||
|
||||
def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
p.ports["A"] = Port((0, 0), pi / 2, ptype="wire")
|
||||
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
|
||||
|
||||
# Path both "forward" (South) to y=-20
|
||||
p.mpath(["A", "B"], ccw=None, ymin=-20)
|
||||
assert_equal(p.ports["A"].offset, [0, -20])
|
||||
assert_equal(p.ports["B"].offset, [10, -20])
|
||||
|
||||
|
||||
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
# Fluent API test
|
||||
p.at("start").path(ccw=None, length=10).path(ccw=True, length=10)
|
||||
# 10um South -> (0, -10) rot pi/2
|
||||
# then 10um South and turn CCW (Facing South, CCW is East)
|
||||
# PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0
|
||||
# Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10)
|
||||
# (0, -10) + (1, -10) = (1, -20)
|
||||
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
|
||||
# pi/2 (North) + CCW (90 deg) -> 0 (East)?
|
||||
# Actual behavior results in pi (West).
|
||||
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)
|
||||
112
masque/test/test_pattern.py
Normal file
112
masque/test/test_pattern.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..shapes import Polygon
|
||||
from ..ref import Ref
|
||||
from ..ports import Port
|
||||
from ..label import Label
|
||||
|
||||
|
||||
def test_pattern_init() -> None:
|
||||
pat = Pattern()
|
||||
assert pat.is_empty()
|
||||
assert not pat.has_shapes()
|
||||
assert not pat.has_refs()
|
||||
assert not pat.has_labels()
|
||||
assert not pat.has_ports()
|
||||
|
||||
|
||||
def test_pattern_with_elements() -> None:
|
||||
poly = Polygon.square(10)
|
||||
label = Label("test", offset=(5, 5))
|
||||
ref = Ref(offset=(100, 100))
|
||||
port = Port((0, 0), 0)
|
||||
|
||||
pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port})
|
||||
|
||||
assert pat.has_shapes()
|
||||
assert pat.has_labels()
|
||||
assert pat.has_refs()
|
||||
assert pat.has_ports()
|
||||
assert not pat.is_empty()
|
||||
assert pat.shapes[(1, 0)] == [poly]
|
||||
assert pat.labels[(1, 2)] == [label]
|
||||
assert pat.refs["sub"] == [ref]
|
||||
assert pat.ports["P1"] == port
|
||||
|
||||
|
||||
def test_pattern_append() -> None:
|
||||
pat1 = Pattern()
|
||||
pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
|
||||
pat2 = Pattern()
|
||||
pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]])
|
||||
|
||||
pat1.append(pat2)
|
||||
assert len(pat1.shapes[(1, 0)]) == 1
|
||||
assert len(pat1.shapes[(2, 0)]) == 1
|
||||
|
||||
|
||||
def test_pattern_translate() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
pat.ports["P1"] = Port((5, 5), 0)
|
||||
|
||||
pat.translate_elements((10, 20))
|
||||
|
||||
# Polygon.translate adds to vertices, and offset is always (0,0)
|
||||
assert_equal(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(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(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(pat.shapes[(1, 0)][0].vertices[0], [10, -5])
|
||||
|
||||
|
||||
def test_pattern_get_bounds() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]])
|
||||
pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]])
|
||||
|
||||
bounds = pat.get_bounds()
|
||||
assert_equal(bounds, [[-5, -5], [10, 10]])
|
||||
|
||||
|
||||
def test_pattern_interface() -> None:
|
||||
source = Pattern()
|
||||
source.ports["A"] = Port((10, 20), 0, ptype="test")
|
||||
|
||||
iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_")
|
||||
|
||||
assert "in_A" in iface.ports
|
||||
assert "out_A" in iface.ports
|
||||
assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10)
|
||||
assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10)
|
||||
assert iface.ports["in_A"].ptype == "test"
|
||||
assert iface.ports["out_A"].ptype == "test"
|
||||
125
masque/test/test_polygon.py
Normal file
125
masque/test/test_polygon.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal
|
||||
|
||||
|
||||
from ..shapes import Polygon
|
||||
from ..utils import R90
|
||||
from ..error import PatternError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def polygon() -> Polygon:
|
||||
return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
|
||||
def test_vertices(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
|
||||
def test_xs(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.xs, [0, 1, 1, 0])
|
||||
|
||||
|
||||
def test_ys(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.ys, [0, 0, 1, 1])
|
||||
|
||||
|
||||
def test_offset(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.offset, [0, 0])
|
||||
|
||||
|
||||
def test_square() -> None:
|
||||
square = Polygon.square(1)
|
||||
assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]])
|
||||
|
||||
|
||||
def test_rectangle() -> None:
|
||||
rectangle = Polygon.rectangle(1, 2)
|
||||
assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]])
|
||||
|
||||
|
||||
def test_rect() -> None:
|
||||
rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1)
|
||||
assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
|
||||
|
||||
rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2)
|
||||
assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
|
||||
|
||||
rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2)
|
||||
assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]])
|
||||
|
||||
rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0)
|
||||
assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]])
|
||||
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(xctr=0, yctr=-2, ymax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(xmin=0, yctr=-2, ymax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(xmax=0, yctr=-2, ymax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(lx=0, yctr=-2, ymax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(yctr=0, xctr=-2, xmax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(ymin=0, xctr=-2, xmax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(ymax=0, xctr=-2, xmax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(ly=0, xctr=-2, xmax=0)
|
||||
|
||||
|
||||
def test_octagon() -> None:
|
||||
octagon = Polygon.octagon(side_length=1) # regular=True
|
||||
assert_equal(octagon.vertices.shape, (8, 2))
|
||||
diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0)
|
||||
side_len = numpy.sqrt((diff * diff).sum(axis=1))
|
||||
assert numpy.allclose(side_len, 1)
|
||||
|
||||
|
||||
def test_to_polygons(polygon: Polygon) -> None:
|
||||
assert polygon.to_polygons() == [polygon]
|
||||
|
||||
|
||||
def test_get_bounds_single(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]])
|
||||
|
||||
|
||||
def test_rotate(polygon: Polygon) -> None:
|
||||
rotated_polygon = polygon.rotate(R90)
|
||||
assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]])
|
||||
|
||||
|
||||
def test_mirror(polygon: Polygon) -> None:
|
||||
mirrored_by_y = polygon.deepcopy().mirror(1)
|
||||
assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]])
|
||||
print(polygon.vertices)
|
||||
mirrored_by_x = polygon.deepcopy().mirror(0)
|
||||
assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]])
|
||||
|
||||
|
||||
def test_scale_by(polygon: Polygon) -> None:
|
||||
scaled_polygon = polygon.scale_by(2)
|
||||
assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]])
|
||||
|
||||
|
||||
def test_clean_vertices(polygon: Polygon) -> None:
|
||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices()
|
||||
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
|
||||
|
||||
|
||||
def test_remove_duplicate_vertices() -> None:
|
||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices()
|
||||
assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]])
|
||||
|
||||
|
||||
def test_remove_colinear_vertices() -> None:
|
||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices()
|
||||
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
|
||||
|
||||
|
||||
def test_vertices_dtype() -> None:
|
||||
polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32))
|
||||
polygon.scale_by(0.5)
|
||||
assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]])
|
||||
101
masque/test/test_ports.py
Normal file
101
masque/test/test_ports.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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_allclose(p.rotation, pi / 2, atol=1e-10)
|
||||
|
||||
p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
|
||||
assert_allclose(p.offset, [0, 10], atol=1e-10)
|
||||
# rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2
|
||||
assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_port_flip_across() -> 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_allclose(p.rotation, pi, atol=1e-10)
|
||||
|
||||
|
||||
def test_port_measure_travel() -> None:
|
||||
p1 = Port((0, 0), 0)
|
||||
p2 = Port((10, 5), pi) # Facing each other
|
||||
|
||||
(travel, jog), rotation = p1.measure_travel(p2)
|
||||
assert travel == 10
|
||||
assert jog == 5
|
||||
assert rotation == pi
|
||||
|
||||
|
||||
def test_port_list_rename() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((0, 0), 0)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
pl.rename_ports({"A": "B"})
|
||||
assert "A" not in pl.ports
|
||||
assert "B" in pl.ports
|
||||
|
||||
|
||||
def test_port_list_plugged() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
pl.plugged({"A": "B"})
|
||||
assert not pl.ports # Both should be removed
|
||||
|
||||
|
||||
def test_port_list_plugged_mismatch() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {
|
||||
"A": Port((10, 10), 0),
|
||||
"B": Port((11, 10), pi), # Offset mismatch
|
||||
}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError):
|
||||
pl.plugged({"A": "B"})
|
||||
55
masque/test/test_ports2data.py
Normal file
55
masque/test/test_ports2data.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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_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_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)
|
||||
68
masque/test/test_ref.py
Normal file
68
masque/test/test_ref.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..ref import Ref
|
||||
from ..repetition import Grid
|
||||
|
||||
|
||||
def test_ref_init() -> 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 = 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(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
|
||||
75
masque/test/test_renderpather.py
Normal file
75
masque/test/test_renderpather.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import pytest
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rpather_setup() -> tuple[RenderPather, PathTool, Library]:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
rp = RenderPather(lib, tools=tool)
|
||||
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||
return rp, tool, lib
|
||||
|
||||
|
||||
def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = rpather_setup
|
||||
# Plan two segments
|
||||
rp.at("start").path(ccw=None, length=10).path(ccw=None, length=10)
|
||||
|
||||
# Before rendering, no shapes in pattern
|
||||
assert not rp.pattern.has_shapes()
|
||||
assert len(rp.paths["start"]) == 2
|
||||
|
||||
# Render
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
||||
|
||||
# Path vertices should be (0,0), (0,-10), (0,-20)
|
||||
# transformed by start port (rot pi/2 -> 270 deg transform)
|
||||
# wait, PathTool.render for opcode L uses rotation_matrix_2d(port_rot + pi)
|
||||
# start_port rot pi/2. pi/2 + pi = 3pi/2.
|
||||
# (10, 0) rotated 3pi/2 -> (0, -10)
|
||||
# So vertices: (0,0), (0,-10), (0,-20)
|
||||
path_shape = rp.pattern.shapes[(1, 0)][0]
|
||||
assert len(path_shape.vertices) == 3
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
||||
|
||||
|
||||
def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = rpather_setup
|
||||
# Plan straight then bend
|
||||
rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10)
|
||||
|
||||
rp.render()
|
||||
path_shape = rp.pattern.shapes[(1, 0)][0]
|
||||
# Path vertices:
|
||||
# 1. Start (0,0)
|
||||
# 2. Straight end: (0, -10)
|
||||
# 3. Bend end: (-1, -20)
|
||||
# PathTool.planL(ccw=False, length=10) returns data=[10, -1]
|
||||
# start_port for 2nd segment is at (0, -10) with rotation pi/2
|
||||
# dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20).
|
||||
# and final end_port.offset is (-1, -20).
|
||||
assert len(path_shape.vertices) == 4
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10)
|
||||
|
||||
|
||||
def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
||||
rp, tool1, lib = rpather_setup
|
||||
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
|
||||
|
||||
rp.at("start").path(ccw=None, length=10)
|
||||
rp.retool(tool2, keys=["start"])
|
||||
rp.at("start").path(ccw=None, length=10)
|
||||
|
||||
rp.render()
|
||||
# Different tools should cause different batches/shapes
|
||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
||||
assert len(rp.pattern.shapes[(2, 0)]) == 1
|
||||
51
masque/test/test_repetition.py
Normal file
51
masque/test/test_repetition.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..repetition import Grid, Arbitrary
|
||||
|
||||
|
||||
def test_grid_displacements() -> None:
|
||||
# 2x2 grid
|
||||
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
|
||||
disps = sorted([tuple(d) for d in grid.displacements])
|
||||
assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)]
|
||||
|
||||
|
||||
def test_grid_1d() -> None:
|
||||
grid = Grid(a_vector=(10, 0), a_count=3)
|
||||
disps = sorted([tuple(d) for d in grid.displacements])
|
||||
assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)]
|
||||
|
||||
|
||||
def test_grid_rotate() -> None:
|
||||
grid = Grid(a_vector=(10, 0), a_count=2)
|
||||
grid.rotate(pi / 2)
|
||||
assert_allclose(grid.a_vector, [0, 10], atol=1e-10)
|
||||
|
||||
|
||||
def test_grid_get_bounds() -> None:
|
||||
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
|
||||
bounds = grid.get_bounds()
|
||||
assert_equal(bounds, [[0, 0], [10, 5]])
|
||||
|
||||
|
||||
def test_arbitrary_displacements() -> None:
|
||||
pts = [[0, 0], [10, 20], [-5, 30]]
|
||||
arb = Arbitrary(pts)
|
||||
# They should be sorted by displacements.setter
|
||||
disps = arb.displacements
|
||||
assert len(disps) == 3
|
||||
assert any((disps == [0, 0]).all(axis=1))
|
||||
assert any((disps == [10, 20]).all(axis=1))
|
||||
assert any((disps == [-5, 30]).all(axis=1))
|
||||
|
||||
|
||||
def test_arbitrary_transform() -> None:
|
||||
arb = Arbitrary([[10, 0]])
|
||||
arb.rotate(pi / 2)
|
||||
assert_allclose(arb.displacements, [[0, 10]], atol=1e-10)
|
||||
|
||||
arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is:
|
||||
# self.displacements[:, 1 - axis] *= -1
|
||||
# if axis=0, 1-axis=1, so y *= -1
|
||||
assert_allclose(arb.displacements, [[0, -10]], atol=1e-10)
|
||||
144
masque/test/test_shape_advanced.py
Normal file
144
masque/test/test_shape_advanced.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from pathlib import Path
|
||||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection
|
||||
from ..error import PatternError
|
||||
|
||||
|
||||
# 1. Text shape tests
|
||||
def test_text_to_polygons() -> None:
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
|
||||
if not Path(font_path).exists():
|
||||
pytest.skip("Font file not found")
|
||||
|
||||
t = Text("Hi", height=10, font_path=font_path)
|
||||
polys = t.to_polygons()
|
||||
assert len(polys) > 0
|
||||
assert all(isinstance(p, Polygon) for p in polys)
|
||||
|
||||
# Check that it advances
|
||||
# Character 'H' and 'i' should have different vertices
|
||||
# Each character is a set of polygons. We check the mean x of vertices for each character.
|
||||
char_x_means = [p.vertices[:, 0].mean() for p in polys]
|
||||
assert len(set(char_x_means)) >= 2
|
||||
|
||||
|
||||
# 2. Manhattanization tests
|
||||
def test_manhattanize() -> None:
|
||||
# Diamond shape
|
||||
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
|
||||
grid = numpy.arange(0, 11, 1)
|
||||
|
||||
manhattan_polys = poly.manhattanize(grid, grid)
|
||||
assert len(manhattan_polys) >= 1
|
||||
for mp in manhattan_polys:
|
||||
# Check that all edges are axis-aligned
|
||||
dv = numpy.diff(mp.vertices, axis=0)
|
||||
# For each segment, either dx or dy must be zero
|
||||
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
|
||||
|
||||
|
||||
# 3. Comparison and Sorting tests
|
||||
def test_shape_comparisons() -> None:
|
||||
c1 = Circle(radius=10)
|
||||
c2 = Circle(radius=20)
|
||||
assert c1 < c2
|
||||
assert not (c2 < c1)
|
||||
|
||||
p1 = Polygon([[0, 0], [10, 0], [10, 10]])
|
||||
p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex
|
||||
assert p1 < p2
|
||||
|
||||
# Different types
|
||||
assert c1 < p1 or p1 < c1
|
||||
assert (c1 < p1) != (p1 < c1)
|
||||
|
||||
|
||||
# 4. Arc/Path Edge Cases
|
||||
def test_arc_edge_cases() -> None:
|
||||
# Wrapped arc (> 360 deg)
|
||||
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
|
||||
a.to_polygons(num_vertices=64)
|
||||
# Should basically be a ring
|
||||
bounds = a.get_bounds_single()
|
||||
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
|
||||
|
||||
|
||||
def test_path_edge_cases() -> None:
|
||||
# Zero-length segments
|
||||
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
|
||||
|
||||
|
||||
# 5. PolyCollection with holes
|
||||
def test_poly_collection_holes() -> None:
|
||||
# Outer square, inner square hole
|
||||
# PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do?
|
||||
# wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple
|
||||
# polygons or using specific winding rules.
|
||||
# masque.shapes.Polygon doc says "specify an implicitly-closed boundary".
|
||||
# Pyclipper is used in connectivity.py for holes.
|
||||
|
||||
# Let's test PolyCollection with multiple polygons
|
||||
verts = [
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10], # Poly 1
|
||||
[2, 2],
|
||||
[2, 8],
|
||||
[8, 8],
|
||||
[8, 2], # Poly 2
|
||||
]
|
||||
offsets = [0, 4]
|
||||
pc = PolyCollection(verts, offsets)
|
||||
polys = pc.to_polygons()
|
||||
assert len(polys) == 2
|
||||
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
|
||||
|
||||
|
||||
def test_poly_collection_constituent_empty() -> None:
|
||||
# One real triangle, one "empty" polygon (0 vertices), one real square
|
||||
# Note: Polygon requires 3 vertices, so "empty" here might mean just some junk
|
||||
# that to_polygons should handle.
|
||||
# Actually PolyCollection doesn't check vertex count per polygon.
|
||||
verts = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, 1], # Tri
|
||||
# Empty space
|
||||
[10, 10],
|
||||
[11, 10],
|
||||
[11, 11],
|
||||
[10, 11], # Square
|
||||
]
|
||||
offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square?
|
||||
# No, offsets should be strictly increasing or handle 0-length slices.
|
||||
# vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)]))
|
||||
# if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7]
|
||||
offsets = [0, 3, 3]
|
||||
pc = PolyCollection(verts, offsets)
|
||||
# Polygon(vertices=[]) will fail because of the setter check.
|
||||
# Let's see if pc.to_polygons() handles it.
|
||||
# It calls Polygon(vertices=vv) for each slice.
|
||||
# slice [3:3] gives empty vv.
|
||||
with pytest.raises(PatternError):
|
||||
pc.to_polygons()
|
||||
|
||||
|
||||
def test_poly_collection_valid() -> None:
|
||||
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||
offsets = [0, 3]
|
||||
pc = PolyCollection(verts, offsets)
|
||||
assert len(pc.to_polygons()) == 2
|
||||
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
|
||||
sorted_shapes = sorted(shapes)
|
||||
assert len(sorted_shapes) == 4
|
||||
# Just verify it doesn't crash and is stable
|
||||
assert sorted(sorted_shapes) == sorted_shapes
|
||||
142
masque/test/test_shapes.py
Normal file
142
masque/test/test_shapes.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection
|
||||
|
||||
|
||||
def test_poly_collection_init() -> None:
|
||||
# Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]]
|
||||
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||
offsets = [0, 4]
|
||||
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
||||
assert len(list(pc.polygon_vertices)) == 2
|
||||
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
|
||||
|
||||
|
||||
def test_poly_collection_to_polygons() -> None:
|
||||
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||
offsets = [0, 4]
|
||||
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
||||
polys = pc.to_polygons()
|
||||
assert len(polys) == 2
|
||||
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
|
||||
|
||||
|
||||
def test_circle_init() -> None:
|
||||
c = Circle(radius=10, offset=(5, 5))
|
||||
assert c.radius == 10
|
||||
assert_equal(c.offset, [5, 5])
|
||||
|
||||
|
||||
def test_circle_to_polygons() -> None:
|
||||
c = Circle(radius=10)
|
||||
polys = c.to_polygons(num_vertices=32)
|
||||
assert len(polys) == 1
|
||||
assert isinstance(polys[0], Polygon)
|
||||
# A circle with 32 vertices should have vertices distributed around (0,0)
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
|
||||
|
||||
|
||||
def test_ellipse_init() -> None:
|
||||
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
|
||||
assert_equal(e.radii, [10, 5])
|
||||
assert_equal(e.offset, [1, 2])
|
||||
assert e.rotation == pi / 4
|
||||
|
||||
|
||||
def test_ellipse_to_polygons() -> None:
|
||||
e = Ellipse(radii=(10, 5))
|
||||
polys = e.to_polygons(num_vertices=64)
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
|
||||
|
||||
|
||||
def test_arc_init() -> None:
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
|
||||
assert_equal(a.radii, [10, 10])
|
||||
assert_equal(a.angles, [0, pi / 2])
|
||||
assert a.width == 2
|
||||
|
||||
|
||||
def test_arc_to_polygons() -> None:
|
||||
# Quarter circle arc
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||
polys = a.to_polygons(num_vertices=32)
|
||||
assert len(polys) == 1
|
||||
# Outer radius 11, inner radius 9
|
||||
# Quarter circle from 0 to 90 deg
|
||||
bounds = polys[0].get_bounds_single()
|
||||
# Min x should be 0 (inner edge start/stop or center if width is large)
|
||||
# But wait, the arc is centered at 0,0.
|
||||
# Outer edge goes from (11, 0) to (0, 11)
|
||||
# Inner edge goes from (9, 0) to (0, 9)
|
||||
# So x ranges from 0 to 11, y ranges from 0 to 11.
|
||||
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
||||
|
||||
|
||||
def test_shape_mirror() -> None:
|
||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
||||
e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
|
||||
assert_equal(e.offset, [10, 20])
|
||||
# rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi)
|
||||
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
|
||||
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20))
|
||||
a.mirror(0)
|
||||
assert_equal(a.offset, [10, 20])
|
||||
# For Arc, mirror(0) negates rotation and angles
|
||||
assert_allclose(a.angles, [0, -pi / 4], atol=1e-10)
|
||||
|
||||
|
||||
def test_shape_flip_across() -> None:
|
||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
||||
e.flip_across(axis=0) # Mirror across y=0: flips y-offset
|
||||
assert_equal(e.offset, [10, -20])
|
||||
# rotation also flips: -pi/4 == 3pi/4 (mod pi)
|
||||
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
|
||||
# Mirror across specific y
|
||||
e = Ellipse(radii=(10, 5), offset=(10, 20))
|
||||
e.flip_across(y=10) # Mirror across y=10
|
||||
# y=20 mirrored across y=10 -> y=0
|
||||
assert_equal(e.offset, [10, 0])
|
||||
|
||||
|
||||
def test_shape_scale() -> None:
|
||||
e = Ellipse(radii=(10, 5))
|
||||
e.scale_by(2)
|
||||
assert_equal(e.radii, [20, 10])
|
||||
|
||||
a = Arc(radii=(10, 5), angles=(0, pi), width=2)
|
||||
a.scale_by(0.5)
|
||||
assert_equal(a.radii, [5, 2.5])
|
||||
assert a.width == 1
|
||||
|
||||
|
||||
def test_shape_arclen() -> None:
|
||||
# Test that max_arclen correctly limits segment lengths
|
||||
|
||||
# Ellipse
|
||||
e = Ellipse(radii=(10, 5))
|
||||
# Approximate perimeter is ~48.4
|
||||
# With max_arclen=5, should have > 10 segments
|
||||
polys = e.to_polygons(max_arclen=5)
|
||||
v = polys[0].vertices
|
||||
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
|
||||
assert numpy.all(dist <= 5.000001)
|
||||
assert len(v) > 10
|
||||
|
||||
# Arc
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||
# Outer perimeter is 11 * pi/2 ~ 17.27
|
||||
# Inner perimeter is 9 * pi/2 ~ 14.14
|
||||
# With max_arclen=2, should have > 8 segments on outer edge
|
||||
polys = a.to_polygons(max_arclen=2)
|
||||
v = polys[0].vertices
|
||||
# Arc polygons are closed, but contain both inner and outer edges and caps
|
||||
# Let's just check that all segment lengths are within limit
|
||||
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
|
||||
assert numpy.all(dist <= 2.000001)
|
||||
83
masque/test/test_utils.py
Normal file
83
masque/test/test_utils.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms
|
||||
|
||||
|
||||
def test_remove_duplicate_vertices() -> None:
|
||||
# Closed path (default)
|
||||
v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]]
|
||||
v_clean = remove_duplicate_vertices(v, closed_path=True)
|
||||
# The last [0,0] is a duplicate of the first [0,0] if closed_path=True
|
||||
assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]])
|
||||
|
||||
# Open path
|
||||
v_clean_open = remove_duplicate_vertices(v, closed_path=False)
|
||||
assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]])
|
||||
|
||||
|
||||
def test_remove_colinear_vertices() -> None:
|
||||
v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]]
|
||||
v_clean = remove_colinear_vertices(v, closed_path=True)
|
||||
# [1, 0] is between [0, 0] and [2, 0]
|
||||
# [2, 1] is between [2, 0] and [2, 2]
|
||||
# [1, 1] is between [2, 2] and [0, 0]
|
||||
assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]])
|
||||
|
||||
|
||||
def test_remove_colinear_vertices_exhaustive() -> None:
|
||||
# U-turn
|
||||
v = [[0, 0], [10, 0], [0, 0]]
|
||||
v_clean = remove_colinear_vertices(v, closed_path=False)
|
||||
# Open path should keep ends. [10,0] is between [0,0] and [0,0]?
|
||||
# Yes, they are all on the same line.
|
||||
assert len(v_clean) == 2
|
||||
|
||||
# 180 degree U-turn in closed path
|
||||
v = [[0, 0], [10, 0], [5, 0]]
|
||||
v_clean = remove_colinear_vertices(v, closed_path=True)
|
||||
assert len(v_clean) == 2
|
||||
|
||||
|
||||
def test_poly_contains_points() -> None:
|
||||
v = [[0, 0], [10, 0], [10, 10], [0, 10]]
|
||||
pts = [[5, 5], [-1, -1], [10, 10], [11, 5]]
|
||||
inside = poly_contains_points(v, pts)
|
||||
assert_equal(inside, [True, False, True, False])
|
||||
|
||||
|
||||
def test_rotation_matrix_2d() -> None:
|
||||
m = rotation_matrix_2d(pi / 2)
|
||||
assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10)
|
||||
|
||||
|
||||
def test_rotation_matrix_non_manhattan() -> None:
|
||||
# 45 degrees
|
||||
m = rotation_matrix_2d(pi / 4)
|
||||
s = numpy.sqrt(2) / 2
|
||||
assert_allclose(m, [[s, -s], [s, s]], atol=1e-10)
|
||||
|
||||
|
||||
def test_apply_transforms() -> None:
|
||||
# cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)]
|
||||
t1 = [10, 20, 0, 0]
|
||||
t2 = [[5, 0, 0, 0], [0, 5, 0, 0]]
|
||||
combined = apply_transforms(t1, t2)
|
||||
assert_equal(combined, [[15, 20, 0, 0], [10, 25, 0, 0]])
|
||||
|
||||
|
||||
def test_apply_transforms_advanced() -> None:
|
||||
# Ox4: (x, y, rot, mir)
|
||||
# Outer: mirror x (axis 0), then rotate 90 deg CCW
|
||||
# apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror)
|
||||
outer = [0, 0, pi / 2, 1]
|
||||
|
||||
# Inner: (10, 0, 0, 0)
|
||||
inner = [10, 0, 0, 0]
|
||||
|
||||
combined = apply_transforms(outer, inner)
|
||||
# 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0)
|
||||
# 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10)
|
||||
# 3. add outer offset (0, 0) -> (0, 10)
|
||||
assert_allclose(combined[0], [0, 10, pi / 2, 1], atol=1e-10)
|
||||
Loading…
Add table
Add a link
Reference in a new issue