[dxf] improve dxf path-to-polygon loading
This commit is contained in:
parent
848fd95e2c
commit
6fee234a7e
3 changed files with 427 additions and 79 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
205
masque/test/test_dxf_modes.py
Normal file
205
masque/test/test_dxf_modes.py
Normal 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)
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue