Skip to content
Open
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
45 changes: 40 additions & 5 deletions Sources/PicoManim/Animation/ManimAnimation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,27 @@ public struct ManimAnimation: Sendable {
case move(to: Vec2)
/// Rotates the mobject about its own center.
case rotate(by: Double)
/// Rotates the mobject about a fixed scene point: its rotation
/// animates while its position orbits the pivot along an arc.
case rotateAbout(pivot: Vec2, by: Double)
/// Scales the mobject about its own center.
case scale(by: Double)
/// Scales the mobject about a fixed scene point: its scale animates
/// while its position moves radially from the pivot.
case scaleAbout(pivot: Vec2, by: Double)
/// Morphs the mobject's shape and style into the target's.
case transform(into: Mobject)
/// Moves the mobject by whatever delta lands the *group's* live
/// bounding-box center on `point`. Group factories emit this so a
/// play call resolves the delta from the scene's current state
/// rather than the (possibly stale) group value they captured.
case groupMove(to: Vec2, members: [Mobject])
/// Rotates about the group's live bounding-box center, resolved
/// from scene state when played (see ``groupMove(to:members:)``).
case groupRotate(by: Double, members: [Mobject])
/// Scales about the group's live bounding-box center, resolved
/// from scene state when played (see ``groupMove(to:members:)``).
case groupScale(by: Double, members: [Mobject])
}

/// The target mobject as it was when the animation was built. The scene
Expand All @@ -33,12 +50,22 @@ public struct ManimAnimation: Sendable {
/// Duration in seconds.
public var duration: Double
public var rate: RateFunction
/// Seconds after the play group starts before this animation begins.
/// Group factories use this to stagger children (lag).
public var delay: Double

public init(mobject: Mobject, kind: Kind, duration: Double = 1, rate: RateFunction = .smooth) {
public init(
mobject: Mobject,
kind: Kind,
duration: Double = 1,
rate: RateFunction = .smooth,
delay: Double = 0
) {
self.mobject = mobject
self.kind = kind
self.duration = duration
self.rate = rate
self.delay = delay
}

// MARK: - Factories
Expand Down Expand Up @@ -92,24 +119,32 @@ public struct ManimAnimation: Sendable {
ManimAnimation(mobject: mobject, kind: .move(to: point), duration: duration, rate: rate)
}

/// Rotates the mobject by `angle` radians about its own center.
/// Rotates the mobject by `angle` radians. Without `about`, rotation is
/// about the mobject's own center; with a pivot, its position also
/// orbits the pivot along a circular arc.
public static func rotate(
_ mobject: Mobject,
by angle: Double,
about pivot: Vec2? = nil,
duration: Double = 1,
rate: RateFunction = .smooth
) -> ManimAnimation {
ManimAnimation(mobject: mobject, kind: .rotate(by: angle), duration: duration, rate: rate)
let kind: Kind = pivot.map { .rotateAbout(pivot: $0, by: angle) } ?? .rotate(by: angle)
return ManimAnimation(mobject: mobject, kind: kind, duration: duration, rate: rate)
}

/// Scales the mobject by `factor` about its own center.
/// Scales the mobject by `factor`. Without `about`, scaling is about
/// the mobject's own center; with a pivot, its position also moves
/// radially from the pivot.
public static func scale(
_ mobject: Mobject,
by factor: Double,
about pivot: Vec2? = nil,
duration: Double = 1,
rate: RateFunction = .smooth
) -> ManimAnimation {
ManimAnimation(mobject: mobject, kind: .scale(by: factor), duration: duration, rate: rate)
let kind: Kind = pivot.map { .scaleAbout(pivot: $0, by: factor) } ?? .scale(by: factor)
return ManimAnimation(mobject: mobject, kind: kind, duration: duration, rate: rate)
}

/// Morphs the mobject into `target`, interpolating shape, placement,
Expand Down
146 changes: 124 additions & 22 deletions Sources/PicoManim/Animation/ManimScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ public struct ManimScene: Sendable {
play(animations)
}

/// Plays several animation lists (for example group animations, which
/// expand to one animation per child) together in one parallel group.
public mutating func play(_ animationGroups: [ManimAnimation]...) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support solo animations in mixed group plays

This overload only accepts variadic animation arrays, while the existing overload only accepts variadic single animations, so the intended mixed call shape scene.play(.create(group), .shift(solo, by: ...)) has no matching overload: the first argument is [ManimAnimation] and the second is ManimAnimation. Users have to manually wrap every solo animation in an array, which makes the advertised group+solo mixing API fail to type-check in normal use.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4853453 — added play(_ animationGroup: [ManimAnimation], _ animations: ManimAnimation...), so scene.play(.create(group), .shift(solo, by: ...)) type-checks as advertised (group array first, any number of solo animations after). Arbitrary interleavings concatenate arrays (play(.create(a) + [b, c])), noted in the doc comment. The mixed test now uses the natural un-wrapped shape.


Generated by Claude Code

play(animationGroups.flatMap { $0 })
}

/// Plays a group animation together with individual animations in one
/// parallel group: `scene.play(.create(row), .shift(dot, by: .up))`.
/// For arbitrary mixes, concatenate arrays: `play(.create(a) + [b, c])`.
public mutating func play(_ animationGroup: [ManimAnimation], _ animations: ManimAnimation...) {
play(animationGroup + animations)
}

/// Plays animations in parallel, then advances the timeline by the
/// longest of their durations.
public mutating func play(_ animations: [ManimAnimation]) {
Expand All @@ -107,35 +120,53 @@ public struct ManimScene: Sendable {
// never from a sibling's end state, which would make the mobject
// jump at the start of the group.
var groupStartStates = currentStates
// Buffered so the group's entries can be appended in chronological
// order below, whatever order the caller listed them in.
var newEntries: [Entry] = []
// A group animation expands to one entry per member, all carrying the
// same members list; caching the resolved center keeps the whole
// group's resolution O(N) instead of recomputing an O(N) bounding
// box for every one of the N members.
var groupCenterCache: [[Mobject.ID]: Vec2] = [:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sort delayed entries by their actual start time

Once animations can start after groupStart, entries are no longer guaranteed to be chronological, but snapshot(at:) still folds them in append order and completed entries keep applying their final value. If a delayed animation on a mobject is listed before an immediate sibling that drives the same property, the immediate sibling is applied after the delayed one even after it has completed, so the delayed motion is overwritten instead of taking effect when its delay elapses.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 30c1fb7play now buffers the group's entries and appends them sorted by start time (stable on ties, preserving the documented "later listed wins" rule for simultaneous same-property animations). Since a play group's entries all start at or before the next group's start, sorting within the group keeps the whole timeline chronological, so the snapshot fold applies entries in the order they actually begin. Test added: delayedSiblingDrivesThePropertyAfterItsDelay (a delayed move listed before an immediate sibling on the same property takes over when its delay elapses).


Generated by Claude Code

for animation in animations {
let id = animation.mobject.id
let entryStart = groupStart + max(0, animation.delay)
// Group-relative kinds carry their sibling mobjects; resolve the
// pivot or delta against the live scene state now, so a group
// animation acts on wherever the group actually is, not on the
// (possibly stale) group value the factory captured.
let kind = Self.resolvingGroupKind(
animation.kind,
states: groupStartStates,
centerCache: &groupCenterCache
)
let startState: Mobject
// The state the mobject is in as this group begins; the authored
// value for a mobject this play call introduces.
let preGroup = groupStartStates[id] ?? animation.mobject
if groupStartStates[id] != nil {
// Re-introducing animations (create, fadeIn) restart from a
// hidden version of wherever the mobject currently is.
startState = Self.introducedStartState(for: animation.kind, from: preGroup)
startState = Self.introducedStartState(for: kind, from: preGroup)
} else {
// First appearance: seed time zero with a hidden state so the
// mobject doesn't exist on screen before this point.
let hidden = Self.initialState(for: animation)
let hidden = Self.initialState(for: kind, from: animation.mobject)
initialStates[id] = hidden
order.append(id)
// A sibling animation in this same group starts from the
// authored state, not from this animation's end.
groupStartStates[id] = animation.mobject
switch animation.kind {
switch kind {
case .create, .fadeIn:
startState = hidden
default:
// Non-revealing animation on a brand-new mobject: show it
// instantly (like `add`) and animate from there.
let visible = animation.mobject
entries.append(Entry(
startTime: groupStart,
newEntries.append(Entry(
startTime: entryStart,
duration: 0,
rate: .linear,
kind: .fadeIn(shift: .zero),
Expand All @@ -149,16 +180,16 @@ public struct ManimScene: Sendable {
}

var entry = Entry(
startTime: groupStart,
startTime: entryStart,
duration: max(0, animation.duration),
rate: animation.rate,
kind: animation.kind,
kind: kind,
targetID: id,
startState: startState,
endState: startState,
alignedPaths: nil
)
if case .transform(let target) = animation.kind {
if case .transform(let target) = kind {
entry.alignedPaths = startState.path.aligned(with: target.path)
}
// The opacity a revealing animation should end at: the current
Expand All @@ -168,26 +199,42 @@ public struct ManimScene: Sendable {
? preGroup.opacity
: (lastVisibleOpacities[id] ?? animation.mobject.opacity)
entry.endState = Self.endState(
for: animation,
for: kind,
from: startState,
preGroup: preGroup,
revealOpacity: revealOpacity
)
entries.append(entry)
newEntries.append(entry)

// Advance the build cursor to the state the animation actually
// leaves behind (for rate functions like `thereAndBack` this is
// not the end pole). Applied on top of the accumulated state so
// sibling animations in this group all contribute.
groupDuration = max(groupDuration, max(0, animation.delay) + entry.duration)
}
// Delayed animations can start after siblings listed later in the
// call. The snapshot fold applies entries in array order and lets a
// completed entry keep asserting its final value, so the timeline
// must stay chronological or an early-listed delayed animation
// would be overwritten by an already-finished sibling. Ties keep
// their listed order (later wins, as documented). Groups only ever
// start at or after every entry of the previous group, so sorting
// within the group keeps the whole array sorted.
let indexed = newEntries.enumerated().sorted {
($0.element.startTime, $0.offset) < ($1.element.startTime, $1.offset)
}
entries.append(contentsOf: indexed.map { $0.element })
Comment on lines +219 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute the build cursor in start-time order

This sort only fixes the order used by snapshot(at:); currentStates has already been advanced in the original caller order in the loop above. When a delayed animation targeting the same property is listed before an immediate sibling, the rendered timeline can end at the delayed value, but state(of:) and any later play still start from the immediate sibling's value, causing follow-up animations to jump or use the wrong live state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4853453 — the build cursor is now advanced after the group's entries are sorted, folding them in the same chronological order snapshot(at:) uses, so state(of:) and follow-up plays agree with the rendered timeline. The delayed-sibling test now also asserts scene.state(of: dot) ends at the delayed animation's value.


Generated by Claude Code

// Advance the build cursor in the same chronological order the
// snapshot fold uses (for rate functions like `thereAndBack` the
// state left behind is rate(1), not the end pole), so `state(of:)`
// and later plays agree with what the timeline actually shows when
// delayed siblings drive the same property.
for (_, entry) in indexed {
let id = entry.targetID
currentStates[id] = Self.apply(
entry,
easedProgress: entry.rate.apply(1),
to: currentStates[id] ?? startState
to: currentStates[id] ?? entry.startState
)
if let opacity = currentStates[id]?.opacity, opacity > 0 {
lastVisibleOpacities[id] = opacity
}
groupDuration = max(groupDuration, entry.duration)
}
duration = groupStart + groupDuration
}
Expand Down Expand Up @@ -227,18 +274,49 @@ public struct ManimScene: Sendable {

/// The state a not-yet-seen mobject should have at time zero so it is
/// invisible until its first animation runs.
private static func initialState(for animation: ManimAnimation) -> Mobject {
switch animation.kind {
private static func initialState(for kind: ManimAnimation.Kind, from mobject: Mobject) -> Mobject {
switch kind {
case .create, .fadeIn:
return introducedStartState(for: animation.kind, from: animation.mobject)
return introducedStartState(for: kind, from: mobject)
default:
// Hidden until the instant reveal entry at the play time fires.
var state = animation.mobject
var state = mobject
state.opacity = 0
return state
}
}

/// Rewrites group-relative kinds (`groupMove`/`groupRotate`/`groupScale`)
/// into concrete ones by resolving the group's bounding-box center from
/// the live scene state (falling back to the carried snapshot for
/// mobjects the scene has not met yet). Everything else passes through.
private static func resolvingGroupKind(
_ kind: ManimAnimation.Kind,
states: [Mobject.ID: Mobject],
centerCache: inout [[Mobject.ID]: Vec2]
) -> ManimAnimation.Kind {
func liveCenter(_ members: [Mobject]) -> Vec2 {
// Members of one group share an identical list, so the expensive
// bounding-box center is computed once and reused for every
// sibling instead of once per member.
let key = members.map { $0.id }
if let cached = centerCache[key] { return cached }
let center = MobjectGroup(members.map { states[$0.id] ?? $0 }).center
centerCache[key] = center
return center
}
switch kind {
case .groupMove(let point, let members):
return .shift(by: point - liveCenter(members))
case .groupRotate(let angle, let members):
return .rotateAbout(pivot: liveCenter(members), by: angle)
case .groupScale(let factor, let members):
return .scaleAbout(pivot: liveCenter(members), by: factor)
default:
return kind
}
}
Comment on lines +293 to +318

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Performance Bottleneck: $O(N^2)$ Complexity in Group Center Resolution

Update resolvingGroupKind to accept and utilize the centerCache to avoid redundant bounding box calculations and path transformations for members of the same group.

    private static func resolvingGroupKind(
        _ kind: ManimAnimation.Kind,
        states: [Mobject.ID: Mobject],
        centerCache: inout [[Mobject.ID]: Vec2]
    ) -> ManimAnimation.Kind {
        func liveCenter(_ members: [Mobject]) -> Vec2 {
            let ids = members.map { $0.id }
            if let cached = centerCache[ids] {
                return cached
            }
            let center = MobjectGroup(members.map { states[$0.id] ?? $0 }).center
            centerCache[ids] = center
            return center
        }
        switch kind {
        case .groupMove(let point, let members):
            return .shift(by: point - liveCenter(members))
        case .groupRotate(let angle, let members):
            return .rotateAbout(pivot: liveCenter(members), by: angle)
        case .groupScale(let factor, let members):
            return .scaleAbout(pivot: liveCenter(members), by: factor)
        default:
            return kind
        }
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 632fba3resolvingGroupKind now takes centerCache: inout [[Mobject.ID]: Vec2] and its liveCenter memoizes the computed center per members-id list, exactly as suggested, so members of the same group reuse one bounding-box computation. (Companion to the reply on the caller-side thread above.)


Generated by Claude Code


/// The start pole for a revealing animation: `state` made invisible in
/// the way the animation expects to undo (outline retracted for
/// `create`; transparent and shifted back for `fadeIn`).
Expand All @@ -262,13 +340,13 @@ public struct ManimScene: Sendable {
/// is the mobject's state as the play group begins (the authored value
/// on first introduction).
private static func endState(
for animation: ManimAnimation,
for kind: ManimAnimation.Kind,
from start: Mobject,
preGroup: Mobject,
revealOpacity: Double
) -> Mobject {
var end = start
switch animation.kind {
switch kind {
case .create:
end.strokeStart = 0
end.strokeEnd = 1
Expand All @@ -288,8 +366,14 @@ public struct ManimScene: Sendable {
end.transform.translation = point
case .rotate(let angle):
end.transform.rotation += angle
case .rotateAbout(let pivot, let angle):
end.transform.rotation += angle
end.transform.translation = pivot + (start.transform.translation - pivot).rotated(by: angle)
case .scale(let factor):
end.transform.scale *= factor
case .scaleAbout(let pivot, let factor):
end.transform.scale *= factor
end.transform.translation = pivot + (start.transform.translation - pivot) * factor
case .transform(let target):
end.path = target.path
end.transform = target.transform
Expand All @@ -300,6 +384,10 @@ public struct ManimScene: Sendable {
end.strokeStart = target.strokeStart
end.strokeEnd = target.strokeEnd
end.fillOpacityFactor = target.fillOpacityFactor
case .groupMove, .groupRotate, .groupScale:
// Rewritten into concrete kinds by play(); an unresolved one
// reaching evaluation is inert rather than a crash.
break
}
return end
}
Expand Down Expand Up @@ -333,8 +421,18 @@ public struct ManimScene: Sendable {
state.transform.translation = Vec2.lerp(a.transform.translation, b.transform.translation, p)
case .rotate:
state.transform.rotation = lerp(a.transform.rotation, b.transform.rotation, p)
case .rotateAbout(let pivot, let angle):
state.transform.rotation = lerp(a.transform.rotation, b.transform.rotation, p)
// The position orbits the pivot along a circular arc, not the
// chord between the poles.
state.transform.translation = pivot
+ (a.transform.translation - pivot).rotated(by: angle * p)
case .scale:
state.transform.scale = Vec2.lerp(a.transform.scale, b.transform.scale, p)
case .scaleAbout(let pivot, let factor):
state.transform.scale = Vec2.lerp(a.transform.scale, b.transform.scale, p)
state.transform.translation = pivot
+ (a.transform.translation - pivot) * lerp(1, factor, p)
case .transform:
// At the poles, return the exact (unaligned) paths: the aligned
// copies are structurally padded, and for an empty target the
Expand All @@ -356,6 +454,10 @@ public struct ManimScene: Sendable {
state.strokeStart = lerp(a.strokeStart, b.strokeStart, p)
state.strokeEnd = lerp(a.strokeEnd, b.strokeEnd, p)
state.fillOpacityFactor = lerp(a.fillOpacityFactor, b.fillOpacityFactor, p)
case .groupMove, .groupRotate, .groupScale:
// Rewritten into concrete kinds by play(); an unresolved one
// reaching evaluation is inert rather than a crash.
break
}
return state
}
Expand Down
Loading