[dxf] improve dxf path-to-polygon loading

This commit is contained in:
Jan Petykiewicz 2026-09-14 22:03:01 -07:00
commit 6fee234a7e
3 changed files with 427 additions and 79 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

@ -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

@ -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"]