""" SVG file format readers and writers """ from collections.abc import Mapping import logging import numpy from numpy.typing import ArrayLike import svgwrite # type: ignore from .utils import mangle_name from .. import Pattern, Ref from ..library import IMaterializable from ..utils import rotation_matrix_2d logger = logging.getLogger(__name__) def _ref_to_svg_transform(ref: Ref) -> str: linear = rotation_matrix_2d(ref.rotation) * ref.scale if ref.mirrored: linear = linear @ numpy.diag((1.0, -1.0)) a = linear[0, 0] b = linear[1, 0] c = linear[0, 1] d = linear[1, 1] e = ref.offset[0] f = ref.offset[1] return f'matrix({a:g} {b:g} {c:g} {d:g} {e:g} {f:g})' def _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]: svg_ids: dict[str, str] = {} seen_ids: set[str] = set() for name in names: base_id = mangle_name(name) svg_id = base_id suffix = 1 while svg_id in seen_ids: suffix += 1 svg_id = f'{base_id}_{suffix}' seen_ids.add(svg_id) svg_ids[name] = svg_id return svg_ids def _detached_library(library: Mapping[str, Pattern]) -> dict[str, Pattern]: if isinstance(library, IMaterializable): detached = library.materialize_many_detached(tuple(library)) return dict(detached.items()) return {name: pat.deepcopy() for name, pat in library.items()} def writefile( library: Mapping[str, Pattern], top: str, filename: str, custom_attributes: bool = False, annotate_ports: bool = False, ) -> None: """ Write a Pattern to an SVG file, by first calling .polygonize() on a detached materialized copy to change the shapes into polygons, and then writing patterns as SVG groups (, inside ), polygons as paths (), and refs as elements. If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute is written to the relevant elements. It is often a good idea to run `pattern.dedup()` on pattern 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. Args: library: Mapping of pattern names to patterns. top: Name of the top-level pattern to render. filename: Filename to write to. custom_attributes: Whether to write non-standard `pattern_layer` attribute to the SVG elements. annotate_ports: If True, draw an arrow for each port (similar to `Pattern.visualize(..., ports=True)`). """ detached = _detached_library(library) pattern = detached[top] # Polygonize pattern pattern.polygonize() bounds = pattern.get_bounds(library=detached) if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) else: bounds_min, bounds_max = bounds viewbox = numpy.hstack((bounds_min - 1, (bounds_max - bounds_min) + 2)) viewbox_string = '{:g} {:g} {:g} {:g}'.format(*viewbox) # Create file svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string, debug=(not custom_attributes)) svg_ids = _make_svg_ids(detached) # Now create a group for each pattern and add in any Boundary and Use elements for name, pat in detached.items(): svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red') for layer, shapes in pat.shapes.items(): for shape in shapes: for polygon in shape.to_polygons(): path_spec = poly2path(polygon.vertices + polygon.offset) path = svg.path(d=path_spec) if custom_attributes: path['pattern_layer'] = layer svg_group.add(path) if annotate_ports: # Draw arrows for the ports, pointing into the device (per port definition) for port_name, port in pat.ports.items(): if port.rotation is not None: p1 = port.offset angle = port.rotation size = 1.0 # arrow size p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) # head head_angle = 0.5 h1 = p1 + 0.7 * size * numpy.array([numpy.cos(angle + head_angle), numpy.sin(angle + head_angle)]) h2 = p1 + 0.7 * size * numpy.array([numpy.cos(angle - head_angle), numpy.sin(angle - head_angle)]) line = svg.line(start=p1, end=p2, stroke='green', stroke_width=0.2) head = svg.polyline(points=[h1, p1, h2], fill='none', stroke='green', stroke_width=0.2) svg_group.add(line) svg_group.add(head) svg_group.add(svg.text(port_name, insert=p2, font_size=0.5, fill='green')) for target, refs in pat.refs.items(): if target is None: continue for ref in refs: transform = _ref_to_svg_transform(ref) use = svg.use(href='#' + svg_ids[target], transform=transform) svg_group.add(use) svg.defs.add(svg_group) svg.add(svg.use(href='#' + svg_ids[top])) svg.save() def writefile_inverted( library: Mapping[str, Pattern], top: str, filename: str, ) -> None: """ Write an inverted Pattern to an SVG file, by first calling `.polygonize()` and `.flatten()` on it to change the shapes into polygons, then drawing a bounding box and drawing the polygons with reverse vertex order inside it, all within one `` element. If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` prior to calling this function. Args: library: Mapping of pattern names to patterns. top: Name of the top-level pattern to render. filename: Filename to write to. """ detached = _detached_library(library) pattern = detached[top] # Polygonize and flatten pattern pattern.polygonize().flatten(detached) bounds = pattern.get_bounds(library=detached) if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) else: bounds_min, bounds_max = bounds viewbox = numpy.hstack((bounds_min - 1, (bounds_max - bounds_min) + 2)) viewbox_string = '{:g} {:g} {:g} {:g}'.format(*viewbox) # Create file svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string) # Draw bounding box slab_edge = [[bounds_min[0] - 1, bounds_max[1] + 1], [bounds_max[0] + 1, bounds_max[1] + 1], [bounds_max[0] + 1, bounds_min[1] - 1], [bounds_min[0] - 1, bounds_min[1] - 1]] path_spec = poly2path(slab_edge) # Draw polygons with reversed vertex order for _layer, shapes in pattern.shapes.items(): for shape in shapes: for polygon in shape.to_polygons(): path_spec += poly2path(polygon.vertices[::-1] + polygon.offset) svg.add(svg.path(d=path_spec, fill='blue', stroke='red')) svg.save() def poly2path(vertices: ArrayLike) -> str: """ Create an SVG path string from an Nx2 list of vertices. Args: vertices: Nx2 array of vertices. Returns: SVG path-string. """ verts = numpy.asarray(vertices) commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) # noqa: UP032 for vertex in verts[1:]: commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) # noqa: UP032 commands += ' Z ' return commands