masque/masque/test/test_pather_rendering.py

526 lines
19 KiB
Python

from typing import TYPE_CHECKING, cast
import logging
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose
from ..builder import Pather
from ..builder.tools import PathTool, RenderStep, StraightOffer, 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, render='deferred')
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return rp, tool, lib
def add_pending_straight(pather: Pather) -> None:
pather.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
pather.straight("start", 10)
def route_in_context(pather: Pather) -> None:
with pather:
add_pending_straight(pather)
def fail_in_context(pather: Pather) -> None:
with pather:
add_pending_straight(pather)
raise RuntimeError('body failed')
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])
# The bend route is explicit straight run plus a fixed-size bend.
assert len(path_shape.vertices) == 5
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -19], [0, -20], [-1, -20]], atol=1e-10)
def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").jog(4, length=10)
assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"]
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -1], [1, -1], [3, -1], [4, -1], [4, -2], [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, 19], [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, render='deferred')
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_pather_default_auto_policy_renders_immediately_outside_context() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool)
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
p.straight("start", 10)
assert p.pattern.has_shapes()
assert not any(p._paths.values())
def test_pather_default_auto_policy_defers_until_clean_context_exit() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with Pather(lib, tools=tool) as p:
add_pending_straight(p)
assert not p.pattern.has_shapes()
assert len(p._paths["start"]) == 1
assert p.pattern.has_shapes()
assert not any(p._paths.values())
def test_pather_context_warn_policy_logs_pending_paths(caplog: pytest.LogCaptureFixture) -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='warn') as p:
add_pending_straight(p)
records = [
record
for record in caplog.records
if 'Pather context exited with 1 pending render step on 1 port' in record.getMessage()
]
assert len(records) == 1
assert records[0].stack_info is None
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_error_policy_rejects_pending_paths() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='error')
with pytest.raises(BuildError, match='1 pending render step on 1 port'):
route_in_context(p)
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_ignore_policy_leaves_pending_paths_silent(caplog: pytest.LogCaptureFixture) -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='ignore') as p:
add_pending_straight(p)
assert not caplog.records
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_policy_does_not_mask_body_exception() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='error')
with pytest.raises(RuntimeError, match='body failed'):
fail_in_context(p)
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_rejects_invalid_render_policy() -> None:
with pytest.raises(BuildError, match='Invalid render policy'):
Pather(Library(), tools=PathTool(layer=(1, 0), width=2), render='later') # type: ignore[arg-type]
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_bend_offer_render_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(1)
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(1)),))
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert offer.length_domain == (1, 1)
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 1]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [1, 1], atol=1e-10)
def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
offer = tool.primitive_offers("s", in_ptype="wire")[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(4)
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(4)),))
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10)
assert_allclose(pat.ports["B"].rotation, 0, atol=1e-10)
def test_deferred_render_uturn_fallback() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
rp = Pather(lib, tools=tool, render='deferred')
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
rp.at('A').uturn(offset=10000, length=5000)
assert len(rp._paths['A']) == 4
assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', '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 primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, 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())
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_custom_tool_render_preserves_segment_subtrees() -> None:
class TraceTreeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: self._trace(length),
),)
def _trace(self, length, *, port_names=('A', 'B')) -> Library: # noqa: ANN001
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
})
child = Pattern(annotations={'length': [length]})
top.ref('_seg')
tree['_top'] = top
tree['_seg'] = child
return tree
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
assert len(batch) == 1
assert isinstance(batch[0].data, Library)
return batch[0].data
lib = Library()
p = Pather(lib, tools=TraceTreeTool())
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 primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
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(), render='deferred')
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 primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
})
top.ref('shared')
tree['_top'] = top
return tree
lib = Library()
lib['shared'] = Pattern(annotations={'shared': [1]})
p = Pather(lib, tools=SharedRefTool())
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())
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: bool) -> None:
class WrongEndpointTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length + 1, 0), pi, ptype='wire'),
})
return tree
lib = Library()
p = Pather(lib, tools=WrongEndpointTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render(append=append)
assert not p.pattern.refs
assert not lib
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
class WrongPtypeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='metal'),
})
return tree
lib = Library()
p = Pather(lib, tools=WrongPtypeTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render(append=append)
assert not p.pattern.refs
assert not lib
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, render='deferred')
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