diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 237b1d8..0a04f61 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -6,17 +6,21 @@ Notes: * ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID to unique values, so byte-for-byte reproducibility is not achievable for now """ -from typing import Any, cast, TextIO, IO -from collections.abc import Mapping, Callable +from typing import Any, cast, TextIO, IO, Literal +from collections import defaultdict +from collections.abc import Mapping, Callable, Sequence import io import logging import pathlib import gzip import numpy +from numpy.typing import NDArray import ezdxf +from ezdxf import edgeminer +from ezdxf.math import Vec3 from ezdxf.enums import TextEntityAlignment -from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace +from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace, Line from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -24,6 +28,7 @@ from ..library import ILibraryView, LibraryView, Library from ..shapes import Shape, Polygon, Path from ..repetition import Grid from ..utils import rotation_matrix_2d, layer_t, normalize_mirror +from ..utils.boolean import _polytree_to_polygons logger = logging.getLogger(__name__) @@ -173,6 +178,9 @@ def readfile( def read( stream: TextIO, + *, + polyline_mode: Literal[0, 1, 2, 3, 4] = 2, + contour_accuracy: float = 0.0, ) -> tuple[Library, dict[str, Any]]: """ Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are @@ -183,16 +191,30 @@ def read( Args: stream: Stream to read from. + polyline_mode: Treatment of straight LINE/POLYLINE/LWPOLYLINE geometry: + 0 selects automatically (1 if SOLID/HATCH exists, otherwise 2 if closed + polylines exist, otherwise 3); 1 keeps paths; 2 fills closed zero-width + polylines; 3 joins zero-width segments into polygons, keeping open + contours as paths; 4 additionally closes open contours. Closure may be + indicated by the DXF flag or exactly equal endpoints. Positive-width + paths are preserved in every mode. Curved and variable-width entities + remain unsupported. Automatic selection uses all imported blocks. + contour_accuracy: Nonnegative, finite endpoint joining distance in DXF + units, used only in modes 3 and 4. Zero requires exact coincidence. + Merged polygons are quantized to 1e-6 DXF units and use even-odd filling + (nested contours form holes), matching KLayout's polyline merge modes. Returns: - - Top level pattern + - Library of patterns + - Layer metadata """ + if polyline_mode not in (0, 1, 2, 3, 4): + raise ValueError(f'Invalid DXF polyline_mode: {polyline_mode!r}') + if not numpy.isfinite(contour_accuracy) or contour_accuracy < 0: + raise ValueError('DXF contour_accuracy must be finite and nonnegative') lib = ezdxf.read(stream) msp = lib.modelspace() - top_name, top_pat = _read_block(msp) - mlib = Library({top_name: top_pat}) - blocks_by_name = { bb.name: bb for bb in lib.blocks @@ -219,12 +241,27 @@ def read( if target in blocks_by_name: pending.append(blocks_by_name[target]) - for bb in lib.blocks: - if bb.is_any_layout: - continue - if bb.name.startswith('_') and bb.name not in referenced: - continue - name, pat = _read_block(bb) + blocks = [msp, *(bb for bb in blocks_by_name.values() + if not bb.name.startswith('_') or bb.name in referenced)] + if polyline_mode == 0: + polyline_mode = 3 + for block in blocks: + for element in block: + if element.dxftype() in ('SOLID', 'HATCH'): + polyline_mode = 1 + break + if isinstance(element, LWPolyline | Polyline): + verts = (numpy.asarray(element.get_points('xy')) if isinstance(element, LWPolyline) + else numpy.asarray([pp.xyz[:2] for pp in element.points()])) + closed = element.closed if isinstance(element, LWPolyline) else element.is_closed + if closed or (len(verts) > 1 and numpy.array_equal(verts[0], verts[-1])): + polyline_mode = 2 + if polyline_mode == 1: + break + + mlib = Library() + for bb in blocks: + name, pat = _read_block(bb, polyline_mode=polyline_mode, contour_accuracy=contour_accuracy) mlib[name] = pat library_info = dict( @@ -234,9 +271,15 @@ def read( return mlib, library_info -def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> tuple[str, Pattern]: +def _read_block( + block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, + *, + polyline_mode: int = 2, + contour_accuracy: float = 0.0, + ) -> tuple[str, Pattern]: name = block.name pat = Pattern() + contours: dict[layer_t, list[numpy.ndarray]] = defaultdict(list) for element in block: if isinstance(element, LWPolyline | Polyline): if isinstance(element, LWPolyline): @@ -247,6 +290,9 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> is_closed = element.is_closed attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) + if len(points) < 2: + logger.warning('Ignoring DXF polyline with fewer than two vertices') + continue width = 0 if isinstance(element, LWPolyline): @@ -260,26 +306,44 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> elif points.shape[1] == 3: # width used to be in column 2 width = points[0, 2] + else: + if any(vertex.dxf.get('bulge', 0) != 0 for vertex in element.vertices): + raise PatternError('Polyline has bulge (not yet representable in masque!)') + widths = numpy.asarray([ + (vertex.dxf.get('start_width', attr.get('default_start_width', 0)), + vertex.dxf.get('end_width', attr.get('default_end_width', 0))) + for vertex in element.vertices + ]) + if (widths != widths[0, 0]).any(): + raise PatternError('Polyline has non-constant width (not yet representable in masque!)') + width = widths[0, 0] if width == 0: width = attr.get('const_width', 0) verts = points[:, :2] - if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): + endpoint_closed = numpy.array_equal(verts[0], verts[-1]) + if is_closed and not endpoint_closed: verts = numpy.vstack((verts, verts[0])) + is_closed = is_closed or endpoint_closed shape: Path | Polygon - if width == 0 and is_closed: - # Use Polygon if it has at least 3 unique vertices - shape_verts = verts[:-1] if len(verts) > 1 else verts - if len(shape_verts) >= 3: - shape = Polygon(vertices=shape_verts) - else: - shape = Path(width=width, vertices=verts) + if width == 0 and polyline_mode >= 3: + contours[layer].append(verts) + continue + if width == 0 and is_closed and polyline_mode == 2 and _is_polygon(verts): + shape = Polygon(vertices=verts[:-1]) else: shape = Path(width=width, vertices=verts) pat.shapes[layer].append(shape) + elif isinstance(element, Line): + layer = element.dxf.get('layer', DEFAULT_LAYER) + verts = numpy.asarray((element.dxf.start.xyz[:2], element.dxf.end.xyz[:2])) + if polyline_mode >= 3: + contours[layer].append(verts) + else: + pat.shapes[layer].append(Path(vertices=verts, width=0)) elif isinstance(element, Solid | Trace): attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) @@ -316,7 +380,8 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> logger.warning('Masque does not support per-axis scaling; using x-scaling only!') scale = abs(xscale) mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0)) - rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle + insert_rotation = numpy.deg2rad(attr.get('rotation', 0)) + rotation = insert_rotation + extra_angle offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2] @@ -328,64 +393,144 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> rotation=rotation, ) - if 'column_count' in attr: - col_spacing = attr['column_spacing'] - row_spacing = attr['row_spacing'] - col_count = attr['column_count'] - row_count = attr['row_count'] + if 'column_count' in attr or 'row_count' in attr: + col_spacing = attr.get('column_spacing', 0) + row_spacing = attr.get('row_spacing', 0) + col_count = attr.get('column_count', 1) + row_count = attr.get('row_count', 1) local_x = numpy.array((col_spacing, 0.0)) local_y = numpy.array((0.0, row_spacing)) - inv_rot = rotation_matrix_2d(-rotation) - - candidates = ( - (inv_rot @ local_x, inv_rot @ local_y, col_count, row_count), - (inv_rot @ local_y, inv_rot @ local_x, row_count, col_count), + # Spacing follows only the original INSERT angle, not its scale + # or the extra angle introduced by mirror normalization. + rot = rotation_matrix_2d(insert_rotation) + args['repetition'] = Grid( + a_vector=rot @ local_x, b_vector=rot @ local_y, + a_count=col_count, b_count=row_count, ) - repetition = None - for a_vector, b_vector, a_count, b_count in candidates: - rotated_a = rotation_matrix_2d(rotation) @ a_vector - rotated_b = rotation_matrix_2d(rotation) @ b_vector - if (numpy.isclose(rotated_a[1], 0, atol=1e-8) - and numpy.isclose(rotated_b[0], 0, atol=1e-8) - and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8) - and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8) - and a_count == col_count - and b_count == row_count): - repetition = Grid( - a_vector=a_vector, - b_vector=b_vector, - a_count=a_count, - b_count=b_count, - ) - break - if (numpy.isclose(rotated_a[0], 0, atol=1e-8) - and numpy.isclose(rotated_b[1], 0, atol=1e-8) - and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8) - and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8) - and b_count == col_count - and a_count == row_count): - repetition = Grid( - a_vector=a_vector, - b_vector=b_vector, - a_count=a_count, - b_count=b_count, - ) - break - - if repetition is None: - repetition = Grid( - a_vector=inv_rot @ local_x, - b_vector=inv_rot @ local_y, - a_count=col_count, - b_count=row_count, - ) - args['repetition'] = repetition pat.ref(**args) else: logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).') + for layer, vertex_lists in contours.items(): + pat.shapes[layer].extend(_merge_polylines(vertex_lists, contour_accuracy, auto_close=polyline_mode == 4)) return name, pat +def _is_polygon(vertices: NDArray) -> bool: + """At least three distinct, noncollinear points (including self-crossing contours).""" + points = numpy.unique(vertices, axis=0) + if len(points) < 3: + return False + vectors = points[1:] - points[0] + return bool(numpy.any(vectors[:, 0] * vectors[0, 1] != vectors[:, 1] * vectors[0, 0])) + + +def _contours(edges: Sequence[edgeminer.Edge], accuracy: float) -> list[tuple[NDArray, bool]]: + """Join each edge once, using indexed endpoint searches rather than loop enumeration.""" + deposit = edgeminer.Deposit(edges, gap_tol=accuracy) + unused = {edge.id for edge in edges} + result = [] + + def grow(points: list[Vec3]) -> bool: + positions = {point: index for index, point in enumerate(points[:-1])} + while True: + # A walk that started on a dangling segment can encounter a cycle + # before returning to its initial point. Extract that rim and keep + # the remaining open tail; every segment is still consumed once. + contacts = { + point for edge in deposit.edges_linked_to(points[-1]) + for point in (edge.start, edge.end) + if point in positions and positions[point] < len(points) - 2 + and point.distance(points[-1]) <= accuracy + } + if contacts: + point = min(contacts, key=lambda point: (point.distance(points[-1]), point.xyz)) + index = positions[point] + loop = points[index:-1] + [point] + result.append((numpy.asarray([pp.xyz[:2] for pp in loop]), True)) + if index == 0: + return True + for removed in points[index + 1:-1]: + positions.pop(removed, None) + del points[index + 1:] + positions[points[-1]] = len(points) - 1 + incoming = points[-1] - points[-2] + candidates = [] + for edge in deposit.edges_linked_to(points[-1]): + if edge.id not in unused: + continue + for oriented in (edge, edge.reversed()): + distance = points[-1].distance(oriented.start) + if distance <= accuracy: + direction = oriented.end - oriented.start + # Like KLayout, use endpoint distance then a signed + # cross product. Canonical seeds follow clockwise rims. + turn = -direction.cross(incoming).z / oriented.length + candidates.append((distance, turn, oriented.end.xyz, oriented.id, oriented)) + if not candidates: + return False + edge = min(candidates, key=lambda item: item[:4])[-1] + unused.remove(edge.id) + # Snap the next start to the preceding endpoint when joining a gap. + points.append(edge.end) + + # Canonical ordering makes results independent of input order/direction. + ordered = sorted(edges, key=lambda edge: sorted((edge.start.xyz, edge.end.xyz))) + for seed in ordered: + if seed.id not in unused: + continue + unused.remove(seed.id) + edge = seed.reversed() if seed.start.xyz > seed.end.xyz else seed + points = [edge.start, edge.end] + closed = grow(points) + if not closed: + points.reverse() + closed = grow(points) + if not closed: + result.append((numpy.asarray([point.xyz[:2] for point in points]), False)) + return result + + +def _merge_polylines( + vertex_lists: Sequence[NDArray], + accuracy: float, + *, + auto_close: bool, + ) -> list[Path | Polygon]: + """Assemble one cell/layer's zero-width segments, with KLayout's even-odd fill.""" + import pyclipper # noqa: PLC0415 + + edges = [] + result: list[Path | Polygon] = [] + for vertices in vertex_lists: + start_count = len(edges) + for start, end in zip(vertices[:-1], vertices[1:], strict=True): + if not numpy.array_equal(start, end): + edges.append(edgeminer.make_edge(start, end)) + if start_count == len(edges): + result.append(Path(vertices=vertices, width=0)) + + scale = 1e6 + clipper = pyclipper.Pyclipper() + has_polygons = False + for vertices, closed in _contours(edges, accuracy): + if (closed or auto_close) and _is_polygon(vertices): + # A contour can collapse at the clipping precision. Preserve its + # centerline in that case rather than silently dropping geometry. + try: + added = clipper.AddPath(pyclipper.scale_to_clipper(vertices, scale), pyclipper.PT_SUBJECT, True) + except pyclipper.ClipperException: + added = False + if added: + has_polygons = True + continue + result.append(Path(vertices=vertices, width=0)) + + if has_polygons: + tree = clipper.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) + result.extend(_polytree_to_polygons(tree, scale)) + return result + + def _mrefs_to_drefs( block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, refs: dict[str | None, list[Ref]], @@ -407,12 +552,10 @@ def _mrefs_to_drefs( # In masque, the grid basis vectors are NOT rotated by the reference's rotation. # In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing], # which ARE then rotated by the block reference's rotation. - # Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90) - # AND the grid is already manhattan. - - # Rotate basis vectors by the reference rotation to see where they end up in the DXF frame - rotated_a = rotation_matrix_2d(ref.rotation) @ a - rotated_b = rotation_matrix_2d(ref.rotation) @ b + # Compensate for that rotation to express the world-space basis in + # the local DXF frame. Only locally Manhattan grids fit an INSERT. + rotated_a = rotation_matrix_2d(-ref.rotation) @ a + rotated_b = rotation_matrix_2d(-ref.rotation) @ b if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8): attribs['column_count'] = rep.a_count diff --git a/masque/file/gdsii/arrow.py b/masque/file/gdsii/arrow.py index 030bdbb..6a2a42c 100644 --- a/masque/file/gdsii/arrow.py +++ b/masque/file/gdsii/arrow.py @@ -327,11 +327,14 @@ def read_arrow( cell_ids = libarr['cells'].values.field('id').to_numpy() cell_names = libarr['cell_names'].as_py() + # Masque geometry is mutable and supports fractional transforms. Convert + # coordinates in bulk before slicing them into objects; scan-only and raw + # GDS copy-through workflows never enter this materialization path. def get_geom(libarr: pyarrow.Array, geom_type: str) -> dict[str, Any]: el = libarr['cells'].values.field(geom_type) elem = dict( offsets = el.offsets.to_numpy(), - xy_arr = el.values.field('xy').values.to_numpy().reshape((-1, 2)), + xy_arr = el.values.field('xy').values.to_numpy().astype(float).reshape((-1, 2)), xy_off = el.values.field('xy').offsets.to_numpy() // 2, layer_inds = el.values.field('layer').to_numpy(), prop_off = el.values.field('properties').offsets.to_numpy(), @@ -345,7 +348,7 @@ def read_arrow( return dict( offsets = batches.offsets.to_numpy(), layer_inds = batches.values.field('layer').to_numpy(), - vert_arr = batches.values.field('vertices').values.to_numpy().reshape((-1, 2)), + vert_arr = batches.values.field('vertices').values.to_numpy().astype(float).reshape((-1, 2)), vert_off = batches.values.field('vertices').offsets.to_numpy() // 2, poly_off = batches.values.field('vertex_offsets').offsets.to_numpy(), poly_offsets = batches.values.field('vertex_offsets').values.to_numpy(), @@ -356,7 +359,7 @@ def read_arrow( return dict( offsets = batches.offsets.to_numpy(), layer_inds = batches.values.field('layer').to_numpy(), - rect_arr = batches.values.field('rects').values.to_numpy().reshape((-1, 4)), + rect_arr = batches.values.field('rects').values.to_numpy().astype(float).reshape((-1, 4)), rect_off = batches.values.field('rects').offsets.to_numpy() // 4, ) @@ -365,7 +368,7 @@ def read_arrow( return dict( offsets = boundaries.offsets.to_numpy(), layer_inds = boundaries.values.field('layer').to_numpy(), - vert_arr = boundaries.values.field('vertices').values.to_numpy().reshape((-1, 2)), + vert_arr = boundaries.values.field('vertices').values.to_numpy().astype(float).reshape((-1, 2)), vert_off = boundaries.values.field('vertices').offsets.to_numpy() // 2, prop_off = boundaries.values.field('properties').offsets.to_numpy(), prop_key = boundaries.values.field('properties').values.field('key').to_numpy(), @@ -378,15 +381,15 @@ def read_arrow( elem = dict( offsets = refs.offsets.to_numpy(), targets = values.field('target').to_numpy(), - xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()), + xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()).astype(float), invert_y = values.field('invert_y').to_numpy(zero_copy_only=False), angle_rad = values.field('angle_rad').to_numpy(), scale = values.field('scale').to_numpy(), ) if has_repetition: elem.update(dict( - xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()), - xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()), + xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()).astype(float), + xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float), counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()), )) return elem @@ -397,7 +400,7 @@ def read_arrow( elem = dict( offsets = refs.offsets.to_numpy(), targets = values.field('target').to_numpy(), - xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()), + xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()).astype(float), invert_y = values.field('invert_y').to_numpy(zero_copy_only=False), angle_rad = values.field('angle_rad').to_numpy(), scale = values.field('scale').to_numpy(), @@ -407,8 +410,8 @@ def read_arrow( ) if has_repetition: elem.update(dict( - xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()), - xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()), + xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()).astype(float), + xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float), counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()), )) return elem @@ -417,7 +420,7 @@ def read_arrow( texts = dict( offsets = txt.offsets.to_numpy(), layer_inds = txt.values.field('layer').to_numpy(), - xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()), + xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()).astype(float), string = txt.values.field('string').to_pylist(), prop_off = txt.values.field('properties').offsets.to_numpy(), prop_key = txt.values.field('properties').values.field('key').to_numpy(), @@ -443,7 +446,7 @@ def read_arrow( extensions = numpy.stack(( paths.values.field('extension_start').fill_null(0).to_numpy(), paths.values.field('extension_end').fill_null(0).to_numpy(), - ), axis=-1), + ), axis=-1, dtype=float), )) global_args = dict( @@ -517,7 +520,7 @@ def _append_plain_refs_sorted( pat: Pattern, cell_names: list[str], elem_targets: NDArray[numpy.integer[Any]], - elem_xy: NDArray[numpy.integer[Any]], + elem_xy: NDArray[numpy.float64], elem_invert_y: NDArray[numpy.bool_ | numpy.bool], elem_angle_rad: NDArray[numpy.floating[Any]], elem_scale: NDArray[numpy.floating[Any]], diff --git a/masque/file/gdsii/lazy_arrow.py b/masque/file/gdsii/lazy_arrow.py index b20edad..d686bfa 100644 --- a/masque/file/gdsii/lazy_arrow.py +++ b/masque/file/gdsii/lazy_arrow.py @@ -209,6 +209,7 @@ class ArrowLibrary(ILibraryView, IMaterializable): self.path = path self.library_info = payload.library_info self._payload = payload + self._name_to_id = {name: cell_id for cell_id, name in enumerate(payload.cell_names)} self._source = source self._cache: dict[str, Pattern] = {} @@ -368,10 +369,10 @@ class ArrowLibrary(ILibraryView, IMaterializable): ) -> list[NDArray[numpy.float64]]: if parent in self._cache: return super()._raw_ref_transforms(parent, target) - target_cell = self._payload.cells.get(target) - if target_cell is None or parent not in self._payload.cells: + target_id = self._name_to_id.get(target) + if target_id is None or parent not in self._payload.cells: return [] - return self._collect_raw_transforms(self._payload.cells[parent], target_cell.cell_id) + return self._collect_raw_transforms(self._payload.cells[parent], target_id) def readfile( diff --git a/masque/file/oasis.py b/masque/file/oasis.py index b5d0cd8..67ea644 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -565,8 +565,9 @@ def _shapes_to_elements( path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup if path_type is None: raise PatternError(f'OASIS writer does not support path cap {shape.cap}') - 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) + extensions = None if shape.cap_extensions is None else rint_cast(shape.cap_extensions) + extension_start = (path_type, extensions[0] if extensions is not None else None) + extension_end = (path_type, extensions[1] if extensions is not None else None) path = fatrec.Path( layer = layer, datatype = datatype, diff --git a/masque/library/overlay.py b/masque/library/overlay.py index 529684b..43e201e 100644 --- a/masque/library/overlay.py +++ b/masque/library/overlay.py @@ -273,7 +273,6 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): self._layers: list[_SourceLayer] = [] self._entries: dict[str, Pattern | _SourceEntry] = {} self._order: list[str] = [] - self._target_remap: dict[str, str] = {} def __iter__(self) -> Iterator[str]: return (name for name in self._order if name in self._entries) @@ -345,6 +344,10 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): source_target_map=dict(source_to_visible), child_graph=child_graph, ) + # Include dangling targets so each source tracks current names directly. + for children in child_graph.values(): + for child in children: + layer.source_target_map.setdefault(child, child) layer_index = len(self._layers) self._layers.append(layer) @@ -371,6 +374,7 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): entry = self._entries.pop(old_name) self._entries[new_name] = entry + self._order = [name for name in self._order if name != new_name] idx = self._order.index(old_name) self._order[idx] = new_name @@ -378,28 +382,13 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): self.move_references(old_name, new_name) return self - def _resolve_target(self, target: str) -> str: - seen: set[str] = set() - current = target - while current in self._target_remap: - if current in seen: - raise LibraryError(f'Cycle encountered while resolving target remap for {target!r}') - seen.add(current) - current = self._target_remap[current] - return current - - def _set_target_remap(self, old_target: str, new_target: str) -> None: - resolved_new = self._resolve_target(new_target) - if resolved_new == old_target: - raise LibraryError(f'Ref target remap would create a cycle: {old_target!r} -> {new_target!r}') - self._target_remap[old_target] = resolved_new - for key in list(self._target_remap): - self._target_remap[key] = self._resolve_target(self._target_remap[key]) - def move_references(self, old_target: str, new_target: str) -> OverlayLibrary: if old_target == new_target: return self - self._set_target_remap(old_target, new_target) + for layer in self._layers: + for source_target, current_target in layer.source_target_map.items(): + if current_target == old_target: + layer.source_target_map[source_target] = new_target for entry in list(self._entries.values()): if isinstance(entry, Pattern) and old_target in entry.refs: entry.refs[new_target].extend(entry.refs[old_target]) @@ -407,8 +396,7 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): return self def _effective_target(self, layer: _SourceLayer, target: str) -> str: - visible = layer.source_target_map.get(target, target) - return self._resolve_target(visible) + return layer.source_target_map.get(target, target) def _remap_source_pattern(self, layer: _SourceLayer, source_pat: Pattern) -> Pattern: def remap(target: str | None) -> str | None: @@ -528,7 +516,6 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): ] new._order = [name for name in self._order if name in keep and name in self._entries] new._entries = {name: self._entries[name] for name in new._order} - new._target_remap = dict(self._target_remap) return new def find_refs_local( diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 53fadb8..35362a9 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -423,11 +423,10 @@ class Arc(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Arc': - if self.angle_ref != ArcAngleRef.Center: - x_major = self.radius_x > self.radius_y - y_major = self.radius_y > self.radius_x - if (axis == 0 and y_major) or (axis == 1 and x_major): - self._swap_focus_ref() + # Both external reflections use a local Y reflection; the extra pi + # rotation below accounts for the external axis. + if self.angle_ref != ArcAngleRef.Center and self.radius_y > self.radius_x: + self._swap_focus_ref() self.rotation *= -1 self.rotation += axis * pi self.angles *= -1 diff --git a/masque/shapes/rect_collection.py b/masque/shapes/rect_collection.py index eaf028f..291f23a 100644 --- a/masque/shapes/rect_collection.py +++ b/masque/shapes/rect_collection.py @@ -187,7 +187,12 @@ class RectCollection(Shape): def rotate(self, theta: float) -> Self: quarter_turns = int(numpy.rint(theta / (pi / 2))) if not numpy.isclose(theta, quarter_turns * (pi / 2)): - raise PatternError('RectCollection only supports Manhattan rotations') + raise PatternError( + f'RectCollection cannot rotate by {theta!r} radians; only Manhattan rotations (multiples of pi/2) are supported. ' + 'Explicitly replace the collection with to_polygons(), or call Pattern.polygonize() ' + 'on the pattern containing it (including referenced child patterns) before transforming, ' + 'flattening, or computing hierarchical bounds.' + ) turns = quarter_turns % 4 if turns == 0 or self._rects.size == 0: return self diff --git a/masque/test/test_arc.py b/masque/test/test_arc.py index dc23144..ae2789a 100644 --- a/masque/test/test_arc.py +++ b/masque/test/test_arc.py @@ -14,6 +14,21 @@ def test_arc_init() -> None: assert_equal(a.angles, [0, pi / 2]) assert a.width == 2 + +@pytest.mark.parametrize('axis', [0, 1]) +@pytest.mark.parametrize('radii', [(10, 6), (6, 10), (10, 10)]) +@pytest.mark.parametrize('angle_ref', list(Arc.AngleRef)) +@pytest.mark.parametrize('rotation', [0, pi / 5]) +def test_arc_reflection_preserves_caps_and_bounds(axis: int, radii: tuple, angle_ref: Arc.AngleRef, rotation: float) -> None: + arc = Arc(radii=radii, angles=(-0.3, 1.1), width=1, angle_ref=angle_ref, rotation=rotation) + reflected = arc.deepcopy().mirror(axis) + signs = numpy.ones(2) + signs[1 - axis] = -1 + assert_allclose(reflected.get_cap_edges(), arc.get_cap_edges() * signs, atol=1e-12) + expected = arc.get_bounds_single() * signs + assert_allclose(reflected.get_bounds_single(), numpy.sort(expected, axis=0), atol=1e-12) + assert_allclose(reflected.mirror(axis).get_cap_edges(), arc.get_cap_edges(), atol=1e-12) + def test_arc_to_polygons() -> None: a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) polys = a.to_polygons(num_vertices=32) diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py index e2fd7ea..b8d0538 100644 --- a/masque/test/test_boolean.py +++ b/masque/test/test_boolean.py @@ -15,6 +15,40 @@ def _poly_area(poly: Polygon) -> float: y = verts[:, 1] return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1))) + +@pytest.mark.parametrize('repeated_clip', [False, True]) +@pytest.mark.parametrize('nested', [False, True]) +def test_boolean_expands_repetitions(repeated_clip: bool, nested: bool) -> None: + from masque import boolean + from masque.repetition import Arbitrary + from masque.shapes import RectCollection + + repeated = RectCollection([[0, 0, 2, 2]], repetition=Arbitrary([[0, 0], [10, 0]])) + clip = Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]) + subject, other = (clip, repeated) if repeated_clip else (repeated, clip) + result = boolean([[subject]] if nested else subject, [other], operation='intersection') + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[10, 0], [12, 2]]) + assert _poly_area(result[0]) == 4 + assert result[0].repetition is None + assert_allclose(repeated.rects, [[0, 0, 2, 2]]) + assert_allclose(repeated.repetition.displacements, [[0, 0], [10, 0]]) + + +@pytest.mark.parametrize('operation', ['union', 'difference', 'xor']) +def test_boolean_single_set_normalizes_overlaps(operation: str) -> None: + from masque import boolean + + subject = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]], repetition=Grid(a_vector=(1, 0), a_count=2)) + for clips in (None, []): + result = boolean(subject, clips, operation=operation) + assert len(result) == 1 + assert _poly_area(result[0]) == 6 + if operation != 'difference': + result = boolean([], subject, operation=operation) + assert len(result) == 1 + assert _poly_area(result[0]) == 6 + def test_layer_as_polygons_basic() -> None: pat = Pattern() pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]]) diff --git a/masque/test/test_dxf_modes.py b/masque/test/test_dxf_modes.py new file mode 100644 index 0000000..10f7f89 --- /dev/null +++ b/masque/test/test_dxf_modes.py @@ -0,0 +1,205 @@ +"""DXF geometry checks independent of masque's writer/reader round trips.""" +import io + +import ezdxf +import numpy +import pytest +from numpy.testing import assert_allclose + +from ..file import dxf +from ..library import Library +from ..pattern import Pattern +from ..repetition import Grid +from ..shapes import Path, Polygon + + +def _read(doc: ezdxf.document.Drawing, mode: int = 2, accuracy: float = 0.0) -> Library: + stream = io.StringIO() + doc.write(stream) + stream.seek(0) + return dxf.read(stream, polyline_mode=mode, contour_accuracy=accuracy)[0] + + +def _area(shapes: list) -> float: + total = 0.0 + for shape in shapes: + if isinstance(shape, Polygon): + xx, yy = shape.vertices.T + total += abs(numpy.dot(xx, numpy.roll(yy, 1)) - numpy.dot(yy, numpy.roll(xx, 1))) / 2 + return total + + +def _origins(rows: numpy.ndarray) -> numpy.ndarray: + rounded = numpy.round(rows, 8) + return rounded[numpy.lexsort((rounded[:, 1], rounded[:, 0]))] + + +@pytest.mark.parametrize('legacy', [False, True]) +@pytest.mark.parametrize('flagged', [False, True]) +@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4]) +def test_closed_polylines(legacy: bool, flagged: bool, mode: int) -> None: + doc = ezdxf.new() + points = [(0, 0), (10, 0), (10, 10), (0, 10)] + if not flagged: + points.append(points[0]) + msp = doc.modelspace() + if legacy: + msp.add_polyline2d(points).close(flagged) + else: + msp.add_lwpolyline(points, close=flagged) + shapes = _read(doc, mode)['Model'].shapes['0'] + assert len(shapes) == 1 + assert isinstance(shapes[0], Path if mode == 1 else Polygon) + assert _area(shapes) == (0 if mode == 1 else 100) + if mode == 1: + assert_allclose(shapes[0].vertices[0], shapes[0].vertices[-1]) + + +@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4]) +@pytest.mark.parametrize('closed', [False, True]) +def test_join_shuffled_lines(mode: int, closed: bool) -> None: + doc = ezdxf.new() + segments = [((10, 10), (10, 0)), ((0, 0), (10, 0)), ((0, 10), (10, 10))] + if closed: + segments.append(((0, 0), (0, 10))) + for start, end in segments: + doc.modelspace().add_line(start, end) + shapes = _read(doc, mode)['Model'].shapes['0'] + filled = mode == 4 or (closed and mode in (0, 3)) + assert _area(shapes) == (100 if filled else 0) + assert len(shapes) == (len(segments) if mode in (1, 2) else 1) + + +@pytest.mark.parametrize('entity', ['SOLID', 'HATCH']) +def test_auto_detects_solids_in_child_blocks(entity: str) -> None: + doc = ezdxf.new() + doc.modelspace().add_lwpolyline([(0, 0), (4, 0), (4, 4)], close=True) + block = doc.blocks.new('child') + if entity == 'SOLID': + block.add_solid([(0, 0), (1, 0), (1, 1)]) + else: + block.add_hatch() + doc.modelspace().add_blockref('child', (0, 0)) + assert isinstance(_read(doc, 0)['Model'].shapes['0'][0], Path) + + +@pytest.mark.parametrize(('accuracy', 'expected'), [(0, 0), (0.005, 0), (0.02, 100)]) +def test_contour_tolerance(accuracy: float, expected: float) -> None: + doc = ezdxf.new() + doc.modelspace().add_lwpolyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0.01)]) + shapes = _read(doc, 3, accuracy)['Model'].shapes['0'] + assert _area(shapes) == expected + + +@pytest.mark.parametrize('mode', [3, 4]) +def test_nested_contours_resolve_holes_and_islands(mode: int) -> None: + doc = ezdxf.new() + for lo, hi in [(0, 10), (2, 8), (4, 6)]: + doc.modelspace().add_lwpolyline([(lo, lo), (hi, lo), (hi, hi), (lo, hi)], close=True) + shapes = _read(doc, mode)['Model'].shapes['0'] + assert len(shapes) == 2 + assert _area(shapes) == 68 + + +@pytest.mark.parametrize('mode', [3, 4]) +def test_layers_and_blocks_are_not_joined(mode: int) -> None: + doc = ezdxf.new() + doc.modelspace().add_line((0, 0), (10, 0), dxfattribs={'layer': 'a'}) + doc.modelspace().add_line((10, 0), (0, 10), dxfattribs={'layer': 'b'}) + doc.blocks.new('child').add_line((0, 10), (0, 0), dxfattribs={'layer': 'a'}) + lib = _read(doc, mode) + assert all(isinstance(shape, Path) for pat in lib.values() for shapes in pat.shapes.values() for shape in shapes) + + +@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4]) +@pytest.mark.parametrize('legacy', [False, True]) +def test_width_and_degenerate_paths_are_preserved(mode: int, legacy: bool) -> None: + doc = ezdxf.new() + if legacy: + doc.modelspace().add_polyline2d([(0, 0), (2, 0), (2, 2)], dxfattribs={ + 'default_start_width': 2, 'default_end_width': 2, + }).close(True) + else: + doc.modelspace().add_lwpolyline([(0, 0), (2, 0), (2, 2)], close=True, dxfattribs={'const_width': 2}) + doc.modelspace().add_lwpolyline([(10, 0), (10, 0)]) + doc.modelspace().add_lwpolyline([(20, 0), (21, 0), (22, 0)]) + shapes = _read(doc, mode)['Model'].shapes['0'] + assert len(shapes) == 3 + assert all(isinstance(shape, Path) for shape in shapes) + assert sorted(shape.width for shape in shapes) == [0, 0, 2] + + +@pytest.mark.parametrize('mode', [3, 4]) +@pytest.mark.parametrize('spur', [((10, 0), (12, 0)), ((-2, 0), (0, 0)), ((0, -2), (0, 0))]) +def test_branch_and_shuffling_preserve_square(mode: int, spur: tuple) -> None: + segments = [((0, 0), (10, 0)), ((10, 0), (10, 10)), ((10, 10), (0, 10)), + ((0, 10), (0, 0)), spur] + for order in (segments, [(b, a) for a, b in segments[::-1]]): + doc = ezdxf.new() + for start, end in order: + doc.modelspace().add_line(start, end) + shapes = _read(doc, mode)['Model'].shapes['0'] + assert _area(shapes) == 100 + assert len(shapes) == 2 + + +@pytest.mark.parametrize('mode', [-1, 5, 'closed']) +def test_invalid_polyline_mode(mode: int) -> None: + with pytest.raises(ValueError, match='polyline_mode'): + _read(ezdxf.new(), mode) + + +@pytest.mark.parametrize('mode', [3, 4]) +def test_merge_uses_even_odd_filling_for_overlapping_contours(mode: int) -> None: + doc = ezdxf.new() + for xx in (0, 5): + doc.modelspace().add_lwpolyline([(xx, 0), (xx + 10, 0), (xx + 10, 10), (xx, 10)], close=True) + assert _area(_read(doc, mode)['Model'].shapes['0']) == 100 + + +@pytest.mark.parametrize('mode', [3, 4]) +def test_contours_below_clipping_precision_remain_paths(mode: int) -> None: + doc = ezdxf.new() + doc.modelspace().add_lwpolyline([(0, 0), (1e-7, 0), (0, 1e-7)], close=True) + shapes = _read(doc, mode)['Model'].shapes['0'] + assert len(shapes) == 1 + assert isinstance(shapes[0], Path) + assert_allclose(shapes[0].get_bounds_single(), [[0, 0], [1e-7, 1e-7]], atol=1e-15) + + +@pytest.mark.parametrize('accuracy', [-1, numpy.nan, numpy.inf]) +def test_invalid_contour_accuracy(accuracy: float) -> None: + with pytest.raises(ValueError, match='contour_accuracy'): + _read(ezdxf.new(), 3, accuracy) + + +@pytest.mark.parametrize('angle', [0, numpy.pi / 2, numpy.pi, numpy.pi / 4]) +@pytest.mark.parametrize('local_grid', [False, True]) +@pytest.mark.parametrize('mirrored', [False, True]) +def test_exported_insert_origins(angle: float, local_grid: bool, mirrored: bool) -> None: + lib = Library({'leaf': Pattern(), 'top': Pattern()}) + lib['leaf'].polygon('1', vertices=[(0, 0), (4, 0), (0, 2)]) + rep = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2) + if local_grid: + rep.rotate(angle) + lib['top'].ref('leaf', offset=(5, 7), rotation=angle, scale=2, mirrored=mirrored, repetition=rep) + stream = io.StringIO() + dxf.write(lib, 'top', stream) + stream.seek(0) + inserts = ezdxf.read(stream).modelspace().query('INSERT') + origins = [instance.dxf.insert.xyz[:2] for ins in inserts for instance in ins.multi_insert()] + assert_allclose(_origins(numpy.asarray(origins)), _origins(rep.displacements + (5, 7)), atol=1e-7) + + +@pytest.mark.parametrize('scales', [(1, 1), (-1, 1), (1, -1), (-1, -1), (2, 2)]) +@pytest.mark.parametrize('angle', [0, 30, 90]) +def test_imported_insert_origins(scales: tuple[float, float], angle: float) -> None: + doc = ezdxf.new() + doc.blocks.new('leaf') + insert = doc.modelspace().add_blockref('leaf', (5, 7), dxfattribs={ + 'rotation': angle, 'xscale': scales[0], 'yscale': scales[1], + 'column_count': 3, 'row_count': 2, 'column_spacing': 20, 'row_spacing': -10, + }) + expected = numpy.asarray([ins.dxf.insert.xyz[:2] for ins in insert.multi_insert()]) + ref = _read(doc)['Model'].refs['leaf'][0] + assert_allclose(_origins(ref.as_transforms()[:, :2]), _origins(expected), atol=1e-7) diff --git a/masque/test/test_gdsii_arrow.py b/masque/test/test_gdsii_arrow.py index 8f0f39f..0a33453 100644 --- a/masque/test/test_gdsii_arrow.py +++ b/masque/test/test_gdsii_arrow.py @@ -23,6 +23,67 @@ if not gdsii_arrow.is_available(): pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True) +def test_arrow_materialized_coordinates_are_writable_floats(tmp_path: Path) -> None: + original = _make_arrow_test_library() + for annotations, layer in [(None, (30, 0)), ({'1': ['prop']}, (31, 0))]: + for xx in (0, 10): + original['leaf'].polygon(layer, [(xx, 0), (xx + 4, 0), (xx, 3)], annotations=annotations) + filename = tmp_path / 'mutable.gds' + gdsii.writefile(original, filename, meters_per_unit=1e-9) + lib, _ = gdsii_arrow.readfile(filename) + arrays = [] + types = set() + for pattern in lib.values(): + for shapes in pattern.shapes.values(): + for shape in shapes: + types.add(type(shape)) + if isinstance(shape, RectCollection): + coordinates = shape.rects + elif isinstance(shape, PolyCollection): + coordinates = shape.vertex_lists + else: + coordinates = shape.vertices + arrays.append(coordinates) + before = coordinates.copy() + shape.translate((0.25, 0.5)).scale_by(1.5) + shift = (0.25, 0.5, 0.25, 0.5) if isinstance(shape, RectCollection) else (0.25, 0.5) + numpy.testing.assert_allclose(coordinates, (before + shift) * 1.5) + if isinstance(shape, MPath) and shape.cap_extensions is not None: + arrays.append(shape.cap_extensions) + for labels in pattern.labels.values(): + arrays.extend(label.offset for label in labels) + for refs in pattern.refs.values(): + for ref in refs: + arrays.append(ref.offset) + ref.translate((0.25, 0.5)) + if ref.repetition is not None: + arrays.extend((ref.repetition.a_vector, ref.repetition.b_vector)) + ref.repetition.scale_by(1.5) + assert {Polygon, PolyCollection, RectCollection, MPath} <= types + for array in arrays: + assert array.dtype == numpy.float64 + assert array.flags.writeable + + +def test_arrow_path_extensions_are_quantized_for_oasis(tmp_path: Path) -> None: + pytest.importorskip('fatamorgana') + from ..file import oasis # noqa: PLC0415 + + source = Library({'top': Pattern().path((1, 0), [(0, 0), (20, 0)], width=4, + cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))}) + gds_path = tmp_path / 'path.gds' + gdsii.writefile(source, gds_path, meters_per_unit=1e-9) + loaded, _ = gdsii_arrow.readfile(gds_path) + path = loaded['top'].shapes[(1, 0)][0] + path.scale_by(1.25) + exported = oasis.build(loaded, units_per_micron=1000).cells[0].geometry[0] + assert exported.extension_start[1] == 1 + assert exported.extension_end[1] == 6 + assert isinstance(exported.extension_start[1], int | numpy.integer) + assert isinstance(exported.extension_end[1], int | numpy.integer) + numpy.testing.assert_allclose(path.cap_extensions, (1.25, 6.25)) + + def _annotations_key(annotations: dict[str, list[object]] | None) -> tuple[tuple[str, tuple[object, ...]], ...] | None: if not annotations: return None diff --git a/masque/test/test_gdsii_lazy_arrow.py b/masque/test/test_gdsii_lazy_arrow.py index 7d840ab..927f35b 100644 --- a/masque/test/test_gdsii_lazy_arrow.py +++ b/masque/test/test_gdsii_lazy_arrow.py @@ -9,7 +9,7 @@ import pytest pytest.importorskip('pyarrow') -from .. import PatternError +from .. import PatternError, LibraryError from ..library import IBorrowing, IMaterializable, LayerMappedView, Library, OverlayLibrary, PortLoadView from ..pattern import Pattern from ..repetition import Grid @@ -25,6 +25,55 @@ if not gdsii_arrow.is_available(): pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True) +@pytest.mark.parametrize('cached', [False, True]) +def test_arrow_detached_mutation_is_isolated(tmp_path: Path, cached: bool) -> None: + filename = tmp_path / 'detached.gds' + gdsii.writefile(_make_small_library(), filename, meters_per_unit=1e-9) + with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib: + if cached: + lib['leaf'] + detached = lib.materialize_detached('leaf') + detached.translate_elements((0.25, 0.5)) + numpy.testing.assert_allclose(detached.get_bounds(), [[0.25, 0.5], [10.25, 5.5]]) + numpy.testing.assert_allclose(lib.materialize_detached('leaf').get_bounds(), [[0, 0], [10, 5]]) + assert lib.can_copy_raw_struct('leaf') == (not cached) + top = lib.materialize_detached('top').flatten(lib) + assert top.get_bounds() is not None + + +def test_arrow_rect_hierarchy_requires_explicit_polygonization(tmp_path: Path) -> None: + original = _make_small_library() + original['mid'].refs['leaf'][0].rotation = numpy.pi / 4 + filename = tmp_path / 'rotated.gds' + gdsii.writefile(original, filename, meters_per_unit=1e-9) + with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib: + for action in ('bounds', 'flatten'): + top = lib.materialize_detached('top') + with pytest.raises(PatternError, match='Pattern.polygonize'): + top.get_bounds(lib) if action == 'bounds' else top.flatten(lib) + lib['leaf'].polygonize() + numpy.testing.assert_allclose(lib['top'].get_bounds(lib), original['top'].get_bounds(original)) + assert lib.materialize_detached('top').flatten(lib).get_bounds() is not None + + +def test_arrow_dangling_ref_queries_are_cache_independent(tmp_path: Path) -> None: + original = Library({'parent': Pattern()}) + original['parent'].ref('missing', offset=(3, 4), rotation=numpy.pi / 3, mirrored=True, scale=2) + original['parent'].ref('missing', offset=(10, 20), repetition=Grid(a_vector=(7, 0), a_count=3)) + filename = tmp_path / 'dangling.gds' + gdsii.writefile(original, filename, meters_per_unit=1e-9) + with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib: + expected = _local_refs_key(original.find_refs_local('missing', dangling='include')) + assert _local_refs_key(lib.find_refs_local('missing', dangling='include')) == expected + assert lib.can_copy_raw_struct('parent') + assert not lib.find_refs_local('missing', dangling='ignore') + with pytest.raises(LibraryError, match='missing'): + lib.find_refs_local('missing', dangling='error') + assert not lib.find_refs_local('unknown', dangling='include') + lib['parent'] + assert _local_refs_key(lib.find_refs_local('missing', dangling='include')) == expected + + def test_gdsii_lazy_arrow_has_reader_only_surface() -> None: assert not hasattr(gdsii_lazy_arrow, 'is_available') assert not hasattr(gdsii_lazy_arrow, 'write') @@ -251,12 +300,17 @@ def test_gdsii_lazy_arrow_invalid_path_type_raises_pattern_error(tmp_path: Path) lib['top'] -def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path) -> None: +def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: gds_file = tmp_path / 'copy_source.gds' src = _make_small_library() gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through') lib, info = gdsii_lazy_arrow.readfile(gds_file) + + def forbid_materialization(*_args, **_kwargs) -> None: + pytest.fail('Untouched GDS writes must not materialize Arrow coordinates') + + monkeypatch.setattr(gdsii_arrow, 'read_arrow', forbid_materialization) out_file = tmp_path / 'copy_out.gds' gdsii.writefile( lib, diff --git a/masque/test/test_library.py b/masque/test/test_library.py index adc6f3c..323bcf2 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -47,6 +47,42 @@ def test_writable_libraries_are_restricted_mappings( assert not lib +@pytest.mark.parametrize('cached', [False, True]) +def test_overlay_reuses_names_and_keeps_new_sources_independent(cached: bool) -> None: + source = Library({'a': Pattern(), 'parent': Pattern().ref('a')}) + overlay = OverlayLibrary() + overlay.add_source(source) + if cached: + overlay['parent'] + overlay.rename('a', 'b', move_references=True) + overlay.rename('b', 'a', move_references=True) + assert overlay.child_graph()['parent'] == {'a'} + assert set(overlay.materialize('parent', persist=False).refs) == {'a'} + overlay.rename('a', 'b', move_references=True) + overlay.add_source(Library({'a': Pattern(), 'new_parent': Pattern().ref('a')})) + assert overlay.child_graph()['parent'] == {'b'} + assert overlay.child_graph()['new_parent'] == {'a'} + assert set(overlay['new_parent'].refs) == {'a'} + assert set(source['parent'].refs) == {'a'} + + +def test_overlay_reuses_dangling_targets_and_preserves_failed_rename() -> None: + overlay = OverlayLibrary() + overlay.add_source(Library({'parent': Pattern().ref('missing')})) + overlay.move_references('missing', 'other') + overlay.move_references('other', 'missing') + assert set(overlay['parent'].refs) == {'missing'} + overlay['used'] = Pattern() + before = list(overlay) + with pytest.raises(LibraryError, match='already exists'): + overlay.rename('parent', 'used', move_references=True) + assert list(overlay) == before + assert set(overlay['parent'].refs) == {'missing'} + del overlay['used'] + overlay.rename('parent', 'used') + assert list(overlay) == ['used'] + + def test_library_tops() -> None: lib = Library() lib["child"] = Pattern() diff --git a/masque/test/test_shape_transforms.py b/masque/test/test_shape_transforms.py index 2a9092a..13bb77d 100644 --- a/masque/test/test_shape_transforms.py +++ b/masque/test/test_shape_transforms.py @@ -17,7 +17,8 @@ def test_shape_mirror() -> None: a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) a.mirror(1) - assert a.angle_ref == Arc.AngleRef.FocusNeg + # The pi rotation already reflects the X-major focus across the Y axis. + assert a.angle_ref == Arc.AngleRef.FocusPos a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) a.mirror(0) diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py index 5181fc5..9dd43bd 100644 --- a/masque/utils/boolean.py +++ b/masque/utils/boolean.py @@ -89,8 +89,8 @@ def boolean( Perform a boolean operation on two sets of polygons. Args: - subjects: List of subjects (Polygons or vertex arrays). - clips: List of clips (Polygons or vertex arrays). + subjects: Subjects (shapes or vertex arrays). Shape repetitions are expanded. + clips: Clips (shapes or vertex arrays). Shape repetitions are expanded. operation: The boolean operation to perform. scale: Scaling factor for integer conversion (pyclipper uses integers). @@ -115,44 +115,31 @@ def boolean( def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]: if objs is None: return [] - if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon): - objs = (objs,) - elif not isinstance(objs, Iterable): + if isinstance(objs, numpy.ndarray): + return [objs] + if hasattr(objs, 'to_polygons'): + verts = [] + for poly in objs.to_polygons(): + if poly.repetition is None: + verts.append(poly.vertices) + else: + verts.extend(poly.vertices + dd for dd in poly.repetition.displacements) + return verts + if isinstance(objs, str | bytes) or not isinstance(objs, Iterable): raise PatternError(f"Unsupported type for boolean operation: {type(objs)}") - verts = [] - for obj in objs: - if hasattr(obj, 'to_polygons'): - for p in obj.to_polygons(): - verts.append(p.vertices) - elif isinstance(obj, numpy.ndarray): - verts.append(obj) - elif isinstance(obj, Polygon): - verts.append(obj.vertices) - else: - # Try to iterate if it's an iterable of shapes - try: - for sub in obj: - if hasattr(sub, 'to_polygons'): - for p in sub.to_polygons(): - verts.append(p.vertices) - elif isinstance(sub, Polygon): - verts.append(sub.vertices) - except TypeError: - raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None - return verts + return [vertices for obj in objs for vertices in to_vertices(obj)] + op = op_map[operation.lower()] subject_verts = to_vertices(subjects) clip_verts = to_vertices(clips) if not subject_verts: - if operation in ('union', 'xor'): - return [Polygon(vertices) for vertices in clip_verts] - return [] - - if not clip_verts: - if operation == 'intersection': + if op not in (pyclipper.CT_UNION, pyclipper.CT_XOR) or not clip_verts: return [] - return [Polygon(vertices) for vertices in subject_verts] + subject_verts, clip_verts = clip_verts, [] + + if not clip_verts and op == pyclipper.CT_INTERSECTION: + return [] pc = pyclipper.Pyclipper() pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) @@ -160,7 +147,13 @@ def boolean( pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True) # Use GetPolyTree to distinguish between outers and holes - polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) + polytree = pc.Execute2(op, pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) + return _polytree_to_polygons(polytree, scale) + + +def _polytree_to_polygons(polytree: Any, scale: float) -> list[Polygon]: + """Convert a Clipper result, bridging holes for masque's polygon representation.""" + import pyclipper # noqa: PLC0415 result_polygons = [] diff --git a/pyproject.toml b/pyproject.toml index ad03fa9..5d7778c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ path = "masque/__init__.py" [project.optional-dependencies] arrow = ["pyarrow", "cffi"] oasis = ["fatamorgana~=0.11"] -dxf = ["ezdxf~=1.4"] +dxf = ["ezdxf~=1.4", "pyclipper"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"]