Compare commits
4 commits
93dd3503ca
...
ac34108253
| Author | SHA1 | Date | |
|---|---|---|---|
| ac34108253 | |||
| 4b0a899e17 | |||
| 2b6782c93e | |||
| e397b3f96e |
12 changed files with 135 additions and 22 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]])
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue