Compare commits

..

4 commits

12 changed files with 135 additions and 22 deletions

View file

@ -32,7 +32,6 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from itertools import combinations from itertools import combinations
from math import cos, sin
from typing import Any, Literal from typing import Any, Literal
import numpy import numpy
@ -41,7 +40,7 @@ from numpy.typing import ArrayLike
from ...error import BuildError, PortError from ...error import BuildError, PortError
from ...ports import Port 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 ( from ..tools import (
BendOffer, BendOffer,
PrimitiveKind, PrimitiveKind,
@ -579,10 +578,11 @@ class Solver:
out_port = step.out_port out_port = step.out_port
if out_port.rotation is None: if out_port.rotation is None:
raise ToolContractError('Primitive endpoints must have rotation') raise ToolContractError('Primitive endpoints must have rotation')
angle_cos = cos(angle) rotation = rotation_matrix_2d(angle)
angle_sin = sin(angle) out_x = float(out_port.x)
x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y) out_y = float(out_port.y)
y += angle_sin * float(out_port.x) + angle_cos * 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 angle += out_port.rotation + pi
ptype = out_port.ptype ptype = out_port.ptype
return Port((x, y), rotation=angle - pi, ptype=ptype) return Port((x, y), rotation=angle - pi, ptype=ptype)

View file

@ -287,7 +287,7 @@ class LibraryBuilder(INameView):
self, self,
source: Mapping[str, Pattern] | ILibraryView, 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', rename_when: Literal['conflict', 'always'] = 'conflict',
) -> dict[str, str]: ) -> dict[str, str]:
""" """
@ -309,6 +309,8 @@ class LibraryBuilder(INameView):
rename_theirs: Function used to choose visible names for imported rename_theirs: Function used to choose visible names for imported
source cells. Its `INameView` argument contains existing and source cells. Its `INameView` argument contains existing and
previously reserved names, but does not support pattern lookup. 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. rename_when: If `'conflict'`, only conflicting names are renamed.
If `'always'`, every imported source name is passed through If `'always'`, every imported source name is passed through
`rename_theirs`. `rename_theirs`.

View file

@ -10,7 +10,14 @@ from ..error import LibraryError
from ..pattern import Pattern, map_layers, map_targets from ..pattern import Pattern, map_layers, map_targets
from .base import ILibrary, ILibraryView from .base import ILibrary, ILibraryView
from .capabilities import IBorrowing, IMaterializable 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 from .mapping import LibraryView
if TYPE_CHECKING: if TYPE_CHECKING:
@ -304,7 +311,7 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
self, self,
source: Mapping[str, Pattern] | ILibraryView, 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', rename_when: Literal['conflict', 'always'] = 'conflict',
) -> dict[str, str]: ) -> dict[str, str]:
""" """
@ -317,6 +324,8 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
rename_theirs: Function used to choose visible names for imported rename_theirs: Function used to choose visible names for imported
source cells. Its `INameView` argument contains existing and source cells. Its `INameView` argument contains existing and
previously reserved names, but does not support pattern lookup. 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. rename_when: If `'conflict'`, only conflicting names are renamed.
If `'always'`, every imported source name is passed through If `'always'`, every imported source name is passed through
`rename_theirs`. `rename_theirs`.

View file

@ -1829,6 +1829,8 @@ def map_layers(
new_elements: defaultdict[layer_t, list[TT]] = defaultdict(list) new_elements: defaultdict[layer_t, list[TT]] = defaultdict(list)
for old_layer, seq in elements.items(): for old_layer, seq in elements.items():
new_layer = map_layer(old_layer) 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) new_elements[new_layer].extend(seq)
return new_elements return new_elements

View file

@ -574,10 +574,10 @@ class PortList(metaclass=ABCMeta):
raise PortError(msg) raise PortError(msg)
translations = a_offsets - b_offsets 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' msg = 'Port translations do not match:\n'
for nn, (kk, vv) in enumerate(connections.items()): 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' msg += f'{kk} | {translations[nn]} | {vv}\n'
raise PortError(msg) raise PortError(msg)

View file

@ -437,6 +437,19 @@ def test_build_library_add_source_can_rename_every_source_cell() -> None:
assert report.provenance["mapped_child"].requested_name == "child" 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: def test_library_builder_adds_an_ordinary_view_eagerly() -> None:
child = Pattern() child = Pattern()
top = Pattern() top = Pattern()

View file

@ -382,7 +382,7 @@ def test_gdsii_lazy_overlay_add_source_rename_when_validation() -> None:
src = _make_lazy_port_library() src = _make_lazy_port_library()
with pytest.raises(TypeError, match='rename_theirs'): 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'): with pytest.raises(ValueError, match='rename mode'):
OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type] OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type]

View file

@ -621,6 +621,26 @@ def test_overlay_add_source_callback_sees_earlier_name_reservations() -> None:
assert set(rename_map.values()) <= set(overlay) 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: def test_port_load_view_detaches_already_materialized_overlay_pattern() -> None:
overlay = OverlayLibrary() overlay = OverlayLibrary()
overlay.add_source(Library({"top": Pattern()})) 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') 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: def test_layer_mapped_view_materialization_and_copy_through() -> None:
pattern = Pattern() pattern = Pattern()
pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]]) pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]])

View file

@ -113,6 +113,22 @@ def test_pather_trace_into_shapes() -> None:
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) 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: def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None:
class TransitionTool(Tool): class TransitionTool(Tool):
def primitive_offers( def primitive_offers(

View file

@ -169,6 +169,17 @@ def test_port_list_plugged() -> None:
assert not pl.ports # Both should be removed 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: def test_port_list_plugged_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level("WARNING", logger="masque.ports") caplog.set_level("WARNING", logger="masque.ports")

View file

@ -89,6 +89,23 @@ def test_rotation_matrix_non_manhattan() -> None:
assert_allclose(m, [[s, -s], [s, s]], atol=1e-10) 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: def test_apply_transforms() -> None:
# cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)] # cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)]
t1 = [10, 20, 0, 0] t1 = [10, 20, 0, 0]

View file

@ -3,6 +3,7 @@ Geometric transforms
""" """
from collections.abc import Sequence from collections.abc import Sequence
from functools import lru_cache from functools import lru_cache
from math import acos
import numpy import numpy
from numpy.typing import NDArray, ArrayLike from numpy.typing import NDArray, ArrayLike
@ -13,8 +14,26 @@ from numpy import pi
R90 = pi / 2 R90 = pi / 2
R180 = pi 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 @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]: def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
""" """
2D rotation matrix for rotating counterclockwise around the origin. 2D rotation matrix for rotating counterclockwise around the origin.
@ -25,16 +44,11 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
Returns: Returns:
rotation matrix rotation matrix
""" """
arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)], theta = float(theta)
[numpy.sin(theta), +numpy.cos(theta)]]) quarter_turn = round(theta / R90)
if abs(theta - quarter_turn * R90) <= _MANHATTAN_SNAP_ATOL:
# If this was a manhattan rotation, round to remove some inaccuracies in sin & cos theta = _CARDINAL_ANGLES[quarter_turn % 4]
# cos(4*theta) is 1 for any multiple of pi/2. return _rotation_matrix_2d(theta)
if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12):
arr = numpy.round(arr)
arr.flags.writeable = False
return arr
def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]: def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]: