[tests] Add machine-generated test suite
This commit is contained in:
parent
9bb0d5190d
commit
1de76bff47
24 changed files with 1703 additions and 0 deletions
3
masque/test/__init__.py
Normal file
3
masque/test/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
|
||||
"""
|
||||
16
masque/test/conftest.py
Normal file
16
masque/test/conftest.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
|
||||
Test fixtures
|
||||
|
||||
"""
|
||||
# ruff: noqa: ARG001
|
||||
from typing import Any
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
||||
import pytest # type: ignore
|
||||
|
||||
|
||||
FixtureRequest = Any
|
||||
PRNG = numpy.random.RandomState(12345)
|
||||
|
||||
58
masque/test/test_abstract.py
Normal file
58
masque/test/test_abstract.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..abstract import Abstract
|
||||
from ..ports import Port
|
||||
from ..ref import Ref
|
||||
|
||||
def test_abstract_init():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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)
|
||||
82
masque/test/test_advanced_routing.py
Normal file
82
masque/test/test_advanced_routing.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import pytest
|
||||
import numpy
|
||||
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 advanced_pather():
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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
|
||||
82
masque/test/test_autotool.py
Normal file
82
masque/test/test_autotool.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, 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
|
||||
from ..abstract import Abstract
|
||||
|
||||
def make_straight(length, width=2, ptype="wire"):
|
||||
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():
|
||||
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
|
||||
bend_abs = 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 l: make_straight(l, 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):
|
||||
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"
|
||||
|
||||
73
masque/test/test_builder.py
Normal file
73
masque/test/test_builder.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import pytest
|
||||
import numpy
|
||||
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():
|
||||
lib = Library()
|
||||
b = Builder(lib, name="mypat")
|
||||
assert b.pattern is lib["mypat"]
|
||||
assert b.library is lib
|
||||
|
||||
def test_builder_place():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
lib = Library()
|
||||
lib["sub"] = Pattern()
|
||||
b = Builder(lib)
|
||||
b.set_dead()
|
||||
|
||||
b.place("sub")
|
||||
assert not b.pattern.has_refs()
|
||||
|
||||
25
masque/test/test_fdfd.py
Normal file
25
masque/test/test_fdfd.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# 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
|
||||
|
||||
70
masque/test/test_gdsii.py
Normal file
70
masque/test/test_gdsii.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import pytest
|
||||
import os
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from pathlib import Path
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..file import gdsii
|
||||
from ..shapes import Polygon, Path as MPath
|
||||
|
||||
def test_gdsii_roundtrip(tmp_path):
|
||||
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):
|
||||
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"]
|
||||
|
||||
48
masque/test/test_label.py
Normal file
48
masque/test/test_label.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..label import Label
|
||||
from ..repetition import Grid
|
||||
|
||||
def test_label_init():
|
||||
l = Label("test", offset=(10, 20))
|
||||
assert l.string == "test"
|
||||
assert_equal(l.offset, [10, 20])
|
||||
|
||||
def test_label_transform():
|
||||
l = Label("test", offset=(10, 0))
|
||||
# Rotate 90 deg CCW around (0,0)
|
||||
l.rotate_around((0, 0), pi/2)
|
||||
assert_allclose(l.offset, [0, 10], atol=1e-10)
|
||||
|
||||
# Translate
|
||||
l.translate((5, 5))
|
||||
assert_allclose(l.offset, [5, 15], atol=1e-10)
|
||||
|
||||
def test_label_repetition():
|
||||
rep = Grid(a_vector=(10, 0), a_count=3)
|
||||
l = Label("rep", offset=(0, 0), repetition=rep)
|
||||
assert l.repetition is rep
|
||||
assert_equal(l.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():
|
||||
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
|
||||
|
||||
import copy
|
||||
109
masque/test/test_library.py
Normal file
109
masque/test/test_library.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import pytest
|
||||
from ..library import Library, LazyLibrary, LibraryView
|
||||
from ..pattern import Pattern
|
||||
from ..ref import Ref
|
||||
from ..error import LibraryError
|
||||
|
||||
def test_library_basic():
|
||||
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():
|
||||
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():
|
||||
lib = Library()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("missing")
|
||||
|
||||
assert lib.dangling_refs() == {"missing"}
|
||||
|
||||
def test_library_flatten():
|
||||
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():
|
||||
lib = LazyLibrary()
|
||||
called = 0
|
||||
def make_pat():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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"
|
||||
|
||||
28
masque/test/test_oasis.py
Normal file
28
masque/test/test_oasis.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal
|
||||
from pathlib import Path
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..file import oasis
|
||||
|
||||
def test_oasis_roundtrip(tmp_path):
|
||||
# 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]])
|
||||
|
||||
53
masque/test/test_pack2d.py
Normal file
53
masque/test/test_pack2d.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal
|
||||
|
||||
from ..utils.pack2d import maxrects_bssf, pack_patterns
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
|
||||
def test_maxrects_bssf_simple():
|
||||
# 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 set([tuple(l) for l in locs]) == {(0.0, 0.0), (10.0, 0.0)}
|
||||
|
||||
def test_maxrects_bssf_reject():
|
||||
# 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():
|
||||
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
|
||||
|
||||
77
masque/test/test_path.py
Normal file
77
masque/test/test_path.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..shapes import Path
|
||||
|
||||
def test_path_init():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
# 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():
|
||||
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():
|
||||
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
|
||||
|
||||
80
masque/test/test_pather.py
Normal file
80
masque/test/test_pather.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import pytest
|
||||
import numpy
|
||||
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 ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
|
||||
@pytest.fixture
|
||||
def pather_setup():
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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)
|
||||
|
||||
111
masque/test/test_pattern.py
Normal file
111
masque/test/test_pattern.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..shapes import Polygon, Circle
|
||||
from ..ref import Ref
|
||||
from ..ports import Port
|
||||
from ..label import Label
|
||||
|
||||
def test_pattern_init():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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"
|
||||
|
||||
110
masque/test/test_polygon.py
Normal file
110
masque/test/test_polygon.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
|
||||
from ..shapes import Polygon
|
||||
from ..utils import R90
|
||||
from ..error import PatternError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def polygon():
|
||||
return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
def test_vertices(polygon) -> None:
|
||||
assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
def test_xs(polygon) -> None:
|
||||
assert_equal(polygon.xs, [0, 1, 1, 0])
|
||||
|
||||
def test_ys(polygon) -> None:
|
||||
assert_equal(polygon.ys, [0, 0, 1, 1])
|
||||
|
||||
def test_offset(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) -> None:
|
||||
assert polygon.to_polygons() == [polygon]
|
||||
|
||||
def test_get_bounds_single(polygon) -> None:
|
||||
assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]])
|
||||
|
||||
def test_rotate(polygon) -> None:
|
||||
rotated_polygon = polygon.rotate(R90)
|
||||
assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]])
|
||||
|
||||
def test_mirror(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) -> 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) -> 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():
|
||||
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]])
|
||||
|
||||
87
masque/test/test_ports.py
Normal file
87
masque/test/test_ports.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import pytest
|
||||
import numpy
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
class MyPorts(PortList):
|
||||
def __init__(self):
|
||||
self._ports = {"A": Port((0, 0), 0)}
|
||||
@property
|
||||
def ports(self): return self._ports
|
||||
@ports.setter
|
||||
def ports(self, val): 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():
|
||||
class MyPorts(PortList):
|
||||
def __init__(self):
|
||||
self._ports = {
|
||||
"A": Port((10, 10), 0),
|
||||
"B": Port((10, 10), pi)
|
||||
}
|
||||
@property
|
||||
def ports(self): return self._ports
|
||||
@ports.setter
|
||||
def ports(self, val): self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
pl.plugged({"A": "B"})
|
||||
assert not pl.ports # Both should be removed
|
||||
|
||||
def test_port_list_plugged_mismatch():
|
||||
class MyPorts(PortList):
|
||||
def __init__(self):
|
||||
self._ports = {
|
||||
"A": Port((10, 10), 0),
|
||||
"B": Port((11, 10), pi) # Offset mismatch
|
||||
}
|
||||
@property
|
||||
def ports(self): return self._ports
|
||||
@ports.setter
|
||||
def ports(self, val): self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError):
|
||||
pl.plugged({"A": "B"})
|
||||
|
||||
55
masque/test/test_ports2data.py
Normal file
55
masque/test/test_ports2data.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import pytest
|
||||
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():
|
||||
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():
|
||||
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)
|
||||
|
||||
66
masque/test/test_ref.py
Normal file
66
masque/test/test_ref.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import pytest
|
||||
import numpy
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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
|
||||
|
||||
73
masque/test/test_renderpather.py
Normal file
73
masque/test/test_renderpather.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, 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():
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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
|
||||
|
||||
48
masque/test/test_repetition.py
Normal file
48
masque/test/test_repetition.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..repetition import Grid, Arbitrary
|
||||
|
||||
def test_grid_displacements():
|
||||
# 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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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)
|
||||
|
||||
134
masque/test/test_shape_advanced.py
Normal file
134
masque/test/test_shape_advanced.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
import os
|
||||
|
||||
from ..shapes import Arc, Ellipse, Circle, Polygon, Path, Text, PolyCollection
|
||||
from ..error import PatternError
|
||||
|
||||
# 1. Text shape tests
|
||||
def test_text_to_polygons():
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
|
||||
if not os.path.exists(font_path):
|
||||
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():
|
||||
# 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():
|
||||
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():
|
||||
# Wrapped arc (> 360 deg)
|
||||
a = Arc(radii=(10, 10), angles=(0, 3*pi), width=2)
|
||||
polys = 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():
|
||||
# Zero-length segments
|
||||
p = Path(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():
|
||||
# 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():
|
||||
# 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():
|
||||
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
|
||||
|
||||
132
masque/test/test_shapes.py
Normal file
132
masque/test/test_shapes.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import pytest
|
||||
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():
|
||||
# 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():
|
||||
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():
|
||||
c = Circle(radius=10, offset=(5, 5))
|
||||
assert c.radius == 10
|
||||
assert_equal(c.offset, [5, 5])
|
||||
|
||||
def test_circle_to_polygons():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
# 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():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
# 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
83
masque/test/test_utils.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import pytest
|
||||
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():
|
||||
# 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():
|
||||
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():
|
||||
# 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():
|
||||
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():
|
||||
m = rotation_matrix_2d(pi/2)
|
||||
assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10)
|
||||
|
||||
def test_rotation_matrix_non_manhattan():
|
||||
# 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():
|
||||
# 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():
|
||||
# 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)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue