Compare commits

..

No commits in common. "c2ef3e42174cea0fb7c3c35625189d95aff97169" and "add82e955ddfe8e558da5d8e4a40e6dae905a931" have entirely different histories.

15 changed files with 11 additions and 252 deletions

View file

@ -157,8 +157,6 @@ class Abstract(PortList, Mirrorable):
self.mirror() self.mirror()
self.rotate_ports(ref.rotation) self.rotate_ports(ref.rotation)
self.rotate_port_offsets(ref.rotation) self.rotate_port_offsets(ref.rotation)
if ref.scale != 1:
self.scale_by(ref.scale)
self.translate_ports(ref.offset) self.translate_ports(ref.offset)
return self return self
@ -176,8 +174,6 @@ class Abstract(PortList, Mirrorable):
# TODO test undo_ref_transform # TODO test undo_ref_transform
""" """
self.translate_ports(-ref.offset) self.translate_ports(-ref.offset)
if ref.scale != 1:
self.scale_by(1 / ref.scale)
self.rotate_port_offsets(-ref.rotation) self.rotate_port_offsets(-ref.rotation)
self.rotate_ports(-ref.rotation) self.rotate_ports(-ref.rotation)
if ref.mirrored: if ref.mirrored:

View file

@ -212,10 +212,8 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
if isinstance(element, LWPolyline | Polyline): if isinstance(element, LWPolyline | Polyline):
if isinstance(element, LWPolyline): if isinstance(element, LWPolyline):
points = numpy.asarray(element.get_points()) points = numpy.asarray(element.get_points())
is_closed = element.closed elif isinstance(element, Polyline):
else:
points = numpy.asarray([pp.xyz for pp in element.points()]) points = numpy.asarray([pp.xyz for pp in element.points()])
is_closed = element.is_closed
attr = element.dxfattribs() attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER) layer = attr.get('layer', DEFAULT_LAYER)
@ -235,6 +233,7 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
if width == 0: if width == 0:
width = attr.get('const_width', 0) width = attr.get('const_width', 0)
is_closed = element.closed
verts = points[:, :2] verts = points[:, :2]
if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])):
verts = numpy.vstack((verts, verts[0])) verts = numpy.vstack((verts, verts[0]))

View file

@ -10,26 +10,11 @@ import svgwrite # type: ignore
from .utils import mangle_name from .utils import mangle_name
from .. import Pattern from .. import Pattern
from ..utils import rotation_matrix_2d
logger = logging.getLogger(__name__) 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( def writefile(
library: Mapping[str, Pattern], library: Mapping[str, Pattern],
top: str, top: str,
@ -122,7 +107,7 @@ def writefile(
if target is None: if target is None:
continue continue
for ref in refs: 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) use = svg.use(href='#' + mangle_name(target), transform=transform)
svg_group.add(use) svg_group.add(use)

View file

@ -303,16 +303,10 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
target_pat = flattened[target] target_pat = flattened[target]
if target_pat is None: if target_pat is None:
raise PatternError(f'Circular reference in {name} to {target}') raise PatternError(f'Circular reference in {name} to {target}')
ports_only = flatten_ports and bool(target_pat.ports) if target_pat.is_empty(): # avoid some extra allocations
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
continue continue
for ref in pat.refs[target]: 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) p = ref.as_pattern(pattern=target_pat)
if not flatten_ports: if not flatten_ports:
p.ports.clear() p.ports.clear()

View file

@ -1077,16 +1077,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if target_pat is None: if target_pat is None:
raise PatternError(f'Circular reference in {name} to {target}') raise PatternError(f'Circular reference in {name} to {target}')
ports_only = flatten_ports and bool(target_pat.ports) if target_pat.is_empty(): # avoid some extra allocations
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
continue continue
for ref in refs: 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) p = ref.as_pattern(pattern=target_pat)
if not flatten_ports: if not flatten_ports:
p.ports.clear() p.ports.clear()

View file

@ -147,7 +147,7 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
""" """
Returns a human-readable description of the port's state including cardinal directions. 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 = "" cardinal = ""
travel_dir = "" 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): if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3):
travel_dir = f" (Travel -> {dirs[closest_t]})" 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:g}{cardinal}{travel_dir}"
return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}"
def __repr__(self) -> str: def __repr__(self) -> str:
if self.rotation is None: if self.rotation is None:
@ -656,3 +655,4 @@ class PortList(metaclass=ABCMeta):
raise PortError(msg) raise PortError(msg)
return translations[0], rotations[0], o_offsets[0] return translations[0], rotations[0], o_offsets[0]

View file

@ -44,7 +44,7 @@ class Ref(
__slots__ = ( __slots__ = (
'_mirrored', '_mirrored',
# inherited # inherited
'_offset', '_rotation', '_scale', '_repetition', '_annotations', '_offset', '_rotation', 'scale', '_repetition', '_annotations',
) )
_mirrored: bool _mirrored: bool

View file

@ -54,17 +54,6 @@ def test_abstract_ref_transform() -> None:
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) 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: def test_abstract_undo_transform() -> None:
abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)}) abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)})
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) 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_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) 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)

View file

@ -1,6 +1,5 @@
import io
import numpy import numpy
import ezdxf
from numpy.testing import assert_allclose from numpy.testing import assert_allclose
from pathlib import Path 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") target_name = next(k for k in read_top.refs if k.upper() == "SUB")
ref = read_top.refs[target_name][0] ref = read_top.refs[target_name][0]
assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation" 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]])

View file

@ -2,9 +2,7 @@ import pytest
from typing import cast, TYPE_CHECKING from typing import cast, TYPE_CHECKING
from ..library import Library, LazyLibrary from ..library import Library, LazyLibrary
from ..pattern import Pattern from ..pattern import Pattern
from ..error import LibraryError, PatternError from ..error import LibraryError
from ..ports import Port
from ..repetition import Grid
if TYPE_CHECKING: if TYPE_CHECKING:
from ..shapes import Polygon from ..shapes import Polygon
@ -61,36 +59,6 @@ def test_library_flatten() -> None:
assert tuple(assert_vertices[0]) == (10.0, 10.0) 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: def test_lazy_library() -> None:
lib = LazyLibrary() lib = LazyLibrary()
called = 0 called = 0

View file

@ -1,15 +1,12 @@
import pytest
from typing import cast from typing import cast
from numpy.testing import assert_equal, assert_allclose from numpy.testing import assert_equal, assert_allclose
from numpy import pi from numpy import pi
from ..error import PatternError
from ..pattern import Pattern from ..pattern import Pattern
from ..shapes import Polygon from ..shapes import Polygon
from ..ref import Ref from ..ref import Ref
from ..ports import Port from ..ports import Port
from ..label import Label from ..label import Label
from ..repetition import Grid
def test_pattern_init() -> None: def test_pattern_init() -> None:
@ -102,30 +99,6 @@ def test_pattern_get_bounds() -> None:
assert_equal(bounds, [[-5, -5], [10, 10]]) 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: def test_pattern_interface() -> None:
source = Pattern() source = Pattern()
source.ports["A"] = Port((10, 20), 0, ptype="test") source.ports["A"] = Port((10, 20), 0, ptype="test")

View file

@ -46,11 +46,6 @@ def test_port_measure_travel() -> None:
assert rotation == pi 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: def test_port_list_rename() -> None:
class MyPorts(PortList): class MyPorts(PortList):
def __init__(self) -> None: def __init__(self) -> None:

View file

@ -55,22 +55,3 @@ def test_data_to_ports_hierarchical() -> None:
assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10)
assert parent.ports["A"].rotation is not None assert parent.ports["A"].rotation is not None
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) 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)

View file

@ -1,9 +1,7 @@
from typing import cast, TYPE_CHECKING from typing import cast, TYPE_CHECKING
import pytest
from numpy.testing import assert_equal, assert_allclose from numpy.testing import assert_equal, assert_allclose
from numpy import pi from numpy import pi
from ..error import MasqueError
from ..pattern import Pattern from ..pattern import Pattern
from ..ref import Ref from ..ref import Ref
from ..repetition import Grid from ..repetition import Grid
@ -72,18 +70,3 @@ def test_ref_copy() -> None:
ref2.offset[0] = 100 ref2.offset[0] = 100
assert ref1.offset[0] == 1 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)

View file

@ -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)