Skip to content

Commit a224eee

Browse files
authored
Merge pull request #6 from pjfo/text-morphing-fill-modes
Morph submobject families and add fill_mode subpath balancing
2 parents 6cbcbc3 + e96b852 commit a224eee

6 files changed

Lines changed: 129 additions & 21 deletions

File tree

manim-polymorph/README.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ built-in `Transform` machinery.
88
Why not just `Transform`? Manim's `VMobject.align_points` pairs subpaths
99
strictly by document order and pads missing subpaths with degenerate curves
1010
collapsed onto a single point. Polymorph instead sorts subpaths by perimeter
11-
before pairing them, grows filler subpaths from a configurable origin, and
12-
rotates each closed subpath's start point toward that origin — which makes
13-
morphs between structurally different shapes (different subpath counts,
14-
holes, wildly different point counts) look intentional rather than glitchy.
11+
before pairing them and rotates each closed subpath's start point toward a
12+
configurable origin — which makes morphs between structurally different
13+
shapes (different subpath counts, holes, wildly different point counts) look
14+
intentional rather than glitchy. When subpath counts differ, the smaller
15+
keyframe's subpaths are shared out across the larger's by default — one
16+
shape splits into every glyph of a word, and every glyph merges back into
17+
one shape — with polymorph's grow-from-origin filler available as
18+
`fill_mode="grow"`.
1519

1620
## Install
1721

@@ -87,10 +91,14 @@ on finish.
8791
Keyword options, besides the usual `Animation` ones (`run_time`, `rate_func`,
8892
...):
8993

90-
- `origin=(0, 0)` — where filler subpaths grow from and where closed subpaths
91-
start drawing. Relative origins use SVG semantics ((0, 0) is the top-left of
92-
each subpath's bounding box; (0.5, 0.5) is the center);
93-
`Origin(x, y, absolute=True)` is a scene coordinate.
94+
- `fill_mode="share"` — how subpath-count mismatches are resolved. `"share"`
95+
clones the smaller keyframe's subpaths so every subpath morphs from/to real
96+
geometry (flubber-style split/merge); `"grow"` pads with degenerate
97+
subpaths that grow from the origin (polymorph's own behavior).
98+
- `origin=(0, 0)` — where filler subpaths grow from (`fill_mode="grow"`) and
99+
where closed subpaths start drawing. Relative origins use SVG semantics
100+
((0, 0) is the top-left of each subpath's bounding box; (0.5, 0.5) is the
101+
center); `Origin(x, y, absolute=True)` is a scene coordinate.
94102
- `add_points=0` — extra curves added to every subpath; raise it to smooth
95103
morphs between shapes of very different complexity.
96104
- `optimize="fill"``"fill"` aligns structurally different paths;

manim-polymorph/examples/demo.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ def construct(self):
4747
self.wait(0.3)
4848

4949
# multi-keyframe sequence morphing through the ring and back to the
50-
# heart, with filler subpaths growing from the shape's center
51-
self.play(Polymorph(shape, ring, heart, run_time=4, origin=(0.5, 0.5)))
50+
# heart; fill_mode="grow" makes the ring's extra subpath grow from
51+
# the shape's center instead of splitting off a clone (the default)
52+
self.play(
53+
Polymorph(shape, ring, heart, run_time=4, fill_mode="grow", origin=(0.5, 0.5))
54+
)
5255
self.wait(0.5)

manim-polymorph/src/manim_polymorph/animation.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,13 @@ def _style_donor(vm: VMobject) -> VMobject:
3737
class Polymorph(Animation):
3838
"""Morph a VMobject through one or more target shapes using polymorph.
3939
40-
Unlike Transform, subpaths are paired by sorted perimeter, missing
41-
subpaths grow from a configurable origin, and closed subpaths rotate
42-
their start point toward that origin — producing stable morphs between
43-
structurally different shapes.
40+
Unlike Transform, subpaths are paired by sorted perimeter and closed
41+
subpaths rotate their start point toward a configurable origin —
42+
producing stable morphs between structurally different shapes. When
43+
subpath counts differ, the smaller keyframe's subpaths are shared out
44+
across the larger's by default (one shape splits into many, many
45+
merge into one, flubber-style); fill_mode="grow" restores polymorph's
46+
own behavior of growing the missing subpaths from the origin.
4447
4548
Mobjects whose geometry lives in submobjects (Text, Tex, VGroup, SVG
4649
imports) are flattened: every subpath in the family joins one morph.
@@ -53,10 +56,14 @@ class Polymorph(Animation):
5356
targets: one or more target VMobjects; with several, the run time is
5457
divided evenly between consecutive pairs, like polymorph's own
5558
multi-path interpolate. Targets do not need to be on screen.
56-
origin: where filler subpaths grow from and where closed subpaths start
57-
drawing. Relative origins use SVG semantics — (0, 0) is the top-left
58-
of each subpath's bounding box — while Origin(..., absolute=True)
59-
is a scene coordinate.
59+
fill_mode: how subpath-count mismatches between keyframes are
60+
resolved. "share" (default) clones the smaller keyframe's
61+
subpaths so every subpath morphs from/to real geometry; "grow"
62+
pads with degenerate subpaths that grow from the origin.
63+
origin: where filler subpaths grow from (fill_mode="grow") and where
64+
closed subpaths start drawing. Relative origins use SVG semantics
65+
— (0, 0) is the top-left of each subpath's bounding box — while
66+
Origin(..., absolute=True) is a scene coordinate.
6067
add_points: extra curves added to every subpath (smooths morphs between
6168
shapes of very different complexity).
6269
optimize: "fill" (default) aligns subpaths automatically; "none"
@@ -71,6 +78,7 @@ def __init__(
7178
self,
7279
mobject: VMobject,
7380
*targets: VMobject,
81+
fill_mode: Literal["share", "grow"] = "share",
7482
origin: tuple[float, float] | Origin = (0.0, 0.0),
7583
add_points: int = 0,
7684
optimize: Literal["fill", "none"] = "fill",
@@ -95,6 +103,7 @@ def __init__(
95103
origin = Origin(*origin)
96104

97105
self.targets = targets
106+
self.fill_mode = fill_mode
98107
self.origin = origin
99108
self.add_points = add_points
100109
self.optimize = optimize
@@ -130,6 +139,7 @@ def begin(self) -> None:
130139
optimize=self.optimize,
131140
origin=origin,
132141
add_points=self.add_points,
142+
fill_mode=self.fill_mode,
133143
)
134144
super().begin()
135145

manim-polymorph/src/manim_polymorph/core.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@
1313
from itertools import pairwise
1414

1515
import numpy as np
16-
from polymorph.normalize import Origin, normalize_paths
16+
from polymorph.normalize import Origin, normalize_paths, sorted_segments
1717

1818
DEFAULT_ORIGIN = Origin(0.0, 0.0)
1919

2020
__all__ = [
2121
"NumericMorph",
2222
"manim_subpaths_to_polymorph_data",
2323
"polymorph_data_to_manim_points",
24+
"share_subpaths",
2425
"subpath_to_manim_points",
2526
]
2627

@@ -89,6 +90,27 @@ def manim_subpaths_to_polymorph_data(
8990
return data
9091

9192

93+
def share_subpaths(
94+
left: Sequence[Sequence[float]], right: Sequence[Sequence[float]]
95+
) -> tuple[list[list[float]], list[list[float]]]:
96+
"""Balance subpath counts by cloning the smaller path's subpaths.
97+
98+
Clones cycle through the smaller side largest-perimeter first, so
99+
normalize_paths pairs every subpath on the larger side with real
100+
geometry instead of a degenerate origin point: one shape splits into
101+
many, many shapes merge into one.
102+
"""
103+
left = [list(ns) for ns in left]
104+
right = [list(ns) for ns in right]
105+
if len(left) == len(right) or not left or not right:
106+
return left, right
107+
smaller, larger = (left, right) if len(left) < len(right) else (right, left)
108+
donors = sorted_segments(smaller)
109+
clones = [list(donors[i % len(donors)]) for i in range(len(larger) - len(smaller))]
110+
smaller.extend(clones)
111+
return left, right
112+
113+
92114
class NumericMorph:
93115
"""Morph between keyframe paths entirely on numpy point arrays.
94116
@@ -98,6 +120,11 @@ class NumericMorph:
98120
with a single vectorized lerp, so t=0 and t=1 have exactly the same
99121
point structure as every interior frame (no polymorph string
100122
short-circuit, no coordinate rounding).
123+
124+
fill_mode: how subpath-count mismatches are resolved. "share"
125+
(default) clones the smaller keyframe's subpaths so every subpath
126+
morphs from/to real geometry; "grow" keeps polymorph's own behavior
127+
of padding with degenerate subpaths that grow from the origin.
101128
"""
102129

103130
def __init__(
@@ -107,14 +134,21 @@ def __init__(
107134
optimize: str = "fill",
108135
origin: Origin = DEFAULT_ORIGIN,
109136
add_points: int = 0,
137+
fill_mode: str = "share",
110138
) -> None:
111139
if len(keyframes) < 2:
112140
raise ValueError("at least two keyframes are required")
141+
if fill_mode not in ("share", "grow"):
142+
raise ValueError(f"fill_mode must be 'share' or 'grow', got {fill_mode!r}")
113143
self._segments: list[tuple[np.ndarray, np.ndarray]] = []
114144
for left, right in pairwise(keyframes):
145+
left = [list(ns) for ns in left]
146+
right = [list(ns) for ns in right]
147+
if fill_mode == "share" and optimize == "fill":
148+
left, right = share_subpaths(left, right)
115149
matrix = normalize_paths(
116-
[list(ns) for ns in left],
117-
[list(ns) for ns in right],
150+
left,
151+
right,
118152
optimize=optimize,
119153
origin=origin,
120154
add_points=add_points,

manim-polymorph/tests/test_animation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,26 @@ def test_multi_target_sequencing():
9999
assert not np.allclose(mid, heart.points)
100100

101101

102+
def test_fill_mode_passes_through():
103+
heart, star = make_pair()
104+
pair_target = manim.VGroup(star.copy(), star.copy().shift(manim.RIGHT * 4))
105+
106+
share = Polymorph(heart.copy(), pair_target, rate_func=manim.linear)
107+
share.begin()
108+
share.interpolate(0.0)
109+
pts = share.mobject.points
110+
first, second = pts[: len(pts) // 2], pts[len(pts) // 2 :]
111+
# both subpaths start as the same full heart — no degenerate filler
112+
assert np.allclose(first, second)
113+
assert np.ptp(second[:, :2], axis=0).min() > 0
114+
115+
grow = Polymorph(heart.copy(), pair_target, fill_mode="grow", rate_func=manim.linear)
116+
grow.begin()
117+
grow.interpolate(0.0)
118+
spans = [np.ptp(sp[:, :2], axis=0).max() for sp in grow.mobject.get_subpaths()]
119+
assert min(spans) == 0 # the filler subpath starts collapsed at the origin
120+
121+
102122
def test_str_target_rejected():
103123
heart, _ = make_pair()
104124
with pytest.raises(TypeError, match="svg_path_mobjects"):

manim-polymorph/tests/test_core.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
NumericMorph,
99
manim_subpaths_to_polymorph_data,
1010
polymorph_data_to_manim_points,
11+
share_subpaths,
1112
subpath_to_manim_points,
1213
)
1314

1415
SQUARE = "M0 0 L10 0 L10 10 L0 10 Z"
1516
CIRCLE = "M50 25 A25 25 0 1 1 0 25 A25 25 0 1 1 50 25 Z"
17+
TWO_SQUARES = "M0 0 L10 0 L10 10 L0 10 Z M20 0 L30 0 L30 10 L20 10 Z"
1618

1719

1820
def test_subpath_layout_and_anchor_duplication():
@@ -114,3 +116,34 @@ def test_numeric_morph_overshoot_extrapolates():
114116
def test_numeric_morph_requires_two_keyframes():
115117
with pytest.raises(ValueError):
116118
NumericMorph([parse_points(SQUARE)])
119+
120+
121+
def test_share_subpaths_clones_smaller_side():
122+
left, right = share_subpaths(parse_points(SQUARE), parse_points(TWO_SQUARES))
123+
assert len(left) == len(right) == 2
124+
assert left[0] == left[1] # the lone square is cloned, not degenerate
125+
126+
127+
def test_share_mode_morphs_from_real_geometry():
128+
# 1 subpath -> 2 subpaths: at t=0 both halves are full copies of the
129+
# square, so nothing grows out of a point
130+
morph = NumericMorph([parse_points(SQUARE), parse_points(TWO_SQUARES)])
131+
p0 = morph.points_at(0.0)
132+
first, second = p0[: len(p0) // 2], p0[len(p0) // 2 :]
133+
assert np.allclose(first, second)
134+
assert np.ptp(second[:, :2], axis=0).min() > 0
135+
136+
137+
def test_grow_mode_keeps_degenerate_filler():
138+
morph = NumericMorph(
139+
[parse_points(SQUARE), parse_points(TWO_SQUARES)], fill_mode="grow"
140+
)
141+
p0 = morph.points_at(0.0)
142+
second = p0[len(p0) // 2 :]
143+
# the filler subpath collapses to a single origin point at t=0
144+
assert np.ptp(second[:, :2], axis=0).max() == 0
145+
146+
147+
def test_invalid_fill_mode_rejected():
148+
with pytest.raises(ValueError, match="fill_mode"):
149+
NumericMorph([parse_points(SQUARE), parse_points(CIRCLE)], fill_mode="clone")

0 commit comments

Comments
 (0)