From c18e5b8d3e0f0ad6b0140525b063be11db64698b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 16:43:46 -0800 Subject: [PATCH 1/2] [OASIS] cleanup --- masque/file/oasis.py | 143 +++++++++++++++++++++++-------------------- 1 file changed, 75 insertions(+), 68 deletions(-) diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 672af25..0a11b24 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -120,10 +120,10 @@ def build( layer, data_type = _mlayer2oas(layer_num) lib.layers += [ fatrec.LayerName( - nstring=name, - layer_interval=(layer, layer), - type_interval=(data_type, data_type), - is_textlayer=tt, + nstring = name, + layer_interval = (layer, layer), + type_interval = (data_type, data_type), + is_textlayer = tt, ) for tt in (True, False)] @@ -286,11 +286,11 @@ def read( annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.polygon( - vertices=vertices, - layer=element.get_layer_tuple(), - offset=element.get_xy(), - annotations=annotations, - repetition=repetition, + vertices = vertices, + layer = element.get_layer_tuple(), + offset = element.get_xy(), + annotations = annotations, + repetition = repetition, ) elif isinstance(element, fatrec.Path): 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) pat.path( - vertices=vertices, - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - annotations=annotations, - width=element.get_half_width() * 2, - cap=cap, + vertices = vertices, + layer = element.get_layer_tuple(), + offset = element.get_xy(), + repetition = repetition, + annotations = annotations, + width = element.get_half_width() * 2, + cap = cap, **path_args, ) @@ -325,11 +325,11 @@ def read( height = element.get_height() annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.polygon( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), - annotations=annotations, + layer = element.get_layer_tuple(), + offset = element.get_xy(), + repetition = repetition, + vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), + annotations = annotations, ) elif isinstance(element, fatrec.Trapezoid): @@ -440,11 +440,11 @@ def read( else: string = str_or_ref.string pat.label( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - annotations=annotations, - string=string, + layer = element.get_layer_tuple(), + offset = element.get_xy(), + repetition = repetition, + annotations = annotations, + string = string, ) else: @@ -549,13 +549,13 @@ def _shapes_to_elements( offset = rint_cast(shape.offset + rep_offset) radius = rint_cast(shape.radius) circle = fatrec.Circle( - layer=layer, - datatype=datatype, - radius=cast('int', radius), - x=offset[0], - y=offset[1], - properties=properties, - repetition=repetition, + layer = layer, + datatype = datatype, + radius = cast('int', radius), + x = offset[0], + y = offset[1], + properties = properties, + repetition = repetition, ) elements.append(circle) 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_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) path = fatrec.Path( - layer=layer, - datatype=datatype, - point_list=cast('Sequence[Sequence[int]]', deltas), - half_width=cast('int', half_width), - x=xy[0], - y=xy[1], - extension_start=extension_start, # TODO implement multiple cap types? - extension_end=extension_end, - properties=properties, - repetition=repetition, + layer = layer, + datatype = datatype, + point_list = cast('Sequence[Sequence[int]]', deltas), + half_width = cast('int', half_width), + x = xy[0], + y = xy[1], + extension_start = extension_start, # TODO implement multiple cap types? + extension_end = extension_end, + properties = properties, + repetition = repetition, ) elements.append(path) else: @@ -583,13 +583,13 @@ def _shapes_to_elements( xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset) points = rint_cast(numpy.diff(polygon.vertices, axis=0)) elements.append(fatrec.Polygon( - layer=layer, - datatype=datatype, - x=xy[0], - y=xy[1], - point_list=cast('list[list[int]]', points), - properties=properties, - repetition=repetition, + layer = layer, + datatype = datatype, + x = xy[0], + y = xy[1], + point_list = cast('list[list[int]]', points), + properties = properties, + repetition = repetition, )) return elements @@ -606,13 +606,13 @@ def _labels_to_texts( xy = rint_cast(label.offset + rep_offset) properties = annotations_to_properties(label.annotations) texts.append(fatrec.Text( - layer=layer, - datatype=datatype, - x=xy[0], - y=xy[1], - string=label.string, - properties=properties, - repetition=repetition, + layer = layer, + datatype = datatype, + x = xy[0], + y = xy[1], + string = label.string, + properties = properties, + repetition = repetition, )) return texts @@ -622,10 +622,12 @@ def repetition_fata2masq( ) -> Repetition | None: mrep: Repetition | None if isinstance(rep, fatamorgana.GridRepetition): - mrep = Grid(a_vector=rep.a_vector, - b_vector=rep.b_vector, - a_count=rep.a_count, - b_count=rep.b_count) + mrep = Grid( + a_vector = rep.a_vector, + b_vector = rep.b_vector, + a_count = rep.a_count, + b_count = rep.b_count, + ) elif isinstance(rep, fatamorgana.ArbitraryRepetition): displacements = numpy.cumsum(numpy.column_stack(( rep.x_displacements, @@ -647,14 +649,19 @@ def repetition_masq2fata( frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None if isinstance(rep, Grid): 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 = rint_cast(rep.a_count) - b_count = rint_cast(rep.b_count) if rep.b_count is not None else None + a_count = int(rep.a_count) + if rep.b_count > 1: + b_vector = rint_cast(rep.b_vector) + b_count = int(rep.b_count) + else: + b_vector = None + b_count = None + frep = fatamorgana.GridRepetition( - a_vector=cast('list[int]', a_vector), - b_vector=cast('list[int] | None', b_vector), - a_count=cast('int', a_count), - b_count=cast('int | None', b_count), + a_vector = a_vector, + b_vector = b_vector, + a_count = a_count, + b_count = b_count, ) offset = (0, 0) elif isinstance(rep, Arbitrary): From 5e085794986b42f3115bf316457ea4f90e53ff2e Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 15 Feb 2026 16:44:17 -0800 Subject: [PATCH 2/2] [tests] add round-trip file tests --- masque/test/test_file_roundtrip.py | 149 +++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 masque/test/test_file_roundtrip.py diff --git a/masque/test/test_file_roundtrip.py b/masque/test/test_file_roundtrip.py new file mode 100644 index 0000000..fbadd7b --- /dev/null +++ b/masque/test/test_file_roundtrip.py @@ -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