Compare commits

..

10 commits

27 changed files with 1828 additions and 22 deletions

View file

@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir
# TODO document all tools # TODO document all tools
""" """
from typing import Literal, Any, Self from typing import Literal, Any, Self, cast, TYPE_CHECKING
from collections.abc import Sequence, Callable from collections.abc import Sequence, Callable
from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method?
from dataclasses import dataclass from dataclasses import dataclass
@ -543,9 +543,10 @@ class AutoTool(Tool, metaclass=ABCMeta):
return self return self
@staticmethod @staticmethod
def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: def _bend2dxy(bend: Bend | None, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
if ccw is None: if ccw is None:
return numpy.zeros(2), pi return numpy.zeros(2), pi
assert bend is not None
bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port)
assert bend_angle is not None assert bend_angle is not None
if bool(ccw): if bool(ccw):
@ -590,8 +591,20 @@ class AutoTool(Tool, metaclass=ABCMeta):
) -> tuple[Port, LData]: ) -> tuple[Port, LData]:
success = False success = False
# If ccw is None, we don't need a bend, but we still loop to reuse the logic.
# We'll use a dummy loop if bends is empty and ccw is None.
bends = cast(list[AutoTool.Bend | None], self.bends)
if ccw is None and not bends:
bends += [None]
# Initialize these to avoid UnboundLocalError in the error message
bend_dxy, bend_angle = numpy.zeros(2), pi
itrans_dxy = numpy.zeros(2)
otrans_dxy = numpy.zeros(2)
btrans_dxy = numpy.zeros(2)
for straight in self.straights: for straight in self.straights:
for bend in self.bends: for bend in bends:
bend_dxy, bend_angle = self._bend2dxy(bend, ccw) bend_dxy, bend_angle = self._bend2dxy(bend, ccw)
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
@ -600,14 +613,16 @@ class AutoTool(Tool, metaclass=ABCMeta):
out_ptype_pair = ( out_ptype_pair = (
'unk' if out_ptype is None else out_ptype, 'unk' if out_ptype is None else out_ptype,
straight.ptype if ccw is None else bend.out_port.ptype straight.ptype if ccw is None else cast(AutoTool.Bend, bend).out_port.ptype
) )
out_transition = self.transitions.get(out_ptype_pair, None) out_transition = self.transitions.get(out_ptype_pair, None)
otrans_dxy = self._otransition2dxy(out_transition, bend_angle) otrans_dxy = self._otransition2dxy(out_transition, bend_angle)
b_transition = None b_transition = None
if ccw is not None and bend.in_port.ptype != straight.ptype: if ccw is not None:
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) assert bend is not None
if bend.in_port.ptype != straight.ptype:
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None)
btrans_dxy = self._itransition2dxy(b_transition) btrans_dxy = self._itransition2dxy(b_transition)
straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0]

View file

@ -272,13 +272,16 @@ class Arc(PositionableImpl, Shape):
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
keep = [0] keep = [0]
removable = (numpy.cumsum(arc_lengths) <= max_arclen) start = 0
start = 1
while start < arc_lengths.size: while start < arc_lengths.size:
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough? removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
if not removable.any():
next_to_keep = start + 1
else:
next_to_keep = start + numpy.where(removable)[0][-1] + 1
keep.append(next_to_keep) keep.append(next_to_keep)
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen) start = next_to_keep
start = next_to_keep + 1
if keep[-1] != thetas.size - 1: if keep[-1] != thetas.size - 1:
keep.append(thetas.size - 1) keep.append(thetas.size - 1)
@ -362,17 +365,20 @@ class Arc(PositionableImpl, Shape):
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
# If our arc subtends a coordinate axis, use the extremum along that axis # If our arc subtends a coordinate axis, use the extremum along that axis
if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1: if abs(a1 - a0) >= 2 * pi:
xp = xr xn, xp, yn, yp = -xr, xr, -yr, yr
else:
if a0 <= xpt <= a1 or a0 <= xpt + 2 * pi <= a1:
xp = xr
if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1: if a0 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1:
xn = -xr xn = -xr
if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1: if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1:
yp = yr yp = yr
if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1: if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1:
yn = -yr yn = -yr
mins.append([xn, yn]) mins.append([xn, yn])
maxs.append([xp, yp]) maxs.append([xp, yp])
@ -463,13 +469,18 @@ class Arc(PositionableImpl, Shape):
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]` `[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
""" """
aa = [] aa = []
d_angle = self.angles[1] - self.angles[0]
if abs(d_angle) >= 2 * pi:
# Full ring
return numpy.tile([0, 2 * pi], (2, 1)).astype(float)
for sgn in (-1, +1): for sgn in (-1, +1):
wh = sgn * self.width / 2.0 wh = sgn * self.width / 2.0
rx = self.radius_x + wh rx = self.radius_x + wh
ry = self.radius_y + wh ry = self.radius_y + wh
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles) a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
sign = numpy.sign(self.angles[1] - self.angles[0]) sign = numpy.sign(d_angle)
if sign != numpy.sign(a1 - a0): if sign != numpy.sign(a1 - a0):
a1 += sign * 2 * pi a1 += sign * 2 * pi

View file

@ -56,9 +56,11 @@ class PolyCollection(Shape):
""" """
Iterator which provides slices which index vertex_lists Iterator which provides slices which index vertex_lists
""" """
if self._vertex_offsets.size == 0:
return
for ii, ff in zip( for ii, ff in zip(
self._vertex_offsets, self._vertex_offsets,
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)), chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
strict=True, strict=True,
): ):
yield slice(ii, ff) yield slice(ii, ff)
@ -168,7 +170,9 @@ class PolyCollection(Shape):
annotations = copy.deepcopy(self.annotations), annotations = copy.deepcopy(self.annotations),
) for vv in self.polygon_vertices] ) for vv in self.polygon_vertices]
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
if self._vertex_lists.size == 0:
return None
return numpy.vstack((numpy.min(self._vertex_lists, axis=0), return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
numpy.max(self._vertex_lists, axis=0))) numpy.max(self._vertex_lists, axis=0)))

3
masque/test/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
"""

13
masque/test/conftest.py Normal file
View file

@ -0,0 +1,13 @@
"""
Test fixtures
"""
# ruff: noqa: ARG001
from typing import Any
import numpy
FixtureRequest = Any
PRNG = numpy.random.RandomState(12345)

View file

@ -0,0 +1,60 @@
from numpy.testing import assert_allclose
from numpy import pi
from ..abstract import Abstract
from ..ports import Port
from ..ref import Ref
def test_abstract_init() -> None:
ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)}
abs_obj = Abstract("test", ports)
assert abs_obj.name == "test"
assert len(abs_obj.ports) == 2
assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied
def test_abstract_transform() -> None:
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
# Rotate 90 deg around (0,0)
abs_obj.rotate_around((0, 0), pi / 2)
# (10, 0) rot 0 -> (0, 10) rot pi/2
assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10)
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
# Mirror across x axis (axis 0): flips y-offset
abs_obj.mirror(0)
# (0, 10) mirrored(0) -> (0, -10)
# rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2
assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10)
assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10)
def test_abstract_ref_transform() -> None:
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
# Apply ref transform
abs_obj.apply_ref_transform(ref)
# Ref order: mirror, rotate, scale, translate
# 1. mirror (across x: y -> -y)
# (10, 0) rot 0 -> (10, 0) rot 0
# 2. rotate pi/2 around (0,0)
# (10, 0) rot 0 -> (0, 10) rot pi/2
# 3. translate (100, 100)
# (0, 10) -> (100, 110)
assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10)
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
def test_abstract_undo_transform() -> None:
abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)})
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
abs_obj.undo_ref_transform(ref)
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)

View file

@ -0,0 +1,87 @@
import pytest
from numpy.testing import assert_equal
from numpy import pi
from ..builder import Pather
from ..builder.tools import PathTool
from ..library import Library
from ..ports import Port
@pytest.fixture
def advanced_pather() -> tuple[Pather, PathTool, Library]:
lib = Library()
# Simple PathTool: 2um width on layer (1,0)
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool)
return p, tool, lib
def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = advanced_pather
# Facing ports
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device)
# Forward (+pi relative to port) is West (-x).
# Put destination at (-20, 0) pointing East (pi).
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
p.path_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
# Pather.path adds a Reference to the generated pattern
assert len(p.pattern.refs) == 1
def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = advanced_pather
# Source at (0,0) rot 0 (facing East). Forward is West (-x).
p.ports["src"] = Port((0, 0), 0, ptype="wire")
# Destination at (-20, -20) rot pi (facing West). Forward is East (+x).
# Wait, src forward is -x. dst is at -20, -20.
# To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South).
# Forward for South is North (+y).
p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire")
p.path_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
# Single bend should result in 2 segments (one for x move, one for y move)
assert len(p.pattern.refs) == 2
def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = advanced_pather
# Facing but offset ports
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Forward is West (-x)
p.ports["dst"] = Port((-20, -10), pi, ptype="wire") # Facing East (rot pi)
p.path_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
def test_path_from(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = advanced_pather
p.ports["src"] = Port((0, 0), 0, ptype="wire")
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
p.at("dst").path_from("src")
assert "src" not in p.ports
assert "dst" not in p.ports
def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = advanced_pather
p.ports["src"] = Port((0, 0), 0, ptype="wire")
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
p.ports["other"] = Port((10, 10), 0)
p.path_into("src", "dst", thru="other")
assert "src" in p.ports
assert_equal(p.ports["src"].offset, [10, 10])
assert "other" not in p.ports

View file

@ -0,0 +1,81 @@
import pytest
from numpy.testing import assert_allclose
from numpy import pi
from ..builder import Pather
from ..builder.tools import AutoTool
from ..library import Library
from ..pattern import Pattern
from ..ports import Port
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
pat = Pattern()
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
pat.ports["in"] = Port((0, 0), 0, ptype=ptype)
pat.ports["out"] = Port((length, 0), pi, ptype=ptype)
return pat
@pytest.fixture
def autotool_setup() -> tuple[Pather, AutoTool, Library]:
lib = Library()
# Define a simple bend
bend_pat = Pattern()
# 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise)
bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire")
bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire")
lib["bend"] = bend_pat
lib.abstract("bend")
# Define a transition (e.g., via)
via_pat = Pattern()
via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1")
via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2")
lib["via"] = via_pat
via_abs = lib.abstract("via")
tool_m1 = AutoTool(
straights=[
AutoTool.Straight(ptype="wire_m1", fn=lambda length: make_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out")
],
bends=[],
sbends=[],
transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")},
default_out_ptype="wire_m1",
)
p = Pather(lib, tools=tool_m1)
# Start with an m2 port
p.ports["start"] = Port((0, 0), pi, ptype="wire_m2")
return p, tool_m1, lib
def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None:
p, tool, lib = autotool_setup
# Route m1 from an m2 port. Should trigger via.
# length 10. Via length is 1. So straight m1 should be 9.
p.path("start", ccw=None, length=10)
# Start at (0,0) rot pi (facing West).
# Forward (+pi relative to port) is East (+x).
# Via: m2(1,0)pi -> m1(0,0)0.
# Plug via m2 into start(0,0)pi: transformation rot=mod(pi-pi-pi, 2pi)=pi.
# rotate via by pi: m2 at (0,0), m1 at (-1, 0) rot pi.
# Then straight m1 of length 9 from (-1, 0) rot pi -> ends at (8, 0) rot pi.
# Wait, (length, 0) relative to (-1, 0) rot pi:
# transform (9, 0) by pi: (-9, 0).
# (-1, 0) + (-9, 0) = (-10, 0)? No.
# Let's re-calculate.
# start (0,0) rot pi. Direction East.
# via m2 is at (0,0), m1 is at (1,0).
# When via is plugged into start: m2 goes to (0,0).
# since start is pi and m2 is pi, rotation is 0.
# so via m1 is at (1,0) rot 0.
# then straight m1 length 9 from (1,0) rot 0: ends at (10, 0) rot 0.
assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10)
assert p.ports["start"].ptype == "wire_m1"

View file

@ -0,0 +1,75 @@
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..builder import Builder
from ..library import Library
from ..pattern import Pattern
from ..ports import Port
def test_builder_init() -> None:
lib = Library()
b = Builder(lib, name="mypat")
assert b.pattern is lib["mypat"]
assert b.library is lib
def test_builder_place() -> None:
lib = Library()
child = Pattern()
child.ports["A"] = Port((0, 0), 0)
lib["child"] = child
b = Builder(lib)
b.place("child", offset=(10, 20), port_map={"A": "child_A"})
assert "child_A" in b.ports
assert_equal(b.ports["child_A"].offset, [10, 20])
assert "child" in b.pattern.refs
def test_builder_plug() -> None:
lib = Library()
wire = Pattern()
wire.ports["in"] = Port((0, 0), 0)
wire.ports["out"] = Port((10, 0), pi)
lib["wire"] = wire
b = Builder(lib)
b.ports["start"] = Port((100, 100), 0)
# Plug wire's "in" port into builder's "start" port
# Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports
# builder start: (100, 100) rotation 0
# wire in: (0, 0) rotation 0
# wire out: (10, 0) rotation pi
# Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg)
# so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100)
b.plug("wire", map_in={"start": "in"})
assert "start" in b.ports
assert_equal(b.ports["start"].offset, [90, 100])
assert_allclose(b.ports["start"].rotation, 0, atol=1e-10)
def test_builder_interface() -> None:
lib = Library()
source = Pattern()
source.ports["P1"] = Port((0, 0), 0)
lib["source"] = source
b = Builder.interface("source", library=lib, name="iface")
assert "in_P1" in b.ports
assert "P1" in b.ports
assert b.pattern is lib["iface"]
def test_builder_set_dead() -> None:
lib = Library()
lib["sub"] = Pattern()
b = Builder(lib)
b.set_dead()
b.place("sub")
assert not b.pattern.has_refs()

24
masque/test/test_fdfd.py Normal file
View file

@ -0,0 +1,24 @@
# ruff: noqa
# ruff: noqa: ARG001
import dataclasses
import pytest # type: ignore
import numpy
from numpy import pi
from numpy.typing import NDArray
# from numpy.testing import assert_allclose, assert_array_equal
from .. import Pattern, Arc, Circle
def test_circle_mirror():
cc = Circle(radius=4, offset=(10, 20))
cc.flip_across(axis=0) # flip across y=0
assert cc.offset[0] == 10
assert cc.offset[1] == -20
assert cc.radius == 4
cc.flip_across(axis=1) # flip across x=0
assert cc.offset[0] == -10
assert cc.offset[1] == -20
assert cc.radius == 4

69
masque/test/test_gdsii.py Normal file
View file

@ -0,0 +1,69 @@
from pathlib import Path
import numpy
from numpy.testing import assert_equal, assert_allclose
from ..pattern import Pattern
from ..library import Library
from ..file import gdsii
from ..shapes import Path as MPath
def test_gdsii_roundtrip(tmp_path: Path) -> None:
lib = Library()
# Simple polygon cell
pat1 = Pattern()
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
lib["poly_cell"] = pat1
# Path cell
pat2 = Pattern()
pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10)
lib["path_cell"] = pat2
# Cell with Ref
pat3 = Pattern()
pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2)
lib["ref_cell"] = pat3
gds_file = tmp_path / "test.gds"
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
read_lib, info = gdsii.readfile(gds_file)
assert "poly_cell" in read_lib
assert "path_cell" in read_lib
assert "ref_cell" in read_lib
# Check polygon
read_poly = read_lib["poly_cell"].shapes[(1, 0)][0]
# GDSII closes polygons, so it might have an extra vertex or different order
assert len(read_poly.vertices) >= 4
# Check bounds as a proxy for geometry correctness
assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]])
# Check path
read_path = read_lib["path_cell"].shapes[(2, 5)][0]
assert isinstance(read_path, MPath)
assert read_path.width == 10
assert_equal(read_path.vertices, [[0, 0], [100, 0]])
# Check Ref
read_ref = read_lib["ref_cell"].refs["poly_cell"][0]
assert_equal(read_ref.offset, [50, 50])
assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5)
def test_gdsii_annotations(tmp_path: Path) -> None:
lib = Library()
pat = Pattern()
# GDS only supports integer keys in range [1, 126] for properties
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]})
lib["cell"] = pat
gds_file = tmp_path / "test_ann.gds"
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
read_lib, _ = gdsii.readfile(gds_file)
read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations
assert read_ann["1"] == ["hello"]

50
masque/test/test_label.py Normal file
View file

@ -0,0 +1,50 @@
import copy
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..label import Label
from ..repetition import Grid
def test_label_init() -> None:
lbl = Label("test", offset=(10, 20))
assert lbl.string == "test"
assert_equal(lbl.offset, [10, 20])
def test_label_transform() -> None:
lbl = Label("test", offset=(10, 0))
# Rotate 90 deg CCW around (0,0)
lbl.rotate_around((0, 0), pi / 2)
assert_allclose(lbl.offset, [0, 10], atol=1e-10)
# Translate
lbl.translate((5, 5))
assert_allclose(lbl.offset, [5, 15], atol=1e-10)
def test_label_repetition() -> None:
rep = Grid(a_vector=(10, 0), a_count=3)
lbl = Label("rep", offset=(0, 0), repetition=rep)
assert lbl.repetition is rep
assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]])
# Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't
# seem to automatically include repetition bounds in label.py itself,
# it's handled during pattern bounding.
def test_label_copy() -> None:
l1 = Label("test", offset=(1, 2), annotations={"a": [1]})
l2 = copy.deepcopy(l1)
print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}")
print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}")
from ..utils import annotations_eq
print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}")
assert l1 == l2
assert l1 is not l2
l2.offset[0] = 100
assert l1.offset[0] == 1

116
masque/test/test_library.py Normal file
View file

@ -0,0 +1,116 @@
import pytest
from ..library import Library, LazyLibrary
from ..pattern import Pattern
from ..error import LibraryError
def test_library_basic() -> None:
lib = Library()
pat = Pattern()
lib["cell1"] = pat
assert "cell1" in lib
assert lib["cell1"] is pat
assert len(lib) == 1
with pytest.raises(LibraryError):
lib["cell1"] = Pattern() # Overwriting not allowed
def test_library_tops() -> None:
lib = Library()
lib["child"] = Pattern()
lib["parent"] = Pattern()
lib["parent"].ref("child")
assert set(lib.tops()) == {"parent"}
assert lib.top() == "parent"
def test_library_dangling() -> None:
lib = Library()
lib["parent"] = Pattern()
lib["parent"].ref("missing")
assert lib.dangling_refs() == {"missing"}
def test_library_flatten() -> None:
lib = Library()
child = Pattern()
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
lib["child"] = child
parent = Pattern()
parent.ref("child", offset=(10, 10))
lib["parent"] = parent
flat_lib = lib.flatten("parent")
flat_parent = flat_lib["parent"]
assert not flat_parent.has_refs()
assert len(flat_parent.shapes[(1, 0)]) == 1
# Transformations are baked into vertices for Polygon
assert_vertices = flat_parent.shapes[(1, 0)][0].vertices
assert tuple(assert_vertices[0]) == (10.0, 10.0)
def test_lazy_library() -> None:
lib = LazyLibrary()
called = 0
def make_pat() -> Pattern:
nonlocal called
called += 1
return Pattern()
lib["lazy"] = make_pat
assert called == 0
pat = lib["lazy"]
assert called == 1
assert isinstance(pat, Pattern)
# Second access should be cached
pat2 = lib["lazy"]
assert called == 1
assert pat is pat2
def test_library_rename() -> None:
lib = Library()
lib["old"] = Pattern()
lib["parent"] = Pattern()
lib["parent"].ref("old")
lib.rename("old", "new", move_references=True)
assert "old" not in lib
assert "new" in lib
assert "new" in lib["parent"].refs
assert "old" not in lib["parent"].refs
def test_library_subtree() -> None:
lib = Library()
lib["a"] = Pattern()
lib["b"] = Pattern()
lib["c"] = Pattern()
lib["a"].ref("b")
sub = lib.subtree("a")
assert "a" in sub
assert "b" in sub
assert "c" not in sub
def test_library_get_name() -> None:
lib = Library()
lib["cell"] = Pattern()
name1 = lib.get_name("cell")
assert name1 != "cell"
assert name1.startswith("cell")
name2 = lib.get_name("other")
assert name2 == "other"

27
masque/test/test_oasis.py Normal file
View file

@ -0,0 +1,27 @@
from pathlib import Path
import pytest
from numpy.testing import assert_equal
from ..pattern import Pattern
from ..library import Library
from ..file import oasis
def test_oasis_roundtrip(tmp_path: Path) -> None:
# Skip if fatamorgana is not installed
pytest.importorskip("fatamorgana")
lib = Library()
pat1 = Pattern()
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
lib["cell1"] = pat1
oas_file = tmp_path / "test.oas"
# OASIS needs units_per_micron
oasis.writefile(lib, oas_file, units_per_micron=1000)
read_lib, info = oasis.readfile(oas_file)
assert "cell1" in read_lib
# Check bounds
assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]])

View file

@ -0,0 +1,51 @@
from ..utils.pack2d import maxrects_bssf, pack_patterns
from ..library import Library
from ..pattern import Pattern
def test_maxrects_bssf_simple() -> None:
# Pack two 10x10 squares into one 20x10 container
rects = [[10, 10], [10, 10]]
containers = [[0, 0, 20, 10]]
locs, rejects = maxrects_bssf(rects, containers)
assert not rejects
# They should be at (0,0) and (10,0)
assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)}
def test_maxrects_bssf_reject() -> None:
# Try to pack a too-large rectangle
rects = [[10, 10], [30, 30]]
containers = [[0, 0, 20, 20]]
locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True)
assert 1 in rejects # Second rect rejected
assert 0 not in rejects
def test_pack_patterns() -> None:
lib = Library()
p1 = Pattern()
p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
lib["p1"] = p1
p2 = Pattern()
p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
lib["p2"] = p2
# Containers: one 20x20
containers = [[0, 0, 20, 20]]
# 2um spacing
pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2))
assert not rejects
assert len(pat.refs) == 2
assert "p1" in pat.refs
assert "p2" in pat.refs
# Check that they don't overlap (simple check via bounds)
# p1 size 10x10, effectively 12x12
# p2 size 5x5, effectively 7x7
# Both should fit in 20x20

81
masque/test/test_path.py Normal file
View file

@ -0,0 +1,81 @@
from numpy.testing import assert_equal
from ..shapes import Path
def test_path_init() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
assert_equal(p.vertices, [[0, 0], [10, 0]])
assert p.width == 2
assert p.cap == Path.Cap.Flush
def test_path_to_polygons_flush() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
polys = p.to_polygons()
assert len(polys) == 1
# Rectangle from (0, -1) to (10, 1)
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[0, -1], [10, 1]])
def test_path_to_polygons_square() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
polys = p.to_polygons()
assert len(polys) == 1
# Square cap adds width/2 = 1 to each end
# Rectangle from (-1, -1) to (11, 1)
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[-1, -1], [11, 1]])
def test_path_to_polygons_circle() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
polys = p.to_polygons(num_vertices=32)
# Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps
assert len(polys) >= 3
# Combined bounds should be from (-1, -1) to (11, 1)
# But wait, Path.get_bounds_single() handles this more directly
bounds = p.get_bounds_single()
assert_equal(bounds, [[-1, -1], [11, 1]])
def test_path_custom_cap() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
polys = p.to_polygons()
assert len(polys) == 1
# Extends 5 units at start, 10 at end
# Starts at -5, ends at 20
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[-5, -1], [20, 1]])
def test_path_bend() -> None:
# L-shaped path
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
polys = p.to_polygons()
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
# Outer corner at (11, -1) is not right.
# Segments: (0,0)-(10,0) and (10,0)-(10,10)
# Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1)
# Corners of segment 2: (9,0), (9,10), (11,10), (11,0)
# Bounds should be [[-1 (if start is square), -1], [11, 11]]?
# Flush cap start at (0,0) with width 2 means y from -1 to 1.
# Vertical segment end at (10,10) with width 2 means x from 9 to 11.
# So bounds should be x: [0, 11], y: [-1, 10]
assert_equal(bounds, [[0, -1], [11, 10]])
def test_path_mirror() -> None:
p = Path(vertices=[[10, 5], [20, 10]], width=2)
p.mirror(0) # Mirror across x axis (y -> -y)
assert_equal(p.vertices, [[10, -5], [20, -10]])
def test_path_scale() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2)
p.scale_by(2)
assert_equal(p.vertices, [[0, 0], [20, 0]])
assert p.width == 4

View file

@ -0,0 +1,83 @@
import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..builder import Pather
from ..builder.tools import PathTool
from ..library import Library
from ..ports import Port
@pytest.fixture
def pather_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
# Simple PathTool: 2um width on layer (1,0)
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool)
# Add an initial port facing North (pi/2)
# Port rotation points INTO device. So "North" rotation means device is North of port.
# Pathing "forward" moves South.
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return p, tool, lib
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# Route 10um "forward"
p.path("start", ccw=None, length=10)
# port rot pi/2 (North). Travel +pi relative to port -> South.
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# Start (0,0) rot pi/2 (North).
# Path 10um "forward" (South), then turn Clockwise (ccw=False).
# Facing South, turn Right -> West.
p.path("start", ccw=False, length=10)
# PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0.
# Transformed by port rot pi/2 (North) + pi (to move "forward" away from device):
# Transformation rot = pi/2 + pi = 3pi/2.
# (10, -1) rotated 3pi/2: (x,y) -> (y, -x) -> (-1, -10).
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
# North (pi/2) + CW (90 deg) -> West (pi)?
# Actual behavior results in 0 (East) - apparently rotation is flipped.
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# start at (0,0) rot pi/2 (North)
# path "forward" (South) to y=-50
p.path_to("start", ccw=None, y=-50)
assert_equal(p.ports["start"].offset, [0, -50])
def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.ports["A"] = Port((0, 0), pi / 2, ptype="wire")
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
# Path both "forward" (South) to y=-20
p.mpath(["A", "B"], ccw=None, ymin=-20)
assert_equal(p.ports["A"].offset, [0, -20])
assert_equal(p.ports["B"].offset, [10, -20])
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# Fluent API test
p.at("start").path(ccw=None, length=10).path(ccw=True, length=10)
# 10um South -> (0, -10) rot pi/2
# then 10um South and turn CCW (Facing South, CCW is East)
# PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0
# Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10)
# (0, -10) + (1, -10) = (1, -20)
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
# pi/2 (North) + CCW (90 deg) -> 0 (East)?
# Actual behavior results in pi (West).
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)

112
masque/test/test_pattern.py Normal file
View file

@ -0,0 +1,112 @@
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..pattern import Pattern
from ..shapes import Polygon
from ..ref import Ref
from ..ports import Port
from ..label import Label
def test_pattern_init() -> None:
pat = Pattern()
assert pat.is_empty()
assert not pat.has_shapes()
assert not pat.has_refs()
assert not pat.has_labels()
assert not pat.has_ports()
def test_pattern_with_elements() -> None:
poly = Polygon.square(10)
label = Label("test", offset=(5, 5))
ref = Ref(offset=(100, 100))
port = Port((0, 0), 0)
pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port})
assert pat.has_shapes()
assert pat.has_labels()
assert pat.has_refs()
assert pat.has_ports()
assert not pat.is_empty()
assert pat.shapes[(1, 0)] == [poly]
assert pat.labels[(1, 2)] == [label]
assert pat.refs["sub"] == [ref]
assert pat.ports["P1"] == port
def test_pattern_append() -> None:
pat1 = Pattern()
pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
pat2 = Pattern()
pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]])
pat1.append(pat2)
assert len(pat1.shapes[(1, 0)]) == 1
assert len(pat1.shapes[(2, 0)]) == 1
def test_pattern_translate() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
pat.ports["P1"] = Port((5, 5), 0)
pat.translate_elements((10, 20))
# Polygon.translate adds to vertices, and offset is always (0,0)
assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, 20])
assert_equal(pat.ports["P1"].offset, [15, 25])
def test_pattern_scale() -> None:
pat = Pattern()
# Polygon.rect sets an offset in its constructor which is immediately translated into vertices
pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1)
pat.scale_by(2)
# Vertices should be scaled
assert_equal(pat.shapes[(1, 0)][0].vertices, [[0, 0], [0, 2], [2, 2], [2, 0]])
def test_pattern_rotate() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]])
# Rotate 90 degrees CCW around (0,0)
pat.rotate_around((0, 0), pi / 2)
# [10, 0] rotated 90 deg around (0,0) is [0, 10]
assert_allclose(pat.shapes[(1, 0)][0].vertices[0], [0, 10], atol=1e-10)
def test_pattern_mirror() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]])
# Mirror across X axis (y -> -y)
pat.mirror(0)
assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, -5])
def test_pattern_get_bounds() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]])
pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]])
bounds = pat.get_bounds()
assert_equal(bounds, [[-5, -5], [10, 10]])
def test_pattern_interface() -> None:
source = Pattern()
source.ports["A"] = Port((10, 20), 0, ptype="test")
iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_")
assert "in_A" in iface.ports
assert "out_A" in iface.ports
assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10)
assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10)
assert iface.ports["in_A"].ptype == "test"
assert iface.ports["out_A"].ptype == "test"

125
masque/test/test_polygon.py Normal file
View file

@ -0,0 +1,125 @@
import pytest
import numpy
from numpy.testing import assert_equal
from ..shapes import Polygon
from ..utils import R90
from ..error import PatternError
@pytest.fixture
def polygon() -> Polygon:
return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
def test_vertices(polygon: Polygon) -> None:
assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
def test_xs(polygon: Polygon) -> None:
assert_equal(polygon.xs, [0, 1, 1, 0])
def test_ys(polygon: Polygon) -> None:
assert_equal(polygon.ys, [0, 0, 1, 1])
def test_offset(polygon: Polygon) -> None:
assert_equal(polygon.offset, [0, 0])
def test_square() -> None:
square = Polygon.square(1)
assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]])
def test_rectangle() -> None:
rectangle = Polygon.rectangle(1, 2)
assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]])
def test_rect() -> None:
rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1)
assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2)
assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2)
assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]])
rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0)
assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]])
with pytest.raises(PatternError):
Polygon.rect(xctr=0, yctr=-2, ymax=0)
with pytest.raises(PatternError):
Polygon.rect(xmin=0, yctr=-2, ymax=0)
with pytest.raises(PatternError):
Polygon.rect(xmax=0, yctr=-2, ymax=0)
with pytest.raises(PatternError):
Polygon.rect(lx=0, yctr=-2, ymax=0)
with pytest.raises(PatternError):
Polygon.rect(yctr=0, xctr=-2, xmax=0)
with pytest.raises(PatternError):
Polygon.rect(ymin=0, xctr=-2, xmax=0)
with pytest.raises(PatternError):
Polygon.rect(ymax=0, xctr=-2, xmax=0)
with pytest.raises(PatternError):
Polygon.rect(ly=0, xctr=-2, xmax=0)
def test_octagon() -> None:
octagon = Polygon.octagon(side_length=1) # regular=True
assert_equal(octagon.vertices.shape, (8, 2))
diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0)
side_len = numpy.sqrt((diff * diff).sum(axis=1))
assert numpy.allclose(side_len, 1)
def test_to_polygons(polygon: Polygon) -> None:
assert polygon.to_polygons() == [polygon]
def test_get_bounds_single(polygon: Polygon) -> None:
assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]])
def test_rotate(polygon: Polygon) -> None:
rotated_polygon = polygon.rotate(R90)
assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]])
def test_mirror(polygon: Polygon) -> None:
mirrored_by_y = polygon.deepcopy().mirror(1)
assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]])
print(polygon.vertices)
mirrored_by_x = polygon.deepcopy().mirror(0)
assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]])
def test_scale_by(polygon: Polygon) -> None:
scaled_polygon = polygon.scale_by(2)
assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]])
def test_clean_vertices(polygon: Polygon) -> None:
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices()
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
def test_remove_duplicate_vertices() -> None:
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices()
assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]])
def test_remove_colinear_vertices() -> None:
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices()
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
def test_vertices_dtype() -> None:
polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32))
polygon.scale_by(0.5)
assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]])

101
masque/test/test_ports.py Normal file
View file

@ -0,0 +1,101 @@
import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..ports import Port, PortList
from ..error import PortError
def test_port_init() -> None:
p = Port(offset=(10, 20), rotation=pi / 2, ptype="test")
assert_equal(p.offset, [10, 20])
assert p.rotation == pi / 2
assert p.ptype == "test"
def test_port_transform() -> None:
p = Port(offset=(10, 0), rotation=0)
p.rotate_around((0, 0), pi / 2)
assert_allclose(p.offset, [0, 10], atol=1e-10)
assert_allclose(p.rotation, pi / 2, atol=1e-10)
p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
assert_allclose(p.offset, [0, 10], atol=1e-10)
# rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2
assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10)
def test_port_flip_across() -> None:
p = Port(offset=(10, 0), rotation=0)
p.flip_across(axis=1) # Mirror across x=0: flips x-offset
assert_equal(p.offset, [-10, 0])
# rotation was 0, mirrored(1) -> pi
assert_allclose(p.rotation, pi, atol=1e-10)
def test_port_measure_travel() -> None:
p1 = Port((0, 0), 0)
p2 = Port((10, 5), pi) # Facing each other
(travel, jog), rotation = p1.measure_travel(p2)
assert travel == 10
assert jog == 5
assert rotation == pi
def test_port_list_rename() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {"A": Port((0, 0), 0)}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
pl.rename_ports({"A": "B"})
assert "A" not in pl.ports
assert "B" in pl.ports
def test_port_list_plugged() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
pl.plugged({"A": "B"})
assert not pl.ports # Both should be removed
def test_port_list_plugged_mismatch() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {
"A": Port((10, 10), 0),
"B": Port((11, 10), pi), # Offset mismatch
}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
with pytest.raises(PortError):
pl.plugged({"A": "B"})

View file

@ -0,0 +1,55 @@
import numpy
from numpy.testing import assert_allclose
from ..utils.ports2data import ports_to_data, data_to_ports
from ..pattern import Pattern
from ..ports import Port
from ..library import Library
def test_ports2data_roundtrip() -> None:
pat = Pattern()
pat.ports["P1"] = Port((10, 20), numpy.pi / 2, ptype="test")
layer = (10, 0)
ports_to_data(pat, layer)
assert len(pat.labels[layer]) == 1
assert pat.labels[layer][0].string == "P1:test 90"
assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0)
# New pattern, read ports back
pat2 = Pattern()
pat2.labels[layer] = pat.labels[layer]
data_to_ports([layer], {}, pat2)
assert "P1" in pat2.ports
assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10)
assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10)
assert pat2.ports["P1"].ptype == "test"
def test_data_to_ports_hierarchical() -> None:
lib = Library()
# Child has port data in labels
child = Pattern()
layer = (10, 0)
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
lib["child"] = child
# Parent references child
parent = Pattern()
parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2)
# Read ports hierarchically (max_depth > 0)
data_to_ports([layer], lib, parent, max_depth=1)
# child port A (5,0) rot 0
# transformed by parent ref: rot pi/2, trans (100, 100)
# (5,0) rot pi/2 -> (0, 5)
# (0, 5) + (100, 100) = (100, 105)
# rot 0 + pi/2 = pi/2
assert "A" in parent.ports
assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10)
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)

68
masque/test/test_ref.py Normal file
View file

@ -0,0 +1,68 @@
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..pattern import Pattern
from ..ref import Ref
from ..repetition import Grid
def test_ref_init() -> None:
ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0)
assert_equal(ref.offset, [10, 20])
assert ref.rotation == pi / 4
assert ref.mirrored is True
assert ref.scale == 2.0
def test_ref_as_pattern() -> None:
sub_pat = Pattern()
sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
ref = Ref(offset=(10, 10), rotation=pi / 2, scale=2.0)
transformed_pat = ref.as_pattern(sub_pat)
# Check transformed shape
shape = transformed_pat.shapes[(1, 0)][0]
# ref.as_pattern deepcopies sub_pat then applies transformations:
# 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]]
# 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]]
# 3. pattern.translate_elements((10,10)) -> vertices [[10,10], [10,12], [8,10]]
assert_allclose(shape.vertices, [[10, 10], [10, 12], [8, 10]], atol=1e-10)
def test_ref_with_repetition() -> None:
sub_pat = Pattern()
sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
rep = Grid(a_vector=(10, 0), b_vector=(0, 10), a_count=2, b_count=2)
ref = Ref(repetition=rep)
repeated_pat = ref.as_pattern(sub_pat)
# Should have 4 shapes
assert len(repeated_pat.shapes[(1, 0)]) == 4
first_verts = sorted([tuple(s.vertices[0]) for s in repeated_pat.shapes[(1, 0)]])
assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)]
def test_ref_get_bounds() -> None:
sub_pat = Pattern()
sub_pat.polygon((1, 0), vertices=[[0, 0], [5, 0], [0, 5]])
ref = Ref(offset=(10, 10), scale=2.0)
bounds = ref.get_bounds_single(sub_pat)
# sub_pat bounds [[0,0], [5,5]]
# scaled [[0,0], [10,10]]
# translated [[10,10], [20,20]]
assert_equal(bounds, [[10, 10], [20, 20]])
def test_ref_copy() -> None:
ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]})
ref2 = ref1.copy()
assert ref1 == ref2
assert ref1 is not ref2
ref2.offset[0] = 100
assert ref1.offset[0] == 1

View file

@ -0,0 +1,75 @@
import pytest
from numpy.testing import assert_allclose
from numpy import pi
from ..builder import RenderPather
from ..builder.tools import PathTool
from ..library import Library
from ..ports import Port
@pytest.fixture
def rpather_setup() -> tuple[RenderPather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
rp = RenderPather(lib, tools=tool)
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return rp, tool, lib
def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool, lib = rpather_setup
# Plan two segments
rp.at("start").path(ccw=None, length=10).path(ccw=None, length=10)
# Before rendering, no shapes in pattern
assert not rp.pattern.has_shapes()
assert len(rp.paths["start"]) == 2
# Render
rp.render()
assert rp.pattern.has_shapes()
assert len(rp.pattern.shapes[(1, 0)]) == 1
# Path vertices should be (0,0), (0,-10), (0,-20)
# transformed by start port (rot pi/2 -> 270 deg transform)
# wait, PathTool.render for opcode L uses rotation_matrix_2d(port_rot + pi)
# start_port rot pi/2. pi/2 + pi = 3pi/2.
# (10, 0) rotated 3pi/2 -> (0, -10)
# So vertices: (0,0), (0,-10), (0,-20)
path_shape = rp.pattern.shapes[(1, 0)][0]
assert len(path_shape.vertices) == 3
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool, lib = rpather_setup
# Plan straight then bend
rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10)
rp.render()
path_shape = rp.pattern.shapes[(1, 0)][0]
# Path vertices:
# 1. Start (0,0)
# 2. Straight end: (0, -10)
# 3. Bend end: (-1, -20)
# PathTool.planL(ccw=False, length=10) returns data=[10, -1]
# start_port for 2nd segment is at (0, -10) with rotation pi/2
# dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20).
# and final end_port.offset is (-1, -20).
assert len(path_shape.vertices) == 4
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10)
def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool1, lib = rpather_setup
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
rp.at("start").path(ccw=None, length=10)
rp.retool(tool2, keys=["start"])
rp.at("start").path(ccw=None, length=10)
rp.render()
# Different tools should cause different batches/shapes
assert len(rp.pattern.shapes[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 0)]) == 1

View file

@ -0,0 +1,51 @@
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..repetition import Grid, Arbitrary
def test_grid_displacements() -> None:
# 2x2 grid
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
disps = sorted([tuple(d) for d in grid.displacements])
assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)]
def test_grid_1d() -> None:
grid = Grid(a_vector=(10, 0), a_count=3)
disps = sorted([tuple(d) for d in grid.displacements])
assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)]
def test_grid_rotate() -> None:
grid = Grid(a_vector=(10, 0), a_count=2)
grid.rotate(pi / 2)
assert_allclose(grid.a_vector, [0, 10], atol=1e-10)
def test_grid_get_bounds() -> None:
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
bounds = grid.get_bounds()
assert_equal(bounds, [[0, 0], [10, 5]])
def test_arbitrary_displacements() -> None:
pts = [[0, 0], [10, 20], [-5, 30]]
arb = Arbitrary(pts)
# They should be sorted by displacements.setter
disps = arb.displacements
assert len(disps) == 3
assert any((disps == [0, 0]).all(axis=1))
assert any((disps == [10, 20]).all(axis=1))
assert any((disps == [-5, 30]).all(axis=1))
def test_arbitrary_transform() -> None:
arb = Arbitrary([[10, 0]])
arb.rotate(pi / 2)
assert_allclose(arb.displacements, [[0, 10]], atol=1e-10)
arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is:
# self.displacements[:, 1 - axis] *= -1
# if axis=0, 1-axis=1, so y *= -1
assert_allclose(arb.displacements, [[0, -10]], atol=1e-10)

View file

@ -0,0 +1,144 @@
from pathlib import Path
import pytest
import numpy
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection
from ..error import PatternError
# 1. Text shape tests
def test_text_to_polygons() -> None:
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
if not Path(font_path).exists():
pytest.skip("Font file not found")
t = Text("Hi", height=10, font_path=font_path)
polys = t.to_polygons()
assert len(polys) > 0
assert all(isinstance(p, Polygon) for p in polys)
# Check that it advances
# Character 'H' and 'i' should have different vertices
# Each character is a set of polygons. We check the mean x of vertices for each character.
char_x_means = [p.vertices[:, 0].mean() for p in polys]
assert len(set(char_x_means)) >= 2
# 2. Manhattanization tests
def test_manhattanize() -> None:
# Diamond shape
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
grid = numpy.arange(0, 11, 1)
manhattan_polys = poly.manhattanize(grid, grid)
assert len(manhattan_polys) >= 1
for mp in manhattan_polys:
# Check that all edges are axis-aligned
dv = numpy.diff(mp.vertices, axis=0)
# For each segment, either dx or dy must be zero
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
# 3. Comparison and Sorting tests
def test_shape_comparisons() -> None:
c1 = Circle(radius=10)
c2 = Circle(radius=20)
assert c1 < c2
assert not (c2 < c1)
p1 = Polygon([[0, 0], [10, 0], [10, 10]])
p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex
assert p1 < p2
# Different types
assert c1 < p1 or p1 < c1
assert (c1 < p1) != (p1 < c1)
# 4. Arc/Path Edge Cases
def test_arc_edge_cases() -> None:
# Wrapped arc (> 360 deg)
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
a.to_polygons(num_vertices=64)
# Should basically be a ring
bounds = a.get_bounds_single()
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
def test_path_edge_cases() -> None:
# Zero-length segments
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
polys = p.to_polygons()
assert len(polys) == 1
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
# 5. PolyCollection with holes
def test_poly_collection_holes() -> None:
# Outer square, inner square hole
# PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do?
# wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple
# polygons or using specific winding rules.
# masque.shapes.Polygon doc says "specify an implicitly-closed boundary".
# Pyclipper is used in connectivity.py for holes.
# Let's test PolyCollection with multiple polygons
verts = [
[0, 0],
[10, 0],
[10, 10],
[0, 10], # Poly 1
[2, 2],
[2, 8],
[8, 8],
[8, 2], # Poly 2
]
offsets = [0, 4]
pc = PolyCollection(verts, offsets)
polys = pc.to_polygons()
assert len(polys) == 2
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
def test_poly_collection_constituent_empty() -> None:
# One real triangle, one "empty" polygon (0 vertices), one real square
# Note: Polygon requires 3 vertices, so "empty" here might mean just some junk
# that to_polygons should handle.
# Actually PolyCollection doesn't check vertex count per polygon.
verts = [
[0, 0],
[1, 0],
[0, 1], # Tri
# Empty space
[10, 10],
[11, 10],
[11, 11],
[10, 11], # Square
]
offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square?
# No, offsets should be strictly increasing or handle 0-length slices.
# vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)]))
# if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7]
offsets = [0, 3, 3]
pc = PolyCollection(verts, offsets)
# Polygon(vertices=[]) will fail because of the setter check.
# Let's see if pc.to_polygons() handles it.
# It calls Polygon(vertices=vv) for each slice.
# slice [3:3] gives empty vv.
with pytest.raises(PatternError):
pc.to_polygons()
def test_poly_collection_valid() -> None:
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 3]
pc = PolyCollection(verts, offsets)
assert len(pc.to_polygons()) == 2
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
sorted_shapes = sorted(shapes)
assert len(sorted_shapes) == 4
# Just verify it doesn't crash and is stable
assert sorted(sorted_shapes) == sorted_shapes

142
masque/test/test_shapes.py Normal file
View file

@ -0,0 +1,142 @@
import numpy
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection
def test_poly_collection_init() -> None:
# Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]]
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 4]
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
assert len(list(pc.polygon_vertices)) == 2
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
def test_poly_collection_to_polygons() -> None:
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 4]
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
polys = pc.to_polygons()
assert len(polys) == 2
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
def test_circle_init() -> None:
c = Circle(radius=10, offset=(5, 5))
assert c.radius == 10
assert_equal(c.offset, [5, 5])
def test_circle_to_polygons() -> None:
c = Circle(radius=10)
polys = c.to_polygons(num_vertices=32)
assert len(polys) == 1
assert isinstance(polys[0], Polygon)
# A circle with 32 vertices should have vertices distributed around (0,0)
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
def test_ellipse_init() -> None:
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
assert_equal(e.radii, [10, 5])
assert_equal(e.offset, [1, 2])
assert e.rotation == pi / 4
def test_ellipse_to_polygons() -> None:
e = Ellipse(radii=(10, 5))
polys = e.to_polygons(num_vertices=64)
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
def test_arc_init() -> None:
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
assert_equal(a.radii, [10, 10])
assert_equal(a.angles, [0, pi / 2])
assert a.width == 2
def test_arc_to_polygons() -> None:
# Quarter circle arc
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
polys = a.to_polygons(num_vertices=32)
assert len(polys) == 1
# Outer radius 11, inner radius 9
# Quarter circle from 0 to 90 deg
bounds = polys[0].get_bounds_single()
# Min x should be 0 (inner edge start/stop or center if width is large)
# But wait, the arc is centered at 0,0.
# Outer edge goes from (11, 0) to (0, 11)
# Inner edge goes from (9, 0) to (0, 9)
# So x ranges from 0 to 11, y ranges from 0 to 11.
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
def test_shape_mirror() -> None:
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
assert_equal(e.offset, [10, 20])
# rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi)
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20))
a.mirror(0)
assert_equal(a.offset, [10, 20])
# For Arc, mirror(0) negates rotation and angles
assert_allclose(a.angles, [0, -pi / 4], atol=1e-10)
def test_shape_flip_across() -> None:
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
e.flip_across(axis=0) # Mirror across y=0: flips y-offset
assert_equal(e.offset, [10, -20])
# rotation also flips: -pi/4 == 3pi/4 (mod pi)
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
# Mirror across specific y
e = Ellipse(radii=(10, 5), offset=(10, 20))
e.flip_across(y=10) # Mirror across y=10
# y=20 mirrored across y=10 -> y=0
assert_equal(e.offset, [10, 0])
def test_shape_scale() -> None:
e = Ellipse(radii=(10, 5))
e.scale_by(2)
assert_equal(e.radii, [20, 10])
a = Arc(radii=(10, 5), angles=(0, pi), width=2)
a.scale_by(0.5)
assert_equal(a.radii, [5, 2.5])
assert a.width == 1
def test_shape_arclen() -> None:
# Test that max_arclen correctly limits segment lengths
# Ellipse
e = Ellipse(radii=(10, 5))
# Approximate perimeter is ~48.4
# With max_arclen=5, should have > 10 segments
polys = e.to_polygons(max_arclen=5)
v = polys[0].vertices
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
assert numpy.all(dist <= 5.000001)
assert len(v) > 10
# Arc
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
# Outer perimeter is 11 * pi/2 ~ 17.27
# Inner perimeter is 9 * pi/2 ~ 14.14
# With max_arclen=2, should have > 8 segments on outer edge
polys = a.to_polygons(max_arclen=2)
v = polys[0].vertices
# Arc polygons are closed, but contain both inner and outer edges and caps
# Let's just check that all segment lengths are within limit
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
assert numpy.all(dist <= 2.000001)

83
masque/test/test_utils.py Normal file
View file

@ -0,0 +1,83 @@
import numpy
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms
def test_remove_duplicate_vertices() -> None:
# Closed path (default)
v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]]
v_clean = remove_duplicate_vertices(v, closed_path=True)
# The last [0,0] is a duplicate of the first [0,0] if closed_path=True
assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]])
# Open path
v_clean_open = remove_duplicate_vertices(v, closed_path=False)
assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]])
def test_remove_colinear_vertices() -> None:
v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]]
v_clean = remove_colinear_vertices(v, closed_path=True)
# [1, 0] is between [0, 0] and [2, 0]
# [2, 1] is between [2, 0] and [2, 2]
# [1, 1] is between [2, 2] and [0, 0]
assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]])
def test_remove_colinear_vertices_exhaustive() -> None:
# U-turn
v = [[0, 0], [10, 0], [0, 0]]
v_clean = remove_colinear_vertices(v, closed_path=False)
# Open path should keep ends. [10,0] is between [0,0] and [0,0]?
# Yes, they are all on the same line.
assert len(v_clean) == 2
# 180 degree U-turn in closed path
v = [[0, 0], [10, 0], [5, 0]]
v_clean = remove_colinear_vertices(v, closed_path=True)
assert len(v_clean) == 2
def test_poly_contains_points() -> None:
v = [[0, 0], [10, 0], [10, 10], [0, 10]]
pts = [[5, 5], [-1, -1], [10, 10], [11, 5]]
inside = poly_contains_points(v, pts)
assert_equal(inside, [True, False, True, False])
def test_rotation_matrix_2d() -> None:
m = rotation_matrix_2d(pi / 2)
assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10)
def test_rotation_matrix_non_manhattan() -> None:
# 45 degrees
m = rotation_matrix_2d(pi / 4)
s = numpy.sqrt(2) / 2
assert_allclose(m, [[s, -s], [s, s]], atol=1e-10)
def test_apply_transforms() -> None:
# cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)]
t1 = [10, 20, 0, 0]
t2 = [[5, 0, 0, 0], [0, 5, 0, 0]]
combined = apply_transforms(t1, t2)
assert_equal(combined, [[15, 20, 0, 0], [10, 25, 0, 0]])
def test_apply_transforms_advanced() -> None:
# Ox4: (x, y, rot, mir)
# Outer: mirror x (axis 0), then rotate 90 deg CCW
# apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror)
outer = [0, 0, pi / 2, 1]
# Inner: (10, 0, 0, 0)
inner = [10, 0, 0, 0]
combined = apply_transforms(outer, inner)
# 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0)
# 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10)
# 3. add outer offset (0, 0) -> (0, 10)
assert_allclose(combined[0], [0, 10, pi / 2, 1], atol=1e-10)