You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
masque/masque/file/dxf.py

370 lines
13 KiB
Python

"""
DXF file format readers and writers
Notes:
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
* 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 List, Any, Dict, Tuple, Callable, Union, Mapping
from typing import cast, TextIO, IO
import io
import logging
import pathlib
import gzip
import numpy
import ezdxf
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, Label
from ..library import Library, WrapROLibrary
from ..shapes import Shape, Polygon, Path
from ..repetition import Grid
from ..utils import rotation_matrix_2d, layer_t
logger = logging.getLogger(__name__)
logger.warning('DXF support is experimental!')
DEFAULT_LAYER = 'DEFAULT'
def write(
library: Mapping[str, Pattern], # TODO could allow library=None for flat DXF
top_name: str,
stream: TextIO,
*,
dxf_version='AC1024',
) -> None:
"""
Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes
into polygons, and then writing patterns as DXF `Block`s, polygons as `LWPolyline`s,
and refs as `Insert`s.
The top level pattern's name is not written to the DXF file. Nested patterns keep their
names.
Layer numbers are translated as follows:
int: 1 -> '1'
tuple: (1, 2) -> '1.2'
str: '1.2' -> '1.2' (no change)
DXF does not support shape repetition (only block repeptition). Please call
library.wrap_repeated_shapes() before writing to file.
Other functions you may want to call:
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
- `library.dangling_refs()` to check for references to missing patterns
- `pattern.polygonize()` for any patterns with shapes other
than `masque.shapes.Polygon` or `masque.shapes.Path`
Only `Grid` repetition objects with manhattan basis vectors are preserved as arrays. Since DXF
rotations apply to basis vectors while `masque`'s rotations do not, the basis vectors of an
array with rotated instances must be manhattan _after_ having a compensating rotation applied.
Args:
library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced
by it are written.
top_name: Name of the top-level pattern to write.
stream: Stream object to write to.
"""
#TODO consider supporting DXF arcs?
if not isinstance(library, Library):
if isinstance(library, dict):
library = WrapROLibrary(library)
else:
library = WrapROLibrary(dict(library))
pattern = library[top_name]
subtree = library.subtree(top_name)
# Create library
lib = ezdxf.new(dxf_version, setup=True)
msp = lib.modelspace()
_shapes_to_elements(msp, pattern.shapes)
_labels_to_texts(msp, pattern.labels)
_mrefs_to_drefs(msp, pattern.refs)
# Now create a block for each referenced pattern, and add in any shapes
for name, pat in subtree.items():
assert pat is not None
if name == top_name:
continue
block = lib.blocks.new(name=name)
_shapes_to_elements(block, pat.shapes)
_labels_to_texts(block, pat.labels)
_mrefs_to_drefs(block, pat.refs)
lib.write(stream)
def writefile(
library: Mapping[str, Pattern],
top_name: str,
filename: Union[str, pathlib.Path],
*args,
**kwargs,
) -> None:
"""
Wrapper for `dxf.write()` that takes a filename or path instead of a stream.
Will automatically compress the file if it has a .gz suffix.
Args:
library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced
by it are written.
top_name: Name of the top-level pattern to write.
filename: Filename to save to.
*args: passed to `dxf.write`
**kwargs: passed to `dxf.write`
"""
path = pathlib.Path(filename)
gz_stream: IO[bytes]
with tmpfile(path) as base_stream:
streams: Tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz':
gz_stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream))
streams = (gz_stream,) + streams
else:
gz_stream = base_stream
stream = io.TextIOWrapper(gz_stream) # type: ignore
streams = (stream,) + streams
try:
write(library, top_name, stream, *args, **kwargs)
finally:
for ss in streams:
ss.close()
def readfile(
filename: Union[str, pathlib.Path],
*args,
**kwargs,
) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
"""
Wrapper for `dxf.read()` that takes a filename or path instead of a stream.
Will automatically decompress gzipped files.
Args:
filename: Filename to save to.
*args: passed to `dxf.read`
**kwargs: passed to `dxf.read`
"""
path = pathlib.Path(filename)
if is_gzipped(path):
open_func: Callable = gzip.open
else:
open_func = open
with open_func(path, mode='rt') as stream:
results = read(stream, *args, **kwargs)
return results
def read(
stream: TextIO,
) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
"""
Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are
translated into `Pattern` objects; `LWPolyline`s are translated into polygons, and `Insert`s
are translated into `Ref` objects.
If an object has no layer it is set to this module's `DEFAULT_LAYER` ("DEFAULT").
Args:
stream: Stream to read from.
Returns:
- Top level pattern
"""
lib = ezdxf.read(stream)
msp = lib.modelspace()
npat = _read_block(msp)
patterns_dict = dict(
[npat] + [_read_block(bb) for bb in lib.blocks if bb.name != '*Model_Space']
)
library_info = dict(
layers=[ll.dxfattribs() for ll in lib.layers],
)
return patterns_dict, library_info
def _read_block(block) -> Tuple[str, Pattern]:
name = block.name
pat = Pattern()
for element in block:
eltype = element.dxftype()
if eltype in ('POLYLINE', 'LWPOLYLINE'):
if eltype == 'LWPOLYLINE':
points = numpy.array(tuple(element.lwpoints))
else:
points = numpy.array(tuple(element.points()))
attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER)
if points.shape[1] == 2:
raise PatternError('Invalid or unimplemented polygon?')
#shape = Polygon(layer=layer)
elif points.shape[1] > 2:
if (points[0, 2] != points[:, 2]).any():
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
elif points.shape[1] == 4 and (points[:, 3] != 0).any():
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
width = points[0, 2]
if width == 0:
width = attr.get('const_width', 0)
shape: Union[Path, Polygon]
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
shape = Polygon(layer=layer, vertices=points[:-1, :2])
else:
shape = Path(layer=layer, width=width, vertices=points[:, :2])
pat.shapes.append(shape)
elif eltype in ('TEXT',):
args = dict(
offset=numpy.array(element.get_pos()[1])[:2],
layer=element.dxfattribs().get('layer', DEFAULT_LAYER),
)
string = element.dxfattribs().get('text', '')
# height = element.dxfattribs().get('height', 0)
# if height != 0:
# logger.warning('Interpreting DXF TEXT as a label despite nonzero height. '
# 'This could be changed in the future by setting a font path in the masque DXF code.')
pat.labels.append(Label(string=string, **args))
# else:
# pat.shapes.append(Text(string=string, height=height, font_path=????))
elif eltype in ('INSERT',):
attr = element.dxfattribs()
xscale = attr.get('xscale', 1)
yscale = attr.get('yscale', 1)
if abs(xscale) != abs(yscale):
logger.warning('Masque does not support per-axis scaling; using x-scaling only!')
scale = abs(xscale)
mirrored = (yscale < 0, xscale < 0)
rotation = numpy.deg2rad(attr.get('rotation', 0))
offset = numpy.array(attr.get('insert', (0, 0, 0)))[:2]
args = dict(
target=attr.get('name', None),
offset=offset,
scale=scale,
mirrored=mirrored,
rotation=rotation,
)
if 'column_count' in attr:
args['repetition'] = Grid(
a_vector=(attr['column_spacing'], 0),
b_vector=(0, attr['row_spacing']),
a_count=attr['column_count'],
b_count=attr['row_count'],
)
pat.ref(**args)
else:
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
return name, pat
def _mrefs_to_drefs(
block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace],
refs: List[Ref],
) -> None:
for ref in refs:
if ref.target is None:
continue
encoded_name = ref.target
rotation = numpy.rad2deg(ref.rotation) % 360
attribs = dict(
xscale=ref.scale * (-1 if ref.mirrored[1] else 1),
yscale=ref.scale * (-1 if ref.mirrored[0] else 1),
rotation=rotation,
)
rep = ref.repetition
if rep is None:
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
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 rotated_a[1] == 0 and rotated_b[0] == 0:
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 rotated_a[0] == 0 and rotated_b[1] == 0:
attribs['column_count'] = rep.b_count
attribs['row_count'] = rep.a_count
attribs['column_spacing'] = rotated_b[0]
attribs['row_spacing'] = rotated_a[1]
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
else:
#NOTE: We could still do non-manhattan (but still orthogonal) grids by getting
# creative with counter-rotated nested patterns, but probably not worth it.
# Instead, just break appart the grid into individual elements:
for dd in rep.displacements:
block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs)
else:
for dd in rep.displacements:
block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs)
def _shapes_to_elements(
block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace],
shapes: List[Shape],
polygonize_paths: bool = False,
) -> None:
# Add `LWPolyline`s for each shape.
# Could set do paths with width setting, but need to consider endcaps.
for shape in shapes:
if shape.repetition is not None:
raise PatternError(
'Shape repetitions are not supported by DXF.'
' Please call library.wrap_repeated_shapes() before writing to file.'
)
attribs = dict(layer=_mlayer2dxf(shape.layer))
for polygon in shape.to_polygons():
xy_open = polygon.vertices + polygon.offset
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
def _labels_to_texts(
block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace],
labels: List[Label],
) -> None:
for label in labels:
attribs = dict(layer=_mlayer2dxf(label.layer))
xy = label.offset
block.add_text(label.string, dxfattribs=attribs).set_pos(xy, align='BOTTOM_LEFT')
def _mlayer2dxf(layer: layer_t) -> str:
if isinstance(layer, str):
return layer
if isinstance(layer, int):
return str(layer)
if isinstance(layer, tuple):
return f'{layer[0]}.{layer[1]}'
raise PatternError(f'Unknown layer type: {layer} ({type(layer)})')