diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0a04f61..237b1d8 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -6,21 +6,17 @@ 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, Literal -from collections import defaultdict -from collections.abc import Mapping, Callable, Sequence +from typing import Any, cast, TextIO, IO +from collections.abc import Mapping, Callable 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, Line +from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -28,7 +24,6 @@ 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__) @@ -178,9 +173,6 @@ 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 @@ -191,30 +183,16 @@ 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: - - Library of patterns - - Layer metadata + - Top level pattern """ - 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 @@ -241,27 +219,12 @@ def read( if target in blocks_by_name: pending.append(blocks_by_name[target]) - 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) + 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) mlib[name] = pat library_info = dict( @@ -271,15 +234,9 @@ def read( return mlib, library_info -def _read_block( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - *, - polyline_mode: int = 2, - contour_accuracy: float = 0.0, - ) -> tuple[str, Pattern]: +def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> 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): @@ -290,9 +247,6 @@ def _read_block( 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): @@ -306,44 +260,26 @@ def _read_block( 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] - endpoint_closed = numpy.array_equal(verts[0], verts[-1]) - if is_closed and not endpoint_closed: + if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): verts = numpy.vstack((verts, verts[0])) - is_closed = is_closed or endpoint_closed shape: Path | Polygon - 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]) + 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) 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) @@ -380,8 +316,7 @@ def _read_block( 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)) - insert_rotation = numpy.deg2rad(attr.get('rotation', 0)) - rotation = insert_rotation + extra_angle + rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2] @@ -393,144 +328,64 @@ def _read_block( rotation=rotation, ) - 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) + 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'] local_x = numpy.array((col_spacing, 0.0)) local_y = numpy.array((0.0, row_spacing)) - # 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, + 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), ) + 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]], @@ -552,10 +407,12 @@ 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. - # 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 + # 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 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 6a2a42c..030bdbb 100644 --- a/masque/file/gdsii/arrow.py +++ b/masque/file/gdsii/arrow.py @@ -327,14 +327,11 @@ 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().astype(float).reshape((-1, 2)), + xy_arr = el.values.field('xy').values.to_numpy().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(), @@ -348,7 +345,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().astype(float).reshape((-1, 2)), + vert_arr = batches.values.field('vertices').values.to_numpy().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(), @@ -359,7 +356,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().astype(float).reshape((-1, 4)), + rect_arr = batches.values.field('rects').values.to_numpy().reshape((-1, 4)), rect_off = batches.values.field('rects').offsets.to_numpy() // 4, ) @@ -368,7 +365,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().astype(float).reshape((-1, 2)), + vert_arr = boundaries.values.field('vertices').values.to_numpy().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(), @@ -381,15 +378,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()).astype(float), + xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()), 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()).astype(float), - xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float), + xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()), + xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()), counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()), )) return elem @@ -400,7 +397,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()).astype(float), + xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()), 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(), @@ -410,8 +407,8 @@ def read_arrow( ) if has_repetition: elem.update(dict( - 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), + xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()), + xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()), counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()), )) return elem @@ -420,7 +417,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()).astype(float), + xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()), 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(), @@ -446,7 +443,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, dtype=float), + ), axis=-1), )) global_args = dict( @@ -520,7 +517,7 @@ def _append_plain_refs_sorted( pat: Pattern, cell_names: list[str], elem_targets: NDArray[numpy.integer[Any]], - elem_xy: NDArray[numpy.float64], + elem_xy: NDArray[numpy.integer[Any]], 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 d686bfa..b20edad 100644 --- a/masque/file/gdsii/lazy_arrow.py +++ b/masque/file/gdsii/lazy_arrow.py @@ -209,7 +209,6 @@ 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] = {} @@ -369,10 +368,10 @@ class ArrowLibrary(ILibraryView, IMaterializable): ) -> list[NDArray[numpy.float64]]: if parent in self._cache: return super()._raw_ref_transforms(parent, target) - target_id = self._name_to_id.get(target) - if target_id is None or parent not in self._payload.cells: + target_cell = self._payload.cells.get(target) + if target_cell is None or parent not in self._payload.cells: return [] - return self._collect_raw_transforms(self._payload.cells[parent], target_id) + return self._collect_raw_transforms(self._payload.cells[parent], target_cell.cell_id) def readfile( diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 67ea644..b5d0cd8 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -565,9 +565,8 @@ 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}') - 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) + 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, diff --git a/masque/library/overlay.py b/masque/library/overlay.py index 43e201e..529684b 100644 --- a/masque/library/overlay.py +++ b/masque/library/overlay.py @@ -273,6 +273,7 @@ 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) @@ -344,10 +345,6 @@ 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) @@ -374,7 +371,6 @@ 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 @@ -382,13 +378,28 @@ 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 - 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 + self._set_target_remap(old_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]) @@ -396,7 +407,8 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): return self def _effective_target(self, layer: _SourceLayer, target: str) -> str: - return layer.source_target_map.get(target, target) + visible = layer.source_target_map.get(target, target) + return self._resolve_target(visible) def _remap_source_pattern(self, layer: _SourceLayer, source_pat: Pattern) -> Pattern: def remap(target: str | None) -> str | None: @@ -516,6 +528,7 @@ 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 35362a9..53fadb8 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -423,10 +423,11 @@ class Arc(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Arc': - # 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() + 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() 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 291f23a..eaf028f 100644 --- a/masque/shapes/rect_collection.py +++ b/masque/shapes/rect_collection.py @@ -187,12 +187,7 @@ 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( - 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.' - ) + raise PatternError('RectCollection only supports Manhattan rotations') 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 ae2789a..dc23144 100644 --- a/masque/test/test_arc.py +++ b/masque/test/test_arc.py @@ -14,21 +14,6 @@ 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 b8d0538..e2fd7ea 100644 --- a/masque/test/test_boolean.py +++ b/masque/test/test_boolean.py @@ -15,40 +15,6 @@ 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 deleted file mode 100644 index 10f7f89..0000000 --- a/masque/test/test_dxf_modes.py +++ /dev/null @@ -1,205 +0,0 @@ -"""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 0a33453..8f0f39f 100644 --- a/masque/test/test_gdsii_arrow.py +++ b/masque/test/test_gdsii_arrow.py @@ -23,67 +23,6 @@ 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 927f35b..7d840ab 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, LibraryError +from .. import PatternError from ..library import IBorrowing, IMaterializable, LayerMappedView, Library, OverlayLibrary, PortLoadView from ..pattern import Pattern from ..repetition import Grid @@ -25,55 +25,6 @@ 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') @@ -300,17 +251,12 @@ 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, monkeypatch: pytest.MonkeyPatch) -> None: +def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path) -> 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 323bcf2..adc6f3c 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -47,42 +47,6 @@ 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 13bb77d..2a9092a 100644 --- a/masque/test/test_shape_transforms.py +++ b/masque/test/test_shape_transforms.py @@ -17,8 +17,7 @@ def test_shape_mirror() -> None: a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) a.mirror(1) - # The pi rotation already reflects the X-major focus across the Y axis. - assert a.angle_ref == Arc.AngleRef.FocusPos + assert a.angle_ref == Arc.AngleRef.FocusNeg 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 9dd43bd..5181fc5 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: Subjects (shapes or vertex arrays). Shape repetitions are expanded. - clips: Clips (shapes or vertex arrays). Shape repetitions are expanded. + subjects: List of subjects (Polygons or vertex arrays). + clips: List of clips (Polygons or vertex arrays). operation: The boolean operation to perform. scale: Scaling factor for integer conversion (pyclipper uses integers). @@ -115,45 +115,52 @@ def boolean( def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]: if objs is None: return [] - 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): + if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon): + objs = (objs,) + elif not isinstance(objs, Iterable): raise PatternError(f"Unsupported type for boolean operation: {type(objs)}") - return [vertices for obj in objs for vertices in to_vertices(obj)] + 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 - op = op_map[operation.lower()] subject_verts = to_vertices(subjects) clip_verts = to_vertices(clips) if not subject_verts: - if op not in (pyclipper.CT_UNION, pyclipper.CT_XOR) or not clip_verts: - return [] - subject_verts, clip_verts = clip_verts, [] - - if not clip_verts and op == pyclipper.CT_INTERSECTION: + if operation in ('union', 'xor'): + return [Polygon(vertices) for vertices in clip_verts] return [] + if not clip_verts: + if operation == 'intersection': + return [] + return [Polygon(vertices) for vertices in subject_verts] + pc = pyclipper.Pyclipper() pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) if clip_verts: 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, 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 + polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) result_polygons = [] diff --git a/pyproject.toml b/pyproject.toml index 5d7778c..ad03fa9 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", "pyclipper"] +dxf = ["ezdxf~=1.4"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"]