Compare commits
No commits in common. "6fee234a7e1d09f5284f72fd4bac9f278b349ea8" and "890e7e5c0f1112f34ecdca08a59e530bdce1b682" have entirely different histories.
6fee234a7e
...
890e7e5c0f
16 changed files with 163 additions and 701 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue