Compare commits

..

2 commits

Author SHA1 Message Date
5e08579498 [tests] add round-trip file tests 2026-02-15 16:44:17 -08:00
c18e5b8d3e [OASIS] cleanup 2026-02-15 16:43:46 -08:00
2 changed files with 224 additions and 68 deletions

View file

@ -120,10 +120,10 @@ def build(
layer, data_type = _mlayer2oas(layer_num) layer, data_type = _mlayer2oas(layer_num)
lib.layers += [ lib.layers += [
fatrec.LayerName( fatrec.LayerName(
nstring=name, nstring = name,
layer_interval=(layer, layer), layer_interval = (layer, layer),
type_interval=(data_type, data_type), type_interval = (data_type, data_type),
is_textlayer=tt, is_textlayer = tt,
) )
for tt in (True, False)] for tt in (True, False)]
@ -286,11 +286,11 @@ def read(
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.polygon( pat.polygon(
vertices=vertices, vertices = vertices,
layer=element.get_layer_tuple(), layer = element.get_layer_tuple(),
offset=element.get_xy(), offset = element.get_xy(),
annotations=annotations, annotations = annotations,
repetition=repetition, repetition = repetition,
) )
elif isinstance(element, fatrec.Path): elif isinstance(element, fatrec.Path):
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
@ -310,13 +310,13 @@ def read(
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.path( pat.path(
vertices=vertices, vertices = vertices,
layer=element.get_layer_tuple(), layer = element.get_layer_tuple(),
offset=element.get_xy(), offset = element.get_xy(),
repetition=repetition, repetition = repetition,
annotations=annotations, annotations = annotations,
width=element.get_half_width() * 2, width = element.get_half_width() * 2,
cap=cap, cap = cap,
**path_args, **path_args,
) )
@ -325,11 +325,11 @@ def read(
height = element.get_height() height = element.get_height()
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.polygon( pat.polygon(
layer=element.get_layer_tuple(), layer = element.get_layer_tuple(),
offset=element.get_xy(), offset = element.get_xy(),
repetition=repetition, repetition = repetition,
vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
annotations=annotations, annotations = annotations,
) )
elif isinstance(element, fatrec.Trapezoid): elif isinstance(element, fatrec.Trapezoid):
@ -440,11 +440,11 @@ def read(
else: else:
string = str_or_ref.string string = str_or_ref.string
pat.label( pat.label(
layer=element.get_layer_tuple(), layer = element.get_layer_tuple(),
offset=element.get_xy(), offset = element.get_xy(),
repetition=repetition, repetition = repetition,
annotations=annotations, annotations = annotations,
string=string, string = string,
) )
else: else:
@ -549,13 +549,13 @@ def _shapes_to_elements(
offset = rint_cast(shape.offset + rep_offset) offset = rint_cast(shape.offset + rep_offset)
radius = rint_cast(shape.radius) radius = rint_cast(shape.radius)
circle = fatrec.Circle( circle = fatrec.Circle(
layer=layer, layer = layer,
datatype=datatype, datatype = datatype,
radius=cast('int', radius), radius = cast('int', radius),
x=offset[0], x = offset[0],
y=offset[1], y = offset[1],
properties=properties, properties = properties,
repetition=repetition, repetition = repetition,
) )
elements.append(circle) elements.append(circle)
elif isinstance(shape, Path): elif isinstance(shape, Path):
@ -566,16 +566,16 @@ def _shapes_to_elements(
extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None) extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
path = fatrec.Path( path = fatrec.Path(
layer=layer, layer = layer,
datatype=datatype, datatype = datatype,
point_list=cast('Sequence[Sequence[int]]', deltas), point_list = cast('Sequence[Sequence[int]]', deltas),
half_width=cast('int', half_width), half_width = cast('int', half_width),
x=xy[0], x = xy[0],
y=xy[1], y = xy[1],
extension_start=extension_start, # TODO implement multiple cap types? extension_start = extension_start, # TODO implement multiple cap types?
extension_end=extension_end, extension_end = extension_end,
properties=properties, properties = properties,
repetition=repetition, repetition = repetition,
) )
elements.append(path) elements.append(path)
else: else:
@ -583,13 +583,13 @@ def _shapes_to_elements(
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset) xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
points = rint_cast(numpy.diff(polygon.vertices, axis=0)) points = rint_cast(numpy.diff(polygon.vertices, axis=0))
elements.append(fatrec.Polygon( elements.append(fatrec.Polygon(
layer=layer, layer = layer,
datatype=datatype, datatype = datatype,
x=xy[0], x = xy[0],
y=xy[1], y = xy[1],
point_list=cast('list[list[int]]', points), point_list = cast('list[list[int]]', points),
properties=properties, properties = properties,
repetition=repetition, repetition = repetition,
)) ))
return elements return elements
@ -606,13 +606,13 @@ def _labels_to_texts(
xy = rint_cast(label.offset + rep_offset) xy = rint_cast(label.offset + rep_offset)
properties = annotations_to_properties(label.annotations) properties = annotations_to_properties(label.annotations)
texts.append(fatrec.Text( texts.append(fatrec.Text(
layer=layer, layer = layer,
datatype=datatype, datatype = datatype,
x=xy[0], x = xy[0],
y=xy[1], y = xy[1],
string=label.string, string = label.string,
properties=properties, properties = properties,
repetition=repetition, repetition = repetition,
)) ))
return texts return texts
@ -622,10 +622,12 @@ def repetition_fata2masq(
) -> Repetition | None: ) -> Repetition | None:
mrep: Repetition | None mrep: Repetition | None
if isinstance(rep, fatamorgana.GridRepetition): if isinstance(rep, fatamorgana.GridRepetition):
mrep = Grid(a_vector=rep.a_vector, mrep = Grid(
b_vector=rep.b_vector, a_vector = rep.a_vector,
a_count=rep.a_count, b_vector = rep.b_vector,
b_count=rep.b_count) a_count = rep.a_count,
b_count = rep.b_count,
)
elif isinstance(rep, fatamorgana.ArbitraryRepetition): elif isinstance(rep, fatamorgana.ArbitraryRepetition):
displacements = numpy.cumsum(numpy.column_stack(( displacements = numpy.cumsum(numpy.column_stack((
rep.x_displacements, rep.x_displacements,
@ -647,14 +649,19 @@ def repetition_masq2fata(
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
if isinstance(rep, Grid): if isinstance(rep, Grid):
a_vector = rint_cast(rep.a_vector) a_vector = rint_cast(rep.a_vector)
b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None a_count = int(rep.a_count)
a_count = rint_cast(rep.a_count) if rep.b_count > 1:
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None b_vector = rint_cast(rep.b_vector)
b_count = int(rep.b_count)
else:
b_vector = None
b_count = None
frep = fatamorgana.GridRepetition( frep = fatamorgana.GridRepetition(
a_vector=cast('list[int]', a_vector), a_vector = a_vector,
b_vector=cast('list[int] | None', b_vector), b_vector = b_vector,
a_count=cast('int', a_count), a_count = a_count,
b_count=cast('int | None', b_count), b_count = b_count,
) )
offset = (0, 0) offset = (0, 0)
elif isinstance(rep, Arbitrary): elif isinstance(rep, Arbitrary):

View file

@ -0,0 +1,149 @@
from pathlib import Path
import pytest
from numpy.testing import assert_allclose
from ..pattern import Pattern
from ..library import Library
from ..file import gdsii, oasis
from ..shapes import Path as MPath, Circle, Polygon
from ..repetition import Grid, Arbitrary
def create_test_library(for_gds: bool = False) -> Library:
lib = Library()
# 1. Polygons
pat_poly = Pattern()
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
lib["polygons"] = pat_poly
# 2. Paths with different endcaps
pat_paths = Pattern()
# Flush
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
# Square
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
# Circle (Only for GDS)
if for_gds:
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
# SquareCustom
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
lib["paths"] = pat_paths
# 3. Circles (only for OASIS or polygonized for GDS)
pat_circles = Pattern()
if for_gds:
# GDS writer calls to_polygons() for non-supported shapes,
# but we can also pre-polygonize
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
else:
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
lib["circles"] = pat_circles
# 4. Refs with repetitions
pat_refs = Pattern()
# Simple Ref
pat_refs.ref("polygons", offset=(0, 0))
# Ref with Grid repetition
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
# Ref with Arbitrary repetition
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
lib["refs"] = pat_refs
# 5. Shapes with repetitions (OASIS only, must be wrapped for GDS)
pat_rep_shapes = Pattern()
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
lib["rep_shapes"] = pat_rep_shapes
if for_gds:
lib.wrap_repeated_shapes()
return lib
def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
lib = create_test_library(for_gds=True)
gds_file = tmp_path / "full_test.gds"
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
read_lib, _ = gdsii.readfile(gds_file)
# Check existence
for name in lib:
assert name in read_lib
# Check Paths
read_paths = read_lib["paths"]
# Check caps (GDS stores them as path_type)
# Order might be different depending on how they were written,
# but here they should match the order they were added if dict order is preserved.
# Actually, they are grouped by layer.
p_flush = read_paths.shapes[(2, 0)][0]
assert p_flush.cap == MPath.Cap.Flush
p_square = read_paths.shapes[(2, 1)][0]
assert p_square.cap == MPath.Cap.Square
p_circle = read_paths.shapes[(2, 2)][0]
assert p_circle.cap == MPath.Cap.Circle
p_custom = read_paths.shapes[(2, 3)][0]
assert p_custom.cap == MPath.Cap.SquareCustom
assert_allclose(p_custom.cap_extensions, (1, 5))
# Check Refs with repetitions
read_refs = read_lib["refs"]
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
# AREF check
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
assert len(arefs) == 1
assert isinstance(arefs[0].repetition, Grid)
assert arefs[0].repetition.a_count == 3
assert arefs[0].repetition.b_count == 2
# Check wrapped shapes
# lib.wrap_repeated_shapes() created new patterns
# Original pattern "rep_shapes" now should have a Ref
assert len(read_lib["rep_shapes"].refs) > 0
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
pytest.importorskip("fatamorgana")
lib = create_test_library(for_gds=False)
oas_file = tmp_path / "full_test.oas"
oasis.writefile(lib, oas_file, units_per_micron=1000)
read_lib, _ = oasis.readfile(oas_file)
# Check existence
for name in lib:
assert name in read_lib
# Check Circle
read_circles = read_lib["circles"]
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
assert read_circles.shapes[(3, 0)][0].radius == 5
# Check Path caps
read_paths = read_lib["paths"]
assert read_paths.shapes[(2, 0)][0].cap == MPath.Cap.Flush
assert read_paths.shapes[(2, 1)][0].cap == MPath.Cap.Square
# OASIS HalfWidth is Square. masque's Square is also HalfWidth extension.
# Wait, Circle cap in OASIS?
# masque/file/oasis.py:
# path_cap_map = {
# PathExtensionScheme.Flush: Path.Cap.Flush,
# PathExtensionScheme.HalfWidth: Path.Cap.Square,
# PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom,
# }
# It seems Circle cap is NOT supported in OASIS by masque currently.
# Let's verify what happens with Circle cap in OASIS write.
# _shapes_to_elements in oasis.py:
# path_type = next(k for k, v in path_cap_map.items() if v == shape.cap)
# This will raise StopIteration if Circle is not in path_cap_map.
# Check Shape repetition
read_rep_shapes = read_lib["rep_shapes"]
poly = read_rep_shapes.shapes[(4, 0)][0]
assert poly.repetition is not None
assert isinstance(poly.repetition, Grid)
assert poly.repetition.a_count == 5