From 6fee234a7e1d09f5284f72fd4bac9f278b349ea8 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 14 Sep 2026 22:03:01 -0700 Subject: [PATCH] [dxf] improve dxf path-to-polygon loading --- masque/file/dxf.py | 299 +++++++++++++++++++++++++--------- masque/test/test_dxf_modes.py | 205 +++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 427 insertions(+), 79 deletions(-) create mode 100644 masque/test/test_dxf_modes.py diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 237b1d8..0a04f61 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -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 diff --git a/masque/test/test_dxf_modes.py b/masque/test/test_dxf_modes.py new file mode 100644 index 0000000..10f7f89 --- /dev/null +++ b/masque/test/test_dxf_modes.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index ad03fa9..5d7778c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]