From 3beadd2bf0f104433cc54f57ca0830fbaa46af31 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 09:23:59 -0700 Subject: [PATCH] [Path] preserve cap extensions in normalized form, and scale them with scale() --- masque/shapes/path.py | 6 +++++- masque/test/test_library.py | 16 ++++++++++++++++ masque/test/test_path.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index fc8793d..3aa6f07 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -453,6 +453,8 @@ class Path(Shape): def scale_by(self, c: float) -> 'Path': self.vertices *= c self.width *= c + if self.cap_extensions is not None: + self.cap_extensions *= c return self def normalized_form(self, norm_value: float) -> normalized_shape_tuple: @@ -476,13 +478,15 @@ class Path(Shape): reordered_vertices = rotated_vertices width0 = self.width / norm_value + cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions) - return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap), + return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0), (offset, scale / norm_value, rotation, False), lambda: Path( reordered_vertices * norm_value, width=width0 * norm_value, cap=self.cap, + cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0), )) def clean_vertices(self) -> 'Path': diff --git a/masque/test/test_library.py b/masque/test/test_library.py index 0a04d98..e58bd10 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -6,6 +6,7 @@ from ..pattern import Pattern from ..error import LibraryError, PatternError from ..ports import Port from ..repetition import Grid +from ..shapes import Path from ..file.utils import preflight if TYPE_CHECKING: @@ -243,3 +244,18 @@ def test_library_get_name() -> None: name2 = lib.get_name("other") assert name2 == "other" + + +def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None: + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [ + Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)), + Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)), + ] + lib["top"] = pat + + lib.dedup(norm_value=1, threshold=2) + + assert not lib["top"].refs + assert len(lib["top"].shapes[(1, 0)]) == 2 diff --git a/masque/test/test_path.py b/masque/test/test_path.py index 766798f..1cdd872 100644 --- a/masque/test/test_path.py +++ b/masque/test/test_path.py @@ -1,4 +1,4 @@ -from numpy.testing import assert_equal +from numpy.testing import assert_equal, assert_allclose from ..shapes import Path @@ -79,3 +79,33 @@ def test_path_scale() -> None: p.scale_by(2) assert_equal(p.vertices, [[0, 0], [20, 0]]) assert p.width == 4 + + +def test_path_scale_custom_cap_extensions() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + p.scale_by(3) + + assert_equal(p.vertices, [[0, 0], [30, 0]]) + assert p.width == 6 + assert p.cap_extensions is not None + assert_allclose(p.cap_extensions, [3, 6]) + assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]]) + + +def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + + intrinsic, _extrinsic, ctor = p.normalized_form(5) + q = ctor() + + assert intrinsic[-1] == (0.2, 0.4) + assert q.width == 2 + assert q.cap_extensions is not None + assert_allclose(q.cap_extensions, [1, 2]) + + +def test_path_normalized_form_distinguishes_custom_caps() -> None: + p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)) + + assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]