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
85 changes: 78 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:)` | white 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.

Expand All @@ -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.
46 changes: 46 additions & 0 deletions Sources/PicoManim/Preview/DemoScenes.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
223 changes: 223 additions & 0 deletions Sources/PicoManim/Preview/ManimView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
#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))
.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()
}
}
}
Comment on lines +68 to +77

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

Initializing @State private var anchorDate = Date() at the property declaration level means the date is captured when the ManimView struct is first instantiated. In SwiftUI, view structs can be created well before they are actually displayed on screen (e.g., when pre-rendering in a navigation stack, tab view, or parent view update).

When the view finally appears and starts playing, anchorDate will be in the past, causing the animation to instantly skip forward by that time difference (or skip the entire animation if the delay was longer than the duration).

To fix this, we should reset anchorDate to the current time when the view actually appears on screen.

        .background(uiColor(background))
        .onAppear {
            if isPlaying {
                anchorDate = Date()
            }
        }
    }

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 caf2a65 ("Address review: re-anchor clock on appear, canvas guards, trim clamps"): added the suggested .onAppear that re-anchors anchorDate when autoplay is on, so a view instantiated before display doesn't start partway through (or past) the scene. Not unit-testable (needs a real SwiftUI lifecycle); noted in the PR description's test-strategy caveat and verified via the preview.


Generated by Claude Code


// 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
// 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
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 {
// trimmedPath expects 0...1; clamp in case a custom
// rate function overshoots.
strokePath = path.trimmedPath(
from: CGFloat(clamp(mobject.strokeStart, 0...1)),
to: CGFloat(clamp(mobject.strokeEnd, 0...1))
)
}
Comment on lines +148 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

SwiftUI's Path.trimmedPath(from:to:) expects values strictly within the range 0...1. If an easing function with overshoot (such as backIn or custom spring-like rate functions) drives strokeStart or strokeEnd slightly below 0 or above 1, trimmedPath can produce rendering artifacts or unexpected behavior.

We should clamp these values to 0...1 before passing them to trimmedPath.

Suggested change
if mobject.strokeStart > 0 || mobject.strokeEnd < 1 {
strokePath = path.trimmedPath(
from: CGFloat(mobject.strokeStart),
to: CGFloat(mobject.strokeEnd)
)
}
if mobject.strokeStart > 0 || mobject.strokeEnd < 1 {
strokePath = path.trimmedPath(
from: CGFloat(clamp(mobject.strokeStart, 0...1)),
to: CGFloat(clamp(mobject.strokeEnd, 0...1))
)
}

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 caf2a65: trimmedPath bounds are clamped to 0...1 exactly as suggested. Belt-and-suspenders with PR #3's clamped effective alphas — strokeStart/strokeEnd themselves can still exceed the range under overshooting custom rates, so the renderer clamps at the boundary it owns.


Generated by Claude Code

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
Loading