diff --git a/masque/builder/utils.py b/masque/builder/utils.py index 5680694..3109f46 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -106,7 +106,7 @@ def ell( raise BuildError('Asked to find aggregation for ports that face in different directions:\n' + pformat(port_rotations)) else: - if set_rotation is None: + if set_rotation is not None: raise BuildError('set_rotation must be specified if no ports have rotations!') rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index db01f51..da35531 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -16,7 +16,7 @@ import gzip import numpy import ezdxf from ezdxf.enums import TextEntityAlignment -from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace +from ezdxf.entities import LWPolyline, Polyline, Text, Insert from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -217,54 +217,27 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) - width = 0 - if isinstance(element, LWPolyline): - # ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge) - if points.shape[1] >= 5: - if (points[:, 4] != 0).any(): - raise PatternError('LWPolyline has bulge (not yet representable in masque!)') - if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any(): - raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)') - width = points[0, 2] - elif points.shape[1] == 3: - # width used to be in column 2 - width = points[0, 2] + if points.shape[1] == 2: + raise PatternError('Invalid or unimplemented polygon?') - if width == 0: - width = attr.get('const_width', 0) + if points.shape[1] > 2: + if (points[0, 2] != points[:, 2]).any(): + raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') + if points.shape[1] == 4 and (points[:, 3] != 0).any(): + raise PatternError('LWPolyLine has bulge (not yet representable in masque!)') - is_closed = element.closed - verts = points[:, :2] - if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): - verts = numpy.vstack((verts, verts[0])) + width = points[0, 2] + if width == 0: + width = attr.get('const_width', 0) - 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) + shape: Path | Polygon + if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]): + shape = Polygon(vertices=points[:-1, :2]) else: - shape = Path(width=width, vertices=verts) - else: - shape = Path(width=width, vertices=verts) + shape = Path(width=width, vertices=points[:, :2]) pat.shapes[layer].append(shape) - elif isinstance(element, Solid | Trace): - attr = element.dxfattribs() - layer = attr.get('layer', DEFAULT_LAYER) - points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4) - if element.has_dxf_attrib(f'vtx{i}')]) - if len(points) >= 3: - # If vtx2 == vtx3, it's a triangle. ezdxf handles this. - if len(points) == 4 and numpy.allclose(points[2], points[3]): - verts = points[:3, :2] - # DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals! - elif len(points) == 4: - verts = points[[0, 1, 3, 2], :2] - else: - verts = points[:, :2] - pat.shapes[layer].append(Polygon(vertices=verts)) + elif isinstance(element, Text): args = dict( offset=numpy.asarray(element.get_placement()[1])[:2], @@ -329,23 +302,15 @@ def _mrefs_to_drefs( elif isinstance(rep, Grid): a = rep.a_vector b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) - # 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 - - if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8): + rotated_a = rotation_matrix_2d(-ref.rotation) @ a + rotated_b = rotation_matrix_2d(-ref.rotation) @ b + if rotated_a[1] == 0 and rotated_b[0] == 0: attribs['column_count'] = rep.a_count attribs['row_count'] = rep.b_count attribs['column_spacing'] = rotated_a[0] attribs['row_spacing'] = rotated_b[1] block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) - elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8): + elif rotated_a[0] == 0 and rotated_b[1] == 0: attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] @@ -383,18 +348,10 @@ def _shapes_to_elements( displacements = shape.repetition.displacements for dd in displacements: - if isinstance(shape, Path): - # preserve path. - # Note: DXF paths don't support endcaps well, so this is still a bit limited. - xy = shape.vertices + dd - attribs_path = {**attribs} - if shape.width > 0: - attribs_path['const_width'] = shape.width - block.add_lwpolyline(xy, dxfattribs=attribs_path) - else: - for polygon in shape.to_polygons(): - xy_open = polygon.vertices + dd - block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs) + for polygon in shape.to_polygons(): + xy_open = polygon.vertices + dd + xy_closed = numpy.vstack((xy_open, xy_open[0, :])) + block.add_lwpolyline(xy_closed, dxfattribs=attribs) def _labels_to_texts( diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index f589ad8..d78f591 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -453,7 +453,7 @@ def _shapes_to_elements( extension: tuple[int, int] if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: - extension = tuple(rint_cast(shape.cap_extensions)) + extension = tuple(shape.cap_extensions) # type: ignore else: extension = (0, 0) @@ -617,12 +617,7 @@ def load_libraryfile( stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore else: stream = path.open(mode='rb') # noqa: SIM115 - - try: - return load_library(stream, full_load=full_load, postprocess=postprocess) - finally: - if full_load: - stream.close() + return load_library(stream, full_load=full_load, postprocess=postprocess) def check_valid_names( @@ -653,7 +648,7 @@ def check_valid_names( logger.error('Names contain invalid characters:\n' + pformat(bad_chars)) if bad_lengths: - logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths)) + logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars)) if bad_chars or bad_lengths: raise LibraryError('Library contains invalid names, see log above') diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 5e343ea..0a11b24 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -182,8 +182,8 @@ def writefile( Args: library: A {name: Pattern} mapping of patterns to write. filename: Filename to save to. - *args: passed to `oasis.build()` - **kwargs: passed to `oasis.build()` + *args: passed to `oasis.write` + **kwargs: passed to `oasis.write` """ path = pathlib.Path(filename) @@ -213,9 +213,9 @@ def readfile( Will automatically decompress gzipped files. Args: - filename: Filename to load from. - *args: passed to `oasis.read()` - **kwargs: passed to `oasis.read()` + filename: Filename to save to. + *args: passed to `oasis.read` + **kwargs: passed to `oasis.read` """ path = pathlib.Path(filename) if is_gzipped(path): @@ -717,6 +717,10 @@ def properties_to_annotations( annotations[key] = values return annotations + properties = [fatrec.Property(key, vals, is_standard=False) + for key, vals in annotations.items()] + return properties + def check_valid_names( names: Iterable[str], diff --git a/masque/library.py b/masque/library.py index 8dc63a4..b730fba 100644 --- a/masque/library.py +++ b/masque/library.py @@ -186,9 +186,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): # Perform recursive lookups, but only once for each name for target in targets - skip: assert target is not None - skip.add(target) if target in self: targets |= self.referenced_patterns(target, skip=skip) + skip.add(target) return targets @@ -466,11 +466,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): memo = {} if transform is None or transform is True: - transform = numpy.array([0, 0, 0, 0, 1], dtype=float) + transform = numpy.zeros(4) elif transform is not False: transform = numpy.asarray(transform, dtype=float) - if transform.size == 4: - transform = numpy.append(transform, 1.0) original_pattern = pattern @@ -1269,12 +1267,12 @@ class LazyLibrary(ILibrary): """ mapping: dict[str, Callable[[], 'Pattern']] cache: dict[str, 'Pattern'] - _lookups_in_progress: list[str] + _lookups_in_progress: set[str] def __init__(self) -> None: self.mapping = {} self.cache = {} - self._lookups_in_progress = [] + self._lookups_in_progress = set() def __setitem__( self, @@ -1305,20 +1303,16 @@ class LazyLibrary(ILibrary): return self.cache[key] if key in self._lookups_in_progress: - chain = ' -> '.join(self._lookups_in_progress + [key]) raise LibraryError( - f'Detected circular reference or recursive lookup of "{key}".\n' - f'Lookup chain: {chain}\n' + f'Detected multiple simultaneous lookups of "{key}".\n' 'This may be caused by an invalid (cyclical) reference, or buggy code.\n' - 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' + 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' # TODO give advice on finding cycles ) - self._lookups_in_progress.append(key) - try: - func = self.mapping[key] - pat = func() - finally: - self._lookups_in_progress.pop() + self._lookups_in_progress.add(key) + func = self.mapping[key] + pat = func() + self._lookups_in_progress.remove(key) self.cache[key] = pat return pat diff --git a/masque/pattern.py b/masque/pattern.py index e515614..ea8fdd5 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -201,7 +201,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def __lt__(self, other: 'Pattern') -> bool: self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] - other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) @@ -215,7 +215,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return refs_ours < refs_theirs self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] - other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) @@ -224,21 +224,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(other.shapes[layer])) + shapes_theirs = tuple(sorted(self.shapes[layer])) if shapes_ours != shapes_theirs: return shapes_ours < shapes_theirs self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] - other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) if self_txtlayerkeys != other_txtlayerkeys: return self_txtlayerkeys < other_txtlayerkeys - for _, _, layer in self_txtlayerkeys: + for _, _, layer in self_layerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(other.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) if labels_ours != labels_theirs: return labels_ours < labels_theirs @@ -255,7 +255,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return False self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] - other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) @@ -269,7 +269,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return False self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] - other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) @@ -278,21 +278,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(other.shapes[layer])) + shapes_theirs = tuple(sorted(self.shapes[layer])) if shapes_ours != shapes_theirs: return False self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] - other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) if self_txtlayerkeys != other_txtlayerkeys: return False - for _, _, layer in self_txtlayerkeys: + for _, _, layer in self_layerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(other.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) if labels_ours != labels_theirs: return False diff --git a/masque/ports.py b/masque/ports.py index 93310f7..4be208d 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -630,7 +630,7 @@ class PortList(metaclass=ABCMeta): rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) if not has_rot.any(): if set_rotation is None: - raise PortError('Must provide set_rotation if rotation is indeterminate') + PortError('Must provide set_rotation if rotation is indeterminate') rotations[:] = set_rotation else: rotations[~has_rot] = rotations[has_rot][0] diff --git a/masque/ref.py b/masque/ref.py index 6423394..3a64dce 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -92,22 +92,18 @@ class Ref( rotation=self.rotation, scale=self.scale, mirrored=self.mirrored, - repetition=self.repetition, - annotations=self.annotations, + repetition=copy.deepcopy(self.repetition), + annotations=copy.deepcopy(self.annotations), ) return new def __deepcopy__(self, memo: dict | None = None) -> 'Ref': memo = {} if memo is None else memo new = copy.copy(self) - new._offset = self._offset.copy() - new.repetition = copy.deepcopy(self.repetition, memo) - new.annotations = copy.deepcopy(self.annotations, memo) + #new.repetition = copy.deepcopy(self.repetition, memo) + #new.annotations = copy.deepcopy(self.annotations, memo) return new - def copy(self) -> 'Ref': - return self.deepcopy() - def __lt__(self, other: 'Ref') -> bool: if (self.offset != other.offset).any(): return tuple(self.offset) < tuple(other.offset) @@ -191,11 +187,10 @@ class Ref( xys = self.offset[None, :] if self.repetition is not None: xys = xys + self.repetition.displacements - transforms = numpy.empty((xys.shape[0], 5)) + transforms = numpy.empty((xys.shape[0], 4)) transforms[:, :2] = xys transforms[:, 2] = self.rotation transforms[:, 3] = self.mirrored - transforms[:, 4] = self.scale return transforms def get_bounds_single( diff --git a/masque/repetition.py b/masque/repetition.py index e1507b8..20ec0a3 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -64,7 +64,7 @@ class Grid(Repetition): _a_count: int """ Number of instances along the direction specified by the `a_vector` """ - _b_vector: NDArray[numpy.float64] + _b_vector: NDArray[numpy.float64] | None """ Vector `[x, y]` specifying a second lattice vector for the grid. Specifies center-to-center spacing between adjacent elements. Can be `None` for a 1D array. @@ -199,6 +199,9 @@ class Grid(Repetition): @property def displacements(self) -> NDArray[numpy.float64]: + if self.b_vector is None: + return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :] + aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij') return (aa.flatten()[:, None] * self.a_vector[None, :] + bb.flatten()[:, None] * self.b_vector[None, :]) # noqa @@ -298,8 +301,12 @@ class Grid(Repetition): return self.b_count < other.b_count if not numpy.array_equal(self.a_vector, other.a_vector): return tuple(self.a_vector) < tuple(other.a_vector) + if self.b_vector is None: + return other.b_vector is not None + if other.b_vector is None: + return False if not numpy.array_equal(self.b_vector, other.b_vector): - return tuple(self.b_vector) < tuple(other.b_vector) + return tuple(self.a_vector) < tuple(other.a_vector) return False @@ -384,9 +391,7 @@ class Arbitrary(Repetition): Returns: self """ - new_displacements = self.displacements.copy() - new_displacements[:, 1 - axis] *= -1 - self.displacements = new_displacements + self.displacements[:, 1 - axis] *= -1 return self def get_bounds(self) -> NDArray[numpy.float64] | None: @@ -411,6 +416,6 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements = self.displacements * c + self.displacements *= c return self diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 3df55f4..654cfaa 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -24,16 +24,7 @@ class PathCap(Enum): # # defined by path.cap_extensions def __lt__(self, other: Any) -> bool: - if self.__class__ is not other.__class__: - return self.__class__.__name__ < other.__class__.__name__ - # Order: Flush, Square, Circle, SquareCustom - order = { - PathCap.Flush: 0, - PathCap.Square: 1, - PathCap.Circle: 2, - PathCap.SquareCustom: 3, - } - return order[self] < order[other] + return self.value == other.value @functools.total_ordering @@ -88,10 +79,10 @@ class Path(Shape): def cap(self, val: PathCap) -> None: self._cap = PathCap(val) if self.cap != PathCap.SquareCustom: - self._cap_extensions = None - elif self._cap_extensions is None: + self.cap_extensions = None + elif self.cap_extensions is None: # just got set to SquareCustom - self._cap_extensions = numpy.zeros(2) + self.cap_extensions = numpy.zeros(2) # cap_extensions property @property @@ -218,12 +209,9 @@ class Path(Shape): self.vertices = vertices self.repetition = repetition self.annotations = annotations - self._cap = cap - if cap == PathCap.SquareCustom and cap_extensions is None: - self._cap_extensions = numpy.zeros(2) - else: - self.cap_extensions = cap_extensions self.width = width + self.cap = cap + self.cap_extensions = cap_extensions if rotation: self.rotate(rotation) if numpy.any(offset): @@ -265,14 +253,6 @@ class Path(Shape): if self.cap_extensions is None: return True return tuple(self.cap_extensions) < tuple(other.cap_extensions) - if not numpy.array_equal(self.vertices, other.vertices): - min_len = min(self.vertices.shape[0], other.vertices.shape[0]) - eq_mask = self.vertices[:min_len] != other.vertices[:min_len] - eq_lt = self.vertices[:min_len] < other.vertices[:min_len] - eq_lt_masked = eq_lt[eq_mask] - if eq_lt_masked.size > 0: - return eq_lt_masked.flat[0] - return self.vertices.shape[0] < other.vertices.shape[0] if self.repetition != other.repetition: return rep2key(self.repetition) < rep2key(other.repetition) return annotations_lt(self.annotations, other.annotations) @@ -323,30 +303,9 @@ class Path(Shape): ) -> list['Polygon']: extensions = self._calculate_cap_extensions() - v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) + v = remove_colinear_vertices(self.vertices, closed_path=False) dv = numpy.diff(v, axis=0) - norms = numpy.sqrt((dv * dv).sum(axis=1)) - - # Filter out zero-length segments if any remained after remove_colinear_vertices - valid = (norms > 1e-18) - if not numpy.all(valid): - # This shouldn't happen much if remove_colinear_vertices is working - v = v[numpy.append(valid, True)] - dv = numpy.diff(v, axis=0) - norms = norms[valid] - - if dv.shape[0] == 0: - # All vertices were the same. It's a point. - if self.width == 0: - return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate - if self.cap == PathCap.Circle: - return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen) - if self.cap == PathCap.Square: - return [Polygon.square(side_length=self.width, offset=v[0])] - # Flush or CustomSquare - return [Polygon(vertices=numpy.zeros((3, 2)))] - - dvdir = dv / norms[:, None] + dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] if self.width == 0: verts = numpy.vstack((v, v[::-1])) @@ -365,21 +324,11 @@ class Path(Shape): bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] - try: - # Vectorized solve for all intersections - # solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1) - rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0] - rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0] - except numpy.linalg.LinAlgError: - # Fallback to slower lstsq if some segments are parallel (singular matrix) - rp = numpy.zeros(As.shape[0]) - rn = numpy.zeros(As.shape[0]) - for ii in range(As.shape[0]): - rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0] - rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0] + rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0] + rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0] - intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1] - intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1] + intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] + intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1] towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp? # straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight @@ -469,11 +418,12 @@ class Path(Shape): rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v) for v in normed_vertices]) - # Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller - if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat): - reordered_vertices = rotated_vertices[::-1] - else: - reordered_vertices = rotated_vertices + # Reorder the vertices so that the one with lowest x, then y, comes first. + x_min = rotated_vertices[:, 0].argmin() + if not is_scalar(x_min): + y_min = rotated_vertices[x_min, 1].argmin() + x_min = cast('Sequence', x_min)[y_min] + reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) width0 = self.width / norm_value @@ -512,7 +462,7 @@ class Path(Shape): Returns: self """ - self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) + self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) return self def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 34a784b..dc5afa1 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -321,7 +321,7 @@ class Polygon(Shape): else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') - poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition) + poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition) return poly @staticmethod @@ -417,15 +417,11 @@ class Polygon(Shape): for v in normed_vertices]) # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min_val = rotated_vertices[:, 0].min() - x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0] - if x_min_inds.size > 1: - y_min_val = rotated_vertices[x_min_inds, 1].min() - tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0] - start_ind = x_min_inds[tie_breaker] - else: - start_ind = x_min_inds[0] - reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0) + x_min = rotated_vertices[:, 0].argmin() + if not is_scalar(x_min): + y_min = rotated_vertices[x_min, 1].argmin() + x_min = cast('Sequence', x_min)[y_min] + reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) # TODO: normalize mirroring? diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py deleted file mode 100644 index e6e6e7e..0000000 --- a/masque/test/test_dxf.py +++ /dev/null @@ -1,111 +0,0 @@ - -import numpy -from numpy.testing import assert_allclose -from pathlib import Path - -from ..pattern import Pattern -from ..library import Library -from ..shapes import Path as MPath, Polygon -from ..repetition import Grid -from ..file import dxf - -def test_dxf_roundtrip(tmp_path: Path): - lib = Library() - pat = Pattern() - - # 1. Polygon (closed) - poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) - pat.polygon("1", vertices=poly_verts) - - # 2. Path (open, 3 points) - path_verts = numpy.array([[20, 0], [30, 0], [30, 10]]) - pat.path("2", vertices=path_verts, width=2) - - # 3. Path (open, 2 points) - Testing the fix for 2-point polylines - path2_verts = numpy.array([[40, 0], [50, 10]]) - pat.path("3", vertices=path2_verts, width=0) # width 0 to be sure it's not a polygonized path if we're not careful - - # 4. Ref with Grid repetition (Manhattan) - subpat = Pattern() - subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]]) - lib["sub"] = subpat - - pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3)) - - lib["top"] = pat - - dxf_file = tmp_path / "test.dxf" - dxf.writefile(lib, "top", dxf_file) - - read_lib, _ = dxf.readfile(dxf_file) - - # In DXF read, the top level is usually called "Model" - top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] - - # Verify Polygon - polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)] - assert len(polys) >= 1 - poly_read = polys[0] - # DXF polyline might be shifted or vertices reordered, but here they should be simple - assert_allclose(poly_read.vertices, poly_verts) - - # Verify 3-point Path - paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)] - assert len(paths) >= 1 - path_read = paths[0] - assert_allclose(path_read.vertices, path_verts) - assert path_read.width == 2 - - # Verify 2-point Path - paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)] - assert len(paths2) >= 1 - path2_read = paths2[0] - assert_allclose(path2_read.vertices, path2_verts) - assert path2_read.width == 0 - - # Verify Ref with Grid - # Finding the sub pattern name might be tricky because of how DXF stores blocks - # but "sub" should be in read_lib - assert "sub" in read_lib - - # Check refs in the top pattern - found_grid = False - for target, reflist in top_pat.refs.items(): - # DXF names might be case-insensitive or modified, but ezdxf usually preserves them - if target.upper() == "SUB": - for ref in reflist: - if isinstance(ref.repetition, Grid): - assert ref.repetition.a_count == 2 - assert ref.repetition.b_count == 3 - assert_allclose(ref.repetition.a_vector, (10, 0)) - assert_allclose(ref.repetition.b_vector, (0, 10)) - found_grid = True - assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}" - -def test_dxf_manhattan_precision(tmp_path: Path): - # Test that float precision doesn't break Manhattan grid detection - lib = Library() - sub = Pattern() - sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) - lib["sub"] = sub - - top = Pattern() - # 90 degree rotation: in masque the grid is NOT rotated, so it stays [[10,0],[0,10]] - # In DXF, an array with rotation 90 has basis vectors [[0,10],[-10,0]]. - # So a masque grid [[10,0],[0,10]] with ref rotation 90 matches a DXF array. - angle = numpy.pi / 2 # 90 degrees - top.ref("sub", offset=(0, 0), rotation=angle, - repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2)) - - lib["top"] = top - - dxf_file = tmp_path / "precision.dxf" - dxf.writefile(lib, "top", dxf_file) - - # If the isclose() fix works, this should still be a Grid when read back - read_lib, _ = dxf.readfile(dxf_file) - read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] - - target_name = next(k for k in read_top.refs if k.upper() == "SUB") - ref = read_top.refs[target_name][0] - assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation" diff --git a/masque/test/test_file_roundtrip.py b/masque/test/test_file_roundtrip.py index 2cfb0d1..c7536a5 100644 --- a/masque/test/test_file_roundtrip.py +++ b/masque/test/test_file_roundtrip.py @@ -5,6 +5,7 @@ from numpy.testing import assert_allclose from ..pattern import Pattern from ..library import Library +from ..file import gdsii, oasis from ..shapes import Path as MPath, Circle, Polygon from ..repetition import Grid, Arbitrary @@ -61,7 +62,6 @@ def create_test_library(for_gds: bool = False) -> Library: return lib def test_gdsii_full_roundtrip(tmp_path: Path) -> None: - from ..file import gdsii lib = create_test_library(for_gds=True) gds_file = tmp_path / "full_test.gds" gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) @@ -110,7 +110,6 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None: def test_oasis_full_roundtrip(tmp_path: Path) -> None: pytest.importorskip("fatamorgana") - from ..file import oasis lib = create_test_library(for_gds=False) oas_file = tmp_path / "full_test.oas" oasis.writefile(lib, oas_file, units_per_micron=1000) diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py index b1129f4..faffa58 100644 --- a/masque/test/test_oasis.py +++ b/masque/test/test_oasis.py @@ -4,10 +4,12 @@ from numpy.testing import assert_equal from ..pattern import Pattern from ..library import Library +from ..file import oasis + + def test_oasis_roundtrip(tmp_path: Path) -> None: # Skip if fatamorgana is not installed pytest.importorskip("fatamorgana") - from ..file import oasis lib = Library() pat1 = Pattern() diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py index f495285..882b5bd 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -29,19 +29,14 @@ def test_remove_colinear_vertices() -> None: def test_remove_colinear_vertices_exhaustive() -> None: # U-turn v = [[0, 0], [10, 0], [0, 0]] - v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True) + v_clean = remove_colinear_vertices(v, closed_path=False) # Open path should keep ends. [10,0] is between [0,0] and [0,0]? - # They are colinear, but it's a 180 degree turn. - # We preserve 180 degree turns if preserve_uturns is True. - assert len(v_clean) == 3 - - v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False) - # If not preserving u-turns, it should collapse to just the endpoints - assert len(v_collapsed) == 2 + # Yes, they are all on the same line. + assert len(v_clean) == 2 # 180 degree U-turn in closed path v = [[0, 0], [10, 0], [5, 0]] - v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False) + v_clean = remove_colinear_vertices(v, closed_path=True) assert len(v_clean) == 2 @@ -69,7 +64,7 @@ def test_apply_transforms() -> None: t1 = [10, 20, 0, 0] t2 = [[5, 0, 0, 0], [0, 5, 0, 0]] combined = apply_transforms(t1, t2) - assert_equal(combined, [[15, 20, 0, 0, 1], [10, 25, 0, 0, 1]]) + assert_equal(combined, [[15, 20, 0, 0], [10, 25, 0, 0]]) def test_apply_transforms_advanced() -> None: @@ -85,4 +80,4 @@ def test_apply_transforms_advanced() -> None: # 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0) # 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10) # 3. add outer offset (0, 0) -> (0, 10) - assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10) + assert_allclose(combined[0], [0, 10, pi / 2, 1], atol=1e-10) diff --git a/masque/utils/deferreddict.py b/masque/utils/deferreddict.py index 31e6943..02c1a22 100644 --- a/masque/utils/deferreddict.py +++ b/masque/utils/deferreddict.py @@ -60,4 +60,4 @@ class DeferredDict(dict, Generic[Key, Value]): Convenience function to avoid having to manually wrap constant values into callables. """ - self[key] = lambda v=value: v + self[key] = lambda: value diff --git a/masque/utils/ports2data.py b/masque/utils/ports2data.py index c7f42e1..b67fa0a 100644 --- a/masque/utils/ports2data.py +++ b/masque/utils/ports2data.py @@ -57,9 +57,11 @@ def data_to_ports( name: str | None = None, # Note: name optional, but arg order different from read(postprocess=) max_depth: int = 0, skip_subcells: bool = True, - visited: set[int] | None = None, + # TODO missing ok? ) -> Pattern: """ + # TODO fixup documentation in ports2data + # TODO move to utils.file? Examine `pattern` for labels specifying port info, and use that info to fill out its `ports` attribute. @@ -68,30 +70,18 @@ def data_to_ports( Args: layers: Search for labels on all the given layers. - library: Mapping from pattern names to patterns. pattern: Pattern object to scan for labels. - name: Name of the pattern object. - max_depth: Maximum hierarcy depth to search. Default 0. + max_depth: Maximum hierarcy depth to search. Default 999_999. Reduce this to 0 to avoid ever searching subcells. skip_subcells: If port labels are found at a given hierarcy level, do not continue searching at deeper levels. This allows subcells to contain their own port info without interfering with supercells' port data. Default True. - visited: Set of object IDs which have already been processed. Returns: The updated `pattern`. Port labels are not removed. """ - if visited is None: - visited = set() - - # Note: visited uses id(pattern) to detect cycles and avoid redundant processing. - # This may not catch identical patterns if they are loaded as separate object instances. - if id(pattern) in visited: - return pattern - visited.add(id(pattern)) - if pattern.ports: logger.warning(f'Pattern {name if name else pattern} already had ports, skipping data_to_ports') return pattern @@ -109,13 +99,12 @@ def data_to_ports( if target is None: continue pp = data_to_ports( - layers = layers, - library = library, - pattern = library[target], - name = target, - max_depth = max_depth - 1, - skip_subcells = skip_subcells, - visited = visited, + layers=layers, + library=library, + pattern=library[target], + name=target, + max_depth=max_depth - 1, + skip_subcells=skip_subcells, ) found_ports |= bool(pp.ports) @@ -171,17 +160,13 @@ def data_to_ports_flat( local_ports = {} for label in labels: - if ':' not in label.string: - logger.warning(f'Invalid port label "{label.string}" in pattern "{pstr}" (missing ":")') - continue - - name, property_string = label.string.split(':', 1) - properties = property_string.split() - ptype = properties[0] if len(properties) > 0 else 'unk' - angle_deg = float(properties[1]) if len(properties) > 1 else numpy.inf + name, property_string = label.string.split(':') + properties = property_string.split(' ') + ptype = properties[0] + angle_deg = float(properties[1]) if len(ptype) else 0 xy = label.offset - angle = numpy.deg2rad(angle_deg) if numpy.isfinite(angle_deg) else None + angle = numpy.deg2rad(angle_deg) if name in local_ports: logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"') diff --git a/masque/utils/transform.py b/masque/utils/transform.py index ed0453b..dfb6492 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -28,9 +28,8 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)], [numpy.sin(theta), +numpy.cos(theta)]]) - # If this was a manhattan rotation, round to remove some inaccuracies in sin & cos - # cos(4*theta) is 1 for any multiple of pi/2. - if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12): + # If this was a manhattan rotation, round to remove some inacuraccies in sin & cos + if numpy.isclose(theta % (pi / 2), 0): arr = numpy.round(arr) arr.flags.writeable = False @@ -87,50 +86,37 @@ def apply_transforms( Apply a set of transforms (`outer`) to a second set (`inner`). This is used to find the "absolute" transform for nested `Ref`s. - The two transforms should be of shape Ox5 and Ix5. - Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x, scale)`. - The output will be of the form (O*I)x5 (if `tensor=False`) or OxIx5 (`tensor=True`). + The two transforms should be of shape Ox4 and Ix4. + Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. + The output will be of the form (O*I)x4 (if `tensor=False`) or OxIx4 (`tensor=True`). Args: - outer: Transforms for the container refs. Shape Ox5. - inner: Transforms for the contained refs. Shape Ix5. - tensor: If `True`, an OxIx5 array is returned, with `result[oo, ii, :]` corresponding + outer: Transforms for the container refs. Shape Ox4. + inner: Transforms for the contained refs. Shape Ix4. + tensor: If `True`, an OxIx4 array is returned, with `result[oo, ii, :]` corresponding to the `oo`th `outer` transform applied to the `ii`th inner transform. - If `False` (default), this is concatenated into `(O*I)x5` to allow simple + If `False` (default), this is concatenated into `(O*I)x4` to allow simple chaining into additional `apply_transforms()` calls. Returns: - OxIx5 or (O*I)x5 array. Final dimension is - `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x, total_scale)`. + OxIx4 or (O*I)x4 array. Final dimension is + `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`. """ outer = numpy.atleast_2d(outer).astype(float, copy=False) inner = numpy.atleast_2d(inner).astype(float, copy=False) - if outer.shape[1] == 4: - outer = numpy.pad(outer, ((0, 0), (0, 1)), constant_values=1.0) - if inner.shape[1] == 4: - inner = numpy.pad(inner, ((0, 0), (0, 1)), constant_values=1.0) - # If mirrored, flip y's xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm xy_mir[outer[:, 3].astype(bool), :, 1] *= -1 - # Apply outer scale to inner offset - xy_mir *= outer[:, None, 4, None] - rot_mats = [rotation_matrix_2d(angle) for angle in outer[:, 2]] xy = numpy.einsum('ort,oit->oir', rot_mats, xy_mir) - tot = numpy.empty((outer.shape[0], inner.shape[0], 5)) + tot = numpy.empty((outer.shape[0], inner.shape[0], 4)) tot[:, :, :2] = outer[:, None, :2] + xy - - # If mirrored, flip inner rotation - mirrored_outer = outer[:, None, 3].astype(bool) - rotations = outer[:, None, 2] + numpy.where(mirrored_outer, -inner[None, :, 2], inner[None, :, 2]) - - tot[:, :, 2] = rotations % (2 * pi) - tot[:, :, 3] = (outer[:, None, 3] + inner[None, :, 3]) % 2 # net mirrored - tot[:, :, 4] = outer[:, None, 4] * inner[None, :, 4] # net scale + tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored + tot[:, :, 2] %= 2 * pi # clamp rot + tot[:, :, 3] %= 2 # clamp mirrored if tensor: return tot diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 5a5df9f..176f0f5 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -30,11 +30,7 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> return result -def remove_colinear_vertices( - vertices: ArrayLike, - closed_path: bool = True, - preserve_uturns: bool = False, - ) -> NDArray[numpy.float64]: +def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any superflous vertices (i.e. those which lie along the line formed by their neighbors) @@ -43,33 +39,21 @@ def remove_colinear_vertices( vertices: Nx2 ndarray of vertices closed_path: If `True`, the vertices are assumed to represent an implicitly closed path. If `False`, the path is assumed to be open. Default `True`. - preserve_uturns: If `True`, colinear vertices that correspond to a 180 degree - turn (a "spike") are preserved. Default `False`. Returns: `vertices` with colinear (superflous) vertices removed. May be a view into the original array. """ - vertices = remove_duplicate_vertices(vertices, closed_path=closed_path) + vertices = remove_duplicate_vertices(vertices) # Check for dx0/dy0 == dx1/dy1 - dv = numpy.roll(vertices, -1, axis=0) - vertices - if not closed_path: - dv[-1] = 0 - # dxdy[i] is based on dv[i] and dv[i-1] - # slopes_equal[i] refers to vertex i - dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] + dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]] dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 slopes_equal = (dxdy_diff / err_mult) < 1e-15 - - if preserve_uturns: - # Only merge if segments are in the same direction (avoid collapsing u-turns) - dot_prod = (dv * numpy.roll(dv, 1, axis=0)).sum(axis=1) - slopes_equal &= (dot_prod > 0) - if not closed_path: slopes_equal[[0, -1]] = False diff --git a/pyproject.toml b/pyproject.toml index a985f60..ba7a240 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ path = "masque/__init__.py" [project.optional-dependencies] oasis = ["fatamorgana~=0.11"] -dxf = ["ezdxf~=1.4"] +dxf = ["ezdxf~=1.0.2"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] @@ -110,9 +110,6 @@ lint.ignore = [ [tool.pytest.ini_options] addopts = "-rsXx" testpaths = ["masque"] -filterwarnings = [ - "ignore::DeprecationWarning:ezdxf.*", -] [tool.mypy] mypy_path = "stubs"