masque/masque/file/svg.py

204 lines
7 KiB
Python

"""
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
from ..utils import rotation_matrix_2d
logger = logging.getLogger(__name__)
def _ref_to_svg_transform(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 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 it
to change the shapes into polygons, and then writing patterns as SVG
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
as <use> elements.
Note that this function modifies the Pattern.
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:
pattern: Pattern to write to file. Modified by this function.
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)`).
"""
pattern = library[top]
# Polygonize pattern
pattern.polygonize()
bounds = pattern.get_bounds(library=library)
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))
# Now create a group for each pattern and add in any Boundary and Use elements
for name, pat in library.items():
svg_group = svg.g(id=mangle_name(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='#' + mangle_name(target), transform=transform)
svg_group.add(use)
svg.defs.add(svg_group)
svg.add(svg.use(href='#' + mangle_name(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 `<path>` element.
Note that this function modifies the Pattern.
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
prior to calling this function.
Args:
pattern: Pattern to write to file. Modified by this function.
filename: Filename to write to.
"""
pattern = library[top]
# Polygonize and flatten pattern
pattern.polygonize().flatten(library)
bounds = pattern.get_bounds(library=library)
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