diff --git a/masque/builder/pather.py b/masque/builder/pather.py index e8804d1..49ad3be 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -228,7 +228,17 @@ class Pather(PortList): @logged_op(lambda args: list(args['mapping'].keys())) def rename_ports(self, mapping: dict[str, str | None], overwrite: bool = False) -> Self: self.pattern.rename_ports(mapping, overwrite) - renamed: dict[str, list[RenderStep]] = {vv: self.paths.pop(kk) for kk, vv in mapping.items() if kk in self.paths and vv is not None} + renamed: dict[str, list[RenderStep]] = {} + for kk, vv in mapping.items(): + if kk not in self.paths: + continue + steps = self.paths.pop(kk) + # Preserve deferred geometry even if the live port is deleted. + # `render()` can still materialize the saved steps using their stored start/end ports. + # Current semantics intentionally keep deleted ports' queued steps under the old key, + # so if a new live port later reuses that name it does not retarget the old geometry; + # the old and new routes merely share a render bucket until `render()` consumes them. + renamed[kk if vv is None else vv] = steps self.paths.update(renamed) return self @@ -789,6 +799,9 @@ class PortPather: # # Delegate to port # + # These mutate only the selected live port state. They do not rewrite already planned + # RenderSteps, so deferred geometry remains as previously planned and only future routing + # starts from the updated port. def set_ptype(self, ptype: str) -> Self: for port in self.ports: self.pather.pattern[port].set_ptype(ptype) @@ -865,8 +878,7 @@ class PortPather: def drop(self) -> Self: """ Remove selected ports from the pattern and the PortPather. """ - for pp in self.ports: - del self.pather.pattern.ports[pp] + self.pather.rename_ports({pp: None for pp in self.ports}) self.ports = [] return self @@ -880,7 +892,7 @@ class PortPather: if name is None: self.drop() return None - del self.pather.pattern.ports[name] + self.pather.rename_ports({name: None}) self.ports = [pp for pp in self.ports if pp != name] return self diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index 41d0881..495d305 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -300,3 +300,20 @@ def test_pather_uturn_failed_fallback_is_atomic() -> None: 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_renderpather_rename_to_none_keeps_pending_geometry_without_port() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + rp = RenderPather(lib, tools=tool) + 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_renderpather.py b/masque/test/test_renderpather.py index 73b5f46..0da9588 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -90,6 +90,22 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar assert len(rp.pattern.shapes[(2, 0)]) == 1 +def test_portpather_translate_only_affects_future_steps(rpather_setup: tuple[RenderPather, 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) @@ -132,6 +148,20 @@ def test_renderpather_rename_port(rpather_setup: tuple[RenderPather, PathTool, L assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10) +def test_renderpather_drop_keeps_pending_geometry_without_port(rpather_setup: tuple[RenderPather, 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")