diff --git a/masque/file/dxf.py b/masque/file/dxf.py new file mode 100644 index 0000000..0f0b753 --- /dev/null +++ b/masque/file/dxf.py @@ -0,0 +1,382 @@ +""" +DXF file format readers and writers +""" +from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional +import re +import io +import copy +import base64 +import struct +import logging +import pathlib +import gzip +import numpy +from numpy import pi + +import ezdxf + +from .utils import mangle_name, make_dose_table +from .. import Pattern, SubPattern, GridRepetition, PatternError, Label, Shape, subpattern_t +from ..shapes import Polygon, Path +from ..utils import rotation_matrix_2d, get_bit, set_bit, vector2, is_scalar, layer_t +from ..utils import remove_colinear_vertices, normalize_mirror + + +logger = logging.getLogger(__name__) + +logger.warning('DXF support is experimental and only slightly tested!') + + +DEFAULT_LAYER = 'DEFAULT' + + +def write(pattern: Pattern, + stream: io.TextIOBase, + modify_originals: bool = False, + dxf_version='AC1024', + disambiguate_func: Callable[[Iterable[Pattern]], None] = 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 subpatterns 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) + + It is often a good idea to run `pattern.subpatternize()` prior to calling this function, + especially if calling `.polygonize()` will result in very many vertices. + + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. + + Only `GridRepetition` 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: + patterns: A Pattern or list of patterns to write to file. + file: Filename or stream object to write to. + modify_original: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`. + WARNING: No additional error checking is performed on the results. + """ + #TODO consider supporting DXF arcs? + if disambiguate_func is None: + disambiguate_func = disambiguate_pattern_names + + if not modify_originals: + pattern = pattern.deepcopy().deepunlock() + + # Get a dict of id(pattern) -> pattern + patterns_by_id = pattern.referenced_patterns_by_id() + disambiguate_func(patterns_by_id.values()) + + # Create library + lib = ezdxf.new(dxf_version, setup=True) + msp = lib.modelspace() + _shapes_to_elements(msp, pattern.shapes) + _labels_to_texts(msp, pattern.labels) + _subpatterns_to_refs(msp, pattern.subpatterns) + + # Now create a block for each referenced pattern, and add in any shapes + for pat in patterns_by_id.values(): + assert(pat is not None) + block = lib.blocks.new(name=pat.name) + + _shapes_to_elements(block, pat.shapes) + _labels_to_texts(block, pat.labels) + _subpatterns_to_refs(block, pat.subpatterns) + + lib.write(stream) + + +def writefile(pattern: Pattern, + filename: Union[str, pathlib.Path], + *args, + **kwargs, + ): + """ + 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: + pattern: `Pattern` to save + filename: Filename to save to. + *args: passed to `dxf.write` + **kwargs: passed to `dxf.write` + """ + path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open + + with open_func(path, mode='wt') as stream: + results = write(pattern, stream, *args, **kwargs) + return results + + +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 files with a .gz suffix. + + Args: + filename: Filename to save to. + *args: passed to `dxf.read` + **kwargs: passed to `dxf.read` + """ + path = pathlib.Path(filename) + if path.suffix == '.gz': + 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: io.TextIOBase, + clean_vertices: bool = True, + ) -> 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 `SubPattern` objects. + + If an object has no layer it is set to this module's `DEFAULT_LAYER` ("DEFAULT"). + + Args: + stream: Stream to read from. + clean_vertices: If `True`, remove any redundant vertices when loading polygons. + The cleaning process removes any polygons with zero area or <3 vertices. + Default `True`. + + Returns: + - Top level pattern + """ + lib = ezdxf.read(stream) + msp = lib.modelspace() + + pat = _read_block(msp, clean_vertices) + patterns = [pat] + [_read_block(bb, clean_vertices) for bb in lib.blocks if bb.name != '*Model_Space'] + + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + sp.pattern = patterns_dict[sp.identifier[0]] + del sp.identifier + + library_info = { + 'layers': [ll.dxfattribs() for ll in lib.layers] + } + + return pat, library_info + + +def _read_block(block, clean_vertices): + pat = Pattern(block.name) + for element in block: + eltype = element.dxftype() + if eltype in ('POLYLINE', 'LWPOLYLINE'): + if eltype == 'LWPOLYLINE': + points = numpy.array(element.lwpoints) + else: + points = numpy.array(element.points) + attr = element.dxfattribs() + args = {'layer': attr.get('layer', DEFAULT_LAYER), + } + + if points.shape[1] == 2: + shape = Polygon(**args) + 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!)') + else: + width = points[0, 2] + if width == 0: + width = attr.get('const_width', 0) + + if width == 0 and numpy.array_equal(points[0], points[-1]): + shape = Polygon(**args, vertices=points[:-1, :2]) + else: + shape = Path(**args, width=width, vertices=points[:, :2]) + + if clean_vertices: + try: + shape.clean_vertices() + except PatternError: + continue + + pat.shapes.append(shape) + + elif eltype in ('TEXT',): + args = {'offset': 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 = attr.get('rotation', 0) * pi/180 + + offset = attr.get('insert', (0, 0, 0))[:2] + + args = { + 'offset': offset, + 'scale': scale, + 'mirrored': mirrored, + 'rotation': rotation, + 'pattern': None, + 'identifier': (attr.get('name', None),), + } + + if 'column_count' in attr: + args['a_vector'] = (attr['column_spacing'], 0) + args['b_vector'] = (0, attr['row_spacing']) + args['a_count'] = attr['column_count'] + args['b_count'] = attr['row_count'] + pat.subpatterns.append(GridRepetition(**args)) + else: + pat.subpatterns.append(SubPattern(**args)) + else: + logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).') + return pat + + +def _subpatterns_to_refs(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], + subpatterns: List[subpattern_t]): + for subpat in subpatterns: + if subpat.pattern is None: + continue + encoded_name = subpat.pattern.name + + rotation = (subpat.rotation * 180 / numpy.pi) % 360 + attribs = { + 'xscale': subpat.scale * (-1 if subpat.mirrored[1] else 1), + 'yscale': subpat.scale * (-1 if subpat.mirrored[0] else 1), + 'rotation': rotation, + } + + if isinstance(subpat, GridRepetition): + a = subpat.a_vector + b = subpat.b_vector if subpat.b_vector is not None else numpy.zeros(2) + rotated_a = rotation_matrix_2d(-subpat.rotation) @ a + rotated_b = rotation_matrix_2d(-subpat.rotation) @ b + if rotated_a[1] == 0 and rotated_b[0] == 0: + attribs['column_count'] = subpat.a_count + attribs['row_count'] = subpat.b_count + attribs['column_spacing'] = rotated_a[0] + attribs['row_spacing'] = rotated_b[1] + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) + elif rotated_a[0] == 0 and rotated_b[1] == 0: + attribs['column_count'] = subpat.b_count + attribs['row_count'] = subpat.a_count + attribs['column_spacing'] = rotated_b[0] + attribs['row_spacing'] = rotated_a[1] + block.add_blockref(encoded_name, subpat.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 aa in numpy.arange(subpat.a_count): + for bb in numpy.arange(subpat.b_count): + block.add_blockref(encoded_name, subpat.offset + aa * a + bb * b, dxfattribs=attribs) + else: + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) + + +def _shapes_to_elements(block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], + shapes: List[Shape], + polygonize_paths: bool = False): + # Add `LWPolyline`s for each shape. + # Could set do paths with width setting, but need to consider endcaps. + for shape in shapes: + attribs = {'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]): + for label in labels: + attribs = {'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)})') + + +def disambiguate_pattern_names(patterns, + max_name_length: int = 32, + suffix_length: int = 6, + dup_warn_filter: Callable[[str,], bool] = None, # If returns False, don't warn about this name + ): + used_names = [] + for pat in patterns: + sanitized_name = re.compile('[^A-Za-z0-9_\?\$]').sub('_', pat.name) + + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning('Empty pattern name saved as "{}"'.format(suffixed_name)) + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning('Pattern name "{}" ({}) appears multiple times;\n renaming to "{}"'.format( + pat.name, sanitized_name, suffixed_name)) + + if len(suffixed_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError('Zero-length name after sanitize,\n originally "{}"'.format(pat.name)) + if len(suffixed_name) > max_name_length: + raise PatternError('Pattern name "{!r}" length > {} after encode,\n originally "{}"'.format(suffixed_name, max_name_length, pat.name)) + + pat.name = suffixed_name + used_names.append(suffixed_name) +