diff --git a/masque/file/svg.py b/masque/file/svg.py index 7c77fd9..f235b50 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -10,11 +10,26 @@ 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, @@ -107,7 +122,7 @@ def writefile( if target is None: continue for ref in refs: - transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})' + transform = _ref_to_svg_transform(ref) use = svg.use(href='#' + mangle_name(target), transform=transform) svg_group.add(use) diff --git a/masque/test/test_svg.py b/masque/test/test_svg.py new file mode 100644 index 0000000..a3261b6 --- /dev/null +++ b/masque/test/test_svg.py @@ -0,0 +1,70 @@ +from pathlib import Path +import xml.etree.ElementTree as ET + +import numpy +import pytest +from numpy.testing import assert_allclose + +pytest.importorskip("svgwrite") + +from ..library import Library +from ..pattern import Pattern +from ..file import svg + + +SVG_NS = "{http://www.w3.org/2000/svg}" +XLINK_HREF = "{http://www.w3.org/1999/xlink}href" + + +def _child_transform(svg_path: Path) -> tuple[float, ...]: + root = ET.fromstring(svg_path.read_text()) + for use in root.iter(f"{SVG_NS}use"): + if use.attrib.get(XLINK_HREF) == "#child": + raw = use.attrib["transform"] + assert raw.startswith("matrix(") and raw.endswith(")") + return tuple(float(value) for value in raw[7:-1].split()) + raise AssertionError("No child reference found in SVG output") + + +def test_svg_ref_rotation_uses_correct_affine_transform(tmp_path: Path) -> None: + lib = Library() + child = Pattern() + child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) + lib["child"] = child + + top = Pattern() + top.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2) + lib["top"] = top + + svg_path = tmp_path / "rotation.svg" + svg.writefile(lib, "top", str(svg_path)) + + assert_allclose(_child_transform(svg_path), (0, 2, -2, 0, 3, 4), atol=1e-10) + + +def test_svg_ref_mirroring_changes_affine_transform(tmp_path: Path) -> None: + base = Library() + child = Pattern() + child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) + base["child"] = child + + top_plain = Pattern() + top_plain.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=False) + base["plain"] = top_plain + + plain_path = tmp_path / "plain.svg" + svg.writefile(base, "plain", str(plain_path)) + plain_transform = _child_transform(plain_path) + + mirrored = Library() + mirrored["child"] = child.deepcopy() + top_mirrored = Pattern() + top_mirrored.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=True) + mirrored["mirrored"] = top_mirrored + + mirrored_path = tmp_path / "mirrored.svg" + svg.writefile(mirrored, "mirrored", str(mirrored_path)) + mirrored_transform = _child_transform(mirrored_path) + + assert_allclose(plain_transform, (0, 2, -2, 0, 3, 4), atol=1e-10) + assert_allclose(mirrored_transform, (0, 2, 2, 0, 3, 4), atol=1e-10)