Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions manim-polymorph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions manim-polymorph/examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 18 additions & 8 deletions manim-polymorph/src/manim_polymorph/animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand Down
40 changes: 37 additions & 3 deletions manim-polymorph/src/manim_polymorph/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
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)

__all__ = [
"NumericMorph",
"manim_subpaths_to_polymorph_data",
"polymorph_data_to_manim_points",
"share_subpaths",
"subpath_to_manim_points",
]

Expand Down Expand Up @@ -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.

Expand All @@ -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__(
Expand All @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions manim-polymorph/tests/test_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
33 changes: 33 additions & 0 deletions manim-polymorph/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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")
Loading