diff --git a/manim-polymorph/README.md b/manim-polymorph/README.md index 20edcb3..ca60834 100644 --- a/manim-polymorph/README.md +++ b/manim-polymorph/README.md @@ -8,10 +8,14 @@ built-in `Transform` machinery. Why not just `Transform`? Manim's `VMobject.align_points` pairs subpaths strictly by document order and pads missing subpaths with degenerate curves collapsed onto a single point. Polymorph instead sorts subpaths by perimeter -before pairing them, grows filler subpaths from a configurable origin, and -rotates each closed subpath's start point toward that origin — which makes -morphs between structurally different shapes (different subpath counts, -holes, wildly different point counts) look intentional rather than glitchy. +before pairing them and rotates each closed subpath's start point toward a +configurable origin — which makes morphs between structurally different +shapes (different subpath counts, holes, wildly different point counts) look +intentional rather than glitchy. When subpath counts differ, the smaller +keyframe's subpaths are shared out across the larger's by default — one +shape splits into every glyph of a word, and every glyph merges back into +one shape — with polymorph's grow-from-origin filler available as +`fill_mode="grow"`. ## Install @@ -87,10 +91,14 @@ on finish. Keyword options, besides the usual `Animation` ones (`run_time`, `rate_func`, ...): -- `origin=(0, 0)` — where filler subpaths grow from and where closed subpaths - start drawing. Relative origins use SVG semantics ((0, 0) is the top-left of - each subpath's bounding box; (0.5, 0.5) is the center); - `Origin(x, y, absolute=True)` is a scene coordinate. +- `fill_mode="share"` — how subpath-count mismatches are resolved. `"share"` + clones the smaller keyframe's subpaths so every subpath morphs from/to real + geometry (flubber-style split/merge); `"grow"` pads with degenerate + subpaths that grow from the origin (polymorph's own behavior). +- `origin=(0, 0)` — where filler subpaths grow from (`fill_mode="grow"`) and + where closed subpaths start drawing. Relative origins use SVG semantics + ((0, 0) is the top-left of each subpath's bounding box; (0.5, 0.5) is the + center); `Origin(x, y, absolute=True)` is a scene coordinate. - `add_points=0` — extra curves added to every subpath; raise it to smooth morphs between shapes of very different complexity. - `optimize="fill"` — `"fill"` aligns structurally different paths; diff --git a/manim-polymorph/examples/demo.py b/manim-polymorph/examples/demo.py index caa6d5f..4c17ac1 100644 --- a/manim-polymorph/examples/demo.py +++ b/manim-polymorph/examples/demo.py @@ -47,6 +47,9 @@ def construct(self): self.wait(0.3) # multi-keyframe sequence morphing through the ring and back to the - # heart, with filler subpaths growing from the shape's center - self.play(Polymorph(shape, ring, heart, run_time=4, origin=(0.5, 0.5))) + # heart; fill_mode="grow" makes the ring's extra subpath grow from + # the shape's center instead of splitting off a clone (the default) + self.play( + Polymorph(shape, ring, heart, run_time=4, fill_mode="grow", origin=(0.5, 0.5)) + ) self.wait(0.5) diff --git a/manim-polymorph/src/manim_polymorph/animation.py b/manim-polymorph/src/manim_polymorph/animation.py index f16a746..2bf619f 100644 --- a/manim-polymorph/src/manim_polymorph/animation.py +++ b/manim-polymorph/src/manim_polymorph/animation.py @@ -37,10 +37,13 @@ def _style_donor(vm: VMobject) -> VMobject: class Polymorph(Animation): """Morph a VMobject through one or more target shapes using polymorph. - Unlike Transform, subpaths are paired by sorted perimeter, missing - subpaths grow from a configurable origin, and closed subpaths rotate - their start point toward that origin — producing stable morphs between - structurally different shapes. + Unlike Transform, subpaths are paired by sorted perimeter and closed + subpaths rotate their start point toward a configurable origin — + producing stable morphs between structurally different shapes. When + subpath counts differ, the smaller keyframe's subpaths are shared out + across the larger's by default (one shape splits into many, many + merge into one, flubber-style); fill_mode="grow" restores polymorph's + own behavior of growing the missing subpaths from the origin. Mobjects whose geometry lives in submobjects (Text, Tex, VGroup, SVG imports) are flattened: every subpath in the family joins one morph. @@ -53,10 +56,14 @@ class Polymorph(Animation): targets: one or more target VMobjects; with several, the run time is divided evenly between consecutive pairs, like polymorph's own multi-path interpolate. Targets do not need to be on screen. - origin: where filler subpaths grow from and where closed subpaths start - drawing. Relative origins use SVG semantics — (0, 0) is the top-left - of each subpath's bounding box — while Origin(..., absolute=True) - is a scene coordinate. + fill_mode: how subpath-count mismatches between keyframes are + resolved. "share" (default) clones the smaller keyframe's + subpaths so every subpath morphs from/to real geometry; "grow" + pads with degenerate subpaths that grow from the origin. + origin: where filler subpaths grow from (fill_mode="grow") and where + closed subpaths start drawing. Relative origins use SVG semantics + — (0, 0) is the top-left of each subpath's bounding box — while + Origin(..., absolute=True) is a scene coordinate. add_points: extra curves added to every subpath (smooths morphs between shapes of very different complexity). optimize: "fill" (default) aligns subpaths automatically; "none" @@ -71,6 +78,7 @@ def __init__( self, mobject: VMobject, *targets: VMobject, + fill_mode: Literal["share", "grow"] = "share", origin: tuple[float, float] | Origin = (0.0, 0.0), add_points: int = 0, optimize: Literal["fill", "none"] = "fill", @@ -95,6 +103,7 @@ def __init__( origin = Origin(*origin) self.targets = targets + self.fill_mode = fill_mode self.origin = origin self.add_points = add_points self.optimize = optimize @@ -130,6 +139,7 @@ def begin(self) -> None: optimize=self.optimize, origin=origin, add_points=self.add_points, + fill_mode=self.fill_mode, ) super().begin() diff --git a/manim-polymorph/src/manim_polymorph/core.py b/manim-polymorph/src/manim_polymorph/core.py index a632452..c9a5e6f 100644 --- a/manim-polymorph/src/manim_polymorph/core.py +++ b/manim-polymorph/src/manim_polymorph/core.py @@ -13,7 +13,7 @@ from itertools import pairwise import numpy as np -from polymorph.normalize import Origin, normalize_paths +from polymorph.normalize import Origin, normalize_paths, sorted_segments DEFAULT_ORIGIN = Origin(0.0, 0.0) @@ -21,6 +21,7 @@ "NumericMorph", "manim_subpaths_to_polymorph_data", "polymorph_data_to_manim_points", + "share_subpaths", "subpath_to_manim_points", ] @@ -89,6 +90,27 @@ def manim_subpaths_to_polymorph_data( return data +def share_subpaths( + left: Sequence[Sequence[float]], right: Sequence[Sequence[float]] +) -> tuple[list[list[float]], list[list[float]]]: + """Balance subpath counts by cloning the smaller path's subpaths. + + Clones cycle through the smaller side largest-perimeter first, so + normalize_paths pairs every subpath on the larger side with real + geometry instead of a degenerate origin point: one shape splits into + many, many shapes merge into one. + """ + left = [list(ns) for ns in left] + right = [list(ns) for ns in right] + if len(left) == len(right) or not left or not right: + return left, right + smaller, larger = (left, right) if len(left) < len(right) else (right, left) + donors = sorted_segments(smaller) + clones = [list(donors[i % len(donors)]) for i in range(len(larger) - len(smaller))] + smaller.extend(clones) + return left, right + + class NumericMorph: """Morph between keyframe paths entirely on numpy point arrays. @@ -98,6 +120,11 @@ class NumericMorph: with a single vectorized lerp, so t=0 and t=1 have exactly the same point structure as every interior frame (no polymorph string short-circuit, no coordinate rounding). + + fill_mode: how subpath-count mismatches are resolved. "share" + (default) clones the smaller keyframe's subpaths so every subpath + morphs from/to real geometry; "grow" keeps polymorph's own behavior + of padding with degenerate subpaths that grow from the origin. """ def __init__( @@ -107,14 +134,21 @@ def __init__( optimize: str = "fill", origin: Origin = DEFAULT_ORIGIN, add_points: int = 0, + fill_mode: str = "share", ) -> None: if len(keyframes) < 2: raise ValueError("at least two keyframes are required") + if fill_mode not in ("share", "grow"): + raise ValueError(f"fill_mode must be 'share' or 'grow', got {fill_mode!r}") self._segments: list[tuple[np.ndarray, np.ndarray]] = [] for left, right in pairwise(keyframes): + left = [list(ns) for ns in left] + right = [list(ns) for ns in right] + if fill_mode == "share" and optimize == "fill": + left, right = share_subpaths(left, right) matrix = normalize_paths( - [list(ns) for ns in left], - [list(ns) for ns in right], + left, + right, optimize=optimize, origin=origin, add_points=add_points, diff --git a/manim-polymorph/tests/test_animation.py b/manim-polymorph/tests/test_animation.py index ab9d1e7..41a4c14 100644 --- a/manim-polymorph/tests/test_animation.py +++ b/manim-polymorph/tests/test_animation.py @@ -99,6 +99,26 @@ def test_multi_target_sequencing(): assert not np.allclose(mid, heart.points) +def test_fill_mode_passes_through(): + heart, star = make_pair() + pair_target = manim.VGroup(star.copy(), star.copy().shift(manim.RIGHT * 4)) + + share = Polymorph(heart.copy(), pair_target, rate_func=manim.linear) + share.begin() + share.interpolate(0.0) + pts = share.mobject.points + first, second = pts[: len(pts) // 2], pts[len(pts) // 2 :] + # both subpaths start as the same full heart — no degenerate filler + assert np.allclose(first, second) + assert np.ptp(second[:, :2], axis=0).min() > 0 + + grow = Polymorph(heart.copy(), pair_target, fill_mode="grow", rate_func=manim.linear) + grow.begin() + grow.interpolate(0.0) + spans = [np.ptp(sp[:, :2], axis=0).max() for sp in grow.mobject.get_subpaths()] + assert min(spans) == 0 # the filler subpath starts collapsed at the origin + + def test_str_target_rejected(): heart, _ = make_pair() with pytest.raises(TypeError, match="svg_path_mobjects"): diff --git a/manim-polymorph/tests/test_core.py b/manim-polymorph/tests/test_core.py index 7c98d43..abba7b7 100644 --- a/manim-polymorph/tests/test_core.py +++ b/manim-polymorph/tests/test_core.py @@ -8,11 +8,13 @@ NumericMorph, manim_subpaths_to_polymorph_data, polymorph_data_to_manim_points, + share_subpaths, subpath_to_manim_points, ) SQUARE = "M0 0 L10 0 L10 10 L0 10 Z" CIRCLE = "M50 25 A25 25 0 1 1 0 25 A25 25 0 1 1 50 25 Z" +TWO_SQUARES = "M0 0 L10 0 L10 10 L0 10 Z M20 0 L30 0 L30 10 L20 10 Z" def test_subpath_layout_and_anchor_duplication(): @@ -114,3 +116,34 @@ def test_numeric_morph_overshoot_extrapolates(): def test_numeric_morph_requires_two_keyframes(): with pytest.raises(ValueError): NumericMorph([parse_points(SQUARE)]) + + +def test_share_subpaths_clones_smaller_side(): + left, right = share_subpaths(parse_points(SQUARE), parse_points(TWO_SQUARES)) + assert len(left) == len(right) == 2 + assert left[0] == left[1] # the lone square is cloned, not degenerate + + +def test_share_mode_morphs_from_real_geometry(): + # 1 subpath -> 2 subpaths: at t=0 both halves are full copies of the + # square, so nothing grows out of a point + morph = NumericMorph([parse_points(SQUARE), parse_points(TWO_SQUARES)]) + p0 = morph.points_at(0.0) + first, second = p0[: len(p0) // 2], p0[len(p0) // 2 :] + assert np.allclose(first, second) + assert np.ptp(second[:, :2], axis=0).min() > 0 + + +def test_grow_mode_keeps_degenerate_filler(): + morph = NumericMorph( + [parse_points(SQUARE), parse_points(TWO_SQUARES)], fill_mode="grow" + ) + p0 = morph.points_at(0.0) + second = p0[len(p0) // 2 :] + # the filler subpath collapses to a single origin point at t=0 + assert np.ptp(second[:, :2], axis=0).max() == 0 + + +def test_invalid_fill_mode_rejected(): + with pytest.raises(ValueError, match="fill_mode"): + NumericMorph([parse_points(SQUARE), parse_points(CIRCLE)], fill_mode="clone")