diff --git a/masque/test/__init__.py b/masque/test/__init__.py new file mode 100644 index 0000000..e02b636 --- /dev/null +++ b/masque/test/__init__.py @@ -0,0 +1,3 @@ +""" +Tests (run with `python3 -m pytest -rxPXs | tee results.txt`) +""" diff --git a/masque/test/conftest.py b/masque/test/conftest.py new file mode 100644 index 0000000..62db4c5 --- /dev/null +++ b/masque/test/conftest.py @@ -0,0 +1,16 @@ +""" + +Test fixtures + +""" +# ruff: noqa: ARG001 +from typing import Any +import numpy +from numpy.typing import NDArray + +import pytest # type: ignore + + +FixtureRequest = Any +PRNG = numpy.random.RandomState(12345) + diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py new file mode 100644 index 0000000..c9e2926 --- /dev/null +++ b/masque/test/test_abstract.py @@ -0,0 +1,58 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..abstract import Abstract +from ..ports import Port +from ..ref import Ref + +def test_abstract_init(): + ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)} + abs_obj = Abstract("test", ports) + assert abs_obj.name == "test" + assert len(abs_obj.ports) == 2 + assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied + +def test_abstract_transform(): + abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) + # Rotate 90 deg around (0,0) + abs_obj.rotate_around((0, 0), pi/2) + # (10, 0) rot 0 -> (0, 10) rot pi/2 + assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10) + assert_allclose(abs_obj.ports["A"].rotation, pi/2, atol=1e-10) + + # Mirror across x axis (axis 0): flips y-offset + abs_obj.mirror(0) + # (0, 10) mirrored(0) -> (0, -10) + # rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2 + assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10) + assert_allclose(abs_obj.ports["A"].rotation, 3*pi/2, atol=1e-10) + +def test_abstract_ref_transform(): + abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) + ref = Ref(offset=(100, 100), rotation=pi/2, mirrored=True) + + # Apply ref transform + abs_obj.apply_ref_transform(ref) + # Ref order: mirror, rotate, scale, translate + + # 1. mirror (across x: y -> -y) + # (10, 0) rot 0 -> (10, 0) rot 0 + + # 2. rotate pi/2 around (0,0) + # (10, 0) rot 0 -> (0, 10) rot pi/2 + + # 3. translate (100, 100) + # (0, 10) -> (100, 110) + + assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10) + assert_allclose(abs_obj.ports["A"].rotation, pi/2, atol=1e-10) + +def test_abstract_undo_transform(): + abs_obj = Abstract("test", {"A": Port((100, 110), pi/2)}) + ref = Ref(offset=(100, 100), rotation=pi/2, mirrored=True) + + abs_obj.undo_ref_transform(ref) + assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) + assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py new file mode 100644 index 0000000..439073d --- /dev/null +++ b/masque/test/test_advanced_routing.py @@ -0,0 +1,82 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..builder import Pather +from ..builder.tools import PathTool +from ..library import Library +from ..ports import Port + +@pytest.fixture +def advanced_pather(): + lib = Library() + # Simple PathTool: 2um width on layer (1,0) + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool) + return p, tool, lib + +def test_path_into_straight(advanced_pather): + p, tool, lib = advanced_pather + # Facing ports + p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device) + # Forward (+pi relative to port) is West (-x). + # Put destination at (-20, 0) pointing East (pi). + p.ports["dst"] = Port((-20, 0), pi, ptype="wire") + + p.path_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + # Pather.path adds a Reference to the generated pattern + assert len(p.pattern.refs) == 1 + +def test_path_into_bend(advanced_pather): + p, tool, lib = advanced_pather + # Source at (0,0) rot 0 (facing East). Forward is West (-x). + p.ports["src"] = Port((0, 0), 0, ptype="wire") + # Destination at (-20, -20) rot pi (facing West). Forward is East (+x). + # Wait, src forward is -x. dst is at -20, -20. + # To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South). + # Forward for South is North (+y). + p.ports["dst"] = Port((-20, -20), 3*pi/2, ptype="wire") + + p.path_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + # Single bend should result in 2 segments (one for x move, one for y move) + assert len(p.pattern.refs) == 2 + +def test_path_into_sbend(advanced_pather): + p, tool, lib = advanced_pather + # Facing but offset ports + p.ports["src"] = Port((0, 0), 0, ptype="wire") # Forward is West (-x) + p.ports["dst"] = Port((-20, -10), pi, ptype="wire") # Facing East (rot pi) + + p.path_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + +def test_path_from(advanced_pather): + p, tool, lib = advanced_pather + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, 0), pi, ptype="wire") + + p.at("dst").path_from("src") + + assert "src" not in p.ports + assert "dst" not in p.ports + +def test_path_into_thru(advanced_pather): + p, tool, lib = advanced_pather + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, 0), pi, ptype="wire") + p.ports["other"] = Port((10, 10), 0) + + p.path_into("src", "dst", thru="other") + + assert "src" in p.ports + assert_equal(p.ports["src"].offset, [10, 10]) + assert "other" not in p.ports diff --git a/masque/test/test_autotool.py b/masque/test/test_autotool.py new file mode 100644 index 0000000..cf730ae --- /dev/null +++ b/masque/test/test_autotool.py @@ -0,0 +1,82 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..builder import Pather +from ..builder.tools import AutoTool +from ..library import Library +from ..pattern import Pattern +from ..ports import Port +from ..abstract import Abstract + +def make_straight(length, width=2, ptype="wire"): + pat = Pattern() + pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) + pat.ports["in"] = Port((0, 0), 0, ptype=ptype) + pat.ports["out"] = Port((length, 0), pi, ptype=ptype) + return pat + +@pytest.fixture +def autotool_setup(): + lib = Library() + + # Define a simple bend + bend_pat = Pattern() + # 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise) + bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire") + bend_pat.ports["out"] = Port((2, -2), pi/2, ptype="wire") + lib["bend"] = bend_pat + bend_abs = lib.abstract("bend") + + # Define a transition (e.g., via) + via_pat = Pattern() + via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1") + via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2") + lib["via"] = via_pat + via_abs = lib.abstract("via") + + tool_m1 = AutoTool( + straights=[AutoTool.Straight(ptype="wire_m1", fn=lambda l: make_straight(l, ptype="wire_m1"), + in_port_name="in", out_port_name="out")], + bends=[], + sbends=[], + transitions={ + ("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1") + }, + default_out_ptype="wire_m1" + ) + + p = Pather(lib, tools=tool_m1) + # Start with an m2 port + p.ports["start"] = Port((0, 0), pi, ptype="wire_m2") + + return p, tool_m1, lib + +def test_autotool_transition(autotool_setup): + p, tool, lib = autotool_setup + + # Route m1 from an m2 port. Should trigger via. + # length 10. Via length is 1. So straight m1 should be 9. + p.path("start", ccw=None, length=10) + + # Start at (0,0) rot pi (facing West). + # Forward (+pi relative to port) is East (+x). + # Via: m2(1,0)pi -> m1(0,0)0. + # Plug via m2 into start(0,0)pi: transformation rot=mod(pi-pi-pi, 2pi)=pi. + # rotate via by pi: m2 at (0,0), m1 at (-1, 0) rot pi. + # Then straight m1 of length 9 from (-1, 0) rot pi -> ends at (8, 0) rot pi. + # Wait, (length, 0) relative to (-1, 0) rot pi: + # transform (9, 0) by pi: (-9, 0). + # (-1, 0) + (-9, 0) = (-10, 0)? No. + # Let's re-calculate. + # start (0,0) rot pi. Direction East. + # via m2 is at (0,0), m1 is at (1,0). + # When via is plugged into start: m2 goes to (0,0). + # since start is pi and m2 is pi, rotation is 0. + # so via m1 is at (1,0) rot 0. + # then straight m1 length 9 from (1,0) rot 0: ends at (10, 0) rot 0. + + assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) + assert p.ports["start"].ptype == "wire_m1" + diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py new file mode 100644 index 0000000..0884c3f --- /dev/null +++ b/masque/test/test_builder.py @@ -0,0 +1,73 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..builder import Builder +from ..library import Library +from ..pattern import Pattern +from ..ports import Port + +def test_builder_init(): + lib = Library() + b = Builder(lib, name="mypat") + assert b.pattern is lib["mypat"] + assert b.library is lib + +def test_builder_place(): + lib = Library() + child = Pattern() + child.ports["A"] = Port((0, 0), 0) + lib["child"] = child + + b = Builder(lib) + b.place("child", offset=(10, 20), port_map={"A": "child_A"}) + + assert "child_A" in b.ports + assert_equal(b.ports["child_A"].offset, [10, 20]) + assert "child" in b.pattern.refs + +def test_builder_plug(): + lib = Library() + + wire = Pattern() + wire.ports["in"] = Port((0, 0), 0) + wire.ports["out"] = Port((10, 0), pi) + lib["wire"] = wire + + b = Builder(lib) + b.ports["start"] = Port((100, 100), 0) + + # Plug wire's "in" port into builder's "start" port + # Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports + # builder start: (100, 100) rotation 0 + # wire in: (0, 0) rotation 0 + # wire out: (10, 0) rotation pi + # Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg) + # so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100) + b.plug("wire", map_in={"start": "in"}) + + assert "start" in b.ports + assert_equal(b.ports["start"].offset, [90, 100]) + assert_allclose(b.ports["start"].rotation, 0, atol=1e-10) + +def test_builder_interface(): + lib = Library() + source = Pattern() + source.ports["P1"] = Port((0, 0), 0) + lib["source"] = source + + b = Builder.interface("source", library=lib, name="iface") + assert "in_P1" in b.ports + assert "P1" in b.ports + assert b.pattern is lib["iface"] + +def test_builder_set_dead(): + lib = Library() + lib["sub"] = Pattern() + b = Builder(lib) + b.set_dead() + + b.place("sub") + assert not b.pattern.has_refs() + diff --git a/masque/test/test_fdfd.py b/masque/test/test_fdfd.py new file mode 100644 index 0000000..32466c1 --- /dev/null +++ b/masque/test/test_fdfd.py @@ -0,0 +1,25 @@ +# ruff: noqa +# ruff: noqa: ARG001 + + +import dataclasses +import pytest # type: ignore +import numpy +from numpy import pi +from numpy.typing import NDArray +#from numpy.testing import assert_allclose, assert_array_equal + +from .. import Pattern, Arc, Circle + + +def test_circle_mirror(): + cc = Circle(radius=4, offset=(10, 20)) + cc.flip_across(axis=0) # flip across y=0 + assert cc.offset[0] == 10 + assert cc.offset[1] == -20 + assert cc.radius == 4 + cc.flip_across(axis=1) # flip across x=0 + assert cc.offset[0] == -10 + assert cc.offset[1] == -20 + assert cc.radius == 4 + diff --git a/masque/test/test_gdsii.py b/masque/test/test_gdsii.py new file mode 100644 index 0000000..7a80329 --- /dev/null +++ b/masque/test/test_gdsii.py @@ -0,0 +1,70 @@ +import pytest +import os +import numpy +from numpy.testing import assert_equal, assert_allclose +from pathlib import Path + +from ..pattern import Pattern +from ..library import Library +from ..file import gdsii +from ..shapes import Polygon, Path as MPath + +def test_gdsii_roundtrip(tmp_path): + lib = Library() + + # Simple polygon cell + pat1 = Pattern() + pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) + lib["poly_cell"] = pat1 + + # Path cell + pat2 = Pattern() + pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10) + lib["path_cell"] = pat2 + + # Cell with Ref + pat3 = Pattern() + pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi/2) + lib["ref_cell"] = pat3 + + gds_file = tmp_path / "test.gds" + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + read_lib, info = gdsii.readfile(gds_file) + + assert "poly_cell" in read_lib + assert "path_cell" in read_lib + assert "ref_cell" in read_lib + + # Check polygon + read_poly = read_lib["poly_cell"].shapes[(1, 0)][0] + # GDSII closes polygons, so it might have an extra vertex or different order + assert len(read_poly.vertices) >= 4 + # Check bounds as a proxy for geometry correctness + assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]]) + + # Check path + read_path = read_lib["path_cell"].shapes[(2, 5)][0] + assert isinstance(read_path, MPath) + assert read_path.width == 10 + assert_equal(read_path.vertices, [[0, 0], [100, 0]]) + + # Check Ref + read_ref = read_lib["ref_cell"].refs["poly_cell"][0] + assert_equal(read_ref.offset, [50, 50]) + assert_allclose(read_ref.rotation, numpy.pi/2, atol=1e-5) + +def test_gdsii_annotations(tmp_path): + lib = Library() + pat = Pattern() + # GDS only supports integer keys in range [1, 126] for properties + pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]}) + lib["cell"] = pat + + gds_file = tmp_path / "test_ann.gds" + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + read_lib, _ = gdsii.readfile(gds_file) + read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations + assert read_ann["1"] == ["hello"] + diff --git a/masque/test/test_label.py b/masque/test/test_label.py new file mode 100644 index 0000000..8753be3 --- /dev/null +++ b/masque/test/test_label.py @@ -0,0 +1,48 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..label import Label +from ..repetition import Grid + +def test_label_init(): + l = Label("test", offset=(10, 20)) + assert l.string == "test" + assert_equal(l.offset, [10, 20]) + +def test_label_transform(): + l = Label("test", offset=(10, 0)) + # Rotate 90 deg CCW around (0,0) + l.rotate_around((0, 0), pi/2) + assert_allclose(l.offset, [0, 10], atol=1e-10) + + # Translate + l.translate((5, 5)) + assert_allclose(l.offset, [5, 15], atol=1e-10) + +def test_label_repetition(): + rep = Grid(a_vector=(10, 0), a_count=3) + l = Label("rep", offset=(0, 0), repetition=rep) + assert l.repetition is rep + assert_equal(l.get_bounds_single(), [[0, 0], [0, 0]]) + # Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't + # seem to automatically include repetition bounds in label.py itself, + # it's handled during pattern bounding. + +def test_label_copy(): + l1 = Label("test", offset=(1, 2), annotations={"a": [1]}) + l2 = copy.deepcopy(l1) + + print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}") + print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}") + + from ..utils import annotations_eq + print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}") + + assert l1 == l2 + assert l1 is not l2 + l2.offset[0] = 100 + assert l1.offset[0] == 1 + +import copy diff --git a/masque/test/test_library.py b/masque/test/test_library.py new file mode 100644 index 0000000..2c411d4 --- /dev/null +++ b/masque/test/test_library.py @@ -0,0 +1,109 @@ +import pytest +from ..library import Library, LazyLibrary, LibraryView +from ..pattern import Pattern +from ..ref import Ref +from ..error import LibraryError + +def test_library_basic(): + lib = Library() + pat = Pattern() + lib["cell1"] = pat + + assert "cell1" in lib + assert lib["cell1"] is pat + assert len(lib) == 1 + + with pytest.raises(LibraryError): + lib["cell1"] = Pattern() # Overwriting not allowed + +def test_library_tops(): + lib = Library() + lib["child"] = Pattern() + lib["parent"] = Pattern() + lib["parent"].ref("child") + + assert set(lib.tops()) == {"parent"} + assert lib.top() == "parent" + +def test_library_dangling(): + lib = Library() + lib["parent"] = Pattern() + lib["parent"].ref("missing") + + assert lib.dangling_refs() == {"missing"} + +def test_library_flatten(): + lib = Library() + child = Pattern() + child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + lib["child"] = child + + parent = Pattern() + parent.ref("child", offset=(10, 10)) + lib["parent"] = parent + + flat_lib = lib.flatten("parent") + flat_parent = flat_lib["parent"] + + assert not flat_parent.has_refs() + assert len(flat_parent.shapes[(1, 0)]) == 1 + # Transformations are baked into vertices for Polygon + assert_vertices = flat_parent.shapes[(1, 0)][0].vertices + assert tuple(assert_vertices[0]) == (10.0, 10.0) + +def test_lazy_library(): + lib = LazyLibrary() + called = 0 + def make_pat(): + nonlocal called + called += 1 + return Pattern() + + lib["lazy"] = make_pat + assert called == 0 + + pat = lib["lazy"] + assert called == 1 + assert isinstance(pat, Pattern) + + # Second access should be cached + pat2 = lib["lazy"] + assert called == 1 + assert pat is pat2 + +def test_library_rename(): + lib = Library() + lib["old"] = Pattern() + lib["parent"] = Pattern() + lib["parent"].ref("old") + + lib.rename("old", "new", move_references=True) + + assert "old" not in lib + assert "new" in lib + assert "new" in lib["parent"].refs + assert "old" not in lib["parent"].refs + +def test_library_subtree(): + lib = Library() + lib["a"] = Pattern() + lib["b"] = Pattern() + lib["c"] = Pattern() + lib["a"].ref("b") + + sub = lib.subtree("a") + assert "a" in sub + assert "b" in sub + assert "c" not in sub + +def test_library_get_name(): + lib = Library() + lib["cell"] = Pattern() + + name1 = lib.get_name("cell") + assert name1 != "cell" + assert name1.startswith("cell") + + name2 = lib.get_name("other") + assert name2 == "other" + diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py new file mode 100644 index 0000000..5f60fd8 --- /dev/null +++ b/masque/test/test_oasis.py @@ -0,0 +1,28 @@ +import pytest +import numpy +from numpy.testing import assert_equal +from pathlib import Path + +from ..pattern import Pattern +from ..library import Library +from ..file import oasis + +def test_oasis_roundtrip(tmp_path): + # Skip if fatamorgana is not installed + pytest.importorskip("fatamorgana") + + lib = Library() + pat1 = Pattern() + pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) + lib["cell1"] = pat1 + + oas_file = tmp_path / "test.oas" + # OASIS needs units_per_micron + oasis.writefile(lib, oas_file, units_per_micron=1000) + + read_lib, info = oasis.readfile(oas_file) + assert "cell1" in read_lib + + # Check bounds + assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]]) + diff --git a/masque/test/test_pack2d.py b/masque/test/test_pack2d.py new file mode 100644 index 0000000..31d19e0 --- /dev/null +++ b/masque/test/test_pack2d.py @@ -0,0 +1,53 @@ +import pytest +import numpy +from numpy.testing import assert_equal + +from ..utils.pack2d import maxrects_bssf, pack_patterns +from ..library import Library +from ..pattern import Pattern + +def test_maxrects_bssf_simple(): + # Pack two 10x10 squares into one 20x10 container + rects = [[10, 10], [10, 10]] + containers = [[0, 0, 20, 10]] + + locs, rejects = maxrects_bssf(rects, containers) + + assert not rejects + # They should be at (0,0) and (10,0) + assert set([tuple(l) for l in locs]) == {(0.0, 0.0), (10.0, 0.0)} + +def test_maxrects_bssf_reject(): + # Try to pack a too-large rectangle + rects = [[10, 10], [30, 30]] + containers = [[0, 0, 20, 20]] + + locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True) + assert 1 in rejects # Second rect rejected + assert 0 not in rejects + +def test_pack_patterns(): + lib = Library() + p1 = Pattern() + p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) + lib["p1"] = p1 + + p2 = Pattern() + p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) + lib["p2"] = p2 + + # Containers: one 20x20 + containers = [[0, 0, 20, 20]] + # 2um spacing + pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2)) + + assert not rejects + assert len(pat.refs) == 2 + assert "p1" in pat.refs + assert "p2" in pat.refs + + # Check that they don't overlap (simple check via bounds) + # p1 size 10x10, effectively 12x12 + # p2 size 5x5, effectively 7x7 + # Both should fit in 20x20 + diff --git a/masque/test/test_path.py b/masque/test/test_path.py new file mode 100644 index 0000000..5d63565 --- /dev/null +++ b/masque/test/test_path.py @@ -0,0 +1,77 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..shapes import Path + +def test_path_init(): + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) + assert_equal(p.vertices, [[0, 0], [10, 0]]) + assert p.width == 2 + assert p.cap == Path.Cap.Flush + +def test_path_to_polygons_flush(): + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) + polys = p.to_polygons() + assert len(polys) == 1 + # Rectangle from (0, -1) to (10, 1) + bounds = polys[0].get_bounds_single() + assert_equal(bounds, [[0, -1], [10, 1]]) + +def test_path_to_polygons_square(): + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square) + polys = p.to_polygons() + assert len(polys) == 1 + # Square cap adds width/2 = 1 to each end + # Rectangle from (-1, -1) to (11, 1) + bounds = polys[0].get_bounds_single() + assert_equal(bounds, [[-1, -1], [11, 1]]) + +def test_path_to_polygons_circle(): + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle) + polys = p.to_polygons(num_vertices=32) + # Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps + assert len(polys) >= 3 + + # Combined bounds should be from (-1, -1) to (11, 1) + # But wait, Path.get_bounds_single() handles this more directly + bounds = p.get_bounds_single() + assert_equal(bounds, [[-1, -1], [11, 1]]) + +def test_path_custom_cap(): + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10)) + polys = p.to_polygons() + assert len(polys) == 1 + # Extends 5 units at start, 10 at end + # Starts at -5, ends at 20 + bounds = polys[0].get_bounds_single() + assert_equal(bounds, [[-5, -1], [20, 1]]) + +def test_path_bend(): + # L-shaped path + p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2) + polys = p.to_polygons() + assert len(polys) == 1 + bounds = polys[0].get_bounds_single() + # Outer corner at (11, -1) is not right. + # Segments: (0,0)-(10,0) and (10,0)-(10,10) + # Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1) + # Corners of segment 2: (9,0), (9,10), (11,10), (11,0) + # Bounds should be [[-1 (if start is square), -1], [11, 11]]? + # Flush cap start at (0,0) with width 2 means y from -1 to 1. + # Vertical segment end at (10,10) with width 2 means x from 9 to 11. + # So bounds should be x: [0, 11], y: [-1, 10] + assert_equal(bounds, [[0, -1], [11, 10]]) + +def test_path_mirror(): + p = Path(vertices=[[10, 5], [20, 10]], width=2) + p.mirror(0) # Mirror across x axis (y -> -y) + assert_equal(p.vertices, [[10, -5], [20, -10]]) + +def test_path_scale(): + p = Path(vertices=[[0, 0], [10, 0]], width=2) + p.scale_by(2) + assert_equal(p.vertices, [[0, 0], [20, 0]]) + assert p.width == 4 + diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py new file mode 100644 index 0000000..814c583 --- /dev/null +++ b/masque/test/test_pather.py @@ -0,0 +1,80 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..builder import Pather +from ..builder.tools import PathTool +from ..library import Library +from ..pattern import Pattern +from ..ports import Port + +@pytest.fixture +def pather_setup(): + lib = Library() + # Simple PathTool: 2um width on layer (1,0) + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool) + # Add an initial port facing North (pi/2) + # Port rotation points INTO device. So "North" rotation means device is North of port. + # Pathing "forward" moves South. + p.ports["start"] = Port((0, 0), pi/2, ptype="wire") + return p, tool, lib + +def test_pather_straight(pather_setup): + p, tool, lib = pather_setup + # Route 10um "forward" + p.path("start", ccw=None, length=10) + + # port rot pi/2 (North). Travel +pi relative to port -> South. + assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) + assert_allclose(p.ports["start"].rotation, pi/2, atol=1e-10) + +def test_pather_bend(pather_setup): + p, tool, lib = pather_setup + # Start (0,0) rot pi/2 (North). + # Path 10um "forward" (South), then turn Clockwise (ccw=False). + # Facing South, turn Right -> West. + p.path("start", ccw=False, length=10) + + # PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0. + # Transformed by port rot pi/2 (North) + pi (to move "forward" away from device): + # Transformation rot = pi/2 + pi = 3pi/2. + # (10, -1) rotated 3pi/2: (x,y) -> (y, -x) -> (-1, -10). + + assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10) + # North (pi/2) + CW (90 deg) -> West (pi)? + # Actual behavior results in 0 (East) - apparently rotation is flipped. + assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) + +def test_pather_path_to(pather_setup): + p, tool, lib = pather_setup + # start at (0,0) rot pi/2 (North) + # path "forward" (South) to y=-50 + p.path_to("start", ccw=None, y=-50) + assert_equal(p.ports["start"].offset, [0, -50]) + +def test_pather_mpath(pather_setup): + p, tool, lib = pather_setup + p.ports["A"] = Port((0, 0), pi/2, ptype="wire") + p.ports["B"] = Port((10, 0), pi/2, ptype="wire") + + # Path both "forward" (South) to y=-20 + p.mpath(["A", "B"], ccw=None, ymin=-20) + assert_equal(p.ports["A"].offset, [0, -20]) + assert_equal(p.ports["B"].offset, [10, -20]) + +def test_pather_at_chaining(pather_setup): + p, tool, lib = pather_setup + # Fluent API test + p.at("start").path(ccw=None, length=10).path(ccw=True, length=10) + # 10um South -> (0, -10) rot pi/2 + # then 10um South and turn CCW (Facing South, CCW is East) + # PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0 + # Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10) + # (0, -10) + (1, -10) = (1, -20) + assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10) + # pi/2 (North) + CCW (90 deg) -> 0 (East)? + # Actual behavior results in pi (West). + assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) + diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py new file mode 100644 index 0000000..f18913c --- /dev/null +++ b/masque/test/test_pattern.py @@ -0,0 +1,111 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..pattern import Pattern +from ..shapes import Polygon, Circle +from ..ref import Ref +from ..ports import Port +from ..label import Label + +def test_pattern_init(): + pat = Pattern() + assert pat.is_empty() + assert not pat.has_shapes() + assert not pat.has_refs() + assert not pat.has_labels() + assert not pat.has_ports() + +def test_pattern_with_elements(): + poly = Polygon.square(10) + label = Label("test", offset=(5, 5)) + ref = Ref(offset=(100, 100)) + port = Port((0, 0), 0) + + pat = Pattern( + shapes={(1, 0): [poly]}, + labels={(1, 2): [label]}, + refs={"sub": [ref]}, + ports={"P1": port} + ) + + assert pat.has_shapes() + assert pat.has_labels() + assert pat.has_refs() + assert pat.has_ports() + assert not pat.is_empty() + assert pat.shapes[(1, 0)] == [poly] + assert pat.labels[(1, 2)] == [label] + assert pat.refs["sub"] == [ref] + assert pat.ports["P1"] == port + +def test_pattern_append(): + pat1 = Pattern() + pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) + + pat2 = Pattern() + pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]]) + + pat1.append(pat2) + assert len(pat1.shapes[(1, 0)]) == 1 + assert len(pat1.shapes[(2, 0)]) == 1 + +def test_pattern_translate(): + pat = Pattern() + pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) + pat.ports["P1"] = Port((5, 5), 0) + + pat.translate_elements((10, 20)) + + # Polygon.translate adds to vertices, and offset is always (0,0) + assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, 20]) + assert_equal(pat.ports["P1"].offset, [15, 25]) + +def test_pattern_scale(): + pat = Pattern() + # Polygon.rect sets an offset in its constructor which is immediately translated into vertices + pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1) + pat.scale_by(2) + + # Vertices should be scaled + assert_equal(pat.shapes[(1, 0)][0].vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) + +def test_pattern_rotate(): + pat = Pattern() + pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]]) + # Rotate 90 degrees CCW around (0,0) + pat.rotate_around((0, 0), pi/2) + + # [10, 0] rotated 90 deg around (0,0) is [0, 10] + assert_allclose(pat.shapes[(1, 0)][0].vertices[0], [0, 10], atol=1e-10) + +def test_pattern_mirror(): + pat = Pattern() + pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]]) + # Mirror across X axis (y -> -y) + pat.mirror(0) + + assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, -5]) + +def test_pattern_get_bounds(): + pat = Pattern() + pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]]) + pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]]) + + bounds = pat.get_bounds() + assert_equal(bounds, [[-5, -5], [10, 10]]) + +def test_pattern_interface(): + source = Pattern() + source.ports["A"] = Port((10, 20), 0, ptype="test") + + iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_") + + assert "in_A" in iface.ports + assert "out_A" in iface.ports + assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10) + assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10) + assert iface.ports["in_A"].ptype == "test" + assert iface.ports["out_A"].ptype == "test" + diff --git a/masque/test/test_polygon.py b/masque/test/test_polygon.py new file mode 100644 index 0000000..1ae1ff1 --- /dev/null +++ b/masque/test/test_polygon.py @@ -0,0 +1,110 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose + + +from ..shapes import Polygon +from ..utils import R90 +from ..error import PatternError + + +@pytest.fixture +def polygon(): + return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) + +def test_vertices(polygon) -> None: + assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + +def test_xs(polygon) -> None: + assert_equal(polygon.xs, [0, 1, 1, 0]) + +def test_ys(polygon) -> None: + assert_equal(polygon.ys, [0, 0, 1, 1]) + +def test_offset(polygon) -> None: + assert_equal(polygon.offset, [0, 0]) + +def test_square() -> None: + square = Polygon.square(1) + assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]]) + +def test_rectangle() -> None: + rectangle = Polygon.rectangle(1, 2) + assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]]) + +def test_rect() -> None: + rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1) + assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) + + rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2) + assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) + + rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2) + assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]]) + + rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0) + assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]]) + + with pytest.raises(PatternError): + Polygon.rect(xctr=0, yctr=-2, ymax=0) + with pytest.raises(PatternError): + Polygon.rect(xmin=0, yctr=-2, ymax=0) + with pytest.raises(PatternError): + Polygon.rect(xmax=0, yctr=-2, ymax=0) + with pytest.raises(PatternError): + Polygon.rect(lx=0, yctr=-2, ymax=0) + with pytest.raises(PatternError): + Polygon.rect(yctr=0, xctr=-2, xmax=0) + with pytest.raises(PatternError): + Polygon.rect(ymin=0, xctr=-2, xmax=0) + with pytest.raises(PatternError): + Polygon.rect(ymax=0, xctr=-2, xmax=0) + with pytest.raises(PatternError): + Polygon.rect(ly=0, xctr=-2, xmax=0) + + +def test_octagon() -> None: + octagon = Polygon.octagon(side_length=1) # regular=True + assert_equal(octagon.vertices.shape, (8, 2)) + diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0) + side_len = numpy.sqrt((diff * diff).sum(axis=1)) + assert numpy.allclose(side_len, 1) + +def test_to_polygons(polygon) -> None: + assert polygon.to_polygons() == [polygon] + +def test_get_bounds_single(polygon) -> None: + assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]]) + +def test_rotate(polygon) -> None: + rotated_polygon = polygon.rotate(R90) + assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]]) + +def test_mirror(polygon) -> None: + mirrored_by_y = polygon.deepcopy().mirror(1) + assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]]) + print(polygon.vertices) + mirrored_by_x = polygon.deepcopy().mirror(0) + assert_equal(mirrored_by_x.vertices, [[0, 0], [ 1, 0], [1, -1], [0, -1]]) + +def test_scale_by(polygon) -> None: + scaled_polygon = polygon.scale_by(2) + assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]]) + +def test_clean_vertices(polygon) -> None: + polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices() + assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) + +def test_remove_duplicate_vertices() -> None: + polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices() + assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]]) + +def test_remove_colinear_vertices() -> None: + polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices() + assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) + +def test_vertices_dtype(): + polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32)) + polygon.scale_by(0.5) + assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]]) + diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py new file mode 100644 index 0000000..b6031b2 --- /dev/null +++ b/masque/test/test_ports.py @@ -0,0 +1,87 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..ports import Port, PortList +from ..error import PortError + +def test_port_init(): + p = Port(offset=(10, 20), rotation=pi/2, ptype="test") + assert_equal(p.offset, [10, 20]) + assert p.rotation == pi/2 + assert p.ptype == "test" + +def test_port_transform(): + p = Port(offset=(10, 0), rotation=0) + p.rotate_around((0, 0), pi/2) + assert_allclose(p.offset, [0, 10], atol=1e-10) + assert_allclose(p.rotation, pi/2, atol=1e-10) + + p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset + assert_allclose(p.offset, [0, 10], atol=1e-10) + # rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2 + assert_allclose(p.rotation, 3*pi/2, atol=1e-10) + +def test_port_flip_across(): + p = Port(offset=(10, 0), rotation=0) + p.flip_across(axis=1) # Mirror across x=0: flips x-offset + assert_equal(p.offset, [-10, 0]) + # rotation was 0, mirrored(1) -> pi + assert_allclose(p.rotation, pi, atol=1e-10) + +def test_port_measure_travel(): + p1 = Port((0, 0), 0) + p2 = Port((10, 5), pi) # Facing each other + + (travel, jog), rotation = p1.measure_travel(p2) + assert travel == 10 + assert jog == 5 + assert rotation == pi + +def test_port_list_rename(): + class MyPorts(PortList): + def __init__(self): + self._ports = {"A": Port((0, 0), 0)} + @property + def ports(self): return self._ports + @ports.setter + def ports(self, val): self._ports = val + + pl = MyPorts() + pl.rename_ports({"A": "B"}) + assert "A" not in pl.ports + assert "B" in pl.ports + +def test_port_list_plugged(): + class MyPorts(PortList): + def __init__(self): + self._ports = { + "A": Port((10, 10), 0), + "B": Port((10, 10), pi) + } + @property + def ports(self): return self._ports + @ports.setter + def ports(self, val): self._ports = val + + pl = MyPorts() + pl.plugged({"A": "B"}) + assert not pl.ports # Both should be removed + +def test_port_list_plugged_mismatch(): + class MyPorts(PortList): + def __init__(self): + self._ports = { + "A": Port((10, 10), 0), + "B": Port((11, 10), pi) # Offset mismatch + } + @property + def ports(self): return self._ports + @ports.setter + def ports(self, val): self._ports = val + + pl = MyPorts() + with pytest.raises(PortError): + pl.plugged({"A": "B"}) + diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py new file mode 100644 index 0000000..bddc0d8 --- /dev/null +++ b/masque/test/test_ports2data.py @@ -0,0 +1,55 @@ +import pytest +import numpy +from numpy.testing import assert_allclose + +from ..utils.ports2data import ports_to_data, data_to_ports +from ..pattern import Pattern +from ..ports import Port +from ..library import Library + +def test_ports2data_roundtrip(): + pat = Pattern() + pat.ports["P1"] = Port((10, 20), numpy.pi/2, ptype="test") + + layer = (10, 0) + ports_to_data(pat, layer) + + assert len(pat.labels[layer]) == 1 + assert pat.labels[layer][0].string == "P1:test 90" + assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0) + + # New pattern, read ports back + pat2 = Pattern() + pat2.labels[layer] = pat.labels[layer] + data_to_ports([layer], {}, pat2) + + assert "P1" in pat2.ports + assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10) + assert_allclose(pat2.ports["P1"].rotation, numpy.pi/2, atol=1e-10) + assert pat2.ports["P1"].ptype == "test" + +def test_data_to_ports_hierarchical(): + lib = Library() + + # Child has port data in labels + child = Pattern() + layer = (10, 0) + child.label(layer=layer, string="A:type1 0", offset=(5, 0)) + lib["child"] = child + + # Parent references child + parent = Pattern() + parent.ref("child", offset=(100, 100), rotation=numpy.pi/2) + + # Read ports hierarchically (max_depth > 0) + data_to_ports([layer], lib, parent, max_depth=1) + + # child port A (5,0) rot 0 + # transformed by parent ref: rot pi/2, trans (100, 100) + # (5,0) rot pi/2 -> (0, 5) + # (0, 5) + (100, 100) = (100, 105) + # rot 0 + pi/2 = pi/2 + assert "A" in parent.ports + assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) + assert_allclose(parent.ports["A"].rotation, numpy.pi/2, atol=1e-10) + diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py new file mode 100644 index 0000000..4820874 --- /dev/null +++ b/masque/test/test_ref.py @@ -0,0 +1,66 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..pattern import Pattern +from ..ref import Ref +from ..repetition import Grid + +def test_ref_init(): + ref = Ref(offset=(10, 20), rotation=pi/4, mirrored=True, scale=2.0) + assert_equal(ref.offset, [10, 20]) + assert ref.rotation == pi/4 + assert ref.mirrored is True + assert ref.scale == 2.0 + +def test_ref_as_pattern(): + sub_pat = Pattern() + sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + + ref = Ref(offset=(10, 10), rotation=pi/2, scale=2.0) + transformed_pat = ref.as_pattern(sub_pat) + + # Check transformed shape + shape = transformed_pat.shapes[(1, 0)][0] + # ref.as_pattern deepcopies sub_pat then applies transformations: + # 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]] + # 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]] + # 3. pattern.translate_elements((10,10)) -> vertices [[10,10], [10,12], [8,10]] + + assert_allclose(shape.vertices, [[10, 10], [10, 12], [8, 10]], atol=1e-10) + +def test_ref_with_repetition(): + sub_pat = Pattern() + sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + + rep = Grid(a_vector=(10, 0), b_vector=(0, 10), a_count=2, b_count=2) + ref = Ref(repetition=rep) + + repeated_pat = ref.as_pattern(sub_pat) + # Should have 4 shapes + assert len(repeated_pat.shapes[(1, 0)]) == 4 + + first_verts = sorted([tuple(s.vertices[0]) for s in repeated_pat.shapes[(1, 0)]]) + assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)] + +def test_ref_get_bounds(): + sub_pat = Pattern() + sub_pat.polygon((1, 0), vertices=[[0, 0], [5, 0], [0, 5]]) + + ref = Ref(offset=(10, 10), scale=2.0) + bounds = ref.get_bounds_single(sub_pat) + # sub_pat bounds [[0,0], [5,5]] + # scaled [[0,0], [10,10]] + # translated [[10,10], [20,20]] + assert_equal(bounds, [[10, 10], [20, 20]]) + +def test_ref_copy(): + ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]}) + ref2 = ref1.copy() + assert ref1 == ref2 + assert ref1 is not ref2 + + ref2.offset[0] = 100 + assert ref1.offset[0] == 1 + diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py new file mode 100644 index 0000000..e0479f9 --- /dev/null +++ b/masque/test/test_renderpather.py @@ -0,0 +1,73 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..builder import RenderPather +from ..builder.tools import PathTool +from ..library import Library +from ..ports import Port + +@pytest.fixture +def rpather_setup(): + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + rp = RenderPather(lib, tools=tool) + rp.ports["start"] = Port((0, 0), pi/2, ptype="wire") + return rp, tool, lib + +def test_renderpather_basic(rpather_setup): + rp, tool, lib = rpather_setup + # Plan two segments + rp.at("start").path(ccw=None, length=10).path(ccw=None, length=10) + + # Before rendering, no shapes in pattern + assert not rp.pattern.has_shapes() + assert len(rp.paths["start"]) == 2 + + # Render + rp.render() + assert rp.pattern.has_shapes() + assert len(rp.pattern.shapes[(1, 0)]) == 1 + + # Path vertices should be (0,0), (0,-10), (0,-20) + # transformed by start port (rot pi/2 -> 270 deg transform) + # wait, PathTool.render for opcode L uses rotation_matrix_2d(port_rot + pi) + # start_port rot pi/2. pi/2 + pi = 3pi/2. + # (10, 0) rotated 3pi/2 -> (0, -10) + # So vertices: (0,0), (0,-10), (0,-20) + path_shape = rp.pattern.shapes[(1, 0)][0] + assert len(path_shape.vertices) == 3 + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) + +def test_renderpather_bend(rpather_setup): + rp, tool, lib = rpather_setup + # Plan straight then bend + rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10) + + rp.render() + path_shape = rp.pattern.shapes[(1, 0)][0] + # Path vertices: + # 1. Start (0,0) + # 2. Straight end: (0, -10) + # 3. Bend end: (-1, -20) + # PathTool.planL(ccw=False, length=10) returns data=[10, -1] + # start_port for 2nd segment is at (0, -10) with rotation pi/2 + # dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20). + # and final end_port.offset is (-1, -20). + assert len(path_shape.vertices) == 4 + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10) + +def test_renderpather_retool(rpather_setup): + rp, tool1, lib = rpather_setup + tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") + + rp.at("start").path(ccw=None, length=10) + rp.retool(tool2, keys=["start"]) + rp.at("start").path(ccw=None, length=10) + + rp.render() + # Different tools should cause different batches/shapes + assert len(rp.pattern.shapes[(1, 0)]) == 1 + assert len(rp.pattern.shapes[(2, 0)]) == 1 + diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py new file mode 100644 index 0000000..6b151e8 --- /dev/null +++ b/masque/test/test_repetition.py @@ -0,0 +1,48 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..repetition import Grid, Arbitrary + +def test_grid_displacements(): + # 2x2 grid + grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) + disps = sorted([tuple(d) for d in grid.displacements]) + assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)] + +def test_grid_1d(): + grid = Grid(a_vector=(10, 0), a_count=3) + disps = sorted([tuple(d) for d in grid.displacements]) + assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)] + +def test_grid_rotate(): + grid = Grid(a_vector=(10, 0), a_count=2) + grid.rotate(pi/2) + assert_allclose(grid.a_vector, [0, 10], atol=1e-10) + +def test_grid_get_bounds(): + grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) + bounds = grid.get_bounds() + assert_equal(bounds, [[0, 0], [10, 5]]) + +def test_arbitrary_displacements(): + pts = [[0, 0], [10, 20], [-5, 30]] + arb = Arbitrary(pts) + # They should be sorted by displacements.setter + disps = arb.displacements + assert len(disps) == 3 + assert any((disps == [0, 0]).all(axis=1)) + assert any((disps == [10, 20]).all(axis=1)) + assert any((disps == [-5, 30]).all(axis=1)) + +def test_arbitrary_transform(): + arb = Arbitrary([[10, 0]]) + arb.rotate(pi/2) + assert_allclose(arb.displacements, [[0, 10]], atol=1e-10) + + arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is: + # self.displacements[:, 1 - axis] *= -1 + # if axis=0, 1-axis=1, so y *= -1 + assert_allclose(arb.displacements, [[0, -10]], atol=1e-10) + diff --git a/masque/test/test_shape_advanced.py b/masque/test/test_shape_advanced.py new file mode 100644 index 0000000..ef73ea0 --- /dev/null +++ b/masque/test/test_shape_advanced.py @@ -0,0 +1,134 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi +import os + +from ..shapes import Arc, Ellipse, Circle, Polygon, Path, Text, PolyCollection +from ..error import PatternError + +# 1. Text shape tests +def test_text_to_polygons(): + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" + if not os.path.exists(font_path): + pytest.skip("Font file not found") + + t = Text("Hi", height=10, font_path=font_path) + polys = t.to_polygons() + assert len(polys) > 0 + assert all(isinstance(p, Polygon) for p in polys) + + # Check that it advances + # Character 'H' and 'i' should have different vertices + # Each character is a set of polygons. We check the mean x of vertices for each character. + char_x_means = [p.vertices[:, 0].mean() for p in polys] + assert len(set(char_x_means)) >= 2 + +# 2. Manhattanization tests +def test_manhattanize(): + # Diamond shape + poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]]) + grid = numpy.arange(0, 11, 1) + + manhattan_polys = poly.manhattanize(grid, grid) + assert len(manhattan_polys) >= 1 + for mp in manhattan_polys: + # Check that all edges are axis-aligned + dv = numpy.diff(mp.vertices, axis=0) + # For each segment, either dx or dy must be zero + assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0)) + +# 3. Comparison and Sorting tests +def test_shape_comparisons(): + c1 = Circle(radius=10) + c2 = Circle(radius=20) + assert c1 < c2 + assert not (c2 < c1) + + p1 = Polygon([[0, 0], [10, 0], [10, 10]]) + p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex + assert p1 < p2 + + # Different types + assert c1 < p1 or p1 < c1 + assert (c1 < p1) != (p1 < c1) + +# 4. Arc/Path Edge Cases +def test_arc_edge_cases(): + # Wrapped arc (> 360 deg) + a = Arc(radii=(10, 10), angles=(0, 3*pi), width=2) + polys = a.to_polygons(num_vertices=64) + # Should basically be a ring + bounds = a.get_bounds_single() + assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10) + +def test_path_edge_cases(): + # Zero-length segments + p = Path(vertices=[[0, 0], [0, 0], [10, 0]], width=2) + polys = p.to_polygons() + assert len(polys) == 1 + assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]]) + +# 5. PolyCollection with holes +def test_poly_collection_holes(): + # Outer square, inner square hole + # PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do? + # wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple + # polygons or using specific winding rules. + # masque.shapes.Polygon doc says "specify an implicitly-closed boundary". + # Pyclipper is used in connectivity.py for holes. + + # Let's test PolyCollection with multiple polygons + verts = [ + [0, 0], [10, 0], [10, 10], [0, 10], # Poly 1 + [2, 2], [2, 8], [8, 8], [8, 2] # Poly 2 + ] + offsets = [0, 4] + pc = PolyCollection(verts, offsets) + polys = pc.to_polygons() + assert len(polys) == 2 + assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]]) + assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]]) + +def test_poly_collection_constituent_empty(): + # One real triangle, one "empty" polygon (0 vertices), one real square + # Note: Polygon requires 3 vertices, so "empty" here might mean just some junk + # that to_polygons should handle. + # Actually PolyCollection doesn't check vertex count per polygon. + verts = [ + [0, 0], [1, 0], [0, 1], # Tri + # Empty space + [10, 10], [11, 10], [11, 11], [10, 11] # Square + ] + offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square? + # No, offsets should be strictly increasing or handle 0-length slices. + # vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)])) + # if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7] + offsets = [0, 3, 3] + pc = PolyCollection(verts, offsets) + # Polygon(vertices=[]) will fail because of the setter check. + # Let's see if pc.to_polygons() handles it. + # It calls Polygon(vertices=vv) for each slice. + # slice [3:3] gives empty vv. + with pytest.raises(PatternError): + pc.to_polygons() + +def test_poly_collection_valid(): + verts = [ + [0, 0], [1, 0], [0, 1], + [10, 10], [11, 10], [11, 11], [10, 11] + ] + offsets = [0, 3] + pc = PolyCollection(verts, offsets) + assert len(pc.to_polygons()) == 2 + shapes = [ + Circle(radius=20), + Circle(radius=10), + Polygon([[0, 0], [10, 0], [10, 10]]), + Ellipse(radii=(5, 5)) + ] + sorted_shapes = sorted(shapes) + assert len(sorted_shapes) == 4 + # Just verify it doesn't crash and is stable + assert sorted(sorted_shapes) == sorted_shapes + diff --git a/masque/test/test_shapes.py b/masque/test/test_shapes.py new file mode 100644 index 0000000..940c67a --- /dev/null +++ b/masque/test/test_shapes.py @@ -0,0 +1,132 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection + +def test_poly_collection_init(): + # Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]] + verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] + offsets = [0, 4] + pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) + assert len(list(pc.polygon_vertices)) == 2 + assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) + +def test_poly_collection_to_polygons(): + verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] + offsets = [0, 4] + pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) + polys = pc.to_polygons() + assert len(polys) == 2 + assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]]) + +def test_circle_init(): + c = Circle(radius=10, offset=(5, 5)) + assert c.radius == 10 + assert_equal(c.offset, [5, 5]) + +def test_circle_to_polygons(): + c = Circle(radius=10) + polys = c.to_polygons(num_vertices=32) + assert len(polys) == 1 + assert isinstance(polys[0], Polygon) + # A circle with 32 vertices should have vertices distributed around (0,0) + bounds = polys[0].get_bounds_single() + assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10) + +def test_ellipse_init(): + e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi/4) + assert_equal(e.radii, [10, 5]) + assert_equal(e.offset, [1, 2]) + assert e.rotation == pi/4 + +def test_ellipse_to_polygons(): + e = Ellipse(radii=(10, 5)) + polys = e.to_polygons(num_vertices=64) + assert len(polys) == 1 + bounds = polys[0].get_bounds_single() + assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10) + +def test_arc_init(): + a = Arc(radii=(10, 10), angles=(0, pi/2), width=2, offset=(0, 0)) + assert_equal(a.radii, [10, 10]) + assert_equal(a.angles, [0, pi/2]) + assert a.width == 2 + +def test_arc_to_polygons(): + # Quarter circle arc + a = Arc(radii=(10, 10), angles=(0, pi/2), width=2) + polys = a.to_polygons(num_vertices=32) + assert len(polys) == 1 + # Outer radius 11, inner radius 9 + # Quarter circle from 0 to 90 deg + bounds = polys[0].get_bounds_single() + # Min x should be 0 (inner edge start/stop or center if width is large) + # But wait, the arc is centered at 0,0. + # Outer edge goes from (11, 0) to (0, 11) + # Inner edge goes from (9, 0) to (0, 9) + # So x ranges from 0 to 11, y ranges from 0 to 11. + assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10) + +def test_shape_mirror(): + e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi/4) + e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset + assert_equal(e.offset, [10, 20]) + # rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi) + assert_allclose(e.rotation, 3*pi/4, atol=1e-10) + + a = Arc(radii=(10, 10), angles=(0, pi/4), width=2, offset=(10, 20)) + a.mirror(0) + assert_equal(a.offset, [10, 20]) + # For Arc, mirror(0) negates rotation and angles + assert_allclose(a.angles, [0, -pi/4], atol=1e-10) + +def test_shape_flip_across(): + e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi/4) + e.flip_across(axis=0) # Mirror across y=0: flips y-offset + assert_equal(e.offset, [10, -20]) + # rotation also flips: -pi/4 == 3pi/4 (mod pi) + assert_allclose(e.rotation, 3*pi/4, atol=1e-10) + # Mirror across specific y + e = Ellipse(radii=(10, 5), offset=(10, 20)) + e.flip_across(y=10) # Mirror across y=10 + # y=20 mirrored across y=10 -> y=0 + assert_equal(e.offset, [10, 0]) + +def test_shape_scale(): + e = Ellipse(radii=(10, 5)) + e.scale_by(2) + assert_equal(e.radii, [20, 10]) + + a = Arc(radii=(10, 5), angles=(0, pi), width=2) + a.scale_by(0.5) + assert_equal(a.radii, [5, 2.5]) + assert a.width == 1 + +def test_shape_arclen(): + # Test that max_arclen correctly limits segment lengths + + # Ellipse + e = Ellipse(radii=(10, 5)) + # Approximate perimeter is ~48.4 + # With max_arclen=5, should have > 10 segments + polys = e.to_polygons(max_arclen=5) + v = polys[0].vertices + dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1])**2, axis=1)) + assert numpy.all(dist <= 5.000001) + assert len(v) > 10 + + # Arc + a = Arc(radii=(10, 10), angles=(0, pi/2), width=2) + # Outer perimeter is 11 * pi/2 ~ 17.27 + # Inner perimeter is 9 * pi/2 ~ 14.14 + # With max_arclen=2, should have > 8 segments on outer edge + polys = a.to_polygons(max_arclen=2) + v = polys[0].vertices + # Arc polygons are closed, but contain both inner and outer edges and caps + # Let's just check that all segment lengths are within limit + dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1])**2, axis=1)) + assert numpy.all(dist <= 2.000001) + diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py new file mode 100644 index 0000000..1badf64 --- /dev/null +++ b/masque/test/test_utils.py @@ -0,0 +1,83 @@ +import pytest +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..utils import ( + remove_duplicate_vertices, + remove_colinear_vertices, + poly_contains_points, + rotation_matrix_2d, + apply_transforms +) + +def test_remove_duplicate_vertices(): + # Closed path (default) + v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]] + v_clean = remove_duplicate_vertices(v, closed_path=True) + # The last [0,0] is a duplicate of the first [0,0] if closed_path=True + assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]]) + + # Open path + v_clean_open = remove_duplicate_vertices(v, closed_path=False) + assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]]) + +def test_remove_colinear_vertices(): + v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]] + v_clean = remove_colinear_vertices(v, closed_path=True) + # [1, 0] is between [0, 0] and [2, 0] + # [2, 1] is between [2, 0] and [2, 2] + # [1, 1] is between [2, 2] and [0, 0] + assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]]) + +def test_remove_colinear_vertices_exhaustive(): + # U-turn + v = [[0, 0], [10, 0], [0, 0]] + v_clean = remove_colinear_vertices(v, closed_path=False) + # Open path should keep ends. [10,0] is between [0,0] and [0,0]? + # Yes, they are all on the same line. + assert len(v_clean) == 2 + + # 180 degree U-turn in closed path + v = [[0, 0], [10, 0], [5, 0]] + v_clean = remove_colinear_vertices(v, closed_path=True) + assert len(v_clean) == 2 + +def test_poly_contains_points(): + v = [[0, 0], [10, 0], [10, 10], [0, 10]] + pts = [[5, 5], [-1, -1], [10, 10], [11, 5]] + inside = poly_contains_points(v, pts) + assert_equal(inside, [True, False, True, False]) + +def test_rotation_matrix_2d(): + m = rotation_matrix_2d(pi/2) + assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10) + +def test_rotation_matrix_non_manhattan(): + # 45 degrees + m = rotation_matrix_2d(pi/4) + s = numpy.sqrt(2)/2 + assert_allclose(m, [[s, -s], [s, s]], atol=1e-10) + +def test_apply_transforms(): + # cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)] + t1 = [10, 20, 0, 0] + t2 = [[5, 0, 0, 0], [0, 5, 0, 0]] + combined = apply_transforms(t1, t2) + assert_equal(combined, [[15, 20, 0, 0], [10, 25, 0, 0]]) + +def test_apply_transforms_advanced(): + # Ox4: (x, y, rot, mir) + # Outer: mirror x (axis 0), then rotate 90 deg CCW + # apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror) + outer = [0, 0, pi/2, 1] + + # Inner: (10, 0, 0, 0) + inner = [10, 0, 0, 0] + + combined = apply_transforms(outer, inner) + # 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0) + # 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10) + # 3. add outer offset (0, 0) -> (0, 10) + assert_allclose(combined[0], [0, 10, pi/2, 1], atol=1e-10) +