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 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)

View file

@ -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`.

View file

@ -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`.

View file

@ -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

View file

@ -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)

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"
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()

View file

@ -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]

View file

@ -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]])

View file

@ -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(

View file

@ -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")

View file

@ -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]

View file

@ -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]: