From 1b077b0b90d98033cb7b726909a0e72b7b5cd467 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 14:30:55 +0000 Subject: [PATCH 1/2] Add ManimView SwiftUI preview, demo scene, and full docs - ManimView: a SwiftUI player for ManimScene. Renders snapshots into a Canvas in Manim coordinates (origin center, +y up, 8-unit-tall frame, uniform fit), strokes partial outlines via trimmedPath for draw-in animations, and fades fills through the mobject's opacity factors. Playback state is just an anchor time plus the TimelineView clock, so play/pause, restart, scrubbing, and looping are pure arithmetic; a paused schedule stops the render loop entirely. - ManimScene.demo: a short tour of Phase 1 (create, shift, parallel rotate+scale, two morphing transforms, fades) wired into #Preview and exercised by timeline tests that run on all platforms. - README: full Phase 1 documentation - scene building, semantics, ManimView usage, shape reference, roadmap. SwiftUI-only code is fenced behind #if canImport(SwiftUI); the Linux CI job builds and tests everything else. Stacked on the animation timeline PR; part 5/5 of the Phase 1 stack. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YGb3ukB5dQy4vgz7Z1hags --- README.md | 85 +++++++- Sources/PicoManim/Preview/DemoScenes.swift | 46 +++++ Sources/PicoManim/Preview/ManimView.swift | 214 +++++++++++++++++++++ Tests/PicoManimTests/DemoSceneTests.swift | 21 ++ 4 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 Sources/PicoManim/Preview/DemoScenes.swift create mode 100644 Sources/PicoManim/Preview/ManimView.swift create mode 100644 Tests/PicoManimTests/DemoSceneTests.swift diff --git a/README.md b/README.md index 90484a1..c31bc59 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,10 @@ A Swift animation library inspired by [Manim](https://www.manim.community), the **Swift:** 6.2+ **Rendering:** SwiftUI `Canvas` -> **Status:** Phase 1 is landing as a stack of focused PRs. This package currently -> contains the core math layer (`Vec2`, `Transform2D`, `ManimColor`); shapes, the -> animation timeline, and the `ManimView` preview follow in the next PRs. - ## Phase 1 Scope - **Shapes** — circle, ellipse, arc, dot, line, polyline, rectangle, square, triangle, regular polygon, arbitrary polygon; all stored as cubic Bézier paths so any shape can morph into any other. -- **Animation** — `create`, `fadeIn`/`fadeOut`, `shift`/`move`, `rotate`, `scale`, and morphing `transform`, with Manim-style rate functions. +- **Animation** — `create`, `fadeIn`/`fadeOut`, `shift`/`move`, `rotate`, `scale`, and morphing `transform`, with Manim-style rate functions (`smooth`, `linear`, `easeIn`/`Out`/`InOut`, `thereAndBack`, custom). - **ManimView** — a SwiftUI player with play/pause, restart, looping, and scrubbing. ## Add PicoManim to Your Project @@ -36,9 +32,84 @@ targets: [ **Xcode:** File > Add Package Dependencies > Add Local > select the `PicoManim` directory. +## Build a Scene + +A `ManimScene` is built imperatively, exactly like a Manim `Scene.construct`: successive `play` calls run one after another, and animations passed to a single `play` call run in parallel. + +```swift +import PicoManim + +let scene = ManimScene { scene in + let circle = Mobject.circle(radius: 1.2) + .stroke(.blue) + .fill(.blue, opacity: 0.5) + + scene.play(.create(circle)) + scene.play(.shift(circle, by: Vec2(-3, 0))) + + let square = Mobject.square(sideLength: 2, at: Vec2(-3, 0)) + .stroke(.red) + .fill(.red, opacity: 0.5) + scene.play(.transform(circle, into: square)) + + scene.play( + .rotate(circle, by: .pi / 2), + .scale(circle, by: 1.3) + ) + scene.wait(0.5) + scene.play(.fadeOut(circle, shift: Vec2(0, 1))) +} +``` + +Scenes use Manim's coordinate system: the origin at the center, +y up, and a frame 8 units tall. + +**Key semantics:** + +- Mobjects are value types with a stable identity. Fluent modifiers (`.fill`, `.stroke`, `.shifted`, ...) return styled copies that keep the same identity, which is how the scene knows later animations target the same on-screen object — even after a `transform` morphs it into another shape. +- `snapshot(at:)` returns every mobject's visual state at any time, as a pure function. Playback can scrub, loop, or render offline without replaying the scene. Use `state(of:)` while building to read where an earlier animation left an object. +- Parallel animations on the same mobject compose per property (a simultaneous `rotate` and `scale` both apply); two parallel animations driving the same property do not blend — the later one wins. + +## Preview with ManimView + +```swift +import SwiftUI +import PicoManim + +struct ContentView: View { + var body: some View { + ManimView(scene: .demo) // or your own scene + } +} +``` + +`ManimView(scene:autoplays:loops:showsControls:frameSize:background:)` renders into a SwiftUI `Canvas` and provides play/pause, restart, and a scrubber. It works in Xcode Previews: + +```swift +#Preview { + ManimView(scene: .demo) + .frame(width: 640, height: 420) +} +``` + +**Verify it worked:** `ManimScene.demo.duration` is greater than 0, and `ManimView(scene: .demo)` shows a blue circle being drawn in, morphing into a red square, and fading out. + +## Shape Reference + +| Factory | Default style | +| --- | --- | +| `Mobject.circle(radius:at:)` | red outline | +| `Mobject.ellipse(width:height:at:)` | red outline | +| `Mobject.arc(radius:startAngle:endAngle:at:)` | white outline | +| `Mobject.dot(at:radius:)` | white fill, no outline | +| `Mobject.line(from:to:)` | white outline | +| `Mobject.rectangle(width:height:at:)` / `.square(sideLength:at:)` | blue outline | +| `Mobject.triangle(radius:at:)` / `.regularPolygon(sides:radius:at:)` | blue outline | +| `Mobject.polygon(_:)` / `.polyline(_:)` | blue / white outline | + +Defaults mirror Manim's traditional colors, and the full Manim palette is available on `ManimColor` (`.blue`, `.red`, `.green`, `.yellow`, `.purple`, ...). + ## Roadmap -- **Phase 1 (in progress):** shapes, animation timeline, SwiftUI preview. - **Phase 2:** text and LaTeX mobjects, mobject groups, axes and coordinate systems, updaters. - **Phase 3:** video export, camera moves, 3D. @@ -48,4 +119,4 @@ targets: [ swift test ``` -The core has no SwiftUI dependency and tests run on any platform with a Swift 6.2 toolchain (CI covers macOS and Linux); SwiftUI-only code is fenced behind `#if canImport(SwiftUI)`. +The core (geometry, paths, timeline) has no SwiftUI dependency and tests run on any platform with a Swift 6.2 toolchain; `ManimView` compiles only where SwiftUI is available. diff --git a/Sources/PicoManim/Preview/DemoScenes.swift b/Sources/PicoManim/Preview/DemoScenes.swift new file mode 100644 index 0000000..0351929 --- /dev/null +++ b/Sources/PicoManim/Preview/DemoScenes.swift @@ -0,0 +1,46 @@ +extension ManimScene { + /// A short tour of Phase 1: create, shift, parallel rotate + scale, + /// morphing transforms, and fades. Used by the `ManimView` preview. + public static var demo: ManimScene { + ManimScene { scene in + let circle = Mobject.circle(radius: 1.2) + .stroke(.blue) + .fill(.blue, opacity: 0.5) + + scene.play(.create(circle, duration: 1.2)) + scene.wait(0.3) + scene.play(.shift(circle, by: Vec2(-3, 0))) + + let square = Mobject.square(sideLength: 2, at: Vec2(-3, 0)) + .stroke(.red) + .fill(.red, opacity: 0.5) + scene.play(.transform(circle, into: square, duration: 1.2)) + scene.wait(0.2) + + let dot = Mobject.dot(at: Vec2(3, 1)) + let label = Mobject.triangle(radius: 0.8, at: Vec2(3, 1)) + .stroke(.green) + .fill(.green, opacity: 0.4) + scene.play(.fadeIn(dot, shift: Vec2(0, -0.5), duration: 0.6)) + scene.play(.create(label, duration: 0.8)) + + scene.play( + .rotate(circle, by: .pi / 2, duration: 1), + .scale(circle, by: 1.3, duration: 1), + .shift(label, by: Vec2(0, -2), duration: 1) + ) + scene.wait(0.3) + + let hexagon = Mobject.regularPolygon(sides: 6, radius: 1.2, at: Vec2(0, 0)) + .stroke(.purple) + .fill(.purple, opacity: 0.5) + scene.play( + .transform(circle, into: hexagon, duration: 1.2), + .fadeOut(dot, duration: 0.6), + .fadeOut(label, shift: Vec2(0, -1), duration: 0.8) + ) + scene.wait(0.4) + scene.play(.fadeOut(circle, shift: Vec2(0, 1), duration: 0.8)) + } + } +} diff --git a/Sources/PicoManim/Preview/ManimView.swift b/Sources/PicoManim/Preview/ManimView.swift new file mode 100644 index 0000000..054cbac --- /dev/null +++ b/Sources/PicoManim/Preview/ManimView.swift @@ -0,0 +1,214 @@ +#if canImport(SwiftUI) +import Foundation +import SwiftUI + +/// A SwiftUI player for a ``ManimScene``: renders the scene into a +/// `Canvas` and provides play/pause, restart, and scrubbing controls. +/// +/// ```swift +/// import PicoManim +/// +/// struct ContentView: View { +/// var body: some View { +/// ManimView(scene: .demo) +/// } +/// } +/// ``` +/// +/// Because scenes are evaluated purely by time, the view can loop and +/// scrub freely; playback state is just an anchor time plus a clock. +public struct ManimView: View { + public var scene: ManimScene + + private let loops: Bool + private let showsControls: Bool + /// Visible scene area in scene units (width, height). The scene is + /// scaled uniformly to fit this frame inside the view. + private let frameSize: Vec2 + private let background: ManimColor + + @State private var isPlaying: Bool + /// Playhead position when `anchorDate` was set. + @State private var anchorTime: Double = 0 + /// Wall-clock moment playback (re)started; ignored while paused. + @State private var anchorDate = Date() + + public init( + scene: ManimScene, + autoplays: Bool = true, + loops: Bool = true, + showsControls: Bool = true, + frameSize: Vec2 = Vec2(14.0 + 2.0 / 9.0, 8.0), + background: ManimColor = .background + ) { + self.scene = scene + self.loops = loops + self.showsControls = showsControls + self.frameSize = frameSize + self.background = background + self._isPlaying = State(initialValue: autoplays && scene.duration > 0) + } + + public var body: some View { + TimelineView(.animation(minimumInterval: nil, paused: !isPlaying)) { timeline in + let time = playhead(at: timeline.date) + VStack(spacing: 0) { + canvas(time: time) + if showsControls { + controls(time: time) + } + } + .onChange(of: timeline.date) { _, newDate in + if isPlaying && !loops && playhead(at: newDate) >= scene.duration { + anchorTime = scene.duration + isPlaying = false + } + } + } + .background(uiColor(background)) + } + + // MARK: - Playback + + private func playhead(at date: Date) -> Double { + let total = scene.duration + guard total > 0 else { return 0 } + guard isPlaying else { return clamp(anchorTime, 0...total) } + let raw = anchorTime + date.timeIntervalSince(anchorDate) + if loops { + return raw.truncatingRemainder(dividingBy: total) + } + return Swift.min(raw, total) + } + + private func togglePlayback(from time: Double) { + if isPlaying { + anchorTime = time + isPlaying = false + } else { + anchorTime = (!loops && time >= scene.duration) ? 0 : time + anchorDate = Date() + isPlaying = true + } + } + + private func seek(to time: Double) { + anchorTime = clamp(time, 0...Swift.max(scene.duration, 0)) + anchorDate = Date() + } + + // MARK: - Rendering + + private func canvas(time: Double) -> some View { + Canvas { context, size in + context.fill( + Path(CGRect(origin: .zero, size: size)), + with: .color(uiColor(background)) + ) + guard size.width > 0, size.height > 0 else { return } + + let scale = Swift.min(Double(size.width) / frameSize.x, Double(size.height) / frameSize.y) + let centerX = Double(size.width) / 2 + let centerY = Double(size.height) / 2 + func viewPoint(_ p: Vec2) -> CGPoint { + CGPoint(x: centerX + p.x * scale, y: centerY - p.y * scale) + } + + for mobject in scene.snapshot(at: time) { + var path = Path() + for subpath in mobject.worldPath.subpaths where !subpath.curves.isEmpty { + path.move(to: viewPoint(subpath.curves[0].p0)) + for curve in subpath.curves { + path.addCurve( + to: viewPoint(curve.p1), + control1: viewPoint(curve.c1), + control2: viewPoint(curve.c2) + ) + } + if subpath.isClosed { + path.closeSubpath() + } + } + + let fillAlpha = mobject.effectiveFillAlpha + if fillAlpha > 0.001 { + context.fill(path, with: .color(uiColor(mobject.fillColor, alpha: fillAlpha))) + } + + let strokeAlpha = mobject.effectiveStrokeAlpha + if strokeAlpha > 0.001, mobject.strokeWidth > 0, mobject.strokeEnd > mobject.strokeStart { + var strokePath = path + if mobject.strokeStart > 0 || mobject.strokeEnd < 1 { + strokePath = path.trimmedPath( + from: CGFloat(mobject.strokeStart), + to: CGFloat(mobject.strokeEnd) + ) + } + context.stroke( + strokePath, + with: .color(uiColor(mobject.strokeColor, alpha: strokeAlpha)), + style: StrokeStyle( + // 100 Manim stroke units = 1 scene unit. + lineWidth: CGFloat(mobject.strokeWidth / 100 * scale), + lineCap: .round, + lineJoin: .round + ) + ) + } + } + } + .accessibilityLabel("Animation preview") + } + + // MARK: - Controls + + private func controls(time: Double) -> some View { + HStack(spacing: 12) { + Button { + togglePlayback(from: time) + } label: { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + } + .buttonStyle(.plain) + .accessibilityLabel(isPlaying ? "Pause" : "Play") + + Button { + seek(to: 0) + } label: { + Image(systemName: "gobackward") + } + .buttonStyle(.plain) + .accessibilityLabel("Restart") + + Slider( + value: Binding( + get: { time }, + set: { seek(to: $0) } + ), + in: 0...Swift.max(scene.duration, 0.001) + ) + .accessibilityLabel("Timeline") + + Text(timeLabel(time)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func timeLabel(_ time: Double) -> String { + String(format: "%.1fs / %.1fs", time, scene.duration) + } + + private func uiColor(_ color: ManimColor, alpha: Double? = nil) -> Color { + Color(red: color.red, green: color.green, blue: color.blue, opacity: alpha ?? color.alpha) + } +} + +#Preview("Demo scene") { + ManimView(scene: .demo) + .frame(width: 640, height: 420) +} +#endif diff --git a/Tests/PicoManimTests/DemoSceneTests.swift b/Tests/PicoManimTests/DemoSceneTests.swift new file mode 100644 index 0000000..ebf07eb --- /dev/null +++ b/Tests/PicoManimTests/DemoSceneTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import PicoManim + +@Suite("Demo scene") +struct DemoSceneTests { + @Test func demoSceneEvaluatesEverywhere() { + let scene = ManimScene.demo + #expect(scene.duration > 0) + for step in 0...20 { + let time = scene.duration * Double(step) / 20 + let snapshot = scene.snapshot(at: time) + #expect(!snapshot.isEmpty) + } + } + + @Test func demoSceneEndsWithEverythingFadedOut() { + let scene = ManimScene.demo + let final = scene.snapshot(at: scene.duration) + #expect(final.allSatisfy { $0.opacity == 0 }) + } +} From 5b7cbd9994385692f84e98fe561ef47ee772c93a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 14:43:44 +0000 Subject: [PATCH 2/2] Address review: re-anchor clock on appear, canvas guards, trim clamps - onAppear re-anchors the playback clock: the view struct (and its @State defaults) can be created well before display, which would otherwise start autoplay partway through the scene. - Drop the redundant per-frame background fill (the view's .background already covers the canvas) and guard non-positive frameSize so the scale can't become infinite or NaN. - Clamp trimmedPath bounds to 0...1 in case a custom rate function overshoots. - README: rectangle/square default is white (matches the PR 3 fix). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YGb3ukB5dQy4vgz7Z1hags --- README.md | 2 +- Sources/PicoManim/Preview/ManimView.swift | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c31bc59..9aec497 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ struct ContentView: View { | `Mobject.arc(radius:startAngle:endAngle:at:)` | white outline | | `Mobject.dot(at:radius:)` | white fill, no outline | | `Mobject.line(from:to:)` | white outline | -| `Mobject.rectangle(width:height:at:)` / `.square(sideLength:at:)` | blue outline | +| `Mobject.rectangle(width:height:at:)` / `.square(sideLength:at:)` | white outline | | `Mobject.triangle(radius:at:)` / `.regularPolygon(sides:radius:at:)` | blue outline | | `Mobject.polygon(_:)` / `.polyline(_:)` | blue / white outline | diff --git a/Sources/PicoManim/Preview/ManimView.swift b/Sources/PicoManim/Preview/ManimView.swift index 054cbac..2b7d936 100644 --- a/Sources/PicoManim/Preview/ManimView.swift +++ b/Sources/PicoManim/Preview/ManimView.swift @@ -66,6 +66,14 @@ public struct ManimView: View { } } .background(uiColor(background)) + .onAppear { + // The struct (and its @State defaults) can be created well before + // the view is displayed; re-anchor the clock so autoplay doesn't + // start partway through the scene. + if isPlaying { + anchorDate = Date() + } + } } // MARK: - Playback @@ -101,11 +109,10 @@ public struct ManimView: View { private func canvas(time: Double) -> some View { Canvas { context, size in - context.fill( - Path(CGRect(origin: .zero, size: size)), - with: .color(uiColor(background)) - ) - guard size.width > 0, size.height > 0 else { return } + // The view's .background fills behind the canvas; no need to + // paint it again here. Guard degenerate sizes (including a + // caller-supplied non-positive frameSize) so scale stays finite. + guard size.width > 0, size.height > 0, frameSize.x > 0, frameSize.y > 0 else { return } let scale = Swift.min(Double(size.width) / frameSize.x, Double(size.height) / frameSize.y) let centerX = Double(size.width) / 2 @@ -139,9 +146,11 @@ public struct ManimView: View { if strokeAlpha > 0.001, mobject.strokeWidth > 0, mobject.strokeEnd > mobject.strokeStart { var strokePath = path if mobject.strokeStart > 0 || mobject.strokeEnd < 1 { + // trimmedPath expects 0...1; clamp in case a custom + // rate function overshoots. strokePath = path.trimmedPath( - from: CGFloat(mobject.strokeStart), - to: CGFloat(mobject.strokeEnd) + from: CGFloat(clamp(mobject.strokeStart, 0...1)), + to: CGFloat(clamp(mobject.strokeEnd, 0...1)) ) } context.stroke(