[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 * ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID
to unique values, so byte-for-byte reproducibility is not achievable for now to unique values, so byte-for-byte reproducibility is not achievable for now
""" """
from typing import Any, cast, TextIO, IO from typing import Any, cast, TextIO, IO, Literal
from collections.abc import Mapping, Callable from collections import defaultdict
from collections.abc import Mapping, Callable, Sequence
import io import io
import logging import logging
import pathlib import pathlib
import gzip import gzip
import numpy import numpy
from numpy.typing import NDArray
import ezdxf import ezdxf
from ezdxf import edgeminer
from ezdxf.math import Vec3
from ezdxf.enums import TextEntityAlignment 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 .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, Label from .. import Pattern, Ref, PatternError, Label
@ -24,6 +28,7 @@ from ..library import ILibraryView, LibraryView, Library
from ..shapes import Shape, Polygon, Path from ..shapes import Shape, Polygon, Path
from ..repetition import Grid from ..repetition import Grid
from ..utils import rotation_matrix_2d, layer_t, normalize_mirror from ..utils import rotation_matrix_2d, layer_t, normalize_mirror
from ..utils.boolean import _polytree_to_polygons
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -173,6 +178,9 @@ def readfile(
def read( def read(
stream: TextIO, stream: TextIO,
*,
polyline_mode: Literal[0, 1, 2, 3, 4] = 2,
contour_accuracy: float = 0.0,
) -> tuple[Library, dict[str, Any]]: ) -> tuple[Library, dict[str, Any]]:
""" """
Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are
@ -183,16 +191,30 @@ def read(
Args: Args:
stream: Stream to read from. 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: 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) lib = ezdxf.read(stream)
msp = lib.modelspace() msp = lib.modelspace()
top_name, top_pat = _read_block(msp)
mlib = Library({top_name: top_pat})
blocks_by_name = { blocks_by_name = {
bb.name: bb bb.name: bb
for bb in lib.blocks for bb in lib.blocks
@ -219,12 +241,27 @@ def read(
if target in blocks_by_name: if target in blocks_by_name:
pending.append(blocks_by_name[target]) pending.append(blocks_by_name[target])
for bb in lib.blocks: blocks = [msp, *(bb for bb in blocks_by_name.values()
if bb.is_any_layout: if not bb.name.startswith('_') or bb.name in referenced)]
continue if polyline_mode == 0:
if bb.name.startswith('_') and bb.name not in referenced: polyline_mode = 3
continue for block in blocks:
name, pat = _read_block(bb) 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 mlib[name] = pat
library_info = dict( library_info = dict(
@ -234,9 +271,15 @@ def read(
return mlib, library_info 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 name = block.name
pat = Pattern() pat = Pattern()
contours: dict[layer_t, list[numpy.ndarray]] = defaultdict(list)
for element in block: for element in block:
if isinstance(element, LWPolyline | Polyline): if isinstance(element, LWPolyline | Polyline):
if isinstance(element, LWPolyline): if isinstance(element, LWPolyline):
@ -247,6 +290,9 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
is_closed = element.is_closed is_closed = element.is_closed
attr = element.dxfattribs() attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER) layer = attr.get('layer', DEFAULT_LAYER)
if len(points) < 2:
logger.warning('Ignoring DXF polyline with fewer than two vertices')
continue
width = 0 width = 0
if isinstance(element, LWPolyline): if isinstance(element, LWPolyline):
@ -260,26 +306,44 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
elif points.shape[1] == 3: elif points.shape[1] == 3:
# width used to be in column 2 # width used to be in column 2
width = points[0, 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: if width == 0:
width = attr.get('const_width', 0) width = attr.get('const_width', 0)
verts = points[:, :2] 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])) verts = numpy.vstack((verts, verts[0]))
is_closed = is_closed or endpoint_closed
shape: Path | Polygon shape: Path | Polygon
if width == 0 and is_closed: if width == 0 and polyline_mode >= 3:
# Use Polygon if it has at least 3 unique vertices contours[layer].append(verts)
shape_verts = verts[:-1] if len(verts) > 1 else verts continue
if len(shape_verts) >= 3: if width == 0 and is_closed and polyline_mode == 2 and _is_polygon(verts):
shape = Polygon(vertices=shape_verts) shape = Polygon(vertices=verts[:-1])
else:
shape = Path(width=width, vertices=verts)
else: else:
shape = Path(width=width, vertices=verts) shape = Path(width=width, vertices=verts)
pat.shapes[layer].append(shape) 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): elif isinstance(element, Solid | Trace):
attr = element.dxfattribs() attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER) 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!') logger.warning('Masque does not support per-axis scaling; using x-scaling only!')
scale = abs(xscale) scale = abs(xscale)
mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0)) 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] 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, rotation=rotation,
) )
if 'column_count' in attr: if 'column_count' in attr or 'row_count' in attr:
col_spacing = attr['column_spacing'] col_spacing = attr.get('column_spacing', 0)
row_spacing = attr['row_spacing'] row_spacing = attr.get('row_spacing', 0)
col_count = attr['column_count'] col_count = attr.get('column_count', 1)
row_count = attr['row_count'] row_count = attr.get('row_count', 1)
local_x = numpy.array((col_spacing, 0.0)) local_x = numpy.array((col_spacing, 0.0))
local_y = numpy.array((0.0, row_spacing)) local_y = numpy.array((0.0, row_spacing))
inv_rot = rotation_matrix_2d(-rotation) # Spacing follows only the original INSERT angle, not its scale
# or the extra angle introduced by mirror normalization.
candidates = ( rot = rotation_matrix_2d(insert_rotation)
(inv_rot @ local_x, inv_rot @ local_y, col_count, row_count), args['repetition'] = Grid(
(inv_rot @ local_y, inv_rot @ local_x, row_count, col_count), 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) pat.ref(**args)
else: else:
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).') 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 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( def _mrefs_to_drefs(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
refs: dict[str | None, list[Ref]], 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 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], # In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing],
# which ARE then rotated by the block reference's rotation. # 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) # Compensate for that rotation to express the world-space basis in
# AND the grid is already manhattan. # the local DXF frame. Only locally Manhattan grids fit an INSERT.
rotated_a = rotation_matrix_2d(-ref.rotation) @ a
# Rotate basis vectors by the reference rotation to see where they end up in the DXF frame rotated_b = rotation_matrix_2d(-ref.rotation) @ b
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): 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 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] [project.optional-dependencies]
arrow = ["pyarrow", "cffi"] arrow = ["pyarrow", "cffi"]
oasis = ["fatamorgana~=0.11"] oasis = ["fatamorgana~=0.11"]
dxf = ["ezdxf~=1.4"] dxf = ["ezdxf~=1.4", "pyclipper"]
svg = ["svgwrite"] svg = ["svgwrite"]
visualize = ["matplotlib"] visualize = ["matplotlib"]
text = ["matplotlib", "freetype-py"] text = ["matplotlib", "freetype-py"]