[Path] preserve cap extensions in normalized form, and scale them with scale()

This commit is contained in:
Jan Petykiewicz 2026-03-31 09:23:59 -07:00
commit 3beadd2bf0
3 changed files with 52 additions and 2 deletions

View file

@ -453,6 +453,8 @@ class Path(Shape):
def scale_by(self, c: float) -> 'Path': def scale_by(self, c: float) -> 'Path':
self.vertices *= c self.vertices *= c
self.width *= c self.width *= c
if self.cap_extensions is not None:
self.cap_extensions *= c
return self return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple: def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
@ -476,13 +478,15 @@ class Path(Shape):
reordered_vertices = rotated_vertices reordered_vertices = rotated_vertices
width0 = self.width / norm_value 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), (offset, scale / norm_value, rotation, False),
lambda: Path( lambda: Path(
reordered_vertices * norm_value, reordered_vertices * norm_value,
width=width0 * norm_value, width=width0 * norm_value,
cap=self.cap, 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': def clean_vertices(self) -> 'Path':

View file

@ -6,6 +6,7 @@ from ..pattern import Pattern
from ..error import LibraryError, PatternError from ..error import LibraryError, PatternError
from ..ports import Port from ..ports import Port
from ..repetition import Grid from ..repetition import Grid
from ..shapes import Path
from ..file.utils import preflight from ..file.utils import preflight
if TYPE_CHECKING: if TYPE_CHECKING:
@ -243,3 +244,18 @@ def test_library_get_name() -> None:
name2 = lib.get_name("other") name2 = lib.get_name("other")
assert name2 == "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

View file

@ -1,4 +1,4 @@
from numpy.testing import assert_equal from numpy.testing import assert_equal, assert_allclose
from ..shapes import Path from ..shapes import Path
@ -79,3 +79,33 @@ def test_path_scale() -> None:
p.scale_by(2) p.scale_by(2)
assert_equal(p.vertices, [[0, 0], [20, 0]]) assert_equal(p.vertices, [[0, 0], [20, 0]])
assert p.width == 4 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]