From 564ff10db34d94f6512b920c0466ee44f3b2567a Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 00:17:23 -0700 Subject: [PATCH] [dxf] add roundtrip dxf test, enable refs and improve path handling --- masque/file/dxf.py | 71 ++++++++++++++++++------- masque/test/test_dxf.py | 111 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 masque/test/test_dxf.py diff --git a/masque/file/dxf.py b/masque/file/dxf.py index d7bc7e8..962d41d 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -16,7 +16,7 @@ import gzip import numpy import ezdxf from ezdxf.enums import TextEntityAlignment -from ezdxf.entities import LWPolyline, Polyline, Text, Insert +from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -217,9 +217,7 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) - if points.shape[1] == 2: - raise PatternError('Invalid or unimplemented polygon?') - + width = 0 if points.shape[1] > 2: if (points[0, 2] != points[:, 2]).any(): raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') @@ -230,14 +228,35 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if width == 0: width = attr.get('const_width', 0) - shape: Path | Polygon - if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]): - shape = Polygon(vertices=points[:-1, :2]) - else: - shape = Path(width=width, vertices=points[:, :2]) + is_closed = element.closed + # If the last point is a repeat of the first, drop it. + if len(points) > 1 and numpy.allclose(points[0, :2], points[-1, :2]): + verts = points[:-1, :2] + else: + verts = points[:, :2] + + shape: Path | Polygon + if width == 0 and is_closed and len(verts) >= 3: + shape = Polygon(vertices=verts) + else: + shape = Path(width=width, vertices=points[:, :2]) pat.shapes[layer].append(shape) - + elif isinstance(element, Solid | Trace): + attr = element.dxfattribs() + layer = attr.get('layer', DEFAULT_LAYER) + points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4) + if element.has_dxf_attrib(f'vtx{i}')]) + if len(points) >= 3: + # If vtx2 == vtx3, it's a triangle. ezdxf handles this. + if len(points) == 4 and numpy.allclose(points[2], points[3]): + verts = points[:3, :2] + # DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals! + elif len(points) == 4: + verts = points[[0, 1, 3, 2], :2] + else: + verts = points[:, :2] + pat.shapes[layer].append(Polygon(vertices=verts)) elif isinstance(element, Text): args = dict( offset=numpy.asarray(element.get_placement()[1])[:2], @@ -302,15 +321,23 @@ def _mrefs_to_drefs( elif isinstance(rep, Grid): a = rep.a_vector b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) - rotated_a = rotation_matrix_2d(-ref.rotation) @ a - rotated_b = rotation_matrix_2d(-ref.rotation) @ b - if numpy.isclose(rotated_a[1], 0) and numpy.isclose(rotated_b[0], 0): + # 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 + + 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['row_count'] = rep.b_count attribs['column_spacing'] = rotated_a[0] attribs['row_spacing'] = rotated_b[1] block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) - elif numpy.isclose(rotated_a[0], 0) and numpy.isclose(rotated_b[1], 0): + elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8): attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] @@ -348,10 +375,18 @@ def _shapes_to_elements( displacements = shape.repetition.displacements for dd in displacements: - for polygon in shape.to_polygons(): - xy_open = polygon.vertices + dd - xy_closed = numpy.vstack((xy_open, xy_open[0, :])) - block.add_lwpolyline(xy_closed, dxfattribs=attribs) + if isinstance(shape, Path): + # preserve path. + # Note: DXF paths don't support endcaps well, so this is still a bit limited. + xy = shape.vertices + dd + attribs_path = {**attribs} + if shape.width > 0: + attribs_path['const_width'] = shape.width + block.add_lwpolyline(xy, dxfattribs=attribs_path) + else: + for polygon in shape.to_polygons(): + xy_open = polygon.vertices + dd + block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs) def _labels_to_texts( diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py new file mode 100644 index 0000000..e6e6e7e --- /dev/null +++ b/masque/test/test_dxf.py @@ -0,0 +1,111 @@ + +import numpy +from numpy.testing import assert_allclose +from pathlib import Path + +from ..pattern import Pattern +from ..library import Library +from ..shapes import Path as MPath, Polygon +from ..repetition import Grid +from ..file import dxf + +def test_dxf_roundtrip(tmp_path: Path): + lib = Library() + pat = Pattern() + + # 1. Polygon (closed) + poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) + pat.polygon("1", vertices=poly_verts) + + # 2. Path (open, 3 points) + path_verts = numpy.array([[20, 0], [30, 0], [30, 10]]) + pat.path("2", vertices=path_verts, width=2) + + # 3. Path (open, 2 points) - Testing the fix for 2-point polylines + path2_verts = numpy.array([[40, 0], [50, 10]]) + pat.path("3", vertices=path2_verts, width=0) # width 0 to be sure it's not a polygonized path if we're not careful + + # 4. Ref with Grid repetition (Manhattan) + subpat = Pattern() + subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]]) + lib["sub"] = subpat + + pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3)) + + lib["top"] = pat + + dxf_file = tmp_path / "test.dxf" + dxf.writefile(lib, "top", dxf_file) + + read_lib, _ = dxf.readfile(dxf_file) + + # In DXF read, the top level is usually called "Model" + top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] + + # Verify Polygon + polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)] + assert len(polys) >= 1 + poly_read = polys[0] + # DXF polyline might be shifted or vertices reordered, but here they should be simple + assert_allclose(poly_read.vertices, poly_verts) + + # Verify 3-point Path + paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)] + assert len(paths) >= 1 + path_read = paths[0] + assert_allclose(path_read.vertices, path_verts) + assert path_read.width == 2 + + # Verify 2-point Path + paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)] + assert len(paths2) >= 1 + path2_read = paths2[0] + assert_allclose(path2_read.vertices, path2_verts) + assert path2_read.width == 0 + + # Verify Ref with Grid + # Finding the sub pattern name might be tricky because of how DXF stores blocks + # but "sub" should be in read_lib + assert "sub" in read_lib + + # Check refs in the top pattern + found_grid = False + for target, reflist in top_pat.refs.items(): + # DXF names might be case-insensitive or modified, but ezdxf usually preserves them + if target.upper() == "SUB": + for ref in reflist: + if isinstance(ref.repetition, Grid): + assert ref.repetition.a_count == 2 + assert ref.repetition.b_count == 3 + assert_allclose(ref.repetition.a_vector, (10, 0)) + assert_allclose(ref.repetition.b_vector, (0, 10)) + found_grid = True + assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}" + +def test_dxf_manhattan_precision(tmp_path: Path): + # Test that float precision doesn't break Manhattan grid detection + lib = Library() + sub = Pattern() + sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) + lib["sub"] = sub + + top = Pattern() + # 90 degree rotation: in masque the grid is NOT rotated, so it stays [[10,0],[0,10]] + # In DXF, an array with rotation 90 has basis vectors [[0,10],[-10,0]]. + # So a masque grid [[10,0],[0,10]] with ref rotation 90 matches a DXF array. + angle = numpy.pi / 2 # 90 degrees + top.ref("sub", offset=(0, 0), rotation=angle, + repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2)) + + lib["top"] = top + + dxf_file = tmp_path / "precision.dxf" + dxf.writefile(lib, "top", dxf_file) + + # If the isclose() fix works, this should still be a Grid when read back + read_lib, _ = dxf.readfile(dxf_file) + read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] + + target_name = next(k for k in read_top.refs if k.upper() == "SUB") + ref = read_top.refs[target_name][0] + assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation"