diff --git a/masque/abstract.py b/masque/abstract.py index d23d7c7..501e394 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -157,8 +157,6 @@ 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 @@ -176,8 +174,6 @@ 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 0c19b5a..db01f51 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -212,10 +212,8 @@ 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()) - is_closed = element.closed - else: + elif isinstance(element, Polyline): points = numpy.asarray([pp.xyz for pp in element.points()]) - is_closed = element.is_closed attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) @@ -235,6 +233,7 @@ 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 f235b50..7c77fd9 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -10,26 +10,11 @@ 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, @@ -122,7 +107,7 @@ def writefile( if target is None: continue for ref in refs: - transform = _ref_to_svg_transform(ref) + transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})' use = svg.use(href='#' + mangle_name(target), transform=transform) svg_group.add(use) diff --git a/masque/library.py b/masque/library.py index afd25c6..8dc63a4 100644 --- a/masque/library.py +++ b/masque/library.py @@ -303,16 +303,10 @@ 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}') - ports_only = flatten_ports and bool(target_pat.ports) - if target_pat.is_empty() and not ports_only: # avoid some extra allocations + if target_pat.is_empty(): # 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 0a66aee..46b564a 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1077,16 +1077,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - ports_only = flatten_ports and bool(target_pat.ports) - if target_pat.is_empty() and not ports_only: # avoid some extra allocations + if target_pat.is_empty(): # 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 ac48681..93310f7 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 None + deg = numpy.rad2deg(self.rotation) if self.rotation is not None else "any" cardinal = "" travel_dir = "" @@ -168,8 +168,7 @@ 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]})" - 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}" + return f"pos=({self.x:g}, {self.y:g}), rot={deg:g}{cardinal}{travel_dir}" def __repr__(self) -> str: if self.rotation is None: @@ -656,3 +655,4 @@ 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 a40776a..f3241e4 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 d2f54ed..7c2dbbb 100644 --- a/masque/test/test_abstract.py +++ b/masque/test/test_abstract.py @@ -54,17 +54,6 @@ 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) @@ -73,13 +62,3 @@ 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 0c0a1a3..e6e6e7e 100644 --- a/masque/test/test_dxf.py +++ b/masque/test/test_dxf.py @@ -1,6 +1,5 @@ -import io + import numpy -import ezdxf from numpy.testing import assert_allclose from pathlib import Path @@ -110,20 +109,3 @@ 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 9eb705e..22ad42a 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -2,9 +2,7 @@ import pytest from typing import cast, TYPE_CHECKING from ..library import Library, LazyLibrary from ..pattern import Pattern -from ..error import LibraryError, PatternError -from ..ports import Port -from ..repetition import Grid +from ..error import LibraryError if TYPE_CHECKING: from ..shapes import Polygon @@ -61,36 +59,6 @@ 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 b459502..f5da195 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -1,15 +1,12 @@ -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: @@ -102,30 +99,6 @@ 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 070bf8e..e1dab87 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -46,11 +46,6 @@ 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 72f6870..f461cb8 100644 --- a/masque/test/test_ports2data.py +++ b/masque/test/test_ports2data.py @@ -55,22 +55,3 @@ 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 c1dbf26..e2d266b 100644 --- a/masque/test/test_ref.py +++ b/masque/test/test_ref.py @@ -1,9 +1,7 @@ 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 @@ -72,18 +70,3 @@ 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 deleted file mode 100644 index a3261b6..0000000 --- a/masque/test/test_svg.py +++ /dev/null @@ -1,70 +0,0 @@ -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)