[dxf] add roundtrip dxf test, enable refs and improve path handling
This commit is contained in:
parent
e261585894
commit
564ff10db3
2 changed files with 164 additions and 18 deletions
|
|
@ -16,7 +16,7 @@ import gzip
|
||||||
import numpy
|
import numpy
|
||||||
import ezdxf
|
import ezdxf
|
||||||
from ezdxf.enums import TextEntityAlignment
|
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 .utils import is_gzipped, tmpfile
|
||||||
from .. import Pattern, Ref, PatternError, Label
|
from .. import Pattern, Ref, PatternError, Label
|
||||||
|
|
@ -217,9 +217,7 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
|
||||||
attr = element.dxfattribs()
|
attr = element.dxfattribs()
|
||||||
layer = attr.get('layer', DEFAULT_LAYER)
|
layer = attr.get('layer', DEFAULT_LAYER)
|
||||||
|
|
||||||
if points.shape[1] == 2:
|
width = 0
|
||||||
raise PatternError('Invalid or unimplemented polygon?')
|
|
||||||
|
|
||||||
if points.shape[1] > 2:
|
if points.shape[1] > 2:
|
||||||
if (points[0, 2] != points[:, 2]).any():
|
if (points[0, 2] != points[:, 2]).any():
|
||||||
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
|
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:
|
if width == 0:
|
||||||
width = attr.get('const_width', 0)
|
width = attr.get('const_width', 0)
|
||||||
|
|
||||||
shape: Path | Polygon
|
is_closed = element.closed
|
||||||
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
|
# If the last point is a repeat of the first, drop it.
|
||||||
shape = Polygon(vertices=points[:-1, :2])
|
if len(points) > 1 and numpy.allclose(points[0, :2], points[-1, :2]):
|
||||||
else:
|
verts = points[:-1, :2]
|
||||||
shape = Path(width=width, vertices=points[:, :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)
|
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):
|
elif isinstance(element, Text):
|
||||||
args = dict(
|
args = dict(
|
||||||
offset=numpy.asarray(element.get_placement()[1])[:2],
|
offset=numpy.asarray(element.get_placement()[1])[:2],
|
||||||
|
|
@ -302,15 +321,23 @@ def _mrefs_to_drefs(
|
||||||
elif isinstance(rep, Grid):
|
elif isinstance(rep, Grid):
|
||||||
a = rep.a_vector
|
a = rep.a_vector
|
||||||
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
||||||
rotated_a = rotation_matrix_2d(-ref.rotation) @ a
|
# In masque, the grid basis vectors are NOT rotated by the reference's rotation.
|
||||||
rotated_b = rotation_matrix_2d(-ref.rotation) @ b
|
# In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing],
|
||||||
if numpy.isclose(rotated_a[1], 0) and numpy.isclose(rotated_b[0], 0):
|
# 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['column_count'] = rep.a_count
|
||||||
attribs['row_count'] = rep.b_count
|
attribs['row_count'] = rep.b_count
|
||||||
attribs['column_spacing'] = rotated_a[0]
|
attribs['column_spacing'] = rotated_a[0]
|
||||||
attribs['row_spacing'] = rotated_b[1]
|
attribs['row_spacing'] = rotated_b[1]
|
||||||
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
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['column_count'] = rep.b_count
|
||||||
attribs['row_count'] = rep.a_count
|
attribs['row_count'] = rep.a_count
|
||||||
attribs['column_spacing'] = rotated_b[0]
|
attribs['column_spacing'] = rotated_b[0]
|
||||||
|
|
@ -348,10 +375,18 @@ def _shapes_to_elements(
|
||||||
displacements = shape.repetition.displacements
|
displacements = shape.repetition.displacements
|
||||||
|
|
||||||
for dd in displacements:
|
for dd in displacements:
|
||||||
for polygon in shape.to_polygons():
|
if isinstance(shape, Path):
|
||||||
xy_open = polygon.vertices + dd
|
# preserve path.
|
||||||
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
|
# Note: DXF paths don't support endcaps well, so this is still a bit limited.
|
||||||
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
|
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(
|
def _labels_to_texts(
|
||||||
|
|
|
||||||
111
masque/test/test_dxf.py
Normal file
111
masque/test/test_dxf.py
Normal file
|
|
@ -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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue