Compare commits
8 commits
add82e955d
...
c2ef3e4217
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ef3e4217 | |||
| c32168dc64 | |||
| b843ffb4d3 | |||
| 9adfcac437 | |||
| 26cc0290b9 | |||
| 548b51df47 | |||
| 06f8611a90 | |||
| 9ede16df5d |
15 changed files with 252 additions and 11 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class Ref(
|
|||
__slots__ = (
|
||||
'_mirrored',
|
||||
# inherited
|
||||
'_offset', '_rotation', 'scale', '_repetition', '_annotations',
|
||||
'_offset', '_rotation', '_scale', '_repetition', '_annotations',
|
||||
)
|
||||
|
||||
_mirrored: bool
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
70
masque/test/test_svg.py
Normal file
70
masque/test/test_svg.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue