diff --git a/masque/test/helpers.py b/masque/test/helpers.py new file mode 100644 index 0000000..32b2f66 --- /dev/null +++ b/masque/test/helpers.py @@ -0,0 +1,27 @@ +from typing import Any + +import numpy +from numpy.typing import ArrayLike, NDArray +from numpy.testing import assert_allclose + + +def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]: + """ + Return lengths for each edge of an implicitly closed vertex loop. + """ + vv = numpy.asarray(vertices, dtype=float) + return numpy.sqrt(numpy.sum(numpy.diff(vv, axis=0, append=vv[:1]) ** 2, axis=1)) + + +def assert_closed_edges_within(vertices: ArrayLike, max_len: float, *, atol: float = 1e-6) -> None: + """ + Assert that every edge in an implicitly closed vertex loop is no longer than `max_len`. + """ + assert numpy.all(closed_edge_lengths(vertices) <= max_len + atol) + + +def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: float = 1e-10) -> None: + """ + Assert that an object's single-shape bounds match `expected`. + """ + assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol) diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py deleted file mode 100644 index 0008172..0000000 --- a/masque/test/test_advanced_routing.py +++ /dev/null @@ -1,77 +0,0 @@ -import pytest -from numpy.testing import assert_equal -from numpy import pi - -from ..builder import Pather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - - -@pytest.fixture -def advanced_pather() -> tuple[Pather, PathTool, Library]: - lib = Library() - # Simple PathTool: 2um width on layer (1,0) - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False) - return p, tool, lib - - -def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = advanced_pather - # Facing ports - p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device) - # Forward (+pi relative to port) is West (-x). - # Put destination at (-20, 0) pointing East (pi). - p.ports["dst"] = Port((-20, 0), pi, ptype="wire") - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - # Pather._traceL adds a Reference to the generated pattern - assert len(p.pattern.refs) == 1 - - -def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = advanced_pather - # Source at (0,0) rot 0 (facing East). Forward is West (-x). - p.ports["src"] = Port((0, 0), 0, ptype="wire") - # Destination at (-20, -20) rot pi (facing West). Forward is East (+x). - # Wait, src forward is -x. dst is at -20, -20. - # To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South). - # Forward for South is North (+y). - p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire") - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - # `trace_into()` now batches its internal legs before auto-rendering so the operation - # can roll back cleanly on later failures. - assert len(p.pattern.refs) == 1 - - -def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = advanced_pather - # Facing but offset ports - p.ports["src"] = Port((0, 0), 0, ptype="wire") # Forward is West (-x) - p.ports["dst"] = Port((-20, -10), pi, ptype="wire") # Facing East (rot pi) - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - - -def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = advanced_pather - p.ports["src"] = Port((0, 0), 0, ptype="wire") - p.ports["dst"] = Port((-20, 0), pi, ptype="wire") - p.ports["other"] = Port((10, 10), 0) - - p.trace_into("src", "dst", thru="other") - - assert "src" in p.ports - assert_equal(p.ports["src"].offset, [10, 10]) - assert "other" not in p.ports diff --git a/masque/test/test_arc.py b/masque/test/test_arc.py new file mode 100644 index 0000000..dc23144 --- /dev/null +++ b/masque/test/test_arc.py @@ -0,0 +1,87 @@ +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_equal, assert_allclose + +from ..error import PatternError +from ..shapes import Arc +from .helpers import assert_closed_edges_within + + +def test_arc_init() -> None: + a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0)) + assert_equal(a.radii, [10, 10]) + assert_equal(a.angles, [0, pi / 2]) + assert a.width == 2 + +def test_arc_to_polygons() -> None: + a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) + polys = a.to_polygons(num_vertices=32) + assert len(polys) == 1 + + # Quarter-circle ring section with outer radius 11 and inner radius 9. + bounds = polys[0].get_bounds_single() + assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10) + +def test_arc_focus_to_polygons() -> None: + a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos) + polys = a.to_polygons(num_vertices=32) + assert len(polys) == 1 + + focus = numpy.array([8.0, 0.0]) + cuts = a.get_cap_edges() + for angle, cut in zip(a.angles, cuts, strict=True): + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + for point in cut: + delta = point - focus + assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10) + assert numpy.dot(direction, delta) > 0 + +def test_arc_circle_focus_matches_center() -> None: + center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) + focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos) + + assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices, + center.to_polygons(num_vertices=32)[0].vertices, + atol=1e-10) + +def test_arc_edge_cases() -> None: + a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2) + a.to_polygons(num_vertices=64) + bounds = a.get_bounds_single() + assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10) + +def test_rotated_arc_bounds_match_polygonized_geometry() -> None: + arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200)) + bounds = arc.get_bounds_single() + poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() + assert_allclose(bounds, poly_bounds, atol=1e-3) + +def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None: + arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4, + offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos) + bounds = arc.get_bounds_single() + poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() + assert_allclose(bounds, poly_bounds, atol=1e-3) + +def test_arc_polygonization_rejects_nan_implied_arclen() -> None: + arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2) + with pytest.raises(PatternError, match='valid max_arclen'): + arc.to_polygons(num_vertices=24) + +def test_focus_arc_rejects_focus_outside_inner_boundary() -> None: + arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos) + with pytest.raises(PatternError, match='inside both arc boundary ellipses'): + arc.to_polygons(num_vertices=24) + +def test_focus_arc_max_arclen_limits_segments() -> None: + arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg) + assert_closed_edges_within(arc.to_polygons(max_arclen=2)[0].vertices, 2) + +def test_arc_rejects_zero_radii_up_front() -> None: + with pytest.raises(PatternError, match='Radii must be positive'): + Arc(radii=(0, 5), angles=(0, 1), width=1) + with pytest.raises(PatternError, match='Radii must be positive'): + Arc(radii=(5, 0), angles=(0, 1), width=1) + with pytest.raises(PatternError, match='Radii must be positive'): + Arc(radii=(0, 0), angles=(0, 1), width=1) diff --git a/masque/test/test_autotool.py b/masque/test/test_autotool.py deleted file mode 100644 index e03994e..0000000 --- a/masque/test/test_autotool.py +++ /dev/null @@ -1,81 +0,0 @@ -import pytest -from numpy.testing import assert_allclose -from numpy import pi - -from ..builder import Pather -from ..builder.tools import AutoTool -from ..library import Library -from ..pattern import Pattern -from ..ports import Port - - -def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) - pat.ports["in"] = Port((0, 0), 0, ptype=ptype) - pat.ports["out"] = Port((length, 0), pi, ptype=ptype) - return pat - - -@pytest.fixture -def autotool_setup() -> tuple[Pather, AutoTool, Library]: - lib = Library() - - # Define a simple bend - bend_pat = Pattern() - # 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise) - bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire") - bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire") - lib["bend"] = bend_pat - lib.abstract("bend") - - # Define a transition (e.g., via) - via_pat = Pattern() - via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1") - via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2") - lib["via"] = via_pat - via_abs = lib.abstract("via") - - tool_m1 = AutoTool( - straights=[ - AutoTool.Straight(ptype="wire_m1", fn=lambda length: make_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out") - ], - bends=[], - sbends=[], - transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")}, - default_out_ptype="wire_m1", - ) - - p = Pather(lib, tools=tool_m1) - # Start with an m2 port - p.ports["start"] = Port((0, 0), pi, ptype="wire_m2") - - return p, tool_m1, lib - - -def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: - p, _tool, _lib = autotool_setup - - # Route m1 from an m2 port. Should trigger via. - # length 10. Via length is 1. So straight m1 should be 9. - p.straight("start", 10) - - # Start at (0,0) rot pi (facing West). - # Forward (+pi relative to port) is East (+x). - # Via: m2(1,0)pi -> m1(0,0)0. - # Plug via m2 into start(0,0)pi: transformation rot=mod(pi-pi-pi, 2pi)=pi. - # rotate via by pi: m2 at (0,0), m1 at (-1, 0) rot pi. - # Then straight m1 of length 9 from (-1, 0) rot pi -> ends at (8, 0) rot pi. - # Wait, (length, 0) relative to (-1, 0) rot pi: - # transform (9, 0) by pi: (-9, 0). - # (-1, 0) + (-9, 0) = (-10, 0)? No. - # Let's re-calculate. - # start (0,0) rot pi. Direction East. - # via m2 is at (0,0), m1 is at (1,0). - # When via is plugged into start: m2 goes to (0,0). - # since start is pi and m2 is pi, rotation is 0. - # so via m1 is at (1,0) rot 0. - # then straight m1 length 9 from (1,0) rot 0: ends at (10, 0) rot 0. - - assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) - assert p.ports["start"].ptype == "wire_m1" diff --git a/masque/test/test_autotool_refactor.py b/masque/test/test_autotool_planning.py similarity index 73% rename from masque/test/test_autotool_refactor.py rename to masque/test/test_autotool_planning.py index 2f560cb..1c98cde 100644 --- a/masque/test/test_autotool_refactor.py +++ b/masque/test/test_autotool_planning.py @@ -1,12 +1,61 @@ import pytest -from numpy.testing import assert_allclose from numpy import pi +from numpy.testing import assert_allclose from masque.builder.tools import AutoTool +from masque.builder.pather import Pather +from masque.library import Library from masque.pattern import Pattern from masque.ports import Port -from masque.library import Library -from masque.builder.pather import Pather + + +def _make_transition_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: + pat = Pattern() + pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) + pat.ports["in"] = Port((0, 0), 0, ptype=ptype) + pat.ports["out"] = Port((length, 0), pi, ptype=ptype) + return pat + + +@pytest.fixture +def autotool_setup() -> tuple[Pather, AutoTool, Library]: + lib = Library() + + bend_pat = Pattern() + bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire") + bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire") + lib["bend"] = bend_pat + lib.abstract("bend") + + via_pat = Pattern() + via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1") + via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2") + lib["via"] = via_pat + via_abs = lib.abstract("via") + + tool_m1 = AutoTool( + straights=[ + AutoTool.Straight(ptype="wire_m1", fn=lambda length: _make_transition_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out") + ], + bends=[], + sbends=[], + transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")}, + default_out_ptype="wire_m1", + ) + + p = Pather(lib, tools=tool_m1) + p.ports["start"] = Port((0, 0), pi, ptype="wire_m2") + + return p, tool_m1, lib + +def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: + p, _tool, _lib = autotool_setup + + p.straight("start", 10) + + # Via length is 1, so the remaining wire_m1 straight length is 9. + assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) + assert p.ports["start"].ptype == "wire_m1" def make_straight(length, width=2, ptype="wire"): pat = Pattern() @@ -17,15 +66,13 @@ def make_straight(length, width=2, ptype="wire"): def make_bend(R, width=2, ptype="wire", clockwise=True): pat = Pattern() - # 90 degree arc approximation (just two rects for start and end) + # Rectangular approximation of a 90 degree bend. if clockwise: - # (0,0) rot 0 to (R, -R) rot pi/2 pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0) pat.ports["A"] = Port((0, 0), 0, ptype=ptype) pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype) else: - # (0,0) rot 0 to (R, R) rot -pi/2 pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R) pat.ports["A"] = Port((0, 0), 0, ptype=ptype) @@ -36,18 +83,14 @@ def make_bend(R, width=2, ptype="wire", clockwise=True): def multi_bend_tool(): lib = Library() - # Bend 1: R=2 lib["b1"] = make_bend(2, ptype="wire") b1_abs = lib.abstract("b1") - # Bend 2: R=5 lib["b2"] = make_bend(5, ptype="wire") b2_abs = lib.abstract("b2") tool = AutoTool( straights=[ - # Straight 1: only for length < 10 AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)), - # Straight 2: for length >= 10 AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8)) ], bends=[ @@ -60,7 +103,6 @@ def multi_bend_tool(): ) return tool, lib - @pytest.fixture def asymmetric_transition_tool() -> AutoTool: lib = Library() @@ -102,7 +144,6 @@ def asymmetric_transition_tool() -> AutoTool: default_out_ptype="core", ).add_complementary_transitions() - def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[str, str] = ("A", "B")) -> None: pat = tree.top_pattern() out_port = pat[port_names[1]] @@ -113,20 +154,15 @@ def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[ assert_allclose(rot, plan_port.rotation) assert out_port.ptype == plan_port.ptype - def test_autotool_planL_selection(multi_bend_tool) -> None: tool, _ = multi_bend_tool - # Small length: should pick straight 1 and bend 1 (R=2) - # L = straight + R. If L=5, straight=3. p, data = tool.planL(True, 5) assert data.straight.length_range == (0, 10) assert data.straight_length == 3 assert data.bend.abstract.name == "b1" assert_allclose(p.offset, [5, 2]) - # Large length: should pick straight 2 and bend 1 (R=2) - # If L=15, straight=13. p, data = tool.planL(True, 15) assert data.straight.length_range == (10, 1e8) assert data.straight_length == 13 @@ -178,11 +214,6 @@ def test_autotool_traceL_matches_plan_with_post_bend_transition(ccw: bool) -> No def test_autotool_planU_consistency(multi_bend_tool) -> None: tool, lib = multi_bend_tool - # length=10, jog=20. - # U-turn: Straight1 -> Bend1 -> Straight_mid -> Straight3(0) -> Bend2 - # X = L1_total - R2 = length - # Y = R1 + L2_mid + R2 = jog - p, data = tool.planU(20, length=10) assert data.ldata0.straight_length == 7 assert data.ldata0.bend.abstract.name == "b2" @@ -190,7 +221,6 @@ def test_autotool_planU_consistency(multi_bend_tool) -> None: assert data.ldata1.straight_length == 0 assert data.ldata1.bend.abstract.name == "b1" - def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None: tool = asymmetric_transition_tool @@ -202,14 +232,9 @@ def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_tran tree = tool.traceU(12, length=0, in_ptype="core") assert_trace_matches_plan(plan_port, tree) - def test_autotool_planS_double_L(multi_bend_tool) -> None: tool, lib = multi_bend_tool - # length=20, jog=10. S-bend (ccw1, cw2) - # X = L1_total + R2 = length - # Y = R1 + L2_mid + R2 = jog - p, data = tool.planS(20, 10) assert_allclose(p.offset, [20, 10]) assert_allclose(p.rotation, pi) @@ -218,7 +243,6 @@ def test_autotool_planS_double_L(multi_bend_tool) -> None: assert data.ldata1.straight_length == 0 assert data.l2_length == 6 - def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None: tool = asymmetric_transition_tool @@ -231,7 +255,6 @@ def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymme tree = tool.traceS(4, 10, in_ptype="core") assert_trace_matches_plan(plan_port, tree) - def test_autotool_planS_pure_sbend_with_transition_dx() -> None: lib = Library() @@ -285,65 +308,3 @@ def test_autotool_planS_pure_sbend_with_transition_dx() -> None: assert data.straight_length == 0 assert data.jog_remaining == 4 assert data.in_transition is not None - - -def test_renderpather_autotool_double_L(multi_bend_tool) -> None: - tool, lib = multi_bend_tool - rp = Pather(lib, tools=tool, auto_render=False) - rp.ports["A"] = Port((0,0), 0, ptype="wire") - - # This should trigger double-L fallback in planS - rp.jog("A", 10, length=20) - - # port_rot=0 -> forward is -x. jog=10 (left) is -y. - assert_allclose(rp.ports["A"].offset, [-20, -10]) - assert_allclose(rp.ports["A"].rotation, 0) # jog rot is pi relative to input, input rot is pi relative to port. - # Wait, planS returns out_port at (length, jog) rot pi relative to input (0,0) rot 0. - # Input rot relative to port is pi. - # Rotate (length, jog) rot pi by pi: (-length, -jog) rot 0. Correct. - - rp.render() - assert len(rp.pattern.refs) > 0 - -def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None: - tool, lib = multi_bend_tool - - class BasicTool(AutoTool): - def planU(self, *args, **kwargs): - raise NotImplementedError() - - tool_basic = BasicTool( - straights=tool.straights, - bends=tool.bends, - sbends=tool.sbends, - transitions=tool.transitions, - default_out_ptype=tool.default_out_ptype - ) - - p = Pather(lib, tools=tool_basic) - p.ports["A"] = Port((0,0), 0, ptype="wire") # facing West (Actually East points Inwards, West is Extension) - - # uturn jog=10, length=5. - # R=2. L1 = 5+2=7. L2 = 10-2=8. - p.uturn("A", 10, length=5) - - # port_rot=0 -> forward is -x. jog=10 (left) is -y. - # L1=7 along -x -> (-7, 0). Bend1 (ccw) -> rot -pi/2 (South). - # L2=8 along -y -> (-7, -8). Bend2 (ccw) -> rot 0 (East). - # wait. CCW turn from facing South (-y): turn towards East (+x). - # Wait. - # Input facing -x. CCW turn -> face -y. - # Input facing -y. CCW turn -> face +x. - # So final rotation is 0. - # Bend1 (ccw) relative to -x: global offset is (-7, -2)? - # Let's re-run my manual calculation. - # Port rot 0. Wire input rot pi. Wire output relative to input: - # L1=7, R1=2, CCW=True. Output (7, 2) rot pi/2. - # Rotate wire by pi: output (-7, -2) rot 3pi/2. - # Second turn relative to (-7, -2) rot 3pi/2: - # local output (8, 2) rot pi/2. - # global: (-7, -2) + 8*rot(3pi/2)*x + 2*rot(3pi/2)*y - # = (-7, -2) + 8*(0, -1) + 2*(1, 0) = (-7, -2) + (0, -8) + (2, 0) = (-5, -10). - # YES! ACTUAL result was (-5, -10). - assert_allclose(p.ports["A"].offset, [-5, -10]) - assert_allclose(p.ports["A"].rotation, pi) diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py index 0249c64..e2fd7ea 100644 --- a/masque/test/test_boolean.py +++ b/masque/test/test_boolean.py @@ -48,12 +48,7 @@ def test_layer_as_polygons_flatten() -> None: polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib) assert len(polys) == 1 - # Original child at (0,0) with rot pi/2 is still at (0,0) in its own space? - # No, ref.as_pattern(child) will apply the transform. - # Child (0,0), (1,0), (1,1) rotated pi/2 around (0,0) -> (0,0), (0,1), (-1,1) - # Then offset by (10,10) -> (10,10), (10,11), (9,11) - - # Let's verify the vertices + # Child vertices are rotated by the ref and then translated by the ref offset. expected = numpy.array([[10, 10], [10, 11], [9, 11]]) assert_allclose(polys[0].vertices, expected, atol=1e-10) diff --git a/masque/test/test_circle.py b/masque/test/test_circle.py new file mode 100644 index 0000000..1b4158e --- /dev/null +++ b/masque/test/test_circle.py @@ -0,0 +1,17 @@ +from numpy.testing import assert_equal, assert_allclose + +from ..shapes import Circle, Polygon + + +def test_circle_init() -> None: + c = Circle(radius=10, offset=(5, 5)) + assert c.radius == 10 + assert_equal(c.offset, [5, 5]) + +def test_circle_to_polygons() -> None: + c = Circle(radius=10) + polys = c.to_polygons(num_vertices=32) + assert len(polys) == 1 + assert isinstance(polys[0], Polygon) + bounds = polys[0].get_bounds_single() + assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10) diff --git a/masque/test/test_curve_polygonization.py b/masque/test/test_curve_polygonization.py new file mode 100644 index 0000000..aaf4070 --- /dev/null +++ b/masque/test/test_curve_polygonization.py @@ -0,0 +1,26 @@ +from numpy import pi + +from ..shapes import Arc, Circle, Ellipse +from .helpers import assert_closed_edges_within + + +def test_shape_arclen() -> None: + e = Ellipse(radii=(10, 5)) + polys = e.to_polygons(max_arclen=5) + v = polys[0].vertices + assert_closed_edges_within(v, 5) + assert len(v) > 10 + + a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) + polys = a.to_polygons(max_arclen=2) + assert_closed_edges_within(polys[0].vertices, 2) + +def test_curve_polygonizers_clamp_large_max_arclen() -> None: + for shape in ( + Circle(radius=10), + Ellipse(radii=(10, 20)), + Arc(radii=(10, 20), angles=(0, 1), width=2), + ): + polys = shape.to_polygons(num_vertices=None, max_arclen=1e9) + assert len(polys) == 1 + assert len(polys[0].vertices) >= 3 diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py index f6dd177..442b62f 100644 --- a/masque/test/test_dxf.py +++ b/masque/test/test_dxf.py @@ -26,19 +26,16 @@ def test_dxf_roundtrip(tmp_path: Path): lib = Library() pat = Pattern() - # 1. Polygon (closed) poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) pat.polygon("1", vertices=poly_verts) - # 2. Path (open, 3 points) path_verts = numpy.array([[20, 0], [30, 0], [30, 10]]) pat.path("2", vertices=path_verts, width=2) - # 3. Path (open, 2 points) - Testing the fix for 2-point polylines + # Two-point paths remain paths rather than being polygonized. path2_verts = numpy.array([[40, 0], [50, 10]]) - pat.path("3", vertices=path2_verts, width=0) # width 0 to be sure it's not a polygonized path if we're not careful + pat.path("3", vertices=path2_verts, width=0) - # 4. Ref with Grid repetition (Manhattan) subpat = Pattern() subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]]) lib["sub"] = subpat @@ -52,38 +49,29 @@ def test_dxf_roundtrip(tmp_path: Path): read_lib, _ = dxf.readfile(dxf_file) - # In DXF read, the top level is usually called "Model" top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] - # Verify Polygon polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)] assert len(polys) >= 1 poly_read = polys[0] assert _matches_closed_vertices(poly_read.vertices, poly_verts) - # Verify 3-point Path paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)] assert len(paths) >= 1 path_read = paths[0] assert _matches_open_path(path_read.vertices, path_verts) assert path_read.width == 2 - # Verify 2-point Path paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)] assert len(paths2) >= 1 path2_read = paths2[0] assert _matches_open_path(path2_read.vertices, path2_verts) assert path2_read.width == 0 - # Verify Ref with Grid - # Finding the sub pattern name might be tricky because of how DXF stores blocks - # but "sub" should be in read_lib assert "sub" in read_lib - # Check refs in the top pattern found_grid = False for target, reflist in top_pat.refs.items(): - # DXF names might be case-insensitive or modified, but ezdxf usually preserves them if target.upper() == "SUB": for ref in reflist: if isinstance(ref.repetition, Grid): @@ -95,16 +83,12 @@ def test_dxf_roundtrip(tmp_path: Path): assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}" def test_dxf_manhattan_precision(tmp_path: Path): - # Test that float precision doesn't break Manhattan grid detection lib = Library() sub = Pattern() sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) lib["sub"] = sub top = Pattern() - # 90 degree rotation: in masque the grid is NOT rotated, so it stays [[10,0],[0,10]] - # In DXF, an array with rotation 90 has basis vectors [[0,10],[-10,0]]. - # So a masque grid [[10,0],[0,10]] with ref rotation 90 matches a DXF array. angle = numpy.pi / 2 # 90 degrees top.ref("sub", offset=(0, 0), rotation=angle, repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2)) @@ -114,7 +98,7 @@ def test_dxf_manhattan_precision(tmp_path: Path): dxf_file = tmp_path / "precision.dxf" dxf.writefile(lib, "top", dxf_file) - # If the isclose() fix works, this should still be a Grid when read back + # Near-integer rotated basis vectors round-trip as a Manhattan Grid. read_lib, _ = dxf.readfile(dxf_file) read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] diff --git a/masque/test/test_ellipse.py b/masque/test/test_ellipse.py new file mode 100644 index 0000000..1125fb1 --- /dev/null +++ b/masque/test/test_ellipse.py @@ -0,0 +1,29 @@ +from numpy import pi +from numpy.testing import assert_equal, assert_allclose + +from ..shapes import Ellipse + + +def test_ellipse_init() -> None: + e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4) + assert_equal(e.radii, [10, 5]) + assert_equal(e.offset, [1, 2]) + assert e.rotation == pi / 4 + +def test_ellipse_to_polygons() -> None: + e = Ellipse(radii=(10, 5)) + polys = e.to_polygons(num_vertices=64) + assert len(polys) == 1 + bounds = polys[0].get_bounds_single() + assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10) + +def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None: + ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200)) + bounds = ellipse.get_bounds_single() + poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single() + assert_allclose(bounds, poly_bounds, atol=1e-3) + +def test_ellipse_integer_radii_scale_cleanly() -> None: + ellipse = Ellipse(radii=(10, 20)) + ellipse.scale_by(0.5) + assert_allclose(ellipse.radii, [5, 10]) diff --git a/masque/test/test_file_roundtrip.py b/masque/test/test_file_format_roundtrip.py similarity index 70% rename from masque/test/test_file_roundtrip.py rename to masque/test/test_file_format_roundtrip.py index 2cfb0d1..6bd079d 100644 --- a/masque/test/test_file_roundtrip.py +++ b/masque/test/test_file_format_roundtrip.py @@ -11,45 +11,31 @@ from ..repetition import Grid, Arbitrary def create_test_library(for_gds: bool = False) -> Library: lib = Library() - # 1. Polygons pat_poly = Pattern() pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]]) lib["polygons"] = pat_poly - # 2. Paths with different endcaps pat_paths = Pattern() - # Flush pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush) - # Square pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square) - # Circle (Only for GDS) if for_gds: pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle) - # SquareCustom pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5)) lib["paths"] = pat_paths - # 3. Circles (only for OASIS or polygonized for GDS) pat_circles = Pattern() if for_gds: - # GDS writer calls to_polygons() for non-supported shapes, - # but we can also pre-polygonize pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0]) else: pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10))) lib["circles"] = pat_circles - # 4. Refs with repetitions pat_refs = Pattern() - # Simple Ref pat_refs.ref("polygons", offset=(0, 0)) - # Ref with Grid repetition pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2)) - # Ref with Arbitrary repetition pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]])) lib["refs"] = pat_refs - # 5. Shapes with repetitions (OASIS only, must be wrapped for GDS) pat_rep_shapes = Pattern() poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5)) pat_rep_shapes.shapes[(4, 0)].append(poly_rep) @@ -68,16 +54,10 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None: read_lib, _ = gdsii.readfile(gds_file) - # Check existence for name in lib: assert name in read_lib - # Check Paths read_paths = read_lib["paths"] - # Check caps (GDS stores them as path_type) - # Order might be different depending on how they were written, - # but here they should match the order they were added if dict order is preserved. - # Actually, they are grouped by layer. p_flush = cast("MPath", read_paths.shapes[(2, 0)][0]) assert p_flush.cap == MPath.Cap.Flush @@ -92,20 +72,16 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None: assert p_custom.cap_extensions is not None assert_allclose(p_custom.cap_extensions, (1, 5)) - # Check Refs with repetitions read_refs = read_lib["refs"] assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs) - # AREF check arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None] assert len(arefs) == 1 assert isinstance(arefs[0].repetition, Grid) assert arefs[0].repetition.a_count == 3 assert arefs[0].repetition.b_count == 2 - # Check wrapped shapes - # lib.wrap_repeated_shapes() created new patterns - # Original pattern "rep_shapes" now should have a Ref + # GDS stores repeated shapes through refs created by wrap_repeated_shapes(). assert len(read_lib["rep_shapes"].refs) > 0 def test_oasis_full_roundtrip(tmp_path: Path) -> None: @@ -117,34 +93,17 @@ def test_oasis_full_roundtrip(tmp_path: Path) -> None: read_lib, _ = oasis.readfile(oas_file) - # Check existence for name in lib: assert name in read_lib - # Check Circle read_circles = read_lib["circles"] assert isinstance(read_circles.shapes[(3, 0)][0], Circle) assert read_circles.shapes[(3, 0)][0].radius == 5 - # Check Path caps read_paths = read_lib["paths"] assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square - # OASIS HalfWidth is Square. masque's Square is also HalfWidth extension. - # Wait, Circle cap in OASIS? - # masque/file/oasis.py: - # path_cap_map = { - # PathExtensionScheme.Flush: Path.Cap.Flush, - # PathExtensionScheme.HalfWidth: Path.Cap.Square, - # PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom, - # } - # It seems Circle cap is NOT supported in OASIS by masque currently. - # Let's verify what happens with Circle cap in OASIS write. - # _shapes_to_elements in oasis.py: - # path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) - # This will raise StopIteration if Circle is not in path_cap_map. - # Check Shape repetition read_rep_shapes = read_lib["rep_shapes"] poly = read_rep_shapes.shapes[(4, 0)][0] assert poly.repetition is not None diff --git a/masque/test/test_manhattanize.py b/masque/test/test_manhattanize.py new file mode 100644 index 0000000..d0f4fa7 --- /dev/null +++ b/masque/test/test_manhattanize.py @@ -0,0 +1,17 @@ +import pytest +import numpy + +from ..shapes import Polygon + + +def test_manhattanize() -> None: + pytest.importorskip("float_raster") + pytest.importorskip("skimage.measure") + 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: + dv = numpy.diff(mp.vertices, axis=0) + assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0)) diff --git a/masque/test/test_path.py b/masque/test/test_path.py index 1cdd872..f8685f4 100644 --- a/masque/test/test_path.py +++ b/masque/test/test_path.py @@ -1,6 +1,6 @@ from numpy.testing import assert_equal, assert_allclose -from ..shapes import Path +from ..shapes import Path, Path as MPath def test_path_init() -> None: @@ -14,7 +14,6 @@ def test_path_to_polygons_flush() -> None: p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) polys = p.to_polygons() assert len(polys) == 1 - # Rectangle from (0, -1) to (10, 1) bounds = polys[0].get_bounds_single() assert_equal(bounds, [[0, -1], [10, 1]]) @@ -23,8 +22,6 @@ def test_path_to_polygons_square() -> None: p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square) polys = p.to_polygons() assert len(polys) == 1 - # Square cap adds width/2 = 1 to each end - # Rectangle from (-1, -1) to (11, 1) bounds = polys[0].get_bounds_single() assert_equal(bounds, [[-1, -1], [11, 1]]) @@ -32,11 +29,8 @@ def test_path_to_polygons_square() -> None: 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]]) @@ -45,32 +39,21 @@ def test_path_custom_cap() -> None: p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10)) polys = p.to_polygons() assert len(polys) == 1 - # Extends 5 units at start, 10 at end - # Starts at -5, ends at 20 bounds = polys[0].get_bounds_single() assert_equal(bounds, [[-5, -1], [20, 1]]) def test_path_bend() -> None: - # L-shaped path p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2) polys = p.to_polygons() assert len(polys) == 1 bounds = polys[0].get_bounds_single() - # Outer corner at (11, -1) is not right. - # Segments: (0,0)-(10,0) and (10,0)-(10,10) - # Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1) - # Corners of segment 2: (9,0), (9,10), (11,10), (11,0) - # Bounds should be [[-1 (if start is square), -1], [11, 11]]? - # Flush cap start at (0,0) with width 2 means y from -1 to 1. - # Vertical segment end at (10,10) with width 2 means x from 9 to 11. - # So bounds should be x: [0, 11], y: [-1, 10] assert_equal(bounds, [[0, -1], [11, 10]]) def test_path_mirror() -> None: p = Path(vertices=[[10, 5], [20, 10]], width=2) - p.mirror(0) # Mirror across x axis (y -> -y) + p.mirror(0) assert_equal(p.vertices, [[10, -5], [20, -10]]) @@ -109,3 +92,10 @@ def test_path_normalized_form_distinguishes_custom_caps() -> None: p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)) assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0] + + +def test_path_edge_cases() -> None: + 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]]) diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py deleted file mode 100644 index 47cae29..0000000 --- a/masque/test/test_pather.py +++ /dev/null @@ -1,108 +0,0 @@ -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..builder import Pather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - - -@pytest.fixture -def pather_setup() -> tuple[Pather, PathTool, Library]: - lib = Library() - # Simple PathTool: 2um width on layer (1,0) - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool) - # Add an initial port facing North (pi/2) - # Port rotation points INTO device. So "North" rotation means device is North of port. - # Pathing "forward" moves South. - p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - return p, tool, lib - - -def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # Route 10um "forward" - p.straight("start", 10) - - # port rot pi/2 (North). Travel +pi relative to port -> South. - assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) - - -def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # Start (0,0) rot pi/2 (North). - # Path 10um "forward" (South), then turn Clockwise (ccw=False). - # Facing South, turn Right -> West. - p.cw("start", 10) - - # PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0. - # Transformed by port rot pi/2 (North) + pi (to move "forward" away from device): - # Transformation rot = pi/2 + pi = 3pi/2. - # (10, -1) rotated 3pi/2: (x,y) -> (y, -x) -> (-1, -10). - - assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10) - # North (pi/2) + CW (90 deg) -> West (pi)? - # Actual behavior results in 0 (East) - apparently rotation is flipped. - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) - - -def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # start at (0,0) rot pi/2 (North) - # path "forward" (South) to y=-50 - p.straight("start", y=-50) - assert_equal(p.ports["start"].offset, [0, -50]) - - -def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - p.ports["A"] = Port((0, 0), pi / 2, ptype="wire") - p.ports["B"] = Port((10, 0), pi / 2, ptype="wire") - - # Path both "forward" (South) to y=-20 - p.straight(["A", "B"], ymin=-20) - assert_equal(p.ports["A"].offset, [0, -20]) - assert_equal(p.ports["B"].offset, [10, -20]) - - -def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - # Fluent API test - p.at("start").straight(10).ccw(10) - # 10um South -> (0, -10) rot pi/2 - # then 10um South and turn CCW (Facing South, CCW is East) - # PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0 - # Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10) - # (0, -10) + (1, -10) = (1, -20) - assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10) - # pi/2 (North) + CCW (90 deg) -> 0 (East)? - # Actual behavior results in pi (West). - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) - - -def test_pather_dead_ports() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=1) - p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool) - p.set_dead() - - # Path with negative length (impossible for PathTool, would normally raise BuildError) - p.straight("in", -10) - - # Port 'in' should be updated by dummy extension despite tool failure - # port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x. - assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10) - - # Downstream path should work correctly using the dummy port location - p.straight("in", 20) - # 10 + (-20) = -10 - assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10) - - # Verify no geometry - assert not p.pattern.has_shapes() diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py deleted file mode 100644 index d01ed44..0000000 --- a/masque/test/test_pather_api.py +++ /dev/null @@ -1,936 +0,0 @@ -from typing import Any - -import pytest -import numpy -from numpy import pi -from masque import Pather, Library, Pattern, Port -from masque.builder.tools import PathTool, Tool -from masque.error import BuildError, PortError, PatternError - -def test_pather_trace_basic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - - # Port rotation 0 points in +x (INTO device). - # To extend it, we move in -x direction. - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - # Trace single port - p.at('A').trace(None, 5000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) - - # Trace with bend - p.at('A').trace(True, 5000) # CCW bend - # Port was at (-5000, 0) rot 0. - # New wire starts at (-5000, 0) rot 0. - # Output port of wire before rotation: (5000, 500) rot -pi/2 - # Rotate by pi (since dev port rot is 0 and tool port rot is 0): - # (-5000, -500) rot pi - pi/2 = pi/2 - # Add to start: (-10000, -500) rot pi/2 - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2) - -def test_pather_trace_to() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - # Trace to x=-10000 - p.at('A').trace_to(None, x=-10000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) - - # Trace to position=-20000 - p.at('A').trace_to(None, p=-20000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0)) - -def test_pather_bundle_trace() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 2000), rotation=0) - - # Straight bundle - all should align to same x - p.at(['A', 'B']).straight(xmin=-10000) - assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) - assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) - - # Bundle with bend - p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) - # Traveling in -x direction. CCW turn turns towards -y. - # A is at y=0, B is at y=2000. - # Rotation center is at y = -R. - # A is closer to center than B. So A is inner, B is outer. - # xmin is coordinate of innermost bend (A). - assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000) - # B's bend is further out (more negative x) - assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) - -def test_pather_each_bound() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((-1000, 2000), rotation=0) - - # Each should move by 5000 (towards -x) - p.at(['A', 'B']).trace(None, each=5000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) - assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000)) - -def test_selection_management() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 0), rotation=0) - - pp = p.at('A') - assert pp.ports == ['A'] - - pp.select('B') - assert pp.ports == ['A', 'B'] - - pp.deselect('A') - assert pp.ports == ['B'] - - pp.select(['A']) - assert pp.ports == ['B', 'A'] - - pp.drop() - assert 'A' not in p.pattern.ports - assert 'B' not in p.pattern.ports - assert pp.ports == [] - -def test_mark_fork() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((100, 200), rotation=1) - - pp = p.at('A') - pp.mark('B') - assert 'B' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200)) - assert p.pattern.ports['B'].rotation == 1 - assert pp.ports == ['A'] # mark keeps current selection - - pp.fork('C') - assert 'C' in p.pattern.ports - assert pp.ports == ['C'] # fork switches to new name - - -def test_mark_fork_reject_overwrite_and_duplicate_targets() -> None: - lib = Library() - - p_mark = Pather(lib, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'C': Port((2, 0), rotation=0), - })) - with pytest.raises(PortError, match='overwrite existing ports'): - p_mark.at('A').mark('C') - assert numpy.allclose(p_mark.pattern.ports['C'].offset, (2, 0)) - - p_fork = Pather(lib, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'B': Port((1, 0), rotation=0), - })) - pp = p_fork.at(['A', 'B']) - with pytest.raises(PortError, match='targets would collide'): - pp.fork({'A': 'X', 'B': 'X'}) - assert set(p_fork.pattern.ports) == {'A', 'B'} - assert pp.ports == ['A', 'B'] - - -def test_mark_fork_dead_overwrite_and_duplicate_targets() -> None: - lib = Library() - p = Pather(lib, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'B': Port((1, 0), rotation=0), - 'C': Port((2, 0), rotation=0), - })) - p.set_dead() - - p.at('A').mark('C') - assert numpy.allclose(p.pattern.ports['C'].offset, (0, 0)) - - pp = p.at(['A', 'B']) - pp.fork({'A': 'X', 'B': 'X'}) - assert numpy.allclose(p.pattern.ports['X'].offset, (1, 0)) - assert pp.ports == ['X'] - - -def test_mark_fork_reject_missing_sources() -> None: - lib = Library() - p = Pather(lib, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'B': Port((1, 0), rotation=0), - })) - - with pytest.raises(PortError, match='selected ports'): - p.at(['A', 'B']).mark({'Z': 'C'}) - - with pytest.raises(PortError, match='selected ports'): - p.at(['A', 'B']).fork({'Z': 'C'}) - -def test_rename() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').rename('B') - assert 'A' not in p.pattern.ports - assert 'B' in p.pattern.ports - - p.pattern.ports['C'] = Port((0, 0), rotation=0) - pp = p.at(['B', 'C']) - pp.rename({'B': 'D', 'C': 'E'}) - assert 'B' not in p.pattern.ports - assert 'C' not in p.pattern.ports - assert 'D' in p.pattern.ports - assert 'E' in p.pattern.ports - assert set(pp.ports) == {'D', 'E'} - -def test_renderpather_uturn_fallback() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - rp = Pather(lib, tools=tool, auto_render=False) - rp.pattern.ports['A'] = Port((0, 0), rotation=0) - - # PathTool doesn't implement planU, so it should fall back to two planL calls - rp.at('A').uturn(offset=10000, length=5000) - - # Two steps should be added - assert len(rp.paths['A']) == 2 - assert rp.paths['A'][0].opcode == 'L' - assert rp.paths['A'][1].opcode == 'L' - - rp.render() - assert rp.pattern.ports['A'].rotation is not None - assert numpy.isclose(rp.pattern.ports['A'].rotation, pi) - -def test_autotool_uturn() -> None: - from masque.builder.tools import AutoTool - lib = Library() - - # Setup AutoTool with a simple straight and a bend - def make_straight(length: float) -> Pattern: - pat = Pattern() - pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000) - pat.ports['in'] = Port((0, 0), 0) - pat.ports['out'] = Port((length, 0), pi) - return pat - - bend_pat = Pattern() - bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)]) - bend_pat.ports['in'] = Port((0, 0), 0) - bend_pat.ports['out'] = Port((500, -500), pi/2) - lib['bend'] = bend_pat - - tool = AutoTool( - straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')], - bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)], - sbends=[], - transitions={}, - default_out_ptype='wire' - ) - - p = Pather(lib, tools=tool, auto_render=False) - p.pattern.ports['A'] = Port((0, 0), 0) - - # CW U-turn (jog < 0) - # R = 500. jog = -2000. length = 1000. - # p0 = planL(length=1000) -> out at (1000, -500) rot pi/2 - # R2 = 500. - # l2_length = abs(-2000) - abs(-500) - 500 = 1000. - p.at('A').uturn(offset=-2000, length=1000) - - # Final port should be at (-1000, 2000) rot pi - # Start: (0,0) rot 0. Wire direction is rot + pi = pi (West, -x). - # Tool planU returns (length, jog) = (1000, -2000) relative to (0,0) rot 0. - # Rotation of pi transforms (1000, -2000) to (-1000, 2000). - # Final rotation: 0 + pi = pi. - assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi) - -def test_pather_trace_into() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - - # 1. Straight connector - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((-10000, 0), rotation=pi) - p.at('A').trace_into('B', plug_destination=False) - assert 'B' in p.pattern.ports - assert 'A' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) - - # 2. Single bend - p.pattern.ports['C'] = Port((0, 0), rotation=0) - p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2) - p.at('C').trace_into('D', plug_destination=False) - assert 'D' in p.pattern.ports - assert 'C' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000)) - - # 3. Jog (S-bend) - p.pattern.ports['E'] = Port((0, 0), rotation=0) - p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi) - p.at('E').trace_into('F', plug_destination=False) - assert 'F' in p.pattern.ports - assert 'E' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000)) - - # 4. U-bend (0 deg angle) - p.pattern.ports['G'] = Port((0, 0), rotation=0) - p.pattern.ports['H'] = Port((-10000, 2000), rotation=0) - p.at('G').trace_into('H', plug_destination=False) - assert 'H' in p.pattern.ports - assert 'G' in p.pattern.ports - # A U-bend with length=-travel=10000 and jog=-2000 from (0,0) rot 0 - # ends up at (-10000, 2000) rot pi. - assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000)) - assert p.pattern.ports['G'].rotation is not None - assert numpy.isclose(p.pattern.ports['G'].rotation, pi) - - # 5. Vertical straight connector - p.pattern.ports['I'] = Port((0, 0), rotation=pi / 2) - p.pattern.ports['J'] = Port((0, -10000), rotation=3 * pi / 2) - p.at('I').trace_into('J', plug_destination=False) - assert 'J' in p.pattern.ports - assert 'I' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['I'].offset, (0, -10000)) - assert p.pattern.ports['I'].rotation is not None - assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) - - -def test_pather_trace_into_dead_updates_ports_without_geometry() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000, ptype='wire') - p = Pather(lib, tools=tool, auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((-10000, 0), rotation=pi, ptype='wire') - p.set_dead() - - p.trace_into('A', 'B', plug_destination=False) - - assert set(p.pattern.ports) == {'A', 'B'} - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p.paths['A']) == 0 - assert not p.pattern.has_shapes() - assert not p.pattern.has_refs() - - -def test_pather_dead_fallback_preserves_out_ptype() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000, ptype='wire') - p = Pather(lib, tools=tool, auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.set_dead() - - p.straight('A', -1000, out_ptype='other') - - assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0)) - assert p.pattern.ports['A'].ptype == 'other' - assert len(p.paths['A']) == 0 - - -def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None: - lib = Library() - p = Pather(lib, pattern=Pattern(ports={ - 'A': Port((5, 5), rotation=0), - 'keep': Port((9, 9), rotation=0), - })) - p.set_dead() - - other = Pattern() - other.ports['X'] = Port((1, 0), rotation=0) - other.ports['Y'] = Port((2, 0), rotation=pi / 2) - - p.place(other, port_map={'X': 'A', 'Y': 'A'}) - - assert set(p.pattern.ports) == {'A', 'keep'} - assert numpy.allclose(p.pattern.ports['A'].offset, (2, 0)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2) - - -def test_pather_dead_plug_overwrites_colliding_outputs_last_wins() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000, ptype='wire') - p = Pather(lib, tools=tool, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0, ptype='wire'), - 'B': Port((99, 99), rotation=0, ptype='wire'), - })) - p.set_dead() - - other = Pattern() - other.ports['in'] = Port((0, 0), rotation=pi, ptype='wire') - other.ports['X'] = Port((10, 0), rotation=0, ptype='wire') - other.ports['Y'] = Port((20, 0), rotation=0, ptype='wire') - - p.plug(other, map_in={'A': 'in'}, map_out={'X': 'B', 'Y': 'B'}) - - assert 'A' not in p.pattern.ports - assert 'B' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['B'].offset, (20, 0)) - assert p.pattern.ports['B'].rotation is not None - assert numpy.isclose(p.pattern.ports['B'].rotation, 0) - - -def test_pather_dead_rename_overwrites_colliding_ports_last_wins() -> None: - p = Pather(Library(), pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'B': Port((1, 0), rotation=0), - 'C': Port((2, 0), rotation=0), - })) - p.set_dead() - - p.rename_ports({'A': 'C', 'B': 'C'}) - - assert set(p.pattern.ports) == {'C'} - assert numpy.allclose(p.pattern.ports['C'].offset, (1, 0)) - - -def test_pather_jog_failed_fallback_is_atomic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='shorter than required bend'): - p.jog('A', 1.5, length=1.5) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation == 0 - assert len(p.paths['A']) == 0 - - -def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.jog('A', 1.5, length=5) - - assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5)) - assert p.pattern.ports['A'].rotation == 0 - assert len(p.paths['A']) == 0 - - -def test_pather_jog_length_solved_from_single_position_bound() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.jog('A', 2, x=-6) - assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - - q = Pather(Library(), tools=tool) - q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - q.jog('A', 2, p=-6) - assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2)) - - -def test_pather_jog_requires_length_or_one_position_bound() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='requires either length'): - p.jog('A', 2) - - with pytest.raises(BuildError, match='exactly one positional bound'): - p.jog('A', 2, x=-6, p=-6) - - -def test_pather_trace_to_rejects_conflicting_position_bounds() -> None: - tool = PathTool(layer='M1', width=1, ptype='wire') - - for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}): - p = Pather(Library(), tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='exactly one positional bound'): - p.trace_to('A', None, **kwargs) - - p = Pather(Library(), tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='length cannot be combined'): - p.trace_to('A', None, x=-5, length=3) - - -def test_pather_trace_rejects_length_with_bundle_bound() -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='length cannot be combined'): - p.trace('A', None, length=5, xmin=-100) - - -@pytest.mark.parametrize('kwargs', ({'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10})) -def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='exactly one bundle bound'): - p.trace(['A', 'B'], None, **kwargs) - - -def test_pather_jog_rejects_length_with_position_bound() -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='length cannot be combined'): - p.jog('A', 2, length=5, x=-999) - - -@pytest.mark.parametrize('kwargs', ({'x': -999}, {'xmin': -10})) -def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'): - p.uturn('A', 4, **kwargs) - - -def test_pather_uturn_none_length_defaults_to_zero() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.uturn('A', 4) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi) - - -def test_pather_trace_into_failure_rolls_back_ports_and_paths() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire') - - with pytest.raises(BuildError, match='does not match path ptype'): - p.trace_into('A', 'B', plug_destination=False, out_ptype='other') - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5)) - assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2) - assert len(p.paths['A']) == 0 - - -def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire') - p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire') - - with pytest.raises(PortError, match='overwritten'): - p.trace_into('A', 'B', plug_destination=False, thru='other') - - assert set(p.pattern.ports) == {'A', 'B', 'other'} - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.allclose(p.pattern.ports['B'].offset, (-10, 0)) - assert numpy.allclose(p.pattern.ports['other'].offset, (3, 4)) - assert len(p.paths['A']) == 0 - - -@pytest.mark.parametrize( - ('dst', 'kwargs', 'match'), - ( - (Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'trace_to\(\) arguments: x'), - (Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'jog\(\) arguments: length'), - (Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'uturn\(\) arguments: length'), - ), -) -def test_pather_trace_into_rejects_reserved_route_kwargs( - dst: Port, - kwargs: dict[str, Any], - match: str, - ) -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = dst - - with pytest.raises(BuildError, match=match): - p.trace_into('A', 'B', plug_destination=False, **kwargs) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset) - assert dst.rotation is not None - assert p.pattern.ports['B'].rotation is not None - assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation) - assert len(p.paths['A']) == 0 - - -def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None: - class OutPtypeSensitiveTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): - radius = 1 if out_ptype is None else 2 - if ccw is None: - rotation = pi - jog = 0 - elif bool(ccw): - rotation = -pi / 2 - jog = radius - else: - rotation = pi / 2 - jog = -radius - ptype = out_ptype or in_ptype or 'wire' - return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} - - p = Pather(Library(), tools=OutPtypeSensitiveTool()) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='fallback via two planL'): - p.jog('A', 5, length=10, out_ptype='wide') - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p.paths['A']) == 0 - - -def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> None: - class OutPtypeSensitiveTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): - radius = 1 if out_ptype is None else 2 - if ccw is None: - rotation = pi - jog = 0 - elif bool(ccw): - rotation = -pi / 2 - jog = radius - else: - rotation = pi / 2 - jog = -radius - ptype = out_ptype or in_ptype or 'wire' - return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} - - p = Pather(Library(), tools=OutPtypeSensitiveTool()) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='fallback via two planL'): - p.uturn('A', 5, length=10, out_ptype='wide') - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p.paths['A']) == 0 - - -def test_tool_planL_fallback_accepts_custom_port_names() -> None: - class DummyTool(Tool): - def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: - lib = Library() - pat = Pattern() - pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire') - pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire') - lib['top'] = pat - return lib - - out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y')) - assert numpy.allclose(out_port.offset, (5, 0)) - assert numpy.isclose(out_port.rotation, pi) - - -def test_tool_planS_fallback_accepts_custom_port_names() -> None: - class DummyTool(Tool): - def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: - lib = Library() - pat = Pattern() - pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire') - pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire') - lib['top'] = pat - return lib - - out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y')) - assert numpy.allclose(out_port.offset, (5, 2)) - assert numpy.isclose(out_port.rotation, pi) - - -def test_pather_uturn_failed_fallback_is_atomic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='shorter than required bend'): - p.uturn('A', 1.5, length=0) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation == 0 - assert len(p.paths['A']) == 0 - - -def test_pather_render_auto_renames_single_use_tool_children() -> None: - class FullTreeTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 - ptype = out_ptype or in_ptype or 'wire' - return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 - tree = Library() - top = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((1, 0), pi, ptype='wire'), - }) - child = Pattern(annotations={'batch': [len(batch)]}) - top.ref('_seg') - tree['_top'] = top - tree['_seg'] = child - return tree - - lib = Library() - p = Pather(lib, tools=FullTreeTool(), auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 10) - p.render() - p.straight('A', 10) - p.render() - - assert len(lib) == 2 - assert set(lib.keys()) == set(p.pattern.refs.keys()) - assert len(set(p.pattern.refs.keys())) == 2 - assert all(name.startswith('_seg') for name in lib) - assert p.pattern.referenced_patterns() <= set(lib.keys()) - - -def test_tool_render_fallback_preserves_segment_subtrees() -> None: - class TraceTreeTool(Tool): - def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001 - tree = Library() - top = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), pi, ptype='wire'), - }) - child = Pattern(annotations={'length': [length]}) - top.ref('_seg') - tree['_top'] = top - tree['_seg'] = child - return tree - - lib = Library() - p = Pather(lib, tools=TraceTreeTool(), auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 10) - p.render() - - assert '_seg' in lib - assert '_seg' in p.pattern.refs - assert p.pattern.referenced_patterns() <= set(lib.keys()) - - -def test_pather_render_rejects_missing_single_use_tool_refs() -> None: - class MissingSingleUseTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 - ptype = out_ptype or in_ptype or 'wire' - return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 - tree = Library() - top = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((1, 0), pi, ptype='wire'), - }) - top.ref('_seg') - tree['_top'] = top - return tree - - lib = Library() - lib['_seg'] = Pattern(annotations={'stale': [1]}) - p = Pather(lib, tools=MissingSingleUseTool(), auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.straight('A', 10) - - with pytest.raises(BuildError, match='missing single-use refs'): - p.render() - - assert list(lib.keys()) == ['_seg'] - assert not p.pattern.refs - - -def test_pather_render_allows_missing_non_single_use_tool_refs() -> None: - class SharedRefTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 - ptype = out_ptype or in_ptype or 'wire' - return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 - tree = Library() - top = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((1, 0), pi, ptype='wire'), - }) - top.ref('shared') - tree['_top'] = top - return tree - - lib = Library() - lib['shared'] = Pattern(annotations={'shared': [1]}) - p = Pather(lib, tools=SharedRefTool(), auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 10) - p.render() - - assert 'shared' in p.pattern.refs - assert p.pattern.referenced_patterns() <= set(lib.keys()) - - -def test_renderpather_rename_to_none_keeps_pending_geometry_without_port() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - rp = Pather(lib, tools=tool, auto_render=False) - rp.pattern.ports['A'] = Port((0, 0), rotation=0) - - rp.at('A').straight(5000) - rp.rename_ports({'A': None}) - - assert 'A' not in rp.pattern.ports - assert len(rp.paths['A']) == 1 - - rp.render() - assert rp.pattern.has_shapes() - assert 'A' not in rp.pattern.ports - - -def test_pather_place_treeview_resolves_once() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})} - - p.place(tree) - - assert len(lib) == 1 - assert 'child' in lib - assert 'child' in p.pattern.refs - assert 'B' in p.pattern.ports - - -def test_pather_plug_treeview_resolves_once() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})} - - p.plug(tree, {'A': 'B'}) - - assert len(lib) == 1 - assert 'child' in lib - assert 'child' in p.pattern.refs - assert 'A' not in p.pattern.ports - - -def test_pather_failed_plug_does_not_add_break_marker() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - p.pattern.annotations = {'k': [1]} - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').trace(None, 5000) - assert [step.opcode for step in p.paths['A']] == ['L'] - - other = Pattern( - annotations={'k': [2]}, - ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)}, - ) - - with pytest.raises(PatternError, match='Annotation keys overlap'): - p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True) - - assert [step.opcode for step in p.paths['A']] == ['L'] - assert set(p.pattern.ports) == {'A'} - - -def test_pather_place_reused_deleted_name_keeps_break_marker() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').straight(5000) - p.rename_ports({'A': None}) - - other = Pattern(ports={'X': Port((-5000, 0), rotation=0)}) - p.place(other, port_map={'X': 'A'}, append=True) - p.at('A').straight(2000) - - assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L'] - - p.render() - assert p.pattern.has_shapes() - assert 'A' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0)) - - -def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 0), rotation=0) - - p.at('A').straight(5000) - p.rename_ports({'A': None}) - - other = Pattern( - ports={ - 'X': Port((0, 0), rotation=pi), - 'Y': Port((-5000, 0), rotation=0), - }, - ) - p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True) - p.at('A').straight(2000) - - assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L'] - - p.render() - assert p.pattern.has_shapes() - assert 'A' in p.pattern.ports - assert 'B' not in p.pattern.ports - assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0)) - - -def test_pather_failed_plugged_does_not_add_break_marker() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, auto_render=False) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').straight(5000) - assert [step.opcode for step in p.paths['A']] == ['L'] - - with pytest.raises(PortError, match='Connection destination ports were not found'): - p.plugged({'A': 'missing'}) - - assert [step.opcode for step in p.paths['A']] == ['L'] - assert set(p.paths) == {'A'} diff --git a/masque/test/test_pather_autotool.py b/masque/test/test_pather_autotool.py new file mode 100644 index 0000000..6ad553a --- /dev/null +++ b/masque/test/test_pather_autotool.py @@ -0,0 +1,127 @@ +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_allclose + +from masque import Pather, Library, Pattern, Port +from masque.builder.tools import AutoTool + + +def make_straight(length, width=2, ptype="wire"): + pat = Pattern() + pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((length, 0), pi, ptype=ptype) + return pat + +def make_bend(R, width=2, ptype="wire", clockwise=True): + pat = Pattern() + # Rectangular approximation of a 90 degree bend. + if clockwise: + pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) + pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype) + else: + pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) + pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype) + return pat + +@pytest.fixture +def multi_bend_tool(): + lib = Library() + + lib["b1"] = make_bend(2, ptype="wire") + b1_abs = lib.abstract("b1") + lib["b2"] = make_bend(5, ptype="wire") + b2_abs = lib.abstract("b2") + + tool = AutoTool( + straights=[ + AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)), + AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8)) + ], + bends=[ + AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True), + AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True) + ], + sbends=[], + transitions={}, + default_out_ptype="wire" + ) + return tool, lib + +def test_autotool_uturn() -> None: + from masque.builder.tools import AutoTool + lib = Library() + + def make_straight(length: float) -> Pattern: + pat = Pattern() + pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000) + pat.ports['in'] = Port((0, 0), 0) + pat.ports['out'] = Port((length, 0), pi) + return pat + + bend_pat = Pattern() + bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)]) + bend_pat.ports['in'] = Port((0, 0), 0) + bend_pat.ports['out'] = Port((500, -500), pi/2) + lib['bend'] = bend_pat + + tool = AutoTool( + straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')], + bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)], + sbends=[], + transitions={}, + default_out_ptype='wire' + ) + + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), 0) + + p.at('A').uturn(offset=-2000, length=1000) + + # U-turn plan output is transformed into the port extension frame. + assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi) + +def test_deferred_render_autotool_double_L(multi_bend_tool) -> None: + tool, lib = multi_bend_tool + rp = Pather(lib, tools=tool, auto_render=False) + rp.ports["A"] = Port((0,0), 0, ptype="wire") + + rp.jog("A", 10, length=20) + + assert_allclose(rp.ports["A"].offset, [-20, -10]) + assert_allclose(rp.ports["A"].rotation, 0) + + rp.render() + assert len(rp.pattern.refs) > 0 + +def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None: + tool, lib = multi_bend_tool + + class BasicTool(AutoTool): + def planU(self, *args, **kwargs): + raise NotImplementedError() + + tool_basic = BasicTool( + straights=tool.straights, + bends=tool.bends, + sbends=tool.sbends, + transitions=tool.transitions, + default_out_ptype=tool.default_out_ptype + ) + + p = Pather(lib, tools=tool_basic) + p.ports["A"] = Port((0,0), 0, ptype="wire") + + p.uturn("A", 10, length=5) + + # Fallback U-turn uses two CCW bends: (7, 2) then (8, 2) in local tool frames, + # yielding a global endpoint at (-5, -10). + assert_allclose(p.ports["A"].offset, [-5, -10]) + assert_allclose(p.ports["A"].rotation, pi) diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py new file mode 100644 index 0000000..1e74bd6 --- /dev/null +++ b/masque/test/test_pather_constraints.py @@ -0,0 +1,213 @@ +from typing import Any + +import pytest +import numpy +from numpy import pi + +from masque import Pather, Library, Pattern, Port +from masque.builder.tools import PathTool, Tool +from masque.error import BuildError, PortError, PatternError + + +def test_pather_jog_failed_fallback_is_atomic() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='shorter than required bend'): + p.jog('A', 1.5, length=1.5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation == 0 + assert len(p.paths['A']) == 0 + +def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.jog('A', 1.5, length=5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5)) + assert p.pattern.ports['A'].rotation == 0 + assert len(p.paths['A']) == 0 + +def test_pather_jog_length_solved_from_single_position_bound() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.jog('A', 2, x=-6) + assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + + q = Pather(Library(), tools=tool) + q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + q.jog('A', 2, p=-6) + assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2)) + +def test_pather_jog_requires_length_or_one_position_bound() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='requires either length'): + p.jog('A', 2) + + with pytest.raises(BuildError, match='exactly one positional bound'): + p.jog('A', 2, x=-6, p=-6) + +def test_pather_trace_to_rejects_conflicting_position_bounds() -> None: + tool = PathTool(layer='M1', width=1, ptype='wire') + + for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}): + p = Pather(Library(), tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + with pytest.raises(BuildError, match='exactly one positional bound'): + p.trace_to('A', None, **kwargs) + + p = Pather(Library(), tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + with pytest.raises(BuildError, match='length cannot be combined'): + p.trace_to('A', None, x=-5, length=3) + +def test_pather_trace_rejects_length_with_bundle_bound() -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='length cannot be combined'): + p.trace('A', None, length=5, xmin=-100) + +@pytest.mark.parametrize('kwargs', ({'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10})) +def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='exactly one bundle bound'): + p.trace(['A', 'B'], None, **kwargs) + +def test_pather_jog_rejects_length_with_position_bound() -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='length cannot be combined'): + p.jog('A', 2, length=5, x=-999) + +@pytest.mark.parametrize('kwargs', ({'x': -999}, {'xmin': -10})) +def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'): + p.uturn('A', 4, **kwargs) + +def test_pather_uturn_none_length_defaults_to_zero() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.uturn('A', 4) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi) + +def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None: + class OutPtypeSensitiveTool(Tool): + def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): + radius = 1 if out_ptype is None else 2 + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = radius + else: + rotation = pi / 2 + jog = -radius + ptype = out_ptype or in_ptype or 'wire' + return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=OutPtypeSensitiveTool()) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='fallback via two planL'): + p.jog('A', 5, length=10, out_ptype='wide') + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p.paths['A']) == 0 + +def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> None: + class OutPtypeSensitiveTool(Tool): + def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): + radius = 1 if out_ptype is None else 2 + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = radius + else: + rotation = pi / 2 + jog = -radius + ptype = out_ptype or in_ptype or 'wire' + return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=OutPtypeSensitiveTool()) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='fallback via two planL'): + p.uturn('A', 5, length=10, out_ptype='wide') + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p.paths['A']) == 0 + +def test_tool_planL_fallback_accepts_custom_port_names() -> None: + class DummyTool(Tool): + def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: + lib = Library() + pat = Pattern() + pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire') + pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire') + lib['top'] = pat + return lib + + out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y')) + assert numpy.allclose(out_port.offset, (5, 0)) + assert numpy.isclose(out_port.rotation, pi) + +def test_tool_planS_fallback_accepts_custom_port_names() -> None: + class DummyTool(Tool): + def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: + lib = Library() + pat = Pattern() + pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire') + pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire') + lib['top'] = pat + return lib + + out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y')) + assert numpy.allclose(out_port.offset, (5, 2)) + assert numpy.isclose(out_port.rotation, pi) + +def test_pather_uturn_failed_fallback_is_atomic() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='shorter than required bend'): + p.uturn('A', 1.5, length=0) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation == 0 + assert len(p.paths['A']) == 0 diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py new file mode 100644 index 0000000..396534e --- /dev/null +++ b/masque/test/test_pather_core.py @@ -0,0 +1,305 @@ +from typing import Any + +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_allclose, assert_equal + +from masque import Pather, Library, Pattern, Port +from masque.builder.tools import PathTool, Tool +from masque.error import BuildError, PortError, PatternError + + +@pytest.fixture +def pather_setup() -> tuple[Pather, PathTool, Library]: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool) + # Port rotation points into the device, so path extension moves in the opposite direction. + p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") + return p, tool, lib + +def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.straight("start", 10) + + assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) + assert p.ports["start"].rotation is not None + assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) + +def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.cw("start", 10) + + assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10) + assert p.ports["start"].rotation is not None + assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) + +def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.straight("start", y=-50) + assert_equal(p.ports["start"].offset, [0, -50]) + +def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.ports["A"] = Port((0, 0), pi / 2, ptype="wire") + p.ports["B"] = Port((10, 0), pi / 2, ptype="wire") + + p.straight(["A", "B"], ymin=-20) + assert_equal(p.ports["A"].offset, [0, -20]) + assert_equal(p.ports["B"].offset, [10, -20]) + +def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.at("start").straight(10).ccw(10) + assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10) + assert p.ports["start"].rotation is not None + assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) + +def test_pather_dead_ports() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=1) + p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool) + p.set_dead() + + p.straight("in", -10) + + assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10) + + p.straight("in", 20) + assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10) + + assert not p.pattern.has_shapes() + +def test_pather_trace_basic() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + + # Routing extends opposite the port's inward-facing rotation. + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').trace(None, 5000) + assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) + + p.at('A').trace(True, 5000) # CCW bend + assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2) + +def test_pather_trace_to() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').trace_to(None, x=-10000) + assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) + + p.at('A').trace_to(None, p=-20000) + assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0)) + +def test_pather_bundle_trace() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p.at(['A', 'B']).straight(xmin=-10000) + assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) + assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) + + p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) + # The lower port is on the inner bend, so `xmin` applies to that route. + assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000) + assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) + +def test_pather_each_bound() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((-1000, 2000), rotation=0) + + p.at(['A', 'B']).trace(None, each=5000) + assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000)) + +def test_selection_management() -> None: + lib = Library() + p = Pather(lib) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 0), rotation=0) + + pp = p.at('A') + assert pp.ports == ['A'] + + pp.select('B') + assert pp.ports == ['A', 'B'] + + pp.deselect('A') + assert pp.ports == ['B'] + + pp.select(['A']) + assert pp.ports == ['B', 'A'] + + pp.drop() + assert 'A' not in p.pattern.ports + assert 'B' not in p.pattern.ports + assert pp.ports == [] + +def test_mark_fork() -> None: + lib = Library() + p = Pather(lib) + p.pattern.ports['A'] = Port((100, 200), rotation=1) + + pp = p.at('A') + pp.mark('B') + assert 'B' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200)) + assert p.pattern.ports['B'].rotation == 1 + assert pp.ports == ['A'] + + pp.fork('C') + assert 'C' in p.pattern.ports + assert pp.ports == ['C'] + +def test_mark_fork_reject_overwrite_and_duplicate_targets() -> None: + lib = Library() + + p_mark = Pather(lib, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'C': Port((2, 0), rotation=0), + })) + with pytest.raises(PortError, match='overwrite existing ports'): + p_mark.at('A').mark('C') + assert numpy.allclose(p_mark.pattern.ports['C'].offset, (2, 0)) + + p_fork = Pather(lib, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'B': Port((1, 0), rotation=0), + })) + pp = p_fork.at(['A', 'B']) + with pytest.raises(PortError, match='targets would collide'): + pp.fork({'A': 'X', 'B': 'X'}) + assert set(p_fork.pattern.ports) == {'A', 'B'} + assert pp.ports == ['A', 'B'] + +def test_mark_fork_dead_overwrite_and_duplicate_targets() -> None: + lib = Library() + p = Pather(lib, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'B': Port((1, 0), rotation=0), + 'C': Port((2, 0), rotation=0), + })) + p.set_dead() + + p.at('A').mark('C') + assert numpy.allclose(p.pattern.ports['C'].offset, (0, 0)) + + pp = p.at(['A', 'B']) + pp.fork({'A': 'X', 'B': 'X'}) + assert numpy.allclose(p.pattern.ports['X'].offset, (1, 0)) + assert pp.ports == ['X'] + +def test_mark_fork_reject_missing_sources() -> None: + lib = Library() + p = Pather(lib, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'B': Port((1, 0), rotation=0), + })) + + with pytest.raises(PortError, match='selected ports'): + p.at(['A', 'B']).mark({'Z': 'C'}) + + with pytest.raises(PortError, match='selected ports'): + p.at(['A', 'B']).fork({'Z': 'C'}) + +def test_rename() -> None: + lib = Library() + p = Pather(lib) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').rename('B') + assert 'A' not in p.pattern.ports + assert 'B' in p.pattern.ports + + p.pattern.ports['C'] = Port((0, 0), rotation=0) + pp = p.at(['B', 'C']) + pp.rename({'B': 'D', 'C': 'E'}) + assert 'B' not in p.pattern.ports + assert 'C' not in p.pattern.ports + assert 'D' in p.pattern.ports + assert 'E' in p.pattern.ports + assert set(pp.ports) == {'D', 'E'} + +def test_pather_dead_fallback_preserves_out_ptype() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000, ptype='wire') + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.set_dead() + + p.straight('A', -1000, out_ptype='other') + + assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0)) + assert p.pattern.ports['A'].ptype == 'other' + assert len(p.paths['A']) == 0 + +def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None: + lib = Library() + p = Pather(lib, pattern=Pattern(ports={ + 'A': Port((5, 5), rotation=0), + 'keep': Port((9, 9), rotation=0), + })) + p.set_dead() + + other = Pattern() + other.ports['X'] = Port((1, 0), rotation=0) + other.ports['Y'] = Port((2, 0), rotation=pi / 2) + + p.place(other, port_map={'X': 'A', 'Y': 'A'}) + + assert set(p.pattern.ports) == {'A', 'keep'} + assert numpy.allclose(p.pattern.ports['A'].offset, (2, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2) + +def test_pather_dead_plug_overwrites_colliding_outputs_last_wins() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000, ptype='wire') + p = Pather(lib, tools=tool, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0, ptype='wire'), + 'B': Port((99, 99), rotation=0, ptype='wire'), + })) + p.set_dead() + + other = Pattern() + other.ports['in'] = Port((0, 0), rotation=pi, ptype='wire') + other.ports['X'] = Port((10, 0), rotation=0, ptype='wire') + other.ports['Y'] = Port((20, 0), rotation=0, ptype='wire') + + p.plug(other, map_in={'A': 'in'}, map_out={'X': 'B', 'Y': 'B'}) + + assert 'A' not in p.pattern.ports + assert 'B' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['B'].offset, (20, 0)) + assert p.pattern.ports['B'].rotation is not None + assert numpy.isclose(p.pattern.ports['B'].rotation, 0) + +def test_pather_dead_rename_overwrites_colliding_ports_last_wins() -> None: + p = Pather(Library(), pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'B': Port((1, 0), rotation=0), + 'C': Port((2, 0), rotation=0), + })) + p.set_dead() + + p.rename_ports({'A': 'C', 'B': 'C'}) + + assert set(p.pattern.ports) == {'C'} + assert numpy.allclose(p.pattern.ports['C'].offset, (1, 0)) diff --git a/masque/test/test_pather_place_plug.py b/masque/test/test_pather_place_plug.py new file mode 100644 index 0000000..0bd6a3c --- /dev/null +++ b/masque/test/test_pather_place_plug.py @@ -0,0 +1,122 @@ +from typing import Any + +import pytest +import numpy +from numpy import pi + +from masque import Pather, Library, Pattern, Port +from masque.builder.tools import PathTool, Tool +from masque.error import BuildError, PortError, PatternError + + +def test_pather_place_treeview_resolves_once() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + + tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})} + + p.place(tree) + + assert len(lib) == 1 + assert 'child' in lib + assert 'child' in p.pattern.refs + assert 'B' in p.pattern.ports + +def test_pather_plug_treeview_resolves_once() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})} + + p.plug(tree, {'A': 'B'}) + + assert len(lib) == 1 + assert 'child' in lib + assert 'child' in p.pattern.refs + assert 'A' not in p.pattern.ports + +def test_pather_failed_plug_does_not_add_break_marker() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.annotations = {'k': [1]} + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').trace(None, 5000) + assert [step.opcode for step in p.paths['A']] == ['L'] + + other = Pattern( + annotations={'k': [2]}, + ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)}, + ) + + with pytest.raises(PatternError, match='Annotation keys overlap'): + p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True) + + assert [step.opcode for step in p.paths['A']] == ['L'] + assert set(p.pattern.ports) == {'A'} + +def test_pather_place_reused_deleted_name_keeps_break_marker() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').straight(5000) + p.rename_ports({'A': None}) + + other = Pattern(ports={'X': Port((-5000, 0), rotation=0)}) + p.place(other, port_map={'X': 'A'}, append=True) + p.at('A').straight(2000) + + assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L'] + + p.render() + assert p.pattern.has_shapes() + assert 'A' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0)) + +def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 0), rotation=0) + + p.at('A').straight(5000) + p.rename_ports({'A': None}) + + other = Pattern( + ports={ + 'X': Port((0, 0), rotation=pi), + 'Y': Port((-5000, 0), rotation=0), + }, + ) + p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True) + p.at('A').straight(2000) + + assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L'] + + p.render() + assert p.pattern.has_shapes() + assert 'A' in p.pattern.ports + assert 'B' not in p.pattern.ports + assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0)) + +def test_pather_failed_plugged_does_not_add_break_marker() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').straight(5000) + assert [step.opcode for step in p.paths['A']] == ['L'] + + with pytest.raises(PortError, match='Connection destination ports were not found'): + p.plugged({'A': 'missing'}) + + assert [step.opcode for step in p.paths['A']] == ['L'] + assert set(p.paths) == {'A'} diff --git a/masque/test/test_pather_rendering.py b/masque/test/test_pather_rendering.py new file mode 100644 index 0000000..2f7360e --- /dev/null +++ b/masque/test/test_pather_rendering.py @@ -0,0 +1,312 @@ +from typing import TYPE_CHECKING, cast + +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_allclose + +from ..builder import Pather +from ..builder.tools import PathTool, Tool +from ..error import BuildError +from ..library import Library +from ..pattern import Pattern +from ..ports import Port + +if TYPE_CHECKING: + from ..shapes import Path + + +@pytest.fixture +def deferred_render_setup() -> tuple[Pather, PathTool, Library]: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + rp = Pather(lib, tools=tool, auto_render=False) + rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire") + return rp, tool, lib + +def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10).straight(10) + + assert not rp.pattern.has_shapes() + assert len(rp.paths["start"]) == 2 + + rp.render() + assert rp.pattern.has_shapes() + assert len(rp.pattern.shapes[(1, 0)]) == 1 + + # PathTool renders length steps in the port extension direction. + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert len(path_shape.vertices) == 3 + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) + +def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10).cw(10) + + rp.render() + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + # Clockwise bend adds the bend endpoint after the straight segment vertex. + assert len(path_shape.vertices) == 4 + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10) + +def test_deferred_render_jog_uses_native_pathtool_planS(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").jog(4, length=10) + + assert len(rp.paths["start"]) == 1 + assert rp.paths["start"][0].opcode == "S" + + rp.render() + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + # Native PathTool S-bends place the jog width/2 before the route end. + assert_allclose(path_shape.vertices, [[0, 0], [0, -9], [4, -9], [4, -10]], atol=1e-10) + assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10) + +def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10).cw(10) + + rp.mirror(0) + rp.render() + + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 20], [-1, 20]], atol=1e-10) + +def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool1, lib = deferred_render_setup + tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") + + rp.at("start").straight(10) + rp.retool(tool2, keys=["start"]) + rp.at("start").straight(10) + + rp.render() + assert len(rp.pattern.shapes[(1, 0)]) == 1 + assert len(rp.pattern.shapes[(2, 0)]) == 1 + +def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + pp = rp.at("start") + pp.straight(10) + pp.translate((5, 0)) + pp.straight(10) + + rp.render() + + shapes = rp.pattern.shapes[(1, 0)] + assert len(shapes) == 2 + assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10) + assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10) + assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10) + +def test_deferred_render_dead_ports() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=1) + rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, auto_render=False) + rp.set_dead() + + rp.straight("in", -10) + + assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10) + + assert len(rp.paths["in"]) == 0 + + rp.render() + assert not rp.pattern.has_shapes() + +def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10) + rp.rename_ports({"start": "new_start"}) + rp.at("new_start").straight(10) + + assert "start" not in rp.paths + assert len(rp.paths["new_start"]) == 2 + + rp.render() + assert rp.pattern.has_shapes() + assert len(rp.pattern.shapes[(1, 0)]) == 1 + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) + assert "new_start" in rp.ports + assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10) + +def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10).drop() + + assert "start" not in rp.ports + assert len(rp.paths["start"]) == 1 + + rp.render() + assert rp.pattern.has_shapes() + assert "start" not in rp.ports + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10) + +def test_pathtool_traceL_bend_geometry_matches_ports() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + + tree = tool.traceL(True, 10) + pat = tree.top_pattern() + path_shape = cast("Path", pat.shapes[(1, 0)][0]) + + assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10) + assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10) + +def test_pathtool_traceS_geometry_matches_ports() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + + tree = tool.traceS(10, 4) + pat = tree.top_pattern() + path_shape = cast("Path", pat.shapes[(1, 0)][0]) + + assert_allclose(path_shape.vertices, [[0, 0], [9, 0], [9, 4], [10, 4]], atol=1e-10) + assert_allclose(pat.ports["B"].offset, [10, 4], atol=1e-10) + assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10) + +def test_deferred_render_uturn_fallback() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + rp = Pather(lib, tools=tool, auto_render=False) + rp.pattern.ports['A'] = Port((0, 0), rotation=0) + + rp.at('A').uturn(offset=10000, length=5000) + + assert len(rp.paths['A']) == 2 + assert rp.paths['A'][0].opcode == 'L' + assert rp.paths['A'][1].opcode == 'L' + + rp.render() + assert rp.pattern.ports['A'].rotation is not None + assert numpy.isclose(rp.pattern.ports['A'].rotation, pi) + +def test_pather_render_auto_renames_single_use_tool_children() -> None: + class FullTreeTool(Tool): + def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 + ptype = out_ptype or in_ptype or 'wire' + return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 + tree = Library() + top = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((1, 0), pi, ptype='wire'), + }) + child = Pattern(annotations={'batch': [len(batch)]}) + top.ref('_seg') + tree['_top'] = top + tree['_seg'] = child + return tree + + lib = Library() + p = Pather(lib, tools=FullTreeTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 10) + p.render() + p.straight('A', 10) + p.render() + + assert len(lib) == 2 + assert set(lib.keys()) == set(p.pattern.refs.keys()) + assert len(set(p.pattern.refs.keys())) == 2 + assert all(name.startswith('_seg') for name in lib) + assert p.pattern.referenced_patterns() <= set(lib.keys()) + +def test_tool_render_fallback_preserves_segment_subtrees() -> None: + class TraceTreeTool(Tool): + def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001 + tree = Library() + top = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), pi, ptype='wire'), + }) + child = Pattern(annotations={'length': [length]}) + top.ref('_seg') + tree['_top'] = top + tree['_seg'] = child + return tree + + lib = Library() + p = Pather(lib, tools=TraceTreeTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 10) + p.render() + + assert '_seg' in lib + assert '_seg' in p.pattern.refs + assert p.pattern.referenced_patterns() <= set(lib.keys()) + +def test_pather_render_rejects_missing_single_use_tool_refs() -> None: + class MissingSingleUseTool(Tool): + def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 + ptype = out_ptype or in_ptype or 'wire' + return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 + tree = Library() + top = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((1, 0), pi, ptype='wire'), + }) + top.ref('_seg') + tree['_top'] = top + return tree + + lib = Library() + lib['_seg'] = Pattern(annotations={'stale': [1]}) + p = Pather(lib, tools=MissingSingleUseTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.straight('A', 10) + + with pytest.raises(BuildError, match='missing single-use refs'): + p.render() + + assert list(lib.keys()) == ['_seg'] + assert not p.pattern.refs + +def test_pather_render_allows_missing_non_single_use_tool_refs() -> None: + class SharedRefTool(Tool): + def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 + ptype = out_ptype or in_ptype or 'wire' + return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 + tree = Library() + top = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((1, 0), pi, ptype='wire'), + }) + top.ref('shared') + tree['_top'] = top + return tree + + lib = Library() + lib['shared'] = Pattern(annotations={'shared': [1]}) + p = Pather(lib, tools=SharedRefTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 10) + p.render() + + assert 'shared' in p.pattern.refs + assert p.pattern.referenced_patterns() <= set(lib.keys()) + +def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + rp = Pather(lib, tools=tool, auto_render=False) + rp.pattern.ports['A'] = Port((0, 0), rotation=0) + + rp.at('A').straight(5000) + rp.rename_ports({'A': None}) + + assert 'A' not in rp.pattern.ports + assert len(rp.paths['A']) == 1 + + rp.render() + assert rp.pattern.has_shapes() + assert 'A' not in rp.pattern.ports diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py new file mode 100644 index 0000000..b5e8aed --- /dev/null +++ b/masque/test/test_pather_trace_into.py @@ -0,0 +1,189 @@ +from typing import Any + +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_equal + +from masque import Pather, Library, Pattern, Port +from masque.builder.tools import PathTool, Tool +from masque.error import BuildError, PortError, PatternError + + +@pytest.fixture +def trace_into_setup() -> tuple[Pather, PathTool, Library]: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False) + return p, tool, lib + +def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: + p, _tool, _lib = trace_into_setup + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, 0), pi, ptype="wire") + + p.trace_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + assert len(p.pattern.refs) == 1 + +def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: + p, _tool, _lib = trace_into_setup + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire") + + p.trace_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + # `trace_into()` batches internal legs before auto-rendering so the operation + # rolls back cleanly on later failures. + assert len(p.pattern.refs) == 1 + +def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: + p, _tool, _lib = trace_into_setup + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, -10), pi, ptype="wire") + + p.trace_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + +def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: + p, _tool, _lib = trace_into_setup + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, 0), pi, ptype="wire") + p.ports["other"] = Port((10, 10), 0) + + p.trace_into("src", "dst", thru="other") + + assert "src" in p.ports + assert_equal(p.ports["src"].offset, [10, 10]) + assert "other" not in p.ports + +def test_pather_trace_into() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((-10000, 0), rotation=pi) + p.at('A').trace_into('B', plug_destination=False) + assert 'B' in p.pattern.ports + assert 'A' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) + + p.pattern.ports['C'] = Port((0, 0), rotation=0) + p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2) + p.at('C').trace_into('D', plug_destination=False) + assert 'D' in p.pattern.ports + assert 'C' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000)) + + p.pattern.ports['E'] = Port((0, 0), rotation=0) + p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi) + p.at('E').trace_into('F', plug_destination=False) + assert 'F' in p.pattern.ports + assert 'E' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000)) + + p.pattern.ports['G'] = Port((0, 0), rotation=0) + p.pattern.ports['H'] = Port((-10000, 2000), rotation=0) + p.at('G').trace_into('H', plug_destination=False) + assert 'H' in p.pattern.ports + assert 'G' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000)) + assert p.pattern.ports['G'].rotation is not None + assert numpy.isclose(p.pattern.ports['G'].rotation, pi) + + p.pattern.ports['I'] = Port((0, 0), rotation=pi / 2) + p.pattern.ports['J'] = Port((0, -10000), rotation=3 * pi / 2) + p.at('I').trace_into('J', plug_destination=False) + assert 'J' in p.pattern.ports + assert 'I' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['I'].offset, (0, -10000)) + assert p.pattern.ports['I'].rotation is not None + assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) + +def test_pather_trace_into_dead_updates_ports_without_geometry() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000, ptype='wire') + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((-10000, 0), rotation=pi, ptype='wire') + p.set_dead() + + p.trace_into('A', 'B', plug_destination=False) + + assert set(p.pattern.ports) == {'A', 'B'} + assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p.paths['A']) == 0 + assert not p.pattern.has_shapes() + assert not p.pattern.has_refs() + +def test_pather_trace_into_failure_rolls_back_ports_and_paths() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire') + + with pytest.raises(BuildError, match='does not match path ptype'): + p.trace_into('A', 'B', plug_destination=False, out_ptype='other') + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5)) + assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2) + assert len(p.paths['A']) == 0 + +def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire') + p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire') + + with pytest.raises(PortError, match='overwritten'): + p.trace_into('A', 'B', plug_destination=False, thru='other') + + assert set(p.pattern.ports) == {'A', 'B', 'other'} + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (-10, 0)) + assert numpy.allclose(p.pattern.ports['other'].offset, (3, 4)) + assert len(p.paths['A']) == 0 + +@pytest.mark.parametrize( + ('dst', 'kwargs', 'match'), + ( + (Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'trace_to\(\) arguments: x'), + (Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'jog\(\) arguments: length'), + (Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'uturn\(\) arguments: length'), + ), +) +def test_pather_trace_into_rejects_reserved_route_kwargs( + dst: Port, + kwargs: dict[str, Any], + match: str, + ) -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = dst + + with pytest.raises(BuildError, match=match): + p.trace_into('A', 'B', plug_destination=False, **kwargs) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset) + assert dst.rotation is not None + assert p.pattern.ports['B'].rotation is not None + assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation) + assert len(p.paths['A']) == 0 diff --git a/masque/test/test_poly_collection.py b/masque/test/test_poly_collection.py new file mode 100644 index 0000000..70b17d2 --- /dev/null +++ b/masque/test/test_poly_collection.py @@ -0,0 +1,89 @@ +import pytest +from numpy.testing import assert_equal + +from ..error import PatternError +from ..shapes import Circle, Ellipse, Polygon, PolyCollection + + +def test_poly_collection_init() -> 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) + assert len(list(pc.polygon_vertices)) == 2 + assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) + +def test_poly_collection_to_polygons() -> None: + verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] + offsets = [0, 4] + pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) + polys = pc.to_polygons() + assert len(polys) == 2 + assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]]) + +def test_poly_collection_holes() -> None: + # PolyCollection represents separate polygon boundaries, including nested boundaries. + verts = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], # Poly 1 + [2, 2], + [2, 8], + [8, 8], + [8, 2], # Poly 2 + ] + offsets = [0, 4] + pc = PolyCollection(verts, offsets) + polys = pc.to_polygons() + assert len(polys) == 2 + assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]]) + assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]]) + +def test_poly_collection_constituent_empty() -> None: + # Duplicate offsets create an empty constituent slice between valid polygons. + verts = [ + [0, 0], + [1, 0], + [0, 1], # Tri + [10, 10], + [11, 10], + [11, 11], + [10, 11], # Square + ] + offsets = [0, 3, 3] + pc = PolyCollection(verts, offsets) + with pytest.raises(PatternError): + pc.to_polygons() + +def test_poly_collection_valid() -> None: + verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] + offsets = [0, 3] + pc = PolyCollection(verts, offsets) + assert len(pc.to_polygons()) == 2 + shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))] + sorted_shapes = sorted(shapes) + assert len(sorted_shapes) == 4 + assert sorted(sorted_shapes) == sorted_shapes + +def test_poly_collection_normalized_form_reconstruction_is_independent() -> None: + pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0]) + _intrinsic, _extrinsic, rebuild = pc.normalized_form(1) + + clone = rebuild() + clone.vertex_offsets[:] = [5] + + assert_equal(pc.vertex_offsets, [0]) + assert_equal(clone.vertex_offsets, [5]) + +def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None: + pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0]) + _intrinsic, _extrinsic, rebuild = pc.normalized_form(1) + + first = rebuild() + second = rebuild() + first.vertex_offsets[:] = [7] + + assert_equal(first.vertex_offsets, [7]) + assert_equal(second.vertex_offsets, [0]) + assert_equal(pc.vertex_offsets, [0]) diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py deleted file mode 100644 index b518a1f..0000000 --- a/masque/test/test_renderpather.py +++ /dev/null @@ -1,199 +0,0 @@ -import pytest -from typing import cast, TYPE_CHECKING -from numpy.testing import assert_allclose -from numpy import pi - -from ..builder import Pather -from ..builder.tools import PathTool -from ..library import Library -from ..ports import Port - -if TYPE_CHECKING: - from ..shapes import Path - - -@pytest.fixture -def rpather_setup() -> tuple[Pather, PathTool, Library]: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - rp = Pather(lib, tools=tool, auto_render=False) - rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - return rp, tool, lib - - -def test_renderpather_basic(rpather_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - # Plan two segments - rp.at("start").straight(10).straight(10) - - # Before rendering, no shapes in pattern - assert not rp.pattern.has_shapes() - assert len(rp.paths["start"]) == 2 - - # Render - rp.render() - assert rp.pattern.has_shapes() - assert len(rp.pattern.shapes[(1, 0)]) == 1 - - # Path vertices should be (0,0), (0,-10), (0,-20) - # transformed by start port (rot pi/2 -> 270 deg transform) - # wait, PathTool.render for opcode L uses rotation_matrix_2d(port_rot + pi) - # start_port rot pi/2. pi/2 + pi = 3pi/2. - # (10, 0) rotated 3pi/2 -> (0, -10) - # So vertices: (0,0), (0,-10), (0,-20) - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert len(path_shape.vertices) == 3 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) - - -def test_renderpather_bend(rpather_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - # Plan straight then bend - rp.at("start").straight(10).cw(10) - - rp.render() - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - # Path vertices: - # 1. Start (0,0) - # 2. Straight end: (0, -10) - # 3. Bend end: (-1, -20) - # PathTool.planL(ccw=False, length=10) returns data=[10, -1] - # start_port for 2nd segment is at (0, -10) with rotation pi/2 - # dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20). - # and final end_port.offset is (-1, -20). - assert len(path_shape.vertices) == 4 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10) - - -def test_renderpather_jog_uses_native_pathtool_planS(rpather_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - rp.at("start").jog(4, length=10) - - assert len(rp.paths["start"]) == 1 - assert rp.paths["start"][0].opcode == "S" - - rp.render() - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - # Native PathTool S-bends place the jog width/2 before the route end. - assert_allclose(path_shape.vertices, [[0, 0], [0, -9], [4, -9], [4, -10]], atol=1e-10) - assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10) - - -def test_renderpather_mirror_preserves_planned_bend_geometry(rpather_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - rp.at("start").straight(10).cw(10) - - rp.mirror(0) - rp.render() - - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 20], [-1, 20]], atol=1e-10) - - -def test_renderpather_retool(rpather_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool1, lib = rpather_setup - tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") - - rp.at("start").straight(10) - rp.retool(tool2, keys=["start"]) - rp.at("start").straight(10) - - rp.render() - # Different tools should cause different batches/shapes - assert len(rp.pattern.shapes[(1, 0)]) == 1 - assert len(rp.pattern.shapes[(2, 0)]) == 1 - - -def test_portpather_translate_only_affects_future_steps(rpather_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - pp = rp.at("start") - pp.straight(10) - pp.translate((5, 0)) - pp.straight(10) - - rp.render() - - shapes = rp.pattern.shapes[(1, 0)] - assert len(shapes) == 2 - assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10) - assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10) - assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10) - - -def test_renderpather_dead_ports() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=1) - rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, auto_render=False) - rp.set_dead() - - # Impossible path - rp.straight("in", -10) - - # port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x. - assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10) - - # Verify no render steps were added - assert len(rp.paths["in"]) == 0 - - # Verify no geometry - rp.render() - assert not rp.pattern.has_shapes() - - -def test_renderpather_rename_port(rpather_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - rp.at("start").straight(10) - # Rename port while path is planned - rp.rename_ports({"start": "new_start"}) - # Continue path on new name - rp.at("new_start").straight(10) - - assert "start" not in rp.paths - assert len(rp.paths["new_start"]) == 2 - - rp.render() - assert rp.pattern.has_shapes() - assert len(rp.pattern.shapes[(1, 0)]) == 1 - # Total length 20. start_port rot pi/2 -> 270 deg transform. - # Vertices (0,0), (0,-10), (0,-20) - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) - assert "new_start" in rp.ports - assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10) - - -def test_renderpather_drop_keeps_pending_geometry_without_port(rpather_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = rpather_setup - rp.at("start").straight(10).drop() - - assert "start" not in rp.ports - assert len(rp.paths["start"]) == 1 - - rp.render() - assert rp.pattern.has_shapes() - assert "start" not in rp.ports - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10) - - -def test_pathtool_traceL_bend_geometry_matches_ports() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - - tree = tool.traceL(True, 10) - pat = tree.top_pattern() - path_shape = cast("Path", pat.shapes[(1, 0)][0]) - - assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10) - assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10) - - -def test_pathtool_traceS_geometry_matches_ports() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - - tree = tool.traceS(10, 4) - pat = tree.top_pattern() - path_shape = cast("Path", pat.shapes[(1, 0)][0]) - - assert_allclose(path_shape.vertices, [[0, 0], [9, 0], [9, 4], [10, 4]], atol=1e-10) - assert_allclose(pat.ports["B"].offset, [10, 4], atol=1e-10) - assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10) diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py index 0d0be41..00d2d7a 100644 --- a/masque/test/test_repetition.py +++ b/masque/test/test_repetition.py @@ -7,7 +7,6 @@ from ..error import PatternError 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)] @@ -34,7 +33,6 @@ def test_grid_get_bounds() -> None: def test_arbitrary_displacements() -> None: pts = [[0, 0], [10, 20], [-5, 30]] arb = Arbitrary(pts) - # They should be sorted by displacements.setter disps = arb.displacements assert len(disps) == 3 assert any((disps == [0, 0]).all(axis=1)) @@ -47,9 +45,7 @@ def test_arbitrary_transform() -> None: 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 + arb.mirror(0) 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 deleted file mode 100644 index 8e35841..0000000 --- a/masque/test/test_shape_advanced.py +++ /dev/null @@ -1,265 +0,0 @@ -from pathlib import Path -import pytest -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection -from ..error import PatternError - - -# 1. Text shape tests -def test_text_to_polygons() -> None: - pytest.importorskip("freetype") - font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" - if not Path(font_path).exists(): - pytest.skip("Font file not found") - - t = Text("Hi", height=10, font_path=font_path) - polys = t.to_polygons() - assert len(polys) > 0 - assert all(isinstance(p, Polygon) for p in polys) - - # Check that it advances - # Character 'H' and 'i' should have different vertices - # Each character is a set of polygons. We check the mean x of vertices for each character. - char_x_means = [p.vertices[:, 0].mean() for p in polys] - assert len(set(char_x_means)) >= 2 - - -def test_text_bounds_and_normalized_form() -> None: - pytest.importorskip("freetype") - font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" - if not Path(font_path).exists(): - pytest.skip("Font file not found") - - text = Text("Hi", height=10, font_path=font_path) - _intrinsic, extrinsic, ctor = text.normalized_form(5) - normalized = ctor() - - assert extrinsic[1] == 2 - assert normalized.height == 5 - - bounds = text.get_bounds_single() - assert bounds is not None - assert numpy.isfinite(bounds).all() - assert numpy.all(bounds[1] > bounds[0]) - - -def test_text_mirroring_affects_comparison() -> None: - text = Text("A", height=10, font_path="dummy.ttf") - mirrored = Text("A", height=10, font_path="dummy.ttf", mirrored=True) - - assert text != mirrored - assert (text < mirrored) != (mirrored < text) - - -# 2. Manhattanization tests -def test_manhattanize() -> None: - pytest.importorskip("float_raster") - pytest.importorskip("skimage.measure") - # Diamond shape - poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]]) - grid = numpy.arange(0, 11, 1) - - manhattan_polys = poly.manhattanize(grid, grid) - assert len(manhattan_polys) >= 1 - for mp in manhattan_polys: - # Check that all edges are axis-aligned - dv = numpy.diff(mp.vertices, axis=0) - # For each segment, either dx or dy must be zero - assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0)) - - -# 3. Comparison and Sorting tests -def test_shape_comparisons() -> None: - c1 = Circle(radius=10) - c2 = Circle(radius=20) - assert c1 < c2 - assert not (c2 < c1) - - p1 = Polygon([[0, 0], [10, 0], [10, 10]]) - p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex - assert p1 < p2 - - # Different types - assert c1 < p1 or p1 < c1 - assert (c1 < p1) != (p1 < c1) - - -# 4. Arc/Path Edge Cases -def test_arc_edge_cases() -> None: - # Wrapped arc (> 360 deg) - a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2) - a.to_polygons(num_vertices=64) - # Should basically be a ring - bounds = a.get_bounds_single() - assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10) - - -def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None: - ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200)) - bounds = ellipse.get_bounds_single() - poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single() - assert_allclose(bounds, poly_bounds, atol=1e-3) - - -def test_rotated_arc_bounds_match_polygonized_geometry() -> None: - arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200)) - bounds = arc.get_bounds_single() - poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() - assert_allclose(bounds, poly_bounds, atol=1e-3) - - -def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None: - arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4, - offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos) - bounds = arc.get_bounds_single() - poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() - assert_allclose(bounds, poly_bounds, atol=1e-3) - - -def test_curve_polygonizers_clamp_large_max_arclen() -> None: - for shape in ( - Circle(radius=10), - Ellipse(radii=(10, 20)), - Arc(radii=(10, 20), angles=(0, 1), width=2), - ): - polys = shape.to_polygons(num_vertices=None, max_arclen=1e9) - assert len(polys) == 1 - assert len(polys[0].vertices) >= 3 - - -def test_arc_polygonization_rejects_nan_implied_arclen() -> None: - arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2) - with pytest.raises(PatternError, match='valid max_arclen'): - arc.to_polygons(num_vertices=24) - - -def test_focus_arc_rejects_focus_outside_inner_boundary() -> None: - arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos) - with pytest.raises(PatternError, match='inside both arc boundary ellipses'): - arc.to_polygons(num_vertices=24) - - -def test_focus_arc_max_arclen_limits_segments() -> None: - arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg) - v = arc.to_polygons(max_arclen=2)[0].vertices - dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1)) - assert numpy.all(dist <= 2.000001) - - -def test_ellipse_integer_radii_scale_cleanly() -> None: - ellipse = Ellipse(radii=(10, 20)) - ellipse.scale_by(0.5) - assert_allclose(ellipse.radii, [5, 10]) - - -def test_arc_rejects_zero_radii_up_front() -> None: - with pytest.raises(PatternError, match='Radii must be positive'): - Arc(radii=(0, 5), angles=(0, 1), width=1) - with pytest.raises(PatternError, match='Radii must be positive'): - Arc(radii=(5, 0), angles=(0, 1), width=1) - with pytest.raises(PatternError, match='Radii must be positive'): - Arc(radii=(0, 0), angles=(0, 1), width=1) - - -def test_path_edge_cases() -> None: - # Zero-length segments - p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2) - polys = p.to_polygons() - assert len(polys) == 1 - assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]]) - - -# 5. PolyCollection with holes -def test_poly_collection_holes() -> None: - # Outer square, inner square hole - # PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do? - # wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple - # polygons or using specific winding rules. - # masque.shapes.Polygon doc says "specify an implicitly-closed boundary". - # Pyclipper is used in connectivity.py for holes. - - # Let's test PolyCollection with multiple polygons - verts = [ - [0, 0], - [10, 0], - [10, 10], - [0, 10], # Poly 1 - [2, 2], - [2, 8], - [8, 8], - [8, 2], # Poly 2 - ] - offsets = [0, 4] - pc = PolyCollection(verts, offsets) - polys = pc.to_polygons() - assert len(polys) == 2 - assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]]) - assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]]) - - -def test_poly_collection_constituent_empty() -> None: - # One real triangle, one "empty" polygon (0 vertices), one real square - # Note: Polygon requires 3 vertices, so "empty" here might mean just some junk - # that to_polygons should handle. - # Actually PolyCollection doesn't check vertex count per polygon. - verts = [ - [0, 0], - [1, 0], - [0, 1], # Tri - # Empty space - [10, 10], - [11, 10], - [11, 11], - [10, 11], # Square - ] - offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square? - # No, offsets should be strictly increasing or handle 0-length slices. - # vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)])) - # if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7] - offsets = [0, 3, 3] - pc = PolyCollection(verts, offsets) - # Polygon(vertices=[]) will fail because of the setter check. - # Let's see if pc.to_polygons() handles it. - # It calls Polygon(vertices=vv) for each slice. - # slice [3:3] gives empty vv. - with pytest.raises(PatternError): - pc.to_polygons() - - -def test_poly_collection_valid() -> None: - verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 3] - pc = PolyCollection(verts, offsets) - assert len(pc.to_polygons()) == 2 - shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))] - sorted_shapes = sorted(shapes) - assert len(sorted_shapes) == 4 - # Just verify it doesn't crash and is stable - assert sorted(sorted_shapes) == sorted_shapes - - -def test_poly_collection_normalized_form_reconstruction_is_independent() -> None: - pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0]) - _intrinsic, _extrinsic, rebuild = pc.normalized_form(1) - - clone = rebuild() - clone.vertex_offsets[:] = [5] - - assert_equal(pc.vertex_offsets, [0]) - assert_equal(clone.vertex_offsets, [5]) - - -def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None: - pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0]) - _intrinsic, _extrinsic, rebuild = pc.normalized_form(1) - - first = rebuild() - second = rebuild() - first.vertex_offsets[:] = [7] - - assert_equal(first.vertex_offsets, [7]) - assert_equal(second.vertex_offsets, [0]) - assert_equal(pc.vertex_offsets, [0]) diff --git a/masque/test/test_shape_ordering.py b/masque/test/test_shape_ordering.py new file mode 100644 index 0000000..fea4efa --- /dev/null +++ b/masque/test/test_shape_ordering.py @@ -0,0 +1,15 @@ +from ..shapes import Circle, Ellipse, Polygon + + +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]]) + assert p1 < p2 + + assert c1 < p1 or p1 < c1 + assert (c1 < p1) != (p1 < c1) diff --git a/masque/test/test_shape_transforms.py b/masque/test/test_shape_transforms.py new file mode 100644 index 0000000..2a9092a --- /dev/null +++ b/masque/test/test_shape_transforms.py @@ -0,0 +1,44 @@ +from numpy import pi +from numpy.testing import assert_equal, assert_allclose + +from ..shapes import Arc, Ellipse + + +def test_shape_mirror() -> None: + e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) + e.mirror(0) + assert_equal(e.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]) + assert_allclose(a.angles, [0, -pi / 4], atol=1e-10) + + a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) + a.mirror(1) + assert a.angle_ref == Arc.AngleRef.FocusNeg + + a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) + a.mirror(0) + assert a.angle_ref == Arc.AngleRef.FocusNeg + +def test_shape_flip_across() -> None: + e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) + e.flip_across(axis=0) + assert_equal(e.offset, [10, -20]) + assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) + + e = Ellipse(radii=(10, 5), offset=(10, 20)) + e.flip_across(y=10) + assert_equal(e.offset, [10, 0]) + +def test_shape_scale() -> None: + e = Ellipse(radii=(10, 5)) + e.scale_by(2) + assert_equal(e.radii, [20, 10]) + + a = Arc(radii=(10, 5), angles=(0, pi), width=2) + a.scale_by(0.5) + assert_equal(a.radii, [5, 2.5]) + assert a.width == 1 diff --git a/masque/test/test_shapes.py b/masque/test/test_shapes.py deleted file mode 100644 index e453f7c..0000000 --- a/masque/test/test_shapes.py +++ /dev/null @@ -1,174 +0,0 @@ -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection - - -def test_poly_collection_init() -> None: - # Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]] - verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 4] - pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) - assert len(list(pc.polygon_vertices)) == 2 - assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) - - -def test_poly_collection_to_polygons() -> None: - verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 4] - pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) - polys = pc.to_polygons() - assert len(polys) == 2 - assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]]) - - -def test_circle_init() -> None: - c = Circle(radius=10, offset=(5, 5)) - assert c.radius == 10 - assert_equal(c.offset, [5, 5]) - - -def test_circle_to_polygons() -> None: - c = Circle(radius=10) - polys = c.to_polygons(num_vertices=32) - assert len(polys) == 1 - assert isinstance(polys[0], Polygon) - # A circle with 32 vertices should have vertices distributed around (0,0) - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10) - - -def test_ellipse_init() -> None: - e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4) - assert_equal(e.radii, [10, 5]) - assert_equal(e.offset, [1, 2]) - assert e.rotation == pi / 4 - - -def test_ellipse_to_polygons() -> None: - e = Ellipse(radii=(10, 5)) - polys = e.to_polygons(num_vertices=64) - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10) - - -def test_arc_init() -> None: - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0)) - assert_equal(a.radii, [10, 10]) - assert_equal(a.angles, [0, pi / 2]) - assert a.width == 2 - - -def test_arc_to_polygons() -> None: - # Quarter circle arc - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - polys = a.to_polygons(num_vertices=32) - assert len(polys) == 1 - # Outer radius 11, inner radius 9 - # Quarter circle from 0 to 90 deg - bounds = polys[0].get_bounds_single() - # Min x should be 0 (inner edge start/stop or center if width is large) - # But wait, the arc is centered at 0,0. - # Outer edge goes from (11, 0) to (0, 11) - # Inner edge goes from (9, 0) to (0, 9) - # So x ranges from 0 to 11, y ranges from 0 to 11. - assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10) - - -def test_arc_focus_to_polygons() -> None: - a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos) - polys = a.to_polygons(num_vertices=32) - assert len(polys) == 1 - - focus = numpy.array([8.0, 0.0]) - cuts = a.get_cap_edges() - for angle, cut in zip(a.angles, cuts, strict=True): - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - for point in cut: - delta = point - focus - assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10) - assert numpy.dot(direction, delta) > 0 - - -def test_arc_circle_focus_matches_center() -> None: - center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos) - - assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices, - center.to_polygons(num_vertices=32)[0].vertices, - atol=1e-10) - - -def test_shape_mirror() -> None: - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) - e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset - assert_equal(e.offset, [10, 20]) - # rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi) - assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) - - a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20)) - a.mirror(0) - assert_equal(a.offset, [10, 20]) - # For Arc, mirror(0) negates rotation and angles - assert_allclose(a.angles, [0, -pi / 4], atol=1e-10) - - a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) - a.mirror(1) - assert a.angle_ref == Arc.AngleRef.FocusNeg - - a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) - a.mirror(0) - assert a.angle_ref == Arc.AngleRef.FocusNeg - - -def test_shape_flip_across() -> None: - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) - e.flip_across(axis=0) # Mirror across y=0: flips y-offset - assert_equal(e.offset, [10, -20]) - # rotation also flips: -pi/4 == 3pi/4 (mod pi) - assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) - # Mirror across specific y - e = Ellipse(radii=(10, 5), offset=(10, 20)) - e.flip_across(y=10) # Mirror across y=10 - # y=20 mirrored across y=10 -> y=0 - assert_equal(e.offset, [10, 0]) - - -def test_shape_scale() -> None: - e = Ellipse(radii=(10, 5)) - e.scale_by(2) - assert_equal(e.radii, [20, 10]) - - a = Arc(radii=(10, 5), angles=(0, pi), width=2) - a.scale_by(0.5) - assert_equal(a.radii, [5, 2.5]) - assert a.width == 1 - - -def test_shape_arclen() -> None: - # Test that max_arclen correctly limits segment lengths - - # Ellipse - e = Ellipse(radii=(10, 5)) - # Approximate perimeter is ~48.4 - # With max_arclen=5, should have > 10 segments - polys = e.to_polygons(max_arclen=5) - v = polys[0].vertices - dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1)) - assert numpy.all(dist <= 5.000001) - assert len(v) > 10 - - # Arc - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - # Outer perimeter is 11 * pi/2 ~ 17.27 - # Inner perimeter is 9 * pi/2 ~ 14.14 - # With max_arclen=2, should have > 8 segments on outer edge - polys = a.to_polygons(max_arclen=2) - v = polys[0].vertices - # Arc polygons are closed, but contain both inner and outer edges and caps - # Let's just check that all segment lengths are within limit - dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1)) - assert numpy.all(dist <= 2.000001) diff --git a/masque/test/test_text.py b/masque/test/test_text.py new file mode 100644 index 0000000..1b6770d --- /dev/null +++ b/masque/test/test_text.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest +import numpy + +from ..shapes import Polygon, Text + + +def test_text_to_polygons() -> None: + pytest.importorskip("freetype") + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" + if not Path(font_path).exists(): + pytest.skip("Font file not found") + + t = Text("Hi", height=10, font_path=font_path) + polys = t.to_polygons() + assert len(polys) > 0 + assert all(isinstance(p, Polygon) for p in polys) + + # Each character produces polygons with distinct horizontal placement. + char_x_means = [p.vertices[:, 0].mean() for p in polys] + assert len(set(char_x_means)) >= 2 + +def test_text_bounds_and_normalized_form() -> None: + pytest.importorskip("freetype") + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" + if not Path(font_path).exists(): + pytest.skip("Font file not found") + + text = Text("Hi", height=10, font_path=font_path) + _intrinsic, extrinsic, ctor = text.normalized_form(5) + normalized = ctor() + + assert extrinsic[1] == 2 + assert normalized.height == 5 + + bounds = text.get_bounds_single() + assert bounds is not None + assert numpy.isfinite(bounds).all() + assert numpy.all(bounds[1] > bounds[0]) + +def test_text_mirroring_affects_comparison() -> None: + text = Text("A", height=10, font_path="dummy.ttf") + mirrored = Text("A", height=10, font_path="dummy.ttf", mirrored=True) + + assert text != mirrored + assert (text < mirrored) != (mirrored < text) diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py index ddab9cd..d142896 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -33,19 +33,13 @@ def test_remove_colinear_vertices() -> None: def test_remove_colinear_vertices_exhaustive() -> None: - # U-turn v = [[0, 0], [10, 0], [0, 0]] v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True) - # Open path should keep ends. [10,0] is between [0,0] and [0,0]? - # They are colinear, but it's a 180 degree turn. - # We preserve 180 degree turns if preserve_uturns is True. assert len(v_clean) == 3 v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False) - # If not preserving u-turns, it should collapse to just the endpoints assert len(v_collapsed) == 2 - # 180 degree U-turn in closed path v = [[0, 0], [10, 0], [5, 0]] v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False) assert len(v_clean) == 2 diff --git a/masque/test/test_visualize.py b/masque/test/test_visualize.py index 4dab435..a20286c 100644 --- a/masque/test/test_visualize.py +++ b/masque/test/test_visualize.py @@ -43,7 +43,6 @@ def test_visualize_noninteractive(tmp_path) -> None: def test_visualize_empty() -> None: """ Test visualizing an empty pattern. """ pat = Pattern() - # Should not raise pat.visualize(overdraw=True) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") @@ -51,5 +50,4 @@ def test_visualize_no_refs() -> None: """ Test visualizing a pattern with only local shapes (no library needed). """ pat = Pattern() pat.polygon('L1', [[0, 0], [1, 0], [0, 1]]) - # Should not raise even if library is None pat.visualize(overdraw=True)