From 66d6fae2bda9f9cd365a45496af5c569bedbee2e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:15:07 -0800 Subject: [PATCH 01/10] [AutoTool] Fix error handling for ccw=None --- masque/builder/tools.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 6bd7547..4162be3 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -590,6 +590,13 @@ class AutoTool(Tool, metaclass=ABCMeta): ) -> tuple[Port, LData]: success = False + + # 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: bend_dxy, bend_angle = self._bend2dxy(bend, ccw) From 72f462d07784aac9c7e3b2ca425a8149d587b8b1 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:18:21 -0800 Subject: [PATCH 02/10] [AutoTool] Enable running AutoTool without any bends in the list --- masque/builder/tools.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 4162be3..148af4d 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -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,6 +591,11 @@ 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 @@ -598,7 +604,7 @@ class AutoTool(Tool, metaclass=ABCMeta): 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) @@ -607,14 +613,16 @@ class AutoTool(Tool, metaclass=ABCMeta): out_ptype_pair = ( 'unk' if out_ptype is None else out_ptype, - straight.ptype if ccw is None else bend.out_port.ptype + straight.ptype if ccw is None else cast(AutoTool.Bend, bend).out_port.ptype ) out_transition = self.transitions.get(out_ptype_pair, None) otrans_dxy = self._otransition2dxy(out_transition, bend_angle) b_transition = None - if ccw is not None and bend.in_port.ptype != straight.ptype: - b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) + if ccw is not None: + assert bend is not None + if bend.in_port.ptype != straight.ptype: + b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) btrans_dxy = self._itransition2dxy(b_transition) straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] From 278f0783dadf8cdb74bb7fe4b28cf5933e9a3f2b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:26:06 -0800 Subject: [PATCH 03/10] [PolyCollection] gracefully handle empty PolyCollections --- masque/shapes/poly_collection.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index afa9c99..73aca30 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -56,6 +56,8 @@ 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],)), @@ -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))) From 36fed84249733bb08d492d61816ea5e8fe4b981b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:31:15 -0800 Subject: [PATCH 04/10] [PolyCollection] fix slicing --- masque/shapes/poly_collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index 73aca30..c714ed5 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -60,7 +60,7 @@ class PolyCollection(Shape): 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) From fe70d0574be92c6f5a0506e4108f477fbcfe0a22 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:34:56 -0800 Subject: [PATCH 05/10] [Arc] Improve handling of full rings --- masque/shapes/arc.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 9ae06ce..f3c9f79 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -463,13 +463,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 From ad49276345e4cc7f0baaa2b0963b44c63d1e02b3 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:35:43 -0800 Subject: [PATCH 06/10] [Arc] improve bounding box edge cases --- masque/shapes/arc.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index f3c9f79..f95c4f6 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -362,17 +362,20 @@ class Arc(PositionableImpl, Shape): yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) # If our arc subtends a coordinate axis, use the extremum along that axis - if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1: - xp = xr + if abs(a1 - a0) >= 2 * pi: + xn, xp, yn, yp = -xr, xr, -yr, yr + else: + if a0 <= xpt <= a1 or a0 <= xpt + 2 * pi <= a1: + xp = xr - if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1: - xn = -xr + if a0 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1: + xn = -xr - if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1: - yp = yr + if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1: + yp = yr - if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1: - yn = -yr + if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1: + yn = -yr mins.append([xn, yn]) maxs.append([xp, yp]) From 9bb0d5190df0687d87c691024b5c45f973039c60 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:37:53 -0800 Subject: [PATCH 07/10] [Arc] improve some edge cases when calculating arclengths --- masque/shapes/arc.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index f95c4f6..6f948cb 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -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) From 1de76bff47ecdf2a718bd7632bb6368d8093c8dc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 01:41:31 -0800 Subject: [PATCH 08/10] [tests] Add machine-generated test suite --- masque/test/__init__.py | 3 + masque/test/conftest.py | 16 ++++ masque/test/test_abstract.py | 58 ++++++++++++ masque/test/test_advanced_routing.py | 82 ++++++++++++++++ masque/test/test_autotool.py | 82 ++++++++++++++++ masque/test/test_builder.py | 73 +++++++++++++++ masque/test/test_fdfd.py | 25 +++++ masque/test/test_gdsii.py | 70 ++++++++++++++ masque/test/test_label.py | 48 ++++++++++ masque/test/test_library.py | 109 ++++++++++++++++++++++ masque/test/test_oasis.py | 28 ++++++ masque/test/test_pack2d.py | 53 +++++++++++ masque/test/test_path.py | 77 +++++++++++++++ masque/test/test_pather.py | 80 ++++++++++++++++ masque/test/test_pattern.py | 111 ++++++++++++++++++++++ masque/test/test_polygon.py | 110 ++++++++++++++++++++++ masque/test/test_ports.py | 87 +++++++++++++++++ masque/test/test_ports2data.py | 55 +++++++++++ masque/test/test_ref.py | 66 +++++++++++++ masque/test/test_renderpather.py | 73 +++++++++++++++ masque/test/test_repetition.py | 48 ++++++++++ masque/test/test_shape_advanced.py | 134 +++++++++++++++++++++++++++ masque/test/test_shapes.py | 132 ++++++++++++++++++++++++++ masque/test/test_utils.py | 83 +++++++++++++++++ 24 files changed, 1703 insertions(+) create mode 100644 masque/test/__init__.py create mode 100644 masque/test/conftest.py create mode 100644 masque/test/test_abstract.py create mode 100644 masque/test/test_advanced_routing.py create mode 100644 masque/test/test_autotool.py create mode 100644 masque/test/test_builder.py create mode 100644 masque/test/test_fdfd.py create mode 100644 masque/test/test_gdsii.py create mode 100644 masque/test/test_label.py create mode 100644 masque/test/test_library.py create mode 100644 masque/test/test_oasis.py create mode 100644 masque/test/test_pack2d.py create mode 100644 masque/test/test_path.py create mode 100644 masque/test/test_pather.py create mode 100644 masque/test/test_pattern.py create mode 100644 masque/test/test_polygon.py create mode 100644 masque/test/test_ports.py create mode 100644 masque/test/test_ports2data.py create mode 100644 masque/test/test_ref.py create mode 100644 masque/test/test_renderpather.py create mode 100644 masque/test/test_repetition.py create mode 100644 masque/test/test_shape_advanced.py create mode 100644 masque/test/test_shapes.py create mode 100644 masque/test/test_utils.py 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) + From d9adb4e1b9766ae07810f88a51c27fb439504e3e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 12:35:58 -0800 Subject: [PATCH 09/10] [Tools] fixup imports --- masque/builder/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 148af4d..0e08674 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir # TODO document all tools """ -from typing import Literal, Any, Self +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 From 1cce6c1f707b39816f833816fee5accda3569e57 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 12:36:13 -0800 Subject: [PATCH 10/10] [Tests] cleanup --- masque/test/conftest.py | 5 +- masque/test/test_abstract.py | 48 +++++++-------- masque/test/test_advanced_routing.py | 49 +++++++++------- masque/test/test_autotool.py | 51 ++++++++-------- masque/test/test_builder.py | 34 ++++++----- masque/test/test_fdfd.py | 9 ++- masque/test/test_gdsii.py | 39 ++++++------ masque/test/test_label.py | 54 +++++++++-------- masque/test/test_library.py | 65 +++++++++++--------- masque/test/test_oasis.py | 17 +++--- masque/test/test_pack2d.py | 32 +++++----- masque/test/test_path.py | 34 ++++++----- masque/test/test_pather.py | 39 ++++++------ masque/test/test_pattern.py | 65 ++++++++++---------- masque/test/test_polygon.py | 47 ++++++++++----- masque/test/test_ports.py | 88 ++++++++++++++++------------ masque/test/test_ports2data.py | 32 +++++----- masque/test/test_ref.py | 40 +++++++------ masque/test/test_renderpather.py | 32 +++++----- masque/test/test_repetition.py | 29 +++++---- masque/test/test_shape_advanced.py | 72 +++++++++++++---------- masque/test/test_shapes.py | 76 +++++++++++++----------- masque/test/test_utils.py | 58 +++++++++--------- 23 files changed, 544 insertions(+), 471 deletions(-) diff --git a/masque/test/conftest.py b/masque/test/conftest.py index 62db4c5..3116ee2 100644 --- a/masque/test/conftest.py +++ b/masque/test/conftest.py @@ -3,14 +3,11 @@ 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 index c9e2926..907cedc 100644 --- a/masque/test/test_abstract.py +++ b/masque/test/test_abstract.py @@ -1,58 +1,60 @@ -import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose +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(): + +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 + assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied -def test_abstract_transform(): + +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) + 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) - + 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) + assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10) -def test_abstract_ref_transform(): + +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) - + 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) - + 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) diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py index 439073d..5afcc21 100644 --- a/masque/test/test_advanced_routing.py +++ b/masque/test/test_advanced_routing.py @@ -1,6 +1,5 @@ import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_equal from numpy import pi from ..builder import Pather @@ -8,30 +7,33 @@ from ..builder.tools import PathTool from ..library import Library from ..ports import Port + @pytest.fixture -def advanced_pather(): +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): + +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) + 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): + +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") @@ -39,44 +41,47 @@ def test_path_into_bend(advanced_pather): # 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.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): + +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.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): + +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): + +def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None: p, tool, lib = advanced_pather p.ports["src"] = Port((0, 0), 0, ptype="wire") p.ports["dst"] = Port((-20, 0), pi, ptype="wire") p.ports["other"] = Port((10, 10), 0) - + p.path_into("src", "dst", thru="other") - + assert "src" in p.ports assert_equal(p.ports["src"].offset, [10, 10]) assert "other" not in p.ports diff --git a/masque/test/test_autotool.py b/masque/test/test_autotool.py index cf730ae..5686193 100644 --- a/masque/test/test_autotool.py +++ b/masque/test/test_autotool.py @@ -1,6 +1,5 @@ import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_allclose from numpy import pi from ..builder import Pather @@ -8,75 +7,75 @@ 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"): + +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(): +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") + bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire") lib["bend"] = bend_pat - bend_abs = lib.abstract("bend") - + 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")], + 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" + 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): + +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: + # 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). + # 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 index 0884c3f..1b67c65 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -1,5 +1,3 @@ -import pytest -import numpy from numpy.testing import assert_equal, assert_allclose from numpy import pi @@ -8,36 +6,39 @@ from ..library import Library from ..pattern import Pattern from ..ports import Port -def test_builder_init(): + +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(): + +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(): + +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 @@ -46,28 +47,29 @@ def test_builder_plug(): # 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(): + +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(): + +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() - diff --git a/masque/test/test_fdfd.py b/masque/test/test_fdfd.py index 32466c1..2b4f3d3 100644 --- a/masque/test/test_fdfd.py +++ b/masque/test/test_fdfd.py @@ -3,23 +3,22 @@ import dataclasses -import pytest # type: ignore +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 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 + 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 + 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 index 7a80329..86e4bbc 100644 --- a/masque/test/test_gdsii.py +++ b/masque/test/test_gdsii.py @@ -1,70 +1,69 @@ -import pytest -import os +from pathlib import Path 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 +from ..shapes import Path as MPath -def test_gdsii_roundtrip(tmp_path): + +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) + 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) + assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5) -def test_gdsii_annotations(tmp_path): + +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"] - diff --git a/masque/test/test_label.py b/masque/test/test_label.py index 8753be3..ed40614 100644 --- a/masque/test/test_label.py +++ b/masque/test/test_label.py @@ -1,48 +1,50 @@ -import pytest -import numpy +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(): - 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)) +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) - 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) + lbl.rotate_around((0, 0), pi / 2) + assert_allclose(lbl.offset, [0, 10], atol=1e-10) -def test_label_repetition(): + # 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) - 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, + 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(): + +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 - -import copy diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 2c411d4..0012219 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -1,109 +1,116 @@ import pytest -from ..library import Library, LazyLibrary, LibraryView +from ..library import Library, LazyLibrary from ..pattern import Pattern -from ..ref import Ref from ..error import LibraryError -def test_library_basic(): + +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(): + 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(): + +def test_library_dangling() -> None: lib = Library() lib["parent"] = Pattern() lib["parent"].ref("missing") - + assert lib.dangling_refs() == {"missing"} -def test_library_flatten(): + +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(): + +def test_lazy_library() -> None: lib = LazyLibrary() called = 0 - def make_pat(): + + 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(): + +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(): + +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(): + +def test_library_get_name() -> None: lib = Library() lib["cell"] = Pattern() - + name1 = lib.get_name("cell") assert name1 != "cell" assert name1.startswith("cell") - + name2 = lib.get_name("other") assert name2 == "other" - diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py index 5f60fd8..faffa58 100644 --- a/masque/test/test_oasis.py +++ b/masque/test/test_oasis.py @@ -1,28 +1,27 @@ -import pytest -import numpy -from numpy.testing import assert_equal 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): + +def test_oasis_roundtrip(tmp_path: Path) -> None: # Skip if fatamorgana is not installed pytest.importorskip("fatamorgana") - + lib = Library() pat1 = Pattern() pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) lib["cell1"] = pat1 - + oas_file = tmp_path / "test.oas" # OASIS needs units_per_micron oasis.writefile(lib, oas_file, units_per_micron=1000) - + read_lib, info = oasis.readfile(oas_file) assert "cell1" in read_lib - + # Check bounds assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]]) - diff --git a/masque/test/test_pack2d.py b/masque/test/test_pack2d.py index 31d19e0..5390a4c 100644 --- a/masque/test/test_pack2d.py +++ b/masque/test/test_pack2d.py @@ -1,53 +1,51 @@ -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(): + +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 set([tuple(l) for l in locs]) == {(0.0, 0.0), (10.0, 0.0)} + assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)} -def test_maxrects_bssf_reject(): + +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 1 in rejects # Second rect rejected assert 0 not in rejects -def test_pack_patterns(): + +def test_pack_patterns() -> None: lib = Library() p1 = Pattern() p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) lib["p1"] = p1 - + p2 = Pattern() p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) lib["p2"] = p2 - + # Containers: one 20x20 containers = [[0, 0, 20, 20]] # 2um spacing pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2)) - + assert not rejects assert len(pat.refs) == 2 assert "p1" in pat.refs assert "p2" in pat.refs - + # Check that they don't overlap (simple check via bounds) # p1 size 10x10, effectively 12x12 # p2 size 5x5, effectively 7x7 # Both should fit in 20x20 - diff --git a/masque/test/test_path.py b/masque/test/test_path.py index 5d63565..766798f 100644 --- a/masque/test/test_path.py +++ b/masque/test/test_path.py @@ -1,17 +1,16 @@ -import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi +from numpy.testing import assert_equal from ..shapes import Path -def test_path_init(): + +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(): + +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 @@ -19,7 +18,8 @@ def test_path_to_polygons_flush(): bounds = polys[0].get_bounds_single() assert_equal(bounds, [[0, -1], [10, 1]]) -def test_path_to_polygons_square(): + +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 @@ -28,18 +28,20 @@ def test_path_to_polygons_square(): bounds = polys[0].get_bounds_single() assert_equal(bounds, [[-1, -1], [11, 1]]) -def test_path_to_polygons_circle(): + +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(): + +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 @@ -48,7 +50,8 @@ def test_path_custom_cap(): bounds = polys[0].get_bounds_single() assert_equal(bounds, [[-5, -1], [20, 1]]) -def test_path_bend(): + +def test_path_bend() -> None: # L-shaped path p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2) polys = p.to_polygons() @@ -64,14 +67,15 @@ def test_path_bend(): # So bounds should be x: [0, 11], y: [-1, 10] assert_equal(bounds, [[0, -1], [11, 10]]) -def test_path_mirror(): + +def test_path_mirror() -> None: p = Path(vertices=[[10, 5], [20, 10]], width=2) - p.mirror(0) # Mirror across x axis (y -> -y) + p.mirror(0) # Mirror across x axis (y -> -y) assert_equal(p.vertices, [[10, -5], [20, -10]]) -def test_path_scale(): + +def test_path_scale() -> None: p = Path(vertices=[[0, 0], [10, 0]], width=2) p.scale_by(2) assert_equal(p.vertices, [[0, 0], [20, 0]]) assert p.width == 4 - diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py index 814c583..e1d28d8 100644 --- a/masque/test/test_pather.py +++ b/masque/test/test_pather.py @@ -1,16 +1,15 @@ 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(): +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") @@ -18,53 +17,58 @@ def pather_setup(): # 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") + p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") return p, tool, lib -def test_pather_straight(pather_setup): + +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) + assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) -def test_pather_bend(pather_setup): + +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)? + # 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): + +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): + +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") - + 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): + +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) @@ -77,4 +81,3 @@ def test_pather_at_chaining(pather_setup): # 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 index f18913c..e66e9d5 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -1,15 +1,14 @@ -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 ..shapes import Polygon from ..ref import Ref from ..ports import Port from ..label import Label -def test_pattern_init(): + +def test_pattern_init() -> None: pat = Pattern() assert pat.is_empty() assert not pat.has_shapes() @@ -17,19 +16,15 @@ def test_pattern_init(): assert not pat.has_labels() assert not pat.has_ports() -def test_pattern_with_elements(): + +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} - ) - + + 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() @@ -40,72 +35,78 @@ def test_pattern_with_elements(): assert pat.refs["sub"] == [ref] assert pat.ports["P1"] == port -def test_pattern_append(): + +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(): + +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(): + +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(): + +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) - + 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(): + +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(): + +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(): + +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" - diff --git a/masque/test/test_polygon.py b/masque/test/test_polygon.py index 1ae1ff1..5d98ad9 100644 --- a/masque/test/test_polygon.py +++ b/masque/test/test_polygon.py @@ -1,6 +1,6 @@ import pytest import numpy -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_equal from ..shapes import Polygon @@ -9,29 +9,36 @@ from ..error import PatternError @pytest.fixture -def polygon(): +def polygon() -> Polygon: return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) -def test_vertices(polygon) -> None: + +def test_vertices(polygon: Polygon) -> None: assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) -def test_xs(polygon) -> None: + +def test_xs(polygon: Polygon) -> None: assert_equal(polygon.xs, [0, 1, 1, 0]) -def test_ys(polygon) -> None: + +def test_ys(polygon: Polygon) -> None: assert_equal(polygon.ys, [0, 0, 1, 1]) -def test_offset(polygon) -> None: + +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]]) @@ -64,47 +71,55 @@ def test_rect() -> None: def test_octagon() -> None: - octagon = Polygon.octagon(side_length=1) # regular=True + 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: + +def test_to_polygons(polygon: Polygon) -> None: assert polygon.to_polygons() == [polygon] -def test_get_bounds_single(polygon) -> None: + +def test_get_bounds_single(polygon: Polygon) -> None: assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]]) -def test_rotate(polygon) -> None: + +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) -> None: + +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]]) + assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]]) -def test_scale_by(polygon) -> None: + +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) -> None: + +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(): + +def test_vertices_dtype() -> None: polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32)) polygon.scale_by(0.5) assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]]) - diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py index b6031b2..4354bff 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -1,87 +1,101 @@ 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") + +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.rotation == pi / 2 assert p.ptype == "test" -def test_port_transform(): + +def test_port_transform() -> None: p = Port(offset=(10, 0), rotation=0) - p.rotate_around((0, 0), pi/2) + 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.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) + assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10) -def test_port_flip_across(): + +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 + 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(): + +def test_port_measure_travel() -> None: p1 = Port((0, 0), 0) - p2 = Port((10, 5), pi) # Facing each other - + 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(): + +def test_port_list_rename() -> None: class MyPorts(PortList): - def __init__(self): + def __init__(self) -> None: self._ports = {"A": Port((0, 0), 0)} + @property - def ports(self): return self._ports + def ports(self) -> dict[str, Port]: + return self._ports + @ports.setter - def ports(self, val): self._ports = val - + 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(): + +def test_port_list_plugged() -> None: class MyPorts(PortList): - def __init__(self): - self._ports = { - "A": Port((10, 10), 0), - "B": Port((10, 10), pi) - } + def __init__(self) -> None: + self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} + @property - def ports(self): return self._ports + def ports(self) -> dict[str, Port]: + return self._ports + @ports.setter - def ports(self, val): self._ports = val - + 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 + assert not pl.ports # Both should be removed -def test_port_list_plugged_mismatch(): + +def test_port_list_plugged_mismatch() -> None: class MyPorts(PortList): - def __init__(self): + def __init__(self) -> None: self._ports = { "A": Port((10, 10), 0), - "B": Port((11, 10), pi) # Offset mismatch + "B": Port((11, 10), pi), # Offset mismatch } + @property - def ports(self): return self._ports + def ports(self) -> dict[str, Port]: + return self._ports + @ports.setter - def ports(self, val): self._ports = val - + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + pl = MyPorts() with pytest.raises(PortError): pl.plugged({"A": "B"}) - diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py index bddc0d8..32bc367 100644 --- a/masque/test/test_ports2data.py +++ b/masque/test/test_ports2data.py @@ -1,4 +1,3 @@ -import pytest import numpy from numpy.testing import assert_allclose @@ -7,43 +6,45 @@ from ..pattern import Pattern from ..ports import Port from ..library import Library -def test_ports2data_roundtrip(): + +def test_ports2data_roundtrip() -> None: pat = Pattern() - pat.ports["P1"] = Port((10, 20), numpy.pi/2, ptype="test") - + 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_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10) assert pat2.ports["P1"].ptype == "test" -def test_data_to_ports_hierarchical(): + +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) - + 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) @@ -51,5 +52,4 @@ def test_data_to_ports_hierarchical(): # 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) - + 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 index 4820874..8872699 100644 --- a/masque/test/test_ref.py +++ b/masque/test/test_ref.py @@ -1,5 +1,3 @@ -import pytest -import numpy from numpy.testing import assert_equal, assert_allclose from numpy import pi @@ -7,47 +5,51 @@ 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) + +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.rotation == pi / 4 assert ref.mirrored is True assert ref.scale == 2.0 -def test_ref_as_pattern(): + +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) + + 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(): + +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(): + +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]] @@ -55,12 +57,12 @@ def test_ref_get_bounds(): # translated [[10,10], [20,20]] assert_equal(bounds, [[10, 10], [20, 20]]) -def test_ref_copy(): + +def test_ref_copy() -> None: ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]}) ref2 = ref1.copy() assert ref1 == ref2 assert ref1 is not ref2 - + ref2.offset[0] = 100 assert ref1.offset[0] == 1 - diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index e0479f9..b843066 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -1,6 +1,5 @@ import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose +from numpy.testing import assert_allclose from numpy import pi from ..builder import RenderPather @@ -8,28 +7,30 @@ from ..builder.tools import PathTool from ..library import Library from ..ports import Port + @pytest.fixture -def rpather_setup(): +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") + rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire") return rp, tool, lib -def test_renderpather_basic(rpather_setup): + +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) @@ -40,14 +41,15 @@ def test_renderpather_basic(rpather_setup): 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): + +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: + # Path vertices: # 1. Start (0,0) # 2. Straight end: (0, -10) # 3. Bend end: (-1, -20) @@ -58,16 +60,16 @@ def test_renderpather_bend(rpather_setup): 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): + +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 - diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py index 6b151e8..5ef2fa9 100644 --- a/masque/test/test_repetition.py +++ b/masque/test/test_repetition.py @@ -1,32 +1,35 @@ -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(): + +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(): + +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(): + +def test_grid_rotate() -> None: grid = Grid(a_vector=(10, 0), a_count=2) - grid.rotate(pi/2) + grid.rotate(pi / 2) assert_allclose(grid.a_vector, [0, 10], atol=1e-10) -def test_grid_get_bounds(): + +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(): + +def test_arbitrary_displacements() -> None: pts = [[0, 0], [10, 20], [-5, 30]] arb = Arbitrary(pts) # They should be sorted by displacements.setter @@ -36,13 +39,13 @@ def test_arbitrary_displacements(): assert any((disps == [10, 20]).all(axis=1)) assert any((disps == [-5, 30]).all(axis=1)) -def test_arbitrary_transform(): + +def test_arbitrary_transform() -> None: arb = Arbitrary([[10, 0]]) - arb.rotate(pi/2) + 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: + + 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 index ef73ea0..f6ba69d 100644 --- a/masque/test/test_shape_advanced.py +++ b/masque/test/test_shape_advanced.py @@ -1,16 +1,17 @@ +from pathlib import Path 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 ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection from ..error import PatternError + # 1. Text shape tests -def test_text_to_polygons(): +def test_text_to_polygons() -> None: font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" - if not os.path.exists(font_path): + if not Path(font_path).exists(): pytest.skip("Font file not found") t = Text("Hi", height=10, font_path=font_path) @@ -24,8 +25,9 @@ def test_text_to_polygons(): char_x_means = [p.vertices[:, 0].mean() for p in polys] assert len(set(char_x_means)) >= 2 + # 2. Manhattanization tests -def test_manhattanize(): +def test_manhattanize() -> None: # Diamond shape poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]]) grid = numpy.arange(0, 11, 1) @@ -38,39 +40,43 @@ def test_manhattanize(): # 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(): +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 + 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(): +def test_arc_edge_cases() -> None: # Wrapped arc (> 360 deg) - a = Arc(radii=(10, 10), angles=(0, 3*pi), width=2) - polys = a.to_polygons(num_vertices=64) + 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(): + +def test_path_edge_cases() -> None: # Zero-length segments - p = Path(vertices=[[0, 0], [0, 0], [10, 0]], width=2) + 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(): +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 @@ -80,8 +86,14 @@ def test_poly_collection_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 + [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) @@ -90,17 +102,23 @@ def test_poly_collection_holes(): 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(): + +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 + [0, 0], + [1, 0], + [0, 1], # Tri # Empty space - [10, 10], [11, 10], [11, 11], [10, 11] # Square + [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? + 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] @@ -113,22 +131,14 @@ def test_poly_collection_constituent_empty(): 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] - ] + +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)) - ] + 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 index 940c67a..b19d6bc 100644 --- a/masque/test/test_shapes.py +++ b/masque/test/test_shapes.py @@ -1,11 +1,11 @@ -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(): + +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] @@ -13,7 +13,8 @@ def test_poly_collection_init(): assert len(list(pc.polygon_vertices)) == 2 assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) -def test_poly_collection_to_polygons(): + +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) @@ -22,12 +23,14 @@ def test_poly_collection_to_polygons(): 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(): + +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(): + +def test_circle_to_polygons() -> None: c = Circle(radius=10) polys = c.to_polygons(num_vertices=32) assert len(polys) == 1 @@ -36,28 +39,32 @@ def test_circle_to_polygons(): 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) + +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 + assert e.rotation == pi / 4 -def test_ellipse_to_polygons(): + +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(): - a = Arc(radii=(10, 10), angles=(0, pi/2), width=2, offset=(0, 0)) + +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_equal(a.angles, [0, pi / 2]) assert a.width == 2 -def test_arc_to_polygons(): + +def test_arc_to_polygons() -> None: # Quarter circle arc - a = Arc(radii=(10, 10), angles=(0, pi/2), width=2) + 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 @@ -70,32 +77,35 @@ def test_arc_to_polygons(): # 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 + +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)) + 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) + 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 + +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) + 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 + 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(): + +def test_shape_scale() -> None: e = Ellipse(radii=(10, 5)) e.scale_by(2) assert_equal(e.radii, [20, 10]) @@ -105,21 +115,22 @@ def test_shape_scale(): assert_equal(a.radii, [5, 2.5]) assert a.width == 1 -def test_shape_arclen(): + +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)) + 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) + 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 @@ -127,6 +138,5 @@ def test_shape_arclen(): 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)) + 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 index 1badf64..882b5bd 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -1,28 +1,23 @@ -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 -) +from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms -def test_remove_duplicate_vertices(): + +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(): + +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] @@ -30,54 +25,59 @@ def test_remove_colinear_vertices(): # [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(): + +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]? + # 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 - + 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 + assert len(v_clean) == 2 -def test_poly_contains_points(): + +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(): - m = rotation_matrix_2d(pi/2) + +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(): + +def test_rotation_matrix_non_manhattan() -> None: # 45 degrees - m = rotation_matrix_2d(pi/4) - s = numpy.sqrt(2)/2 + 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(): + +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(): + +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] - + 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) - + assert_allclose(combined[0], [0, 10, pi / 2, 1], atol=1e-10)