-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 2-B: MobjectGroup, layout helpers, and group animations #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e0e91f7
4eff597
30c1fb7
4853453
632fba3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]...) { | ||
| 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]) { | ||
|
|
@@ -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] = [:] | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Once animations can start after Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 30c1fb7 — 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), | ||
|
|
@@ -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 | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This sort only fixes the order used by Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
| } | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance Bottleneck:
|
||
|
|
||
| /// 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`). | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 isManimAnimation. 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 👍 / 👎.
There was a problem hiding this comment.
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...), soscene.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