diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py index 282a835..5efcaa1 100644 --- a/masque/builder/planner/planner.py +++ b/masque/builder/planner/planner.py @@ -32,7 +32,6 @@ from __future__ import annotations from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace from itertools import combinations -from math import cos, sin from typing import Any, Literal import numpy @@ -41,7 +40,7 @@ from numpy.typing import ArrayLike from ...error import BuildError, PortError from ...ports import Port -from ...utils import PTypeMatch, SupportsBool, ptype_match, ptypes_compatible +from ...utils import PTypeMatch, SupportsBool, ptype_match, ptypes_compatible, rotation_matrix_2d from ..tools import ( BendOffer, PrimitiveKind, @@ -579,10 +578,11 @@ class Solver: out_port = step.out_port if out_port.rotation is None: raise ToolContractError('Primitive endpoints must have rotation') - angle_cos = cos(angle) - angle_sin = sin(angle) - x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y) - y += angle_sin * float(out_port.x) + angle_cos * float(out_port.y) + rotation = rotation_matrix_2d(angle) + out_x = float(out_port.x) + out_y = float(out_port.y) + x += rotation[0, 0] * out_x + rotation[0, 1] * out_y + y += rotation[1, 0] * out_x + rotation[1, 1] * out_y angle += out_port.rotation + pi ptype = out_port.ptype return Port((x, y), rotation=angle - pi, ptype=ptype) diff --git a/masque/library/build.py b/masque/library/build.py index c4b7f8f..e69fa9a 100644 --- a/masque/library/build.py +++ b/masque/library/build.py @@ -287,7 +287,7 @@ class LibraryBuilder(INameView): self, source: Mapping[str, Pattern] | ILibraryView, *, - rename_theirs: Callable[[INameView, str], str] | None = None, + rename_theirs: Callable[[INameView, str], str] | None = _rename_patterns, rename_when: Literal['conflict', 'always'] = 'conflict', ) -> dict[str, str]: """ @@ -309,6 +309,8 @@ class LibraryBuilder(INameView): rename_theirs: Function used to choose visible names for imported source cells. Its `INameView` argument contains existing and previously reserved names, but does not support pattern lookup. + By default, conflicting single-use names are made unique; + pass `None` to reject every conflict. rename_when: If `'conflict'`, only conflicting names are renamed. If `'always'`, every imported source name is passed through `rename_theirs`. diff --git a/masque/library/overlay.py b/masque/library/overlay.py index 698a457..529684b 100644 --- a/masque/library/overlay.py +++ b/masque/library/overlay.py @@ -10,7 +10,14 @@ from ..error import LibraryError from ..pattern import Pattern, map_layers, map_targets from .base import ILibrary, ILibraryView from .capabilities import IBorrowing, IMaterializable -from .utils import INameView, dangling_mode_t, _plan_source_names, _source_rename_map, _validate_dangling_mode +from .utils import ( + INameView, + dangling_mode_t, + _plan_source_names, + _rename_patterns, + _source_rename_map, + _validate_dangling_mode, + ) from .mapping import LibraryView if TYPE_CHECKING: @@ -304,7 +311,7 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): self, source: Mapping[str, Pattern] | ILibraryView, *, - rename_theirs: Callable[[INameView, str], str] | None = None, + rename_theirs: Callable[[INameView, str], str] | None = _rename_patterns, rename_when: Literal['conflict', 'always'] = 'conflict', ) -> dict[str, str]: """ @@ -317,6 +324,8 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): rename_theirs: Function used to choose visible names for imported source cells. Its `INameView` argument contains existing and previously reserved names, but does not support pattern lookup. + By default, conflicting single-use names are made unique; + pass `None` to reject every conflict. rename_when: If `'conflict'`, only conflicting names are renamed. If `'always'`, every imported source name is passed through `rename_theirs`. diff --git a/masque/pattern.py b/masque/pattern.py index a630552..c649543 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1829,6 +1829,8 @@ def map_layers( new_elements: defaultdict[layer_t, list[TT]] = defaultdict(list) for old_layer, seq in elements.items(): new_layer = map_layer(old_layer) + if new_layer is None: + raise PatternError(f'Layer mapping returned None for source layer {old_layer!r}') new_elements[new_layer].extend(seq) return new_elements diff --git a/masque/ports.py b/masque/ports.py index bbd18b7..552f081 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -574,10 +574,10 @@ class PortList(metaclass=ABCMeta): raise PortError(msg) translations = a_offsets - b_offsets - if not numpy.allclose(translations, 0): + if not numpy.allclose(a_offsets, b_offsets): msg = 'Port translations do not match:\n' for nn, (kk, vv) in enumerate(connections.items()): - if not numpy.allclose(translations[nn], 0): + if not numpy.allclose(a_offsets[nn], b_offsets[nn]): msg += f'{kk} | {translations[nn]} | {vv}\n' raise PortError(msg) diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index 36dd05d..e020912 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -437,6 +437,19 @@ def test_build_library_add_source_can_rename_every_source_cell() -> None: assert report.provenance["mapped_child"].requested_name == "child" +def test_build_library_add_source_renames_conflicting_single_use_name_by_default() -> None: + source = Library({'_myCellName$F': Pattern(), 'source_top': Pattern()}) + source['source_top'].ref('_myCellName$F') + builder = LibraryBuilder() + builder['_myCellName$F'] = Pattern() + + rename_map = builder.add_source(source) + built, _report = builder.build() + + assert rename_map == {'_myCellName$F': '_myCellName'} + assert set(built['source_top'].refs) == {'_myCellName'} + + def test_library_builder_adds_an_ordinary_view_eagerly() -> None: child = Pattern() top = Pattern() diff --git a/masque/test/test_gdsii_lazy.py b/masque/test/test_gdsii_lazy.py index d155d74..610d743 100644 --- a/masque/test/test_gdsii_lazy.py +++ b/masque/test/test_gdsii_lazy.py @@ -382,7 +382,7 @@ def test_gdsii_lazy_overlay_add_source_rename_when_validation() -> None: src = _make_lazy_port_library() with pytest.raises(TypeError, match='rename_theirs'): - OverlayLibrary().add_source(src, rename_when='always') + OverlayLibrary().add_source(src, rename_theirs=None, rename_when='always') with pytest.raises(ValueError, match='rename mode'): OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type] diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 32e7c3f..adc6f3c 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -621,6 +621,26 @@ def test_overlay_add_source_callback_sees_earlier_name_reservations() -> None: assert set(rename_map.values()) <= set(overlay) +def test_overlay_add_source_renames_conflicting_single_use_name_by_default() -> None: + overlay = OverlayLibrary() + overlay['_myCellName$F'] = Pattern() + source = Library({'_myCellName$F': Pattern(), 'source_top': Pattern()}) + source['source_top'].ref('_myCellName$F') + + rename_map = overlay.add_source(source) + + assert rename_map == {'_myCellName$F': '_myCellName'} + assert set(overlay['source_top'].refs) == {'_myCellName'} + + +def test_overlay_add_source_can_explicitly_disable_default_renaming() -> None: + overlay = OverlayLibrary() + overlay['_helper$A'] = Pattern() + + with pytest.raises(LibraryError, match='Conflicting name'): + overlay.add_source(Library({'_helper$A': Pattern()}), rename_theirs=None) + + def test_port_load_view_detaches_already_materialized_overlay_pattern() -> None: overlay = OverlayLibrary() overlay.add_source(Library({"top": Pattern()})) @@ -673,6 +693,15 @@ def test_layer_mapped_view_detaches_and_maps_shapes_and_labels() -> None: assert not hasattr(masque.library, 'PortsLibraryView') +def test_layer_mapped_view_preflight_rejects_none_layer() -> None: + pattern = Pattern() + pattern.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + mapped = LayerMappedView(Library({'top': pattern}), lambda _layer: None) # type: ignore[arg-type] + + with pytest.raises(PatternError, match=r"returned None for source layer \(1, 0\)"): + preflight_source_aware(mapped) + + def test_layer_mapped_view_materialization_and_copy_through() -> None: pattern = Pattern() pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]]) diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py index 9e5e268..48ab125 100644 --- a/masque/test/test_pather_trace_into.py +++ b/masque/test/test_pather_trace_into.py @@ -113,6 +113,22 @@ def test_pather_trace_into_shapes() -> None: assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) +def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None: + p = Pather( + Library(), + tools=PathTool(layer='M1', width=1000, ptype='wire'), + render='deferred', + ) + p.ports['src'] = Port((123.25, 456.75), rotation=0, ptype='wire') + p.ports['dst'] = Port((-99_999_876.75, 20_000_456.75), rotation=0, ptype='wire') + + p.trace_into('src', 'dst') + + assert 'src' not in p.ports + assert 'dst' not in p.ports + assert [step.kind for step in p._paths['src']] == ['straight', 'bend', 'straight', 'bend', 'plug'] + + def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None: class TransitionTool(Tool): def primitive_offers( diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py index f17921f..a4c560a 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -169,6 +169,17 @@ def test_port_list_plugged() -> None: assert not pl.ports # Both should be removed +def test_port_list_plugged_uses_coordinate_relative_tolerance() -> None: + pattern = Pattern(ports={ + "A": Port((10, 10), 0), + "B": Port((10 + 5e-8, 10), pi), + }) + + pattern.plugged({"A": "B"}) + + assert not pattern.ports + + def test_port_list_plugged_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level("WARNING", logger="masque.ports") diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py index f5e0215..fcef0de 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -89,6 +89,23 @@ def test_rotation_matrix_non_manhattan() -> None: assert_allclose(m, [[s, -s], [s, s]], atol=1e-10) +def test_rotation_matrix_canonicalizes_before_caching() -> None: + expected = rotation_matrix_2d(pi / 2) + + for angle in (pi / 2 + 1e-12, pi / 2 - 1e-12, -3 * pi / 2, 5 * pi / 2): + assert rotation_matrix_2d(angle) is expected + + assert not expected.flags.writeable + + +def test_rotation_matrix_does_not_snap_non_manhattan_angle() -> None: + cardinal = rotation_matrix_2d(pi / 2) + nearby = rotation_matrix_2d(pi / 2 + 2e-3) + + assert nearby is not cardinal + assert not numpy.array_equal(nearby, cardinal) + + def test_apply_transforms() -> None: # cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)] t1 = [10, 20, 0, 0] diff --git a/masque/utils/transform.py b/masque/utils/transform.py index 7b39122..935b0b3 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -3,6 +3,7 @@ Geometric transforms """ from collections.abc import Sequence from functools import lru_cache +from math import acos import numpy from numpy.typing import NDArray, ArrayLike @@ -13,8 +14,26 @@ from numpy import pi R90 = pi / 2 R180 = pi +# Preserve the effective tolerance of the historical +# ``isclose(cos(4 * theta), 1, atol=1e-12)`` Manhattan check. Expressing it as +# an angular distance lets us canonicalize before consulting the matrix cache. +_MANHATTAN_SNAP_ATOL = acos(1 - (1e-5 + 1e-12)) / 4 +_CARDINAL_ANGLES = (0.0, R90, R180, 3 * R90) + @lru_cache +def _rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: + """Build and cache an immutable matrix for a canonicalized angle.""" + arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)], + [numpy.sin(theta), +numpy.cos(theta)]]) + + if theta in _CARDINAL_ANGLES: + arr = numpy.round(arr) + + arr.flags.writeable = False + return arr + + def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: """ 2D rotation matrix for rotating counterclockwise around the origin. @@ -25,16 +44,11 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: Returns: rotation matrix """ - arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)], - [numpy.sin(theta), +numpy.cos(theta)]]) - - # If this was a manhattan rotation, round to remove some inaccuracies in sin & cos - # cos(4*theta) is 1 for any multiple of pi/2. - if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12): - arr = numpy.round(arr) - - arr.flags.writeable = False - return arr + theta = float(theta) + quarter_turn = round(theta / R90) + if abs(theta - quarter_turn * R90) <= _MANHATTAN_SNAP_ATOL: + theta = _CARDINAL_ANGLES[quarter_turn % 4] + return _rotation_matrix_2d(theta) def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]: