Compare commits

...

7 commits

16 changed files with 701 additions and 163 deletions

View file

@ -6,17 +6,21 @@ Notes:
* ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID
to unique values, so byte-for-byte reproducibility is not achievable for now
"""
from typing import Any, cast, TextIO, IO
from collections.abc import Mapping, Callable
from typing import Any, cast, TextIO, IO, Literal
from collections import defaultdict
from collections.abc import Mapping, Callable, Sequence
import io
import logging
import pathlib
import gzip
import numpy
from numpy.typing import NDArray
import ezdxf
from ezdxf import edgeminer
from ezdxf.math import Vec3
from ezdxf.enums import TextEntityAlignment
from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace
from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace, Line
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, Label
@ -24,6 +28,7 @@ from ..library import ILibraryView, LibraryView, Library
from ..shapes import Shape, Polygon, Path
from ..repetition import Grid
from ..utils import rotation_matrix_2d, layer_t, normalize_mirror
from ..utils.boolean import _polytree_to_polygons
logger = logging.getLogger(__name__)
@ -173,6 +178,9 @@ def readfile(
def read(
stream: TextIO,
*,
polyline_mode: Literal[0, 1, 2, 3, 4] = 2,
contour_accuracy: float = 0.0,
) -> tuple[Library, dict[str, Any]]:
"""
Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are
@ -183,16 +191,30 @@ def read(
Args:
stream: Stream to read from.
polyline_mode: Treatment of straight LINE/POLYLINE/LWPOLYLINE geometry:
0 selects automatically (1 if SOLID/HATCH exists, otherwise 2 if closed
polylines exist, otherwise 3); 1 keeps paths; 2 fills closed zero-width
polylines; 3 joins zero-width segments into polygons, keeping open
contours as paths; 4 additionally closes open contours. Closure may be
indicated by the DXF flag or exactly equal endpoints. Positive-width
paths are preserved in every mode. Curved and variable-width entities
remain unsupported. Automatic selection uses all imported blocks.
contour_accuracy: Nonnegative, finite endpoint joining distance in DXF
units, used only in modes 3 and 4. Zero requires exact coincidence.
Merged polygons are quantized to 1e-6 DXF units and use even-odd filling
(nested contours form holes), matching KLayout's polyline merge modes.
Returns:
- Top level pattern
- Library of patterns
- Layer metadata
"""
if polyline_mode not in (0, 1, 2, 3, 4):
raise ValueError(f'Invalid DXF polyline_mode: {polyline_mode!r}')
if not numpy.isfinite(contour_accuracy) or contour_accuracy < 0:
raise ValueError('DXF contour_accuracy must be finite and nonnegative')
lib = ezdxf.read(stream)
msp = lib.modelspace()
top_name, top_pat = _read_block(msp)
mlib = Library({top_name: top_pat})
blocks_by_name = {
bb.name: bb
for bb in lib.blocks
@ -219,12 +241,27 @@ def read(
if target in blocks_by_name:
pending.append(blocks_by_name[target])
for bb in lib.blocks:
if bb.is_any_layout:
continue
if bb.name.startswith('_') and bb.name not in referenced:
continue
name, pat = _read_block(bb)
blocks = [msp, *(bb for bb in blocks_by_name.values()
if not bb.name.startswith('_') or bb.name in referenced)]
if polyline_mode == 0:
polyline_mode = 3
for block in blocks:
for element in block:
if element.dxftype() in ('SOLID', 'HATCH'):
polyline_mode = 1
break
if isinstance(element, LWPolyline | Polyline):
verts = (numpy.asarray(element.get_points('xy')) if isinstance(element, LWPolyline)
else numpy.asarray([pp.xyz[:2] for pp in element.points()]))
closed = element.closed if isinstance(element, LWPolyline) else element.is_closed
if closed or (len(verts) > 1 and numpy.array_equal(verts[0], verts[-1])):
polyline_mode = 2
if polyline_mode == 1:
break
mlib = Library()
for bb in blocks:
name, pat = _read_block(bb, polyline_mode=polyline_mode, contour_accuracy=contour_accuracy)
mlib[name] = pat
library_info = dict(
@ -234,9 +271,15 @@ def read(
return mlib, library_info
def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> tuple[str, Pattern]:
def _read_block(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
*,
polyline_mode: int = 2,
contour_accuracy: float = 0.0,
) -> tuple[str, Pattern]:
name = block.name
pat = Pattern()
contours: dict[layer_t, list[numpy.ndarray]] = defaultdict(list)
for element in block:
if isinstance(element, LWPolyline | Polyline):
if isinstance(element, LWPolyline):
@ -247,6 +290,9 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
is_closed = element.is_closed
attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER)
if len(points) < 2:
logger.warning('Ignoring DXF polyline with fewer than two vertices')
continue
width = 0
if isinstance(element, LWPolyline):
@ -260,26 +306,44 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
elif points.shape[1] == 3:
# width used to be in column 2
width = points[0, 2]
else:
if any(vertex.dxf.get('bulge', 0) != 0 for vertex in element.vertices):
raise PatternError('Polyline has bulge (not yet representable in masque!)')
widths = numpy.asarray([
(vertex.dxf.get('start_width', attr.get('default_start_width', 0)),
vertex.dxf.get('end_width', attr.get('default_end_width', 0)))
for vertex in element.vertices
])
if (widths != widths[0, 0]).any():
raise PatternError('Polyline has non-constant width (not yet representable in masque!)')
width = widths[0, 0]
if width == 0:
width = attr.get('const_width', 0)
verts = points[:, :2]
if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])):
endpoint_closed = numpy.array_equal(verts[0], verts[-1])
if is_closed and not endpoint_closed:
verts = numpy.vstack((verts, verts[0]))
is_closed = is_closed or endpoint_closed
shape: Path | Polygon
if width == 0 and is_closed:
# Use Polygon if it has at least 3 unique vertices
shape_verts = verts[:-1] if len(verts) > 1 else verts
if len(shape_verts) >= 3:
shape = Polygon(vertices=shape_verts)
else:
shape = Path(width=width, vertices=verts)
if width == 0 and polyline_mode >= 3:
contours[layer].append(verts)
continue
if width == 0 and is_closed and polyline_mode == 2 and _is_polygon(verts):
shape = Polygon(vertices=verts[:-1])
else:
shape = Path(width=width, vertices=verts)
pat.shapes[layer].append(shape)
elif isinstance(element, Line):
layer = element.dxf.get('layer', DEFAULT_LAYER)
verts = numpy.asarray((element.dxf.start.xyz[:2], element.dxf.end.xyz[:2]))
if polyline_mode >= 3:
contours[layer].append(verts)
else:
pat.shapes[layer].append(Path(vertices=verts, width=0))
elif isinstance(element, Solid | Trace):
attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER)
@ -316,7 +380,8 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
logger.warning('Masque does not support per-axis scaling; using x-scaling only!')
scale = abs(xscale)
mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0))
rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle
insert_rotation = numpy.deg2rad(attr.get('rotation', 0))
rotation = insert_rotation + extra_angle
offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2]
@ -328,64 +393,144 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
rotation=rotation,
)
if 'column_count' in attr:
col_spacing = attr['column_spacing']
row_spacing = attr['row_spacing']
col_count = attr['column_count']
row_count = attr['row_count']
if 'column_count' in attr or 'row_count' in attr:
col_spacing = attr.get('column_spacing', 0)
row_spacing = attr.get('row_spacing', 0)
col_count = attr.get('column_count', 1)
row_count = attr.get('row_count', 1)
local_x = numpy.array((col_spacing, 0.0))
local_y = numpy.array((0.0, row_spacing))
inv_rot = rotation_matrix_2d(-rotation)
candidates = (
(inv_rot @ local_x, inv_rot @ local_y, col_count, row_count),
(inv_rot @ local_y, inv_rot @ local_x, row_count, col_count),
# Spacing follows only the original INSERT angle, not its scale
# or the extra angle introduced by mirror normalization.
rot = rotation_matrix_2d(insert_rotation)
args['repetition'] = Grid(
a_vector=rot @ local_x, b_vector=rot @ local_y,
a_count=col_count, b_count=row_count,
)
repetition = None
for a_vector, b_vector, a_count, b_count in candidates:
rotated_a = rotation_matrix_2d(rotation) @ a_vector
rotated_b = rotation_matrix_2d(rotation) @ b_vector
if (numpy.isclose(rotated_a[1], 0, atol=1e-8)
and numpy.isclose(rotated_b[0], 0, atol=1e-8)
and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8)
and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8)
and a_count == col_count
and b_count == row_count):
repetition = Grid(
a_vector=a_vector,
b_vector=b_vector,
a_count=a_count,
b_count=b_count,
)
break
if (numpy.isclose(rotated_a[0], 0, atol=1e-8)
and numpy.isclose(rotated_b[1], 0, atol=1e-8)
and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8)
and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8)
and b_count == col_count
and a_count == row_count):
repetition = Grid(
a_vector=a_vector,
b_vector=b_vector,
a_count=a_count,
b_count=b_count,
)
break
if repetition is None:
repetition = Grid(
a_vector=inv_rot @ local_x,
b_vector=inv_rot @ local_y,
a_count=col_count,
b_count=row_count,
)
args['repetition'] = repetition
pat.ref(**args)
else:
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
for layer, vertex_lists in contours.items():
pat.shapes[layer].extend(_merge_polylines(vertex_lists, contour_accuracy, auto_close=polyline_mode == 4))
return name, pat
def _is_polygon(vertices: NDArray) -> bool:
"""At least three distinct, noncollinear points (including self-crossing contours)."""
points = numpy.unique(vertices, axis=0)
if len(points) < 3:
return False
vectors = points[1:] - points[0]
return bool(numpy.any(vectors[:, 0] * vectors[0, 1] != vectors[:, 1] * vectors[0, 0]))
def _contours(edges: Sequence[edgeminer.Edge], accuracy: float) -> list[tuple[NDArray, bool]]:
"""Join each edge once, using indexed endpoint searches rather than loop enumeration."""
deposit = edgeminer.Deposit(edges, gap_tol=accuracy)
unused = {edge.id for edge in edges}
result = []
def grow(points: list[Vec3]) -> bool:
positions = {point: index for index, point in enumerate(points[:-1])}
while True:
# A walk that started on a dangling segment can encounter a cycle
# before returning to its initial point. Extract that rim and keep
# the remaining open tail; every segment is still consumed once.
contacts = {
point for edge in deposit.edges_linked_to(points[-1])
for point in (edge.start, edge.end)
if point in positions and positions[point] < len(points) - 2
and point.distance(points[-1]) <= accuracy
}
if contacts:
point = min(contacts, key=lambda point: (point.distance(points[-1]), point.xyz))
index = positions[point]
loop = points[index:-1] + [point]
result.append((numpy.asarray([pp.xyz[:2] for pp in loop]), True))
if index == 0:
return True
for removed in points[index + 1:-1]:
positions.pop(removed, None)
del points[index + 1:]
positions[points[-1]] = len(points) - 1
incoming = points[-1] - points[-2]
candidates = []
for edge in deposit.edges_linked_to(points[-1]):
if edge.id not in unused:
continue
for oriented in (edge, edge.reversed()):
distance = points[-1].distance(oriented.start)
if distance <= accuracy:
direction = oriented.end - oriented.start
# Like KLayout, use endpoint distance then a signed
# cross product. Canonical seeds follow clockwise rims.
turn = -direction.cross(incoming).z / oriented.length
candidates.append((distance, turn, oriented.end.xyz, oriented.id, oriented))
if not candidates:
return False
edge = min(candidates, key=lambda item: item[:4])[-1]
unused.remove(edge.id)
# Snap the next start to the preceding endpoint when joining a gap.
points.append(edge.end)
# Canonical ordering makes results independent of input order/direction.
ordered = sorted(edges, key=lambda edge: sorted((edge.start.xyz, edge.end.xyz)))
for seed in ordered:
if seed.id not in unused:
continue
unused.remove(seed.id)
edge = seed.reversed() if seed.start.xyz > seed.end.xyz else seed
points = [edge.start, edge.end]
closed = grow(points)
if not closed:
points.reverse()
closed = grow(points)
if not closed:
result.append((numpy.asarray([point.xyz[:2] for point in points]), False))
return result
def _merge_polylines(
vertex_lists: Sequence[NDArray],
accuracy: float,
*,
auto_close: bool,
) -> list[Path | Polygon]:
"""Assemble one cell/layer's zero-width segments, with KLayout's even-odd fill."""
import pyclipper # noqa: PLC0415
edges = []
result: list[Path | Polygon] = []
for vertices in vertex_lists:
start_count = len(edges)
for start, end in zip(vertices[:-1], vertices[1:], strict=True):
if not numpy.array_equal(start, end):
edges.append(edgeminer.make_edge(start, end))
if start_count == len(edges):
result.append(Path(vertices=vertices, width=0))
scale = 1e6
clipper = pyclipper.Pyclipper()
has_polygons = False
for vertices, closed in _contours(edges, accuracy):
if (closed or auto_close) and _is_polygon(vertices):
# A contour can collapse at the clipping precision. Preserve its
# centerline in that case rather than silently dropping geometry.
try:
added = clipper.AddPath(pyclipper.scale_to_clipper(vertices, scale), pyclipper.PT_SUBJECT, True)
except pyclipper.ClipperException:
added = False
if added:
has_polygons = True
continue
result.append(Path(vertices=vertices, width=0))
if has_polygons:
tree = clipper.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
result.extend(_polytree_to_polygons(tree, scale))
return result
def _mrefs_to_drefs(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
refs: dict[str | None, list[Ref]],
@ -407,12 +552,10 @@ def _mrefs_to_drefs(
# In masque, the grid basis vectors are NOT rotated by the reference's rotation.
# In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing],
# which ARE then rotated by the block reference's rotation.
# Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90)
# AND the grid is already manhattan.
# Rotate basis vectors by the reference rotation to see where they end up in the DXF frame
rotated_a = rotation_matrix_2d(ref.rotation) @ a
rotated_b = rotation_matrix_2d(ref.rotation) @ b
# Compensate for that rotation to express the world-space basis in
# the local DXF frame. Only locally Manhattan grids fit an INSERT.
rotated_a = rotation_matrix_2d(-ref.rotation) @ a
rotated_b = rotation_matrix_2d(-ref.rotation) @ b
if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8):
attribs['column_count'] = rep.a_count

View file

@ -327,11 +327,14 @@ def read_arrow(
cell_ids = libarr['cells'].values.field('id').to_numpy()
cell_names = libarr['cell_names'].as_py()
# Masque geometry is mutable and supports fractional transforms. Convert
# coordinates in bulk before slicing them into objects; scan-only and raw
# GDS copy-through workflows never enter this materialization path.
def get_geom(libarr: pyarrow.Array, geom_type: str) -> dict[str, Any]:
el = libarr['cells'].values.field(geom_type)
elem = dict(
offsets = el.offsets.to_numpy(),
xy_arr = el.values.field('xy').values.to_numpy().reshape((-1, 2)),
xy_arr = el.values.field('xy').values.to_numpy().astype(float).reshape((-1, 2)),
xy_off = el.values.field('xy').offsets.to_numpy() // 2,
layer_inds = el.values.field('layer').to_numpy(),
prop_off = el.values.field('properties').offsets.to_numpy(),
@ -345,7 +348,7 @@ def read_arrow(
return dict(
offsets = batches.offsets.to_numpy(),
layer_inds = batches.values.field('layer').to_numpy(),
vert_arr = batches.values.field('vertices').values.to_numpy().reshape((-1, 2)),
vert_arr = batches.values.field('vertices').values.to_numpy().astype(float).reshape((-1, 2)),
vert_off = batches.values.field('vertices').offsets.to_numpy() // 2,
poly_off = batches.values.field('vertex_offsets').offsets.to_numpy(),
poly_offsets = batches.values.field('vertex_offsets').values.to_numpy(),
@ -356,7 +359,7 @@ def read_arrow(
return dict(
offsets = batches.offsets.to_numpy(),
layer_inds = batches.values.field('layer').to_numpy(),
rect_arr = batches.values.field('rects').values.to_numpy().reshape((-1, 4)),
rect_arr = batches.values.field('rects').values.to_numpy().astype(float).reshape((-1, 4)),
rect_off = batches.values.field('rects').offsets.to_numpy() // 4,
)
@ -365,7 +368,7 @@ def read_arrow(
return dict(
offsets = boundaries.offsets.to_numpy(),
layer_inds = boundaries.values.field('layer').to_numpy(),
vert_arr = boundaries.values.field('vertices').values.to_numpy().reshape((-1, 2)),
vert_arr = boundaries.values.field('vertices').values.to_numpy().astype(float).reshape((-1, 2)),
vert_off = boundaries.values.field('vertices').offsets.to_numpy() // 2,
prop_off = boundaries.values.field('properties').offsets.to_numpy(),
prop_key = boundaries.values.field('properties').values.field('key').to_numpy(),
@ -378,15 +381,15 @@ def read_arrow(
elem = dict(
offsets = refs.offsets.to_numpy(),
targets = values.field('target').to_numpy(),
xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()),
xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()).astype(float),
invert_y = values.field('invert_y').to_numpy(zero_copy_only=False),
angle_rad = values.field('angle_rad').to_numpy(),
scale = values.field('scale').to_numpy(),
)
if has_repetition:
elem.update(dict(
xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()),
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()),
xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()).astype(float),
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float),
counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()),
))
return elem
@ -397,7 +400,7 @@ def read_arrow(
elem = dict(
offsets = refs.offsets.to_numpy(),
targets = values.field('target').to_numpy(),
xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()),
xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()).astype(float),
invert_y = values.field('invert_y').to_numpy(zero_copy_only=False),
angle_rad = values.field('angle_rad').to_numpy(),
scale = values.field('scale').to_numpy(),
@ -407,8 +410,8 @@ def read_arrow(
)
if has_repetition:
elem.update(dict(
xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()),
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()),
xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()).astype(float),
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float),
counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()),
))
return elem
@ -417,7 +420,7 @@ def read_arrow(
texts = dict(
offsets = txt.offsets.to_numpy(),
layer_inds = txt.values.field('layer').to_numpy(),
xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()),
xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()).astype(float),
string = txt.values.field('string').to_pylist(),
prop_off = txt.values.field('properties').offsets.to_numpy(),
prop_key = txt.values.field('properties').values.field('key').to_numpy(),
@ -443,7 +446,7 @@ def read_arrow(
extensions = numpy.stack((
paths.values.field('extension_start').fill_null(0).to_numpy(),
paths.values.field('extension_end').fill_null(0).to_numpy(),
), axis=-1),
), axis=-1, dtype=float),
))
global_args = dict(
@ -517,7 +520,7 @@ def _append_plain_refs_sorted(
pat: Pattern,
cell_names: list[str],
elem_targets: NDArray[numpy.integer[Any]],
elem_xy: NDArray[numpy.integer[Any]],
elem_xy: NDArray[numpy.float64],
elem_invert_y: NDArray[numpy.bool_ | numpy.bool],
elem_angle_rad: NDArray[numpy.floating[Any]],
elem_scale: NDArray[numpy.floating[Any]],

View file

@ -209,6 +209,7 @@ class ArrowLibrary(ILibraryView, IMaterializable):
self.path = path
self.library_info = payload.library_info
self._payload = payload
self._name_to_id = {name: cell_id for cell_id, name in enumerate(payload.cell_names)}
self._source = source
self._cache: dict[str, Pattern] = {}
@ -368,10 +369,10 @@ class ArrowLibrary(ILibraryView, IMaterializable):
) -> list[NDArray[numpy.float64]]:
if parent in self._cache:
return super()._raw_ref_transforms(parent, target)
target_cell = self._payload.cells.get(target)
if target_cell is None or parent not in self._payload.cells:
target_id = self._name_to_id.get(target)
if target_id is None or parent not in self._payload.cells:
return []
return self._collect_raw_transforms(self._payload.cells[parent], target_cell.cell_id)
return self._collect_raw_transforms(self._payload.cells[parent], target_id)
def readfile(

View file

@ -565,8 +565,9 @@ def _shapes_to_elements(
path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup
if path_type is None:
raise PatternError(f'OASIS writer does not support path cap {shape.cap}')
extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
extensions = None if shape.cap_extensions is None else rint_cast(shape.cap_extensions)
extension_start = (path_type, extensions[0] if extensions is not None else None)
extension_end = (path_type, extensions[1] if extensions is not None else None)
path = fatrec.Path(
layer = layer,
datatype = datatype,

View file

@ -273,7 +273,6 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
self._layers: list[_SourceLayer] = []
self._entries: dict[str, Pattern | _SourceEntry] = {}
self._order: list[str] = []
self._target_remap: dict[str, str] = {}
def __iter__(self) -> Iterator[str]:
return (name for name in self._order if name in self._entries)
@ -345,6 +344,10 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
source_target_map=dict(source_to_visible),
child_graph=child_graph,
)
# Include dangling targets so each source tracks current names directly.
for children in child_graph.values():
for child in children:
layer.source_target_map.setdefault(child, child)
layer_index = len(self._layers)
self._layers.append(layer)
@ -371,6 +374,7 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
entry = self._entries.pop(old_name)
self._entries[new_name] = entry
self._order = [name for name in self._order if name != new_name]
idx = self._order.index(old_name)
self._order[idx] = new_name
@ -378,28 +382,13 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
self.move_references(old_name, new_name)
return self
def _resolve_target(self, target: str) -> str:
seen: set[str] = set()
current = target
while current in self._target_remap:
if current in seen:
raise LibraryError(f'Cycle encountered while resolving target remap for {target!r}')
seen.add(current)
current = self._target_remap[current]
return current
def _set_target_remap(self, old_target: str, new_target: str) -> None:
resolved_new = self._resolve_target(new_target)
if resolved_new == old_target:
raise LibraryError(f'Ref target remap would create a cycle: {old_target!r} -> {new_target!r}')
self._target_remap[old_target] = resolved_new
for key in list(self._target_remap):
self._target_remap[key] = self._resolve_target(self._target_remap[key])
def move_references(self, old_target: str, new_target: str) -> OverlayLibrary:
if old_target == new_target:
return self
self._set_target_remap(old_target, new_target)
for layer in self._layers:
for source_target, current_target in layer.source_target_map.items():
if current_target == old_target:
layer.source_target_map[source_target] = new_target
for entry in list(self._entries.values()):
if isinstance(entry, Pattern) and old_target in entry.refs:
entry.refs[new_target].extend(entry.refs[old_target])
@ -407,8 +396,7 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
return self
def _effective_target(self, layer: _SourceLayer, target: str) -> str:
visible = layer.source_target_map.get(target, target)
return self._resolve_target(visible)
return layer.source_target_map.get(target, target)
def _remap_source_pattern(self, layer: _SourceLayer, source_pat: Pattern) -> Pattern:
def remap(target: str | None) -> str | None:
@ -528,7 +516,6 @@ class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
]
new._order = [name for name in self._order if name in keep and name in self._entries]
new._entries = {name: self._entries[name] for name in new._order}
new._target_remap = dict(self._target_remap)
return new
def find_refs_local(

View file

@ -423,10 +423,9 @@ class Arc(PositionableImpl, Shape):
return self
def mirror(self, axis: int = 0) -> 'Arc':
if self.angle_ref != ArcAngleRef.Center:
x_major = self.radius_x > self.radius_y
y_major = self.radius_y > self.radius_x
if (axis == 0 and y_major) or (axis == 1 and x_major):
# Both external reflections use a local Y reflection; the extra pi
# rotation below accounts for the external axis.
if self.angle_ref != ArcAngleRef.Center and self.radius_y > self.radius_x:
self._swap_focus_ref()
self.rotation *= -1
self.rotation += axis * pi

View file

@ -187,7 +187,12 @@ class RectCollection(Shape):
def rotate(self, theta: float) -> Self:
quarter_turns = int(numpy.rint(theta / (pi / 2)))
if not numpy.isclose(theta, quarter_turns * (pi / 2)):
raise PatternError('RectCollection only supports Manhattan rotations')
raise PatternError(
f'RectCollection cannot rotate by {theta!r} radians; only Manhattan rotations (multiples of pi/2) are supported. '
'Explicitly replace the collection with to_polygons(), or call Pattern.polygonize() '
'on the pattern containing it (including referenced child patterns) before transforming, '
'flattening, or computing hierarchical bounds.'
)
turns = quarter_turns % 4
if turns == 0 or self._rects.size == 0:
return self

View file

@ -14,6 +14,21 @@ def test_arc_init() -> None:
assert_equal(a.angles, [0, pi / 2])
assert a.width == 2
@pytest.mark.parametrize('axis', [0, 1])
@pytest.mark.parametrize('radii', [(10, 6), (6, 10), (10, 10)])
@pytest.mark.parametrize('angle_ref', list(Arc.AngleRef))
@pytest.mark.parametrize('rotation', [0, pi / 5])
def test_arc_reflection_preserves_caps_and_bounds(axis: int, radii: tuple, angle_ref: Arc.AngleRef, rotation: float) -> None:
arc = Arc(radii=radii, angles=(-0.3, 1.1), width=1, angle_ref=angle_ref, rotation=rotation)
reflected = arc.deepcopy().mirror(axis)
signs = numpy.ones(2)
signs[1 - axis] = -1
assert_allclose(reflected.get_cap_edges(), arc.get_cap_edges() * signs, atol=1e-12)
expected = arc.get_bounds_single() * signs
assert_allclose(reflected.get_bounds_single(), numpy.sort(expected, axis=0), atol=1e-12)
assert_allclose(reflected.mirror(axis).get_cap_edges(), arc.get_cap_edges(), atol=1e-12)
def test_arc_to_polygons() -> None:
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
polys = a.to_polygons(num_vertices=32)

View file

@ -15,6 +15,40 @@ def _poly_area(poly: Polygon) -> float:
y = verts[:, 1]
return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1)))
@pytest.mark.parametrize('repeated_clip', [False, True])
@pytest.mark.parametrize('nested', [False, True])
def test_boolean_expands_repetitions(repeated_clip: bool, nested: bool) -> None:
from masque import boolean
from masque.repetition import Arbitrary
from masque.shapes import RectCollection
repeated = RectCollection([[0, 0, 2, 2]], repetition=Arbitrary([[0, 0], [10, 0]]))
clip = Polygon([[10, 0], [12, 0], [12, 2], [10, 2]])
subject, other = (clip, repeated) if repeated_clip else (repeated, clip)
result = boolean([[subject]] if nested else subject, [other], operation='intersection')
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[10, 0], [12, 2]])
assert _poly_area(result[0]) == 4
assert result[0].repetition is None
assert_allclose(repeated.rects, [[0, 0, 2, 2]])
assert_allclose(repeated.repetition.displacements, [[0, 0], [10, 0]])
@pytest.mark.parametrize('operation', ['union', 'difference', 'xor'])
def test_boolean_single_set_normalizes_overlaps(operation: str) -> None:
from masque import boolean
subject = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]], repetition=Grid(a_vector=(1, 0), a_count=2))
for clips in (None, []):
result = boolean(subject, clips, operation=operation)
assert len(result) == 1
assert _poly_area(result[0]) == 6
if operation != 'difference':
result = boolean([], subject, operation=operation)
assert len(result) == 1
assert _poly_area(result[0]) == 6
def test_layer_as_polygons_basic() -> None:
pat = Pattern()
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])

View file

@ -0,0 +1,205 @@
"""DXF geometry checks independent of masque's writer/reader round trips."""
import io
import ezdxf
import numpy
import pytest
from numpy.testing import assert_allclose
from ..file import dxf
from ..library import Library
from ..pattern import Pattern
from ..repetition import Grid
from ..shapes import Path, Polygon
def _read(doc: ezdxf.document.Drawing, mode: int = 2, accuracy: float = 0.0) -> Library:
stream = io.StringIO()
doc.write(stream)
stream.seek(0)
return dxf.read(stream, polyline_mode=mode, contour_accuracy=accuracy)[0]
def _area(shapes: list) -> float:
total = 0.0
for shape in shapes:
if isinstance(shape, Polygon):
xx, yy = shape.vertices.T
total += abs(numpy.dot(xx, numpy.roll(yy, 1)) - numpy.dot(yy, numpy.roll(xx, 1))) / 2
return total
def _origins(rows: numpy.ndarray) -> numpy.ndarray:
rounded = numpy.round(rows, 8)
return rounded[numpy.lexsort((rounded[:, 1], rounded[:, 0]))]
@pytest.mark.parametrize('legacy', [False, True])
@pytest.mark.parametrize('flagged', [False, True])
@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4])
def test_closed_polylines(legacy: bool, flagged: bool, mode: int) -> None:
doc = ezdxf.new()
points = [(0, 0), (10, 0), (10, 10), (0, 10)]
if not flagged:
points.append(points[0])
msp = doc.modelspace()
if legacy:
msp.add_polyline2d(points).close(flagged)
else:
msp.add_lwpolyline(points, close=flagged)
shapes = _read(doc, mode)['Model'].shapes['0']
assert len(shapes) == 1
assert isinstance(shapes[0], Path if mode == 1 else Polygon)
assert _area(shapes) == (0 if mode == 1 else 100)
if mode == 1:
assert_allclose(shapes[0].vertices[0], shapes[0].vertices[-1])
@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4])
@pytest.mark.parametrize('closed', [False, True])
def test_join_shuffled_lines(mode: int, closed: bool) -> None:
doc = ezdxf.new()
segments = [((10, 10), (10, 0)), ((0, 0), (10, 0)), ((0, 10), (10, 10))]
if closed:
segments.append(((0, 0), (0, 10)))
for start, end in segments:
doc.modelspace().add_line(start, end)
shapes = _read(doc, mode)['Model'].shapes['0']
filled = mode == 4 or (closed and mode in (0, 3))
assert _area(shapes) == (100 if filled else 0)
assert len(shapes) == (len(segments) if mode in (1, 2) else 1)
@pytest.mark.parametrize('entity', ['SOLID', 'HATCH'])
def test_auto_detects_solids_in_child_blocks(entity: str) -> None:
doc = ezdxf.new()
doc.modelspace().add_lwpolyline([(0, 0), (4, 0), (4, 4)], close=True)
block = doc.blocks.new('child')
if entity == 'SOLID':
block.add_solid([(0, 0), (1, 0), (1, 1)])
else:
block.add_hatch()
doc.modelspace().add_blockref('child', (0, 0))
assert isinstance(_read(doc, 0)['Model'].shapes['0'][0], Path)
@pytest.mark.parametrize(('accuracy', 'expected'), [(0, 0), (0.005, 0), (0.02, 100)])
def test_contour_tolerance(accuracy: float, expected: float) -> None:
doc = ezdxf.new()
doc.modelspace().add_lwpolyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0.01)])
shapes = _read(doc, 3, accuracy)['Model'].shapes['0']
assert _area(shapes) == expected
@pytest.mark.parametrize('mode', [3, 4])
def test_nested_contours_resolve_holes_and_islands(mode: int) -> None:
doc = ezdxf.new()
for lo, hi in [(0, 10), (2, 8), (4, 6)]:
doc.modelspace().add_lwpolyline([(lo, lo), (hi, lo), (hi, hi), (lo, hi)], close=True)
shapes = _read(doc, mode)['Model'].shapes['0']
assert len(shapes) == 2
assert _area(shapes) == 68
@pytest.mark.parametrize('mode', [3, 4])
def test_layers_and_blocks_are_not_joined(mode: int) -> None:
doc = ezdxf.new()
doc.modelspace().add_line((0, 0), (10, 0), dxfattribs={'layer': 'a'})
doc.modelspace().add_line((10, 0), (0, 10), dxfattribs={'layer': 'b'})
doc.blocks.new('child').add_line((0, 10), (0, 0), dxfattribs={'layer': 'a'})
lib = _read(doc, mode)
assert all(isinstance(shape, Path) for pat in lib.values() for shapes in pat.shapes.values() for shape in shapes)
@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4])
@pytest.mark.parametrize('legacy', [False, True])
def test_width_and_degenerate_paths_are_preserved(mode: int, legacy: bool) -> None:
doc = ezdxf.new()
if legacy:
doc.modelspace().add_polyline2d([(0, 0), (2, 0), (2, 2)], dxfattribs={
'default_start_width': 2, 'default_end_width': 2,
}).close(True)
else:
doc.modelspace().add_lwpolyline([(0, 0), (2, 0), (2, 2)], close=True, dxfattribs={'const_width': 2})
doc.modelspace().add_lwpolyline([(10, 0), (10, 0)])
doc.modelspace().add_lwpolyline([(20, 0), (21, 0), (22, 0)])
shapes = _read(doc, mode)['Model'].shapes['0']
assert len(shapes) == 3
assert all(isinstance(shape, Path) for shape in shapes)
assert sorted(shape.width for shape in shapes) == [0, 0, 2]
@pytest.mark.parametrize('mode', [3, 4])
@pytest.mark.parametrize('spur', [((10, 0), (12, 0)), ((-2, 0), (0, 0)), ((0, -2), (0, 0))])
def test_branch_and_shuffling_preserve_square(mode: int, spur: tuple) -> None:
segments = [((0, 0), (10, 0)), ((10, 0), (10, 10)), ((10, 10), (0, 10)),
((0, 10), (0, 0)), spur]
for order in (segments, [(b, a) for a, b in segments[::-1]]):
doc = ezdxf.new()
for start, end in order:
doc.modelspace().add_line(start, end)
shapes = _read(doc, mode)['Model'].shapes['0']
assert _area(shapes) == 100
assert len(shapes) == 2
@pytest.mark.parametrize('mode', [-1, 5, 'closed'])
def test_invalid_polyline_mode(mode: int) -> None:
with pytest.raises(ValueError, match='polyline_mode'):
_read(ezdxf.new(), mode)
@pytest.mark.parametrize('mode', [3, 4])
def test_merge_uses_even_odd_filling_for_overlapping_contours(mode: int) -> None:
doc = ezdxf.new()
for xx in (0, 5):
doc.modelspace().add_lwpolyline([(xx, 0), (xx + 10, 0), (xx + 10, 10), (xx, 10)], close=True)
assert _area(_read(doc, mode)['Model'].shapes['0']) == 100
@pytest.mark.parametrize('mode', [3, 4])
def test_contours_below_clipping_precision_remain_paths(mode: int) -> None:
doc = ezdxf.new()
doc.modelspace().add_lwpolyline([(0, 0), (1e-7, 0), (0, 1e-7)], close=True)
shapes = _read(doc, mode)['Model'].shapes['0']
assert len(shapes) == 1
assert isinstance(shapes[0], Path)
assert_allclose(shapes[0].get_bounds_single(), [[0, 0], [1e-7, 1e-7]], atol=1e-15)
@pytest.mark.parametrize('accuracy', [-1, numpy.nan, numpy.inf])
def test_invalid_contour_accuracy(accuracy: float) -> None:
with pytest.raises(ValueError, match='contour_accuracy'):
_read(ezdxf.new(), 3, accuracy)
@pytest.mark.parametrize('angle', [0, numpy.pi / 2, numpy.pi, numpy.pi / 4])
@pytest.mark.parametrize('local_grid', [False, True])
@pytest.mark.parametrize('mirrored', [False, True])
def test_exported_insert_origins(angle: float, local_grid: bool, mirrored: bool) -> None:
lib = Library({'leaf': Pattern(), 'top': Pattern()})
lib['leaf'].polygon('1', vertices=[(0, 0), (4, 0), (0, 2)])
rep = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2)
if local_grid:
rep.rotate(angle)
lib['top'].ref('leaf', offset=(5, 7), rotation=angle, scale=2, mirrored=mirrored, repetition=rep)
stream = io.StringIO()
dxf.write(lib, 'top', stream)
stream.seek(0)
inserts = ezdxf.read(stream).modelspace().query('INSERT')
origins = [instance.dxf.insert.xyz[:2] for ins in inserts for instance in ins.multi_insert()]
assert_allclose(_origins(numpy.asarray(origins)), _origins(rep.displacements + (5, 7)), atol=1e-7)
@pytest.mark.parametrize('scales', [(1, 1), (-1, 1), (1, -1), (-1, -1), (2, 2)])
@pytest.mark.parametrize('angle', [0, 30, 90])
def test_imported_insert_origins(scales: tuple[float, float], angle: float) -> None:
doc = ezdxf.new()
doc.blocks.new('leaf')
insert = doc.modelspace().add_blockref('leaf', (5, 7), dxfattribs={
'rotation': angle, 'xscale': scales[0], 'yscale': scales[1],
'column_count': 3, 'row_count': 2, 'column_spacing': 20, 'row_spacing': -10,
})
expected = numpy.asarray([ins.dxf.insert.xyz[:2] for ins in insert.multi_insert()])
ref = _read(doc)['Model'].refs['leaf'][0]
assert_allclose(_origins(ref.as_transforms()[:, :2]), _origins(expected), atol=1e-7)

View file

@ -23,6 +23,67 @@ if not gdsii_arrow.is_available():
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
def test_arrow_materialized_coordinates_are_writable_floats(tmp_path: Path) -> None:
original = _make_arrow_test_library()
for annotations, layer in [(None, (30, 0)), ({'1': ['prop']}, (31, 0))]:
for xx in (0, 10):
original['leaf'].polygon(layer, [(xx, 0), (xx + 4, 0), (xx, 3)], annotations=annotations)
filename = tmp_path / 'mutable.gds'
gdsii.writefile(original, filename, meters_per_unit=1e-9)
lib, _ = gdsii_arrow.readfile(filename)
arrays = []
types = set()
for pattern in lib.values():
for shapes in pattern.shapes.values():
for shape in shapes:
types.add(type(shape))
if isinstance(shape, RectCollection):
coordinates = shape.rects
elif isinstance(shape, PolyCollection):
coordinates = shape.vertex_lists
else:
coordinates = shape.vertices
arrays.append(coordinates)
before = coordinates.copy()
shape.translate((0.25, 0.5)).scale_by(1.5)
shift = (0.25, 0.5, 0.25, 0.5) if isinstance(shape, RectCollection) else (0.25, 0.5)
numpy.testing.assert_allclose(coordinates, (before + shift) * 1.5)
if isinstance(shape, MPath) and shape.cap_extensions is not None:
arrays.append(shape.cap_extensions)
for labels in pattern.labels.values():
arrays.extend(label.offset for label in labels)
for refs in pattern.refs.values():
for ref in refs:
arrays.append(ref.offset)
ref.translate((0.25, 0.5))
if ref.repetition is not None:
arrays.extend((ref.repetition.a_vector, ref.repetition.b_vector))
ref.repetition.scale_by(1.5)
assert {Polygon, PolyCollection, RectCollection, MPath} <= types
for array in arrays:
assert array.dtype == numpy.float64
assert array.flags.writeable
def test_arrow_path_extensions_are_quantized_for_oasis(tmp_path: Path) -> None:
pytest.importorskip('fatamorgana')
from ..file import oasis # noqa: PLC0415
source = Library({'top': Pattern().path((1, 0), [(0, 0), (20, 0)], width=4,
cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))})
gds_path = tmp_path / 'path.gds'
gdsii.writefile(source, gds_path, meters_per_unit=1e-9)
loaded, _ = gdsii_arrow.readfile(gds_path)
path = loaded['top'].shapes[(1, 0)][0]
path.scale_by(1.25)
exported = oasis.build(loaded, units_per_micron=1000).cells[0].geometry[0]
assert exported.extension_start[1] == 1
assert exported.extension_end[1] == 6
assert isinstance(exported.extension_start[1], int | numpy.integer)
assert isinstance(exported.extension_end[1], int | numpy.integer)
numpy.testing.assert_allclose(path.cap_extensions, (1.25, 6.25))
def _annotations_key(annotations: dict[str, list[object]] | None) -> tuple[tuple[str, tuple[object, ...]], ...] | None:
if not annotations:
return None

View file

@ -9,7 +9,7 @@ import pytest
pytest.importorskip('pyarrow')
from .. import PatternError
from .. import PatternError, LibraryError
from ..library import IBorrowing, IMaterializable, LayerMappedView, Library, OverlayLibrary, PortLoadView
from ..pattern import Pattern
from ..repetition import Grid
@ -25,6 +25,55 @@ if not gdsii_arrow.is_available():
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
@pytest.mark.parametrize('cached', [False, True])
def test_arrow_detached_mutation_is_isolated(tmp_path: Path, cached: bool) -> None:
filename = tmp_path / 'detached.gds'
gdsii.writefile(_make_small_library(), filename, meters_per_unit=1e-9)
with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib:
if cached:
lib['leaf']
detached = lib.materialize_detached('leaf')
detached.translate_elements((0.25, 0.5))
numpy.testing.assert_allclose(detached.get_bounds(), [[0.25, 0.5], [10.25, 5.5]])
numpy.testing.assert_allclose(lib.materialize_detached('leaf').get_bounds(), [[0, 0], [10, 5]])
assert lib.can_copy_raw_struct('leaf') == (not cached)
top = lib.materialize_detached('top').flatten(lib)
assert top.get_bounds() is not None
def test_arrow_rect_hierarchy_requires_explicit_polygonization(tmp_path: Path) -> None:
original = _make_small_library()
original['mid'].refs['leaf'][0].rotation = numpy.pi / 4
filename = tmp_path / 'rotated.gds'
gdsii.writefile(original, filename, meters_per_unit=1e-9)
with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib:
for action in ('bounds', 'flatten'):
top = lib.materialize_detached('top')
with pytest.raises(PatternError, match='Pattern.polygonize'):
top.get_bounds(lib) if action == 'bounds' else top.flatten(lib)
lib['leaf'].polygonize()
numpy.testing.assert_allclose(lib['top'].get_bounds(lib), original['top'].get_bounds(original))
assert lib.materialize_detached('top').flatten(lib).get_bounds() is not None
def test_arrow_dangling_ref_queries_are_cache_independent(tmp_path: Path) -> None:
original = Library({'parent': Pattern()})
original['parent'].ref('missing', offset=(3, 4), rotation=numpy.pi / 3, mirrored=True, scale=2)
original['parent'].ref('missing', offset=(10, 20), repetition=Grid(a_vector=(7, 0), a_count=3))
filename = tmp_path / 'dangling.gds'
gdsii.writefile(original, filename, meters_per_unit=1e-9)
with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib:
expected = _local_refs_key(original.find_refs_local('missing', dangling='include'))
assert _local_refs_key(lib.find_refs_local('missing', dangling='include')) == expected
assert lib.can_copy_raw_struct('parent')
assert not lib.find_refs_local('missing', dangling='ignore')
with pytest.raises(LibraryError, match='missing'):
lib.find_refs_local('missing', dangling='error')
assert not lib.find_refs_local('unknown', dangling='include')
lib['parent']
assert _local_refs_key(lib.find_refs_local('missing', dangling='include')) == expected
def test_gdsii_lazy_arrow_has_reader_only_surface() -> None:
assert not hasattr(gdsii_lazy_arrow, 'is_available')
assert not hasattr(gdsii_lazy_arrow, 'write')
@ -251,12 +300,17 @@ def test_gdsii_lazy_arrow_invalid_path_type_raises_pattern_error(tmp_path: Path)
lib['top']
def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path) -> None:
def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gds_file = tmp_path / 'copy_source.gds'
src = _make_small_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through')
lib, info = gdsii_lazy_arrow.readfile(gds_file)
def forbid_materialization(*_args, **_kwargs) -> None:
pytest.fail('Untouched GDS writes must not materialize Arrow coordinates')
monkeypatch.setattr(gdsii_arrow, 'read_arrow', forbid_materialization)
out_file = tmp_path / 'copy_out.gds'
gdsii.writefile(
lib,

View file

@ -47,6 +47,42 @@ def test_writable_libraries_are_restricted_mappings(
assert not lib
@pytest.mark.parametrize('cached', [False, True])
def test_overlay_reuses_names_and_keeps_new_sources_independent(cached: bool) -> None:
source = Library({'a': Pattern(), 'parent': Pattern().ref('a')})
overlay = OverlayLibrary()
overlay.add_source(source)
if cached:
overlay['parent']
overlay.rename('a', 'b', move_references=True)
overlay.rename('b', 'a', move_references=True)
assert overlay.child_graph()['parent'] == {'a'}
assert set(overlay.materialize('parent', persist=False).refs) == {'a'}
overlay.rename('a', 'b', move_references=True)
overlay.add_source(Library({'a': Pattern(), 'new_parent': Pattern().ref('a')}))
assert overlay.child_graph()['parent'] == {'b'}
assert overlay.child_graph()['new_parent'] == {'a'}
assert set(overlay['new_parent'].refs) == {'a'}
assert set(source['parent'].refs) == {'a'}
def test_overlay_reuses_dangling_targets_and_preserves_failed_rename() -> None:
overlay = OverlayLibrary()
overlay.add_source(Library({'parent': Pattern().ref('missing')}))
overlay.move_references('missing', 'other')
overlay.move_references('other', 'missing')
assert set(overlay['parent'].refs) == {'missing'}
overlay['used'] = Pattern()
before = list(overlay)
with pytest.raises(LibraryError, match='already exists'):
overlay.rename('parent', 'used', move_references=True)
assert list(overlay) == before
assert set(overlay['parent'].refs) == {'missing'}
del overlay['used']
overlay.rename('parent', 'used')
assert list(overlay) == ['used']
def test_library_tops() -> None:
lib = Library()
lib["child"] = Pattern()

View file

@ -17,7 +17,8 @@ def test_shape_mirror() -> None:
a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos)
a.mirror(1)
assert a.angle_ref == Arc.AngleRef.FocusNeg
# The pi rotation already reflects the X-major focus across the Y axis.
assert a.angle_ref == Arc.AngleRef.FocusPos
a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos)
a.mirror(0)

View file

@ -89,8 +89,8 @@ def boolean(
Perform a boolean operation on two sets of polygons.
Args:
subjects: List of subjects (Polygons or vertex arrays).
clips: List of clips (Polygons or vertex arrays).
subjects: Subjects (shapes or vertex arrays). Shape repetitions are expanded.
clips: Clips (shapes or vertex arrays). Shape repetitions are expanded.
operation: The boolean operation to perform.
scale: Scaling factor for integer conversion (pyclipper uses integers).
@ -115,44 +115,31 @@ def boolean(
def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]:
if objs is None:
return []
if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon):
objs = (objs,)
elif not isinstance(objs, Iterable):
raise PatternError(f"Unsupported type for boolean operation: {type(objs)}")
if isinstance(objs, numpy.ndarray):
return [objs]
if hasattr(objs, 'to_polygons'):
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)
for poly in objs.to_polygons():
if poly.repetition is None:
verts.append(poly.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
verts.extend(poly.vertices + dd for dd in poly.repetition.displacements)
return verts
if isinstance(objs, str | bytes) or not isinstance(objs, Iterable):
raise PatternError(f"Unsupported type for boolean operation: {type(objs)}")
return [vertices for obj in objs for vertices in to_vertices(obj)]
op = op_map[operation.lower()]
subject_verts = to_vertices(subjects)
clip_verts = to_vertices(clips)
if not subject_verts:
if operation in ('union', 'xor'):
return [Polygon(vertices) for vertices in clip_verts]
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:
if operation == 'intersection':
if not clip_verts and op == pyclipper.CT_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)
@ -160,7 +147,13 @@ def boolean(
pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True)
# Use GetPolyTree to distinguish between outers and holes
polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
polytree = pc.Execute2(op, pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
return _polytree_to_polygons(polytree, scale)
def _polytree_to_polygons(polytree: Any, scale: float) -> list[Polygon]:
"""Convert a Clipper result, bridging holes for masque's polygon representation."""
import pyclipper # noqa: PLC0415
result_polygons = []

View file

@ -69,7 +69,7 @@ path = "masque/__init__.py"
[project.optional-dependencies]
arrow = ["pyarrow", "cffi"]
oasis = ["fatamorgana~=0.11"]
dxf = ["ezdxf~=1.4"]
dxf = ["ezdxf~=1.4", "pyclipper"]
svg = ["svgwrite"]
visualize = ["matplotlib"]
text = ["matplotlib", "freetype-py"]