diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 3478c32..e8804d1 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -415,8 +415,17 @@ class Pather(PortList): self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) return self - self._traceL(portspec, ccw0, L1, **(kwargs | {'out_ptype': None})) - self._traceL(portspec, not ccw0, L2, **(kwargs | {'plug_into': plug_into})) + try: + out_port0, data0 = tool.planL(ccw0, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None})) + out_port1, data1 = tool.planL(not ccw0, L2, in_ptype=out_port0.ptype, **kwargs) + except (BuildError, NotImplementedError): + if not self._dead: + raise + self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) + return self + + self._apply_step('L', portspec, out_port0, data0, tool) + self._apply_step('L', portspec, out_port1, data1, tool, plug_into) return self if out_port is not None: self._apply_step('S', portspec, out_port, data, tool, plug_into) @@ -436,14 +445,16 @@ class Pather(PortList): try: R = self._get_tool_R(tool, ccw, in_ptype, **kwargs) L1, L2 = length + R, abs(jog) - R - self._traceL(portspec, ccw, L1, **(kwargs | {'out_ptype': None})) - self._traceL(portspec, ccw, L2, **(kwargs | {'plug_into': plug_into})) + out_port0, data0 = tool.planL(ccw, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None})) + out_port1, data1 = tool.planL(ccw, L2, in_ptype=out_port0.ptype, **kwargs) except (BuildError, NotImplementedError): if not self._dead: raise self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=0) return self else: + self._apply_step('L', portspec, out_port0, data0, tool) + self._apply_step('L', portspec, out_port1, data1, tool, plug_into) return self if out_port is not None: self._apply_step('U', portspec, out_port, data, tool, plug_into) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 4a82b34..f8779bd 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1031,7 +1031,7 @@ class AutoTool(Tool, metaclass=ABCMeta): jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]: sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) + success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[0] + otrans_dxy[0]) if success: b_transition = None straight_length = 0 @@ -1244,7 +1244,7 @@ class PathTool(Tool, metaclass=ABCMeta): port_names: tuple[str, str] = ('A', 'B'), **kwargs, # noqa: ARG002 (unused) ) -> Library: - out_port, dxy = self.planL( + out_port, _data = self.planL( ccw, length, in_ptype=in_ptype, @@ -1252,7 +1252,12 @@ class PathTool(Tool, metaclass=ABCMeta): ) tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)]) + vertices: list[tuple[float, float]] + if ccw is None: + vertices = [(0.0, 0.0), (length, 0.0)] + else: + vertices = [(0.0, 0.0), (length, 0.0), tuple(out_port.offset)] + pat.path(layer=self.layer, width=self.width, vertices=vertices) if ccw is None: out_rot = pi @@ -1263,7 +1268,7 @@ class PathTool(Tool, metaclass=ABCMeta): pat.ports = { port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), - port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype), + port_names[1]: Port(out_port.offset, rotation=out_rot, ptype=self.ptype), } return tree diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 3df55f4..3aa6f07 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -453,6 +453,8 @@ class Path(Shape): def scale_by(self, c: float) -> 'Path': self.vertices *= c self.width *= c + if self.cap_extensions is not None: + self.cap_extensions *= c return self def normalized_form(self, norm_value: float) -> normalized_shape_tuple: @@ -476,13 +478,15 @@ class Path(Shape): reordered_vertices = rotated_vertices width0 = self.width / norm_value + cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions) - return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap), + return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0), (offset, scale / norm_value, rotation, False), lambda: Path( reordered_vertices * norm_value, - width=self.width * norm_value, + width=width0 * norm_value, cap=self.cap, + cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0), )) def clean_vertices(self) -> 'Path': diff --git a/masque/test/test_autotool_refactor.py b/masque/test/test_autotool_refactor.py index 677ddd6..d93f935 100644 --- a/masque/test/test_autotool_refactor.py +++ b/masque/test/test_autotool_refactor.py @@ -108,6 +108,62 @@ def test_autotool_planS_double_L(multi_bend_tool) -> None: assert data.ldata1.straight_length == 0 assert data.l2_length == 6 + +def test_autotool_planS_pure_sbend_with_transition_dx() -> None: + lib = Library() + + def make_straight(length: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((length, 0), pi, ptype="core") + return pat + + def make_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((10, jog), pi, ptype="core") + return pat + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core") + lib["xin"] = trans_pat + + tool = AutoTool( + straights=[ + AutoTool.Straight( + ptype="core", + fn=make_straight, + in_port_name="A", + out_port_name="B", + length_range=(1, 1e8), + ) + ], + bends=[], + sbends=[ + AutoTool.SBend( + ptype="core", + fn=make_sbend, + in_port_name="A", + out_port_name="B", + jog_range=(0, 1e8), + ) + ], + transitions={ + ("ext", "core"): AutoTool.Transition(lib.abstract("xin"), "EXT", "CORE"), + }, + default_out_ptype="core", + ) + + p, data = tool.planS(15, 4, in_ptype="ext") + + assert_allclose(p.offset, [15, 4]) + assert_allclose(p.rotation, pi) + 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 = RenderPather(lib, tools=tool) diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 0a04d98..e58bd10 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -6,6 +6,7 @@ from ..pattern import Pattern from ..error import LibraryError, PatternError from ..ports import Port from ..repetition import Grid +from ..shapes import Path from ..file.utils import preflight if TYPE_CHECKING: @@ -243,3 +244,18 @@ def test_library_get_name() -> None: name2 = lib.get_name("other") assert name2 == "other" + + +def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None: + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [ + Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)), + Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)), + ] + lib["top"] = pat + + lib.dedup(norm_value=1, threshold=2) + + assert not lib["top"].refs + assert len(lib["top"].shapes[(1, 0)]) == 2 diff --git a/masque/test/test_pack2d.py b/masque/test/test_pack2d.py index 5390a4c..914c23e 100644 --- a/masque/test/test_pack2d.py +++ b/masque/test/test_pack2d.py @@ -1,4 +1,4 @@ -from ..utils.pack2d import maxrects_bssf, pack_patterns +from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns from ..library import Library from ..pattern import Pattern @@ -25,6 +25,34 @@ def test_maxrects_bssf_reject() -> None: assert 0 not in rejects +def test_maxrects_bssf_exact_fill_rejects_remaining() -> None: + rects = [[20, 20], [1, 1]] + containers = [[0, 0, 20, 20]] + + locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True) + + assert tuple(locs[0]) == (0.0, 0.0) + assert rejects == {1} + + +def test_maxrects_bssf_presort_reject_mapping() -> None: + rects = [[10, 12], [19, 14], [13, 11]] + containers = [[0, 0, 20, 20]] + + _locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True) + + assert rejects == {0, 2} + + +def test_guillotine_bssf_sas_presort_reject_mapping() -> None: + rects = [[2, 1], [17, 15], [16, 11]] + containers = [[0, 0, 20, 20]] + + _locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True) + + assert rejects == {2} + + def test_pack_patterns() -> None: lib = Library() p1 = Pattern() @@ -49,3 +77,20 @@ def test_pack_patterns() -> None: # p1 size 10x10, effectively 12x12 # p2 size 5x5, effectively 7x7 # Both should fit in 20x20 + + +def test_pack_patterns_reject_names_match_original_patterns() -> None: + lib = Library() + for name, (lx, ly) in { + "p0": (10, 12), + "p1": (19, 14), + "p2": (13, 11), + }.items(): + pat = Pattern() + pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly) + lib[name] = pat + + pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0)) + + assert set(rejects) == {"p0", "p2"} + assert set(pat.refs) == {"p1"} diff --git a/masque/test/test_path.py b/masque/test/test_path.py index 766798f..1cdd872 100644 --- a/masque/test/test_path.py +++ b/masque/test/test_path.py @@ -1,4 +1,4 @@ -from numpy.testing import assert_equal +from numpy.testing import assert_equal, assert_allclose from ..shapes import Path @@ -79,3 +79,33 @@ def test_path_scale() -> None: p.scale_by(2) assert_equal(p.vertices, [[0, 0], [20, 0]]) assert p.width == 4 + + +def test_path_scale_custom_cap_extensions() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + p.scale_by(3) + + assert_equal(p.vertices, [[0, 0], [30, 0]]) + assert p.width == 6 + assert p.cap_extensions is not None + assert_allclose(p.cap_extensions, [3, 6]) + assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]]) + + +def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + + intrinsic, _extrinsic, ctor = p.normalized_form(5) + q = ctor() + + assert intrinsic[-1] == (0.2, 0.4) + assert q.width == 2 + assert q.cap_extensions is not None + assert_allclose(q.cap_extensions, [1, 2]) + + +def test_path_normalized_form_distinguishes_custom_caps() -> None: + p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + 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] diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index 9ac1b78..c837280 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -1,7 +1,9 @@ +import pytest import numpy from numpy import pi from masque import Pather, RenderPather, Library, Pattern, Port from masque.builder.tools import PathTool +from masque.error import BuildError def test_pather_trace_basic() -> None: lib = Library() @@ -240,3 +242,31 @@ def test_pather_trace_into() -> None: 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) + + +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=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_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_renderpather.py b/masque/test/test_renderpather.py index ee04671..3ad0d95 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -119,3 +119,14 @@ def test_renderpather_rename_port(rpather_setup: tuple[RenderPather, PathTool, L 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_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) diff --git a/masque/utils/pack2d.py b/masque/utils/pack2d.py index a99b01e..248f408 100644 --- a/masque/utils/pack2d.py +++ b/masque/utils/pack2d.py @@ -60,6 +60,12 @@ def maxrects_bssf( degenerate = (min_more & max_less).any(axis=0) regions = regions[~degenerate] + if regions.shape[0] == 0: + if allow_rejects: + rejected_inds.add(rect_ind) + continue + raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}') + ''' Place the rect ''' # Best short-side fit (bssf) to pick a region region_sizes = regions[:, 2:] - regions[:, :2] @@ -102,7 +108,7 @@ def maxrects_bssf( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = set(unsort_order[list(rejected_inds)]) + rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} return rect_locs, rejected_inds @@ -187,7 +193,7 @@ def guillotine_bssf_sas( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = set(unsort_order[list(rejected_inds)]) + rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} return rect_locs, rejected_inds