diff --git a/masque/abstract.py b/masque/abstract.py index 501e394..d23d7c7 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -157,6 +157,8 @@ class Abstract(PortList, Mirrorable): self.mirror() self.rotate_ports(ref.rotation) self.rotate_port_offsets(ref.rotation) + if ref.scale != 1: + self.scale_by(ref.scale) self.translate_ports(ref.offset) return self @@ -174,6 +176,8 @@ class Abstract(PortList, Mirrorable): # TODO test undo_ref_transform """ self.translate_ports(-ref.offset) + if ref.scale != 1: + self.scale_by(1 / ref.scale) self.rotate_port_offsets(-ref.rotation) self.rotate_ports(-ref.rotation) if ref.mirrored: diff --git a/masque/file/dxf.py b/masque/file/dxf.py index db01f51..0c19b5a 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -212,8 +212,10 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if isinstance(element, LWPolyline | Polyline): if isinstance(element, LWPolyline): points = numpy.asarray(element.get_points()) - elif isinstance(element, Polyline): + is_closed = element.closed + else: points = numpy.asarray([pp.xyz for pp in element.points()]) + is_closed = element.is_closed attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) @@ -233,7 +235,6 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if width == 0: width = attr.get('const_width', 0) - is_closed = element.closed verts = points[:, :2] if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): verts = numpy.vstack((verts, verts[0])) 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/library.py b/masque/library.py index 8dc63a4..afd25c6 100644 --- a/masque/library.py +++ b/masque/library.py @@ -303,10 +303,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): target_pat = flattened[target] if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - if target_pat.is_empty(): # avoid some extra allocations + ports_only = flatten_ports and bool(target_pat.ports) + if target_pat.is_empty() and not ports_only: # avoid some extra allocations continue for ref in pat.refs[target]: + if flatten_ports and ref.repetition is not None and target_pat.ports: + raise PatternError( + f'Cannot flatten ports from repeated ref to {target!r}; ' + 'flatten with flatten_ports=False or expand/rename the ports manually first.' + ) p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() diff --git a/masque/pattern.py b/masque/pattern.py index 46b564a..0a66aee 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1077,10 +1077,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - if target_pat.is_empty(): # avoid some extra allocations + ports_only = flatten_ports and bool(target_pat.ports) + if target_pat.is_empty() and not ports_only: # avoid some extra allocations continue for ref in refs: + if flatten_ports and ref.repetition is not None and target_pat.ports: + raise PatternError( + f'Cannot flatten ports from repeated ref to {target!r}; ' + 'flatten with flatten_ports=False or expand/rename the ports manually first.' + ) p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() diff --git a/masque/ports.py b/masque/ports.py index 93310f7..ac48681 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -147,7 +147,7 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): """ Returns a human-readable description of the port's state including cardinal directions. """ - deg = numpy.rad2deg(self.rotation) if self.rotation is not None else "any" + deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None cardinal = "" travel_dir = "" @@ -168,7 +168,8 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3): travel_dir = f" (Travel -> {dirs[closest_t]})" - return f"pos=({self.x:g}, {self.y:g}), rot={deg:g}{cardinal}{travel_dir}" + deg_text = 'any' if deg is None else f'{deg:g}' + return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}" def __repr__(self) -> str: if self.rotation is None: @@ -655,4 +656,3 @@ class PortList(metaclass=ABCMeta): raise PortError(msg) return translations[0], rotations[0], o_offsets[0] - diff --git a/masque/ref.py b/masque/ref.py index f3241e4..a40776a 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -44,7 +44,7 @@ class Ref( __slots__ = ( '_mirrored', # inherited - '_offset', '_rotation', 'scale', '_repetition', '_annotations', + '_offset', '_rotation', '_scale', '_repetition', '_annotations', ) _mirrored: bool diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py index 7c2dbbb..d2f54ed 100644 --- a/masque/test/test_abstract.py +++ b/masque/test/test_abstract.py @@ -54,6 +54,17 @@ def test_abstract_ref_transform() -> None: assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) +def test_abstract_ref_transform_scales_offsets() -> None: + abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) + ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2) + + abs_obj.apply_ref_transform(ref) + + assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10) + assert abs_obj.ports["A"].rotation is not None + assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) + + def test_abstract_undo_transform() -> None: abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)}) ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) @@ -62,3 +73,13 @@ def test_abstract_undo_transform() -> None: assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) assert abs_obj.ports["A"].rotation is not None assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) + + +def test_abstract_undo_transform_scales_offsets() -> None: + abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)}) + ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2) + + abs_obj.undo_ref_transform(ref) + assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) + assert abs_obj.ports["A"].rotation is not None + assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py index e6e6e7e..0c0a1a3 100644 --- a/masque/test/test_dxf.py +++ b/masque/test/test_dxf.py @@ -1,5 +1,6 @@ - +import io import numpy +import ezdxf from numpy.testing import assert_allclose from pathlib import Path @@ -109,3 +110,20 @@ def test_dxf_manhattan_precision(tmp_path: Path): target_name = next(k for k in read_top.refs if k.upper() == "SUB") ref = read_top.refs[target_name][0] assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation" + + +def test_dxf_read_legacy_polyline() -> None: + doc = ezdxf.new() + msp = doc.modelspace() + msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True) + + stream = io.StringIO() + doc.write(stream) + stream.seek(0) + + read_lib, _ = dxf.read(stream) + top_pat = read_lib.get("Model") or list(read_lib.values())[0] + + polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)] + assert len(polys) == 1 + assert_allclose(polys[0].vertices, [[0, 0], [10, 0], [10, 10]]) diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 22ad42a..9eb705e 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -2,7 +2,9 @@ import pytest from typing import cast, TYPE_CHECKING from ..library import Library, LazyLibrary from ..pattern import Pattern -from ..error import LibraryError +from ..error import LibraryError, PatternError +from ..ports import Port +from ..repetition import Grid if TYPE_CHECKING: from ..shapes import Polygon @@ -59,6 +61,36 @@ def test_library_flatten() -> None: assert tuple(assert_vertices[0]) == (10.0, 10.0) +def test_library_flatten_preserves_ports_only_child() -> None: + lib = Library() + child = Pattern(ports={"P1": Port((1, 2), 0)}) + lib["child"] = child + + parent = Pattern() + parent.ref("child", offset=(10, 10)) + lib["parent"] = parent + + flat_parent = lib.flatten("parent", flatten_ports=True)["parent"] + + assert set(flat_parent.ports) == {"P1"} + assert cast("Port", flat_parent.ports["P1"]).rotation == 0 + assert tuple(flat_parent.ports["P1"].offset) == (11.0, 12.0) + + +def test_library_flatten_repeated_ref_with_ports_raises() -> None: + lib = Library() + child = Pattern(ports={"P1": Port((1, 2), 0)}) + child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + lib["child"] = child + + parent = Pattern() + parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2)) + lib["parent"] = parent + + with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'): + lib.flatten("parent", flatten_ports=True) + + def test_lazy_library() -> None: lib = LazyLibrary() called = 0 diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py index f5da195..b459502 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -1,12 +1,15 @@ +import pytest from typing import cast from numpy.testing import assert_equal, assert_allclose from numpy import pi +from ..error import PatternError from ..pattern import Pattern from ..shapes import Polygon from ..ref import Ref from ..ports import Port from ..label import Label +from ..repetition import Grid def test_pattern_init() -> None: @@ -99,6 +102,30 @@ def test_pattern_get_bounds() -> None: assert_equal(bounds, [[-5, -5], [10, 10]]) +def test_pattern_flatten_preserves_ports_only_child() -> None: + child = Pattern(ports={"P1": Port((1, 2), 0)}) + + parent = Pattern() + parent.ref("child", offset=(10, 10)) + + parent.flatten({"child": child}, flatten_ports=True) + + assert set(parent.ports) == {"P1"} + assert parent.ports["P1"].rotation == 0 + assert tuple(parent.ports["P1"].offset) == (11.0, 12.0) + + +def test_pattern_flatten_repeated_ref_with_ports_raises() -> None: + child = Pattern(ports={"P1": Port((1, 2), 0)}) + child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + + parent = Pattern() + parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2)) + + with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'): + parent.flatten({"child": child}, flatten_ports=True) + + def test_pattern_interface() -> None: source = Pattern() source.ports["A"] = Port((10, 20), 0, ptype="test") diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py index e1dab87..070bf8e 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -46,6 +46,11 @@ def test_port_measure_travel() -> None: assert rotation == pi +def test_port_describe_any_rotation() -> None: + p = Port((0, 0), None) + assert p.describe() == "pos=(0, 0), rot=any" + + def test_port_list_rename() -> None: class MyPorts(PortList): def __init__(self) -> None: diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py index f461cb8..72f6870 100644 --- a/masque/test/test_ports2data.py +++ b/masque/test/test_ports2data.py @@ -55,3 +55,22 @@ def test_data_to_ports_hierarchical() -> None: assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) assert parent.ports["A"].rotation is not None assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) + + +def test_data_to_ports_hierarchical_scaled_ref() -> None: + lib = Library() + + child = Pattern() + layer = (10, 0) + child.label(layer=layer, string="A:type1 0", offset=(5, 0)) + lib["child"] = child + + parent = Pattern() + parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2, scale=2) + + data_to_ports([layer], lib, parent, max_depth=1) + + assert "A" in parent.ports + assert_allclose(parent.ports["A"].offset, [100, 110], atol=1e-10) + assert parent.ports["A"].rotation is not None + assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py index e2d266b..c1dbf26 100644 --- a/masque/test/test_ref.py +++ b/masque/test/test_ref.py @@ -1,7 +1,9 @@ from typing import cast, TYPE_CHECKING +import pytest from numpy.testing import assert_equal, assert_allclose from numpy import pi +from ..error import MasqueError from ..pattern import Pattern from ..ref import Ref from ..repetition import Grid @@ -70,3 +72,18 @@ def test_ref_copy() -> None: ref2.offset[0] = 100 assert ref1.offset[0] == 1 + + +def test_ref_rejects_nonpositive_scale() -> None: + with pytest.raises(MasqueError, match='Scale must be positive'): + Ref(scale=0) + + with pytest.raises(MasqueError, match='Scale must be positive'): + Ref(scale=-1) + + +def test_ref_scale_by_rejects_nonpositive_scale() -> None: + ref = Ref(scale=2.0) + + with pytest.raises(MasqueError, match='Scale must be positive'): + ref.scale_by(-1) 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)