diff --git a/Sources/PicoManim/Core/BezierPath.swift b/Sources/PicoManim/Core/BezierPath.swift new file mode 100644 index 0000000..b326fb4 --- /dev/null +++ b/Sources/PicoManim/Core/BezierPath.swift @@ -0,0 +1,331 @@ +import Foundation + +/// A vector path made of one or more subpaths, each a chain of cubic +/// Bézier curves. This mirrors Manim's `VMobject` representation: every +/// shape — including straight-edged polygons — is stored as cubics so that +/// any shape can morph smoothly into any other. +public struct BezierPath: Sendable, Hashable { + /// A connected chain of cubic curves. + public struct Subpath: Sendable, Hashable { + public var curves: [CubicCurve] + public var isClosed: Bool + + public init(curves: [CubicCurve], isClosed: Bool = false) { + self.curves = curves + self.isClosed = isClosed + } + } + + public var subpaths: [Subpath] + + public init(subpaths: [Subpath] = []) { + self.subpaths = subpaths + } + + /// Creates a single-subpath path. + public init(curves: [CubicCurve], isClosed: Bool = false) { + self.subpaths = [Subpath(curves: curves, isClosed: isClosed)] + } + + /// Total number of curves across all subpaths. + public var curveCount: Int { + subpaths.reduce(0) { $0 + $1.curves.count } + } + + public var isEmpty: Bool { + subpaths.allSatisfy { $0.curves.isEmpty } + } + + // MARK: - Construction + + /// An open polyline through `points`. + public static func polyline(_ points: [Vec2]) -> BezierPath { + guard points.count >= 2 else { return BezierPath() } + var curves: [CubicCurve] = [] + curves.reserveCapacity(points.count - 1) + for i in 0..<(points.count - 1) { + curves.append(.line(from: points[i], to: points[i + 1])) + } + return BezierPath(curves: curves, isClosed: false) + } + + /// A closed polygon through `points` (the closing edge is added + /// automatically). + public static func polygon(_ points: [Vec2]) -> BezierPath { + guard points.count >= 3 else { return polyline(points) } + var curves: [CubicCurve] = [] + curves.reserveCapacity(points.count) + for i in 0.. BezierPath { + BezierPath(curves: [.line(from: start, to: end)], isClosed: false) + } + + /// A circular arc centered at `center`, from `startAngle` to `endAngle` + /// (radians, counterclockwise when `endAngle > startAngle`). + public static func arc( + center: Vec2 = .zero, + radius: Double, + startAngle: Double, + endAngle: Double + ) -> BezierPath { + let sweep = endAngle - startAngle + guard abs(sweep) > 1e-9, radius > 0 else { return BezierPath() } + // Use one cubic segment per (up to) 45 degrees of sweep. + let segmentCount = max(1, Int(ceil(abs(sweep) / (Double.pi / 4) - 1e-9))) + let delta = sweep / Double(segmentCount) + // Standard cubic approximation of a circular arc segment. + let k = (4.0 / 3.0) * tan(delta / 4) + var curves: [CubicCurve] = [] + curves.reserveCapacity(segmentCount) + for i in 0.. BezierPath { + var path = arc(center: center, radius: radius, startAngle: 0, endAngle: 2 * Double.pi) + for i in path.subpaths.indices { + path.subpaths[i].isClosed = true + } + return path + } + + /// An axis-aligned ellipse centered at `center`. + public static func ellipse(center: Vec2 = .zero, width: Double, height: Double) -> BezierPath { + var path = circle(center: .zero, radius: 1) + let scale = Vec2(width / 2, height / 2) + path = path.mapPoints { $0 * scale + center } + return path + } + + /// An axis-aligned rectangle centered at `center`. + public static func rectangle(center: Vec2 = .zero, width: Double, height: Double) -> BezierPath { + let w = width / 2 + let h = height / 2 + return polygon([ + Vec2(center.x + w, center.y + h), + Vec2(center.x - w, center.y + h), + Vec2(center.x - w, center.y - h), + Vec2(center.x + w, center.y - h) + ]) + } + + /// A regular polygon with `sides` vertices inscribed in a circle of + /// `radius`, with the first vertex at `startAngle` radians. + public static func regularPolygon( + sides: Int, + radius: Double, + center: Vec2 = .zero, + startAngle: Double = Double.pi / 2 + ) -> BezierPath { + guard sides >= 3 else { return BezierPath() } + let points = (0.. Vec2 in + let angle = startAngle + 2 * Double.pi * Double(i) / Double(sides) + return center + Vec2.direction(angle) * radius + } + return polygon(points) + } + + // MARK: - Geometry + + /// Applies `transform` to every control point. + public func mapPoints(_ transform: (Vec2) -> Vec2) -> BezierPath { + var result = self + for si in result.subpaths.indices { + for ci in result.subpaths[si].curves.indices { + var curve = result.subpaths[si].curves[ci] + curve.p0 = transform(curve.p0) + curve.c1 = transform(curve.c1) + curve.c2 = transform(curve.c2) + curve.p1 = transform(curve.p1) + result.subpaths[si].curves[ci] = curve + } + } + return result + } + + /// The path with `transform` applied to every control point. + public func transformed(by transform: Transform2D) -> BezierPath { + mapPoints { transform.apply(to: $0) } + } + + /// An approximate axis-aligned bounding box, computed by sampling each + /// curve. Returns `nil` for an empty path. `samplesPerCurve` is clamped + /// to at least 1. + public func boundingBox(samplesPerCurve: Int = 8) -> (min: Vec2, max: Vec2)? { + let samples = Swift.max(1, samplesPerCurve) + var minPoint = Vec2(Double.infinity, Double.infinity) + var maxPoint = Vec2(-Double.infinity, -Double.infinity) + var found = false + for subpath in subpaths { + for curve in subpath.curves { + for i in 0...samples { + let p = curve.point(at: Double(i) / Double(samples)) + minPoint = Vec2(Swift.min(minPoint.x, p.x), Swift.min(minPoint.y, p.y)) + maxPoint = Vec2(Swift.max(maxPoint.x, p.x), Swift.max(maxPoint.y, p.y)) + found = true + } + } + } + return found ? (minPoint, maxPoint) : nil + } + + /// The center of the bounding box, or the origin for an empty path. + public var boundingBoxCenter: Vec2 { + guard let box = boundingBox() else { return .zero } + return (box.min + box.max) / 2 + } + + // MARK: - Partial paths + + /// The leading portion of the path, up to `proportion` (0...1) of its + /// total curve count. Used for progressive "draw" animations. + public func partial(upTo proportion: Double) -> BezierPath { + let t = clamp(proportion, 0...1) + if t >= 1 { return self } + let total = curveCount + guard total > 0, t > 0 else { return BezierPath() } + var remaining = t * Double(total) + var resultSubpaths: [Subpath] = [] + for subpath in subpaths { + if remaining <= 0 { break } + let count = Double(subpath.curves.count) + if remaining >= count { + resultSubpaths.append(subpath) + remaining -= count + } else { + let whole = Int(remaining) + let fraction = remaining - Double(whole) + var curves = Array(subpath.curves.prefix(whole)) + if fraction > 1e-9, whole < subpath.curves.count { + curves.append(subpath.curves[whole].clipped(from: 0, to: fraction)) + } + if !curves.isEmpty { + resultSubpaths.append(Subpath(curves: curves, isClosed: false)) + } + remaining = 0 + } + } + return BezierPath(subpaths: resultSubpaths) + } + + // MARK: - Alignment & interpolation + + /// Returns copies of `self` and `other` restructured to have the same + /// number of subpaths and the same number of curves per subpath, so the + /// two paths can be interpolated point-for-point. + public func aligned(with other: BezierPath) -> (BezierPath, BezierPath) { + var a = subpaths + var b = other.subpaths + + // Anchor a padding subpath at the end of its own path, or — when the + // path was originally empty — at the start of the counterpart subpath + // it will pair with, so morphs never fly in from the origin. The + // own-side anchor is resolved against the pre-padding subpaths so a + // pad never latches onto an earlier pad's degenerate point. + func degenerateSubpath(ownLastPoint: Vec2?, counterpart: [Subpath], pairIndex: Int) -> Subpath { + let anchor = ownLastPoint + ?? (pairIndex < counterpart.count ? counterpart[pairIndex].curves.first?.p0 : nil) + ?? .zero + return Subpath( + curves: [CubicCurve(p0: anchor, c1: anchor, c2: anchor, p1: anchor)], + isClosed: false + ) + } + + let lastPointA = a.reversed().first { !$0.curves.isEmpty }?.curves.last?.p1 + let lastPointB = b.reversed().first { !$0.curves.isEmpty }?.curves.last?.p1 + while a.count < b.count { + a.append(degenerateSubpath(ownLastPoint: lastPointA, counterpart: b, pairIndex: a.count)) + } + while b.count < a.count { + b.append(degenerateSubpath(ownLastPoint: lastPointB, counterpart: a, pairIndex: b.count)) + } + + for i in a.indices { + let target = Swift.max(a[i].curves.count, b[i].curves.count) + // Anchor an empty subpath at its counterpart's start so its + // degenerate curves don't fly in from the origin during a morph. + let anchorA = a[i].curves.first?.p0 ?? b[i].curves.first?.p0 ?? .zero + let anchorB = b[i].curves.first?.p0 ?? a[i].curves.first?.p0 ?? .zero + a[i] = a[i].subdividedEvenly(to: target, fallbackAnchor: anchorA) + b[i] = b[i].subdividedEvenly(to: target, fallbackAnchor: anchorB) + } + return (BezierPath(subpaths: a), BezierPath(subpaths: b)) + } + + /// Interpolates between two structurally aligned paths (see + /// ``aligned(with:)``). The inputs should have matching structure; + /// subpaths and curves beyond the shorter path's count are dropped + /// for 0 < t < 1. + public static func interpolate(_ a: BezierPath, _ b: BezierPath, _ t: Double) -> BezierPath { + if t <= 0 { return a } + if t >= 1 { return b } + var result: [Subpath] = [] + let subpathCount = Swift.min(a.subpaths.count, b.subpaths.count) + result.reserveCapacity(subpathCount) + for i in 0.. BezierPath.Subpath { + let count = curves.count + guard target > count else { return self } + guard count > 0 else { + let degenerate = CubicCurve( + p0: fallbackAnchor, c1: fallbackAnchor, c2: fallbackAnchor, p1: fallbackAnchor + ) + return BezierPath.Subpath( + curves: Array(repeating: degenerate, count: target), + isClosed: isClosed + ) + } + let base = target / count + let remainder = target % count + var result: [CubicCurve] = [] + result.reserveCapacity(target) + for (i, curve) in curves.enumerated() { + let pieces = base + (i < remainder ? 1 : 0) + result.append(contentsOf: curve.subdivided(into: pieces)) + } + return BezierPath.Subpath(curves: result, isClosed: isClosed) + } +} diff --git a/Sources/PicoManim/Core/CubicCurve.swift b/Sources/PicoManim/Core/CubicCurve.swift new file mode 100644 index 0000000..df9827d --- /dev/null +++ b/Sources/PicoManim/Core/CubicCurve.swift @@ -0,0 +1,89 @@ +/// A single cubic Bézier curve defined by four control points. +public struct CubicCurve: Sendable, Hashable { + public var p0: Vec2 + public var c1: Vec2 + public var c2: Vec2 + public var p1: Vec2 + + public init(p0: Vec2, c1: Vec2, c2: Vec2, p1: Vec2) { + self.p0 = p0 + self.c1 = c1 + self.c2 = c2 + self.p1 = p1 + } + + /// A cubic curve that traces the straight line from `start` to `end`, + /// with control points at the one-third points so that `point(at:)` + /// moves linearly along the segment. + public static func line(from start: Vec2, to end: Vec2) -> CubicCurve { + CubicCurve( + p0: start, + c1: Vec2.lerp(start, end, 1.0 / 3.0), + c2: Vec2.lerp(start, end, 2.0 / 3.0), + p1: end + ) + } + + /// Evaluates the curve at parameter `t` in 0...1. + public func point(at t: Double) -> Vec2 { + let u = 1 - t + let a = u * u * u + let b = 3 * u * u * t + let c = 3 * u * t * t + let d = t * t * t + return p0 * a + c1 * b + c2 * c + p1 * d + } + + /// Splits the curve at parameter `t` using de Casteljau's algorithm, + /// returning the two halves. + public func split(at t: Double) -> (CubicCurve, CubicCurve) { + let q0 = Vec2.lerp(p0, c1, t) + let q1 = Vec2.lerp(c1, c2, t) + let q2 = Vec2.lerp(c2, p1, t) + let r0 = Vec2.lerp(q0, q1, t) + let r1 = Vec2.lerp(q1, q2, t) + let s = Vec2.lerp(r0, r1, t) + return ( + CubicCurve(p0: p0, c1: q0, c2: r0, p1: s), + CubicCurve(p0: s, c1: r1, c2: q2, p1: p1) + ) + } + + /// The sub-curve covering parameters `a...b` of this curve. + public func clipped(from a: Double, to b: Double) -> CubicCurve { + let a = clamp(a, 0...1) + let b = clamp(b, a...1) + if a <= 0 && b >= 1 { return self } + if a >= 1 { + return CubicCurve(p0: p1, c1: p1, c2: p1, p1: p1) + } + let tail = a <= 0 ? self : split(at: a).1 + // Remap b into the tail's parameter space. + let tb = (b - a) / (1 - a) + if tb >= 1 { return tail } + return tail.split(at: tb).0 + } + + /// Splits the curve into `count` sub-curves of equal parameter span. + public func subdivided(into count: Int) -> [CubicCurve] { + guard count > 1 else { return [self] } + var pieces: [CubicCurve] = [] + pieces.reserveCapacity(count) + for i in 0.. CubicCurve { + CubicCurve( + p0: Vec2.lerp(a.p0, b.p0, t), + c1: Vec2.lerp(a.c1, b.c1, t), + c2: Vec2.lerp(a.c2, b.c2, t), + p1: Vec2.lerp(a.p1, b.p1, t) + ) + } +} diff --git a/Tests/PicoManimTests/BezierTests.swift b/Tests/PicoManimTests/BezierTests.swift new file mode 100644 index 0000000..daa94dc --- /dev/null +++ b/Tests/PicoManimTests/BezierTests.swift @@ -0,0 +1,214 @@ +import Testing +@testable import PicoManim + +private func approx(_ a: Double, _ b: Double, tolerance: Double = 1e-9) -> Bool { + abs(a - b) <= tolerance +} + +private func approx(_ a: Vec2, _ b: Vec2, tolerance: Double = 1e-9) -> Bool { + approx(a.x, b.x, tolerance: tolerance) && approx(a.y, b.y, tolerance: tolerance) +} + +@Suite("Cubic curves") +struct CubicCurveTests { + @Test func linePointIsLinear() { + let curve = CubicCurve.line(from: Vec2(0, 0), to: Vec2(4, 2)) + #expect(approx(curve.point(at: 0), Vec2(0, 0))) + #expect(approx(curve.point(at: 0.5), Vec2(2, 1))) + #expect(approx(curve.point(at: 1), Vec2(4, 2))) + } + + @Test func splitPreservesGeometry() { + let curve = CubicCurve( + p0: Vec2(0, 0), c1: Vec2(1, 2), c2: Vec2(3, -1), p1: Vec2(4, 0) + ) + let (head, tail) = curve.split(at: 0.3) + #expect(approx(head.p1, tail.p0)) + #expect(approx(head.p0, curve.p0)) + #expect(approx(tail.p1, curve.p1)) + // A point in the head half maps to the original curve. + #expect(approx(head.point(at: 0.5), curve.point(at: 0.15), tolerance: 1e-9)) + // A point in the tail half maps to the original curve. + #expect(approx(tail.point(at: 0.5), curve.point(at: 0.3 + 0.7 * 0.5), tolerance: 1e-9)) + } + + @Test func subdividedPreservesEndpointsAndCount() { + let curve = CubicCurve( + p0: Vec2(0, 0), c1: Vec2(1, 2), c2: Vec2(3, -1), p1: Vec2(4, 0) + ) + let pieces = curve.subdivided(into: 3) + #expect(pieces.count == 3) + #expect(approx(pieces[0].p0, curve.p0)) + #expect(approx(pieces[2].p1, curve.p1)) + #expect(approx(pieces[0].p1, pieces[1].p0)) + #expect(approx(pieces[1].p1, pieces[2].p0)) + #expect(approx(pieces[1].p0, curve.point(at: 1.0 / 3.0), tolerance: 1e-9)) + } +} + +@Suite("Bezier paths") +struct BezierPathTests { + @Test func circleBoundsMatchRadius() throws { + let path = BezierPath.circle(radius: 1.5) + let box = try #require(path.boundingBox()) + #expect(approx(box.max.x, 1.5, tolerance: 0.01)) + #expect(approx(box.max.y, 1.5, tolerance: 0.01)) + #expect(approx(box.min.x, -1.5, tolerance: 0.01)) + #expect(approx(box.min.y, -1.5, tolerance: 0.01)) + #expect(path.subpaths.count == 1) + #expect(path.subpaths[0].isClosed) + #expect(path.curveCount == 8) + } + + @Test func polygonIsClosedPolylineIsOpen() { + let triangle = BezierPath.polygon([Vec2(0, 0), Vec2(1, 0), Vec2(0, 1)]) + #expect(triangle.subpaths[0].isClosed) + #expect(triangle.curveCount == 3) + + let open = BezierPath.polyline([Vec2(0, 0), Vec2(1, 0), Vec2(0, 1)]) + #expect(!open.subpaths[0].isClosed) + #expect(open.curveCount == 2) + } + + @Test func rectangleBounds() throws { + let path = BezierPath.rectangle(width: 4, height: 2) + let box = try #require(path.boundingBox()) + #expect(approx(box.min.x, -2)) + #expect(approx(box.max.x, 2)) + #expect(approx(box.min.y, -1)) + #expect(approx(box.max.y, 1)) + } + + @Test func arcEndpointsLieOnCircle() throws { + let path = BezierPath.arc(radius: 2, startAngle: 0, endAngle: .pi) + let first = try #require(path.subpaths.first?.curves.first) + let last = try #require(path.subpaths.first?.curves.last) + #expect(approx(first.p0, Vec2(2, 0), tolerance: 1e-9)) + #expect(approx(last.p1, Vec2(-2, 0), tolerance: 1e-9)) + // Half circle at 45 degrees per segment. + #expect(path.curveCount == 4) + } + + @Test func negativeSweepArcRunsClockwise() throws { + let path = BezierPath.arc(radius: 1, startAngle: .pi / 2, endAngle: 0) + let first = try #require(path.subpaths.first?.curves.first) + let last = try #require(path.subpaths.first?.curves.last) + #expect(approx(first.p0, Vec2(0, 1), tolerance: 1e-9)) + #expect(approx(last.p1, Vec2(1, 0), tolerance: 1e-9)) + // Midpoint of the quarter sweep stays on the circle. + let mid = first.point(at: 0.5) + #expect(approx(mid.length, 1, tolerance: 1e-3)) + } + + @Test func partialHalfOfSquareKeepsTwoEdges() { + let square = BezierPath.rectangle(width: 2, height: 2) + let half = square.partial(upTo: 0.5) + #expect(half.curveCount == 2) + #expect(!half.subpaths[0].isClosed) + } + + @Test func partialSplitsBoundaryCurve() throws { + let square = BezierPath.rectangle(width: 2, height: 2) + let partial = square.partial(upTo: 0.375) // 1.5 of 4 curves + #expect(partial.curveCount == 2) + let lastPoint = try #require(partial.subpaths.first?.curves.last?.p1) + let expected = square.subpaths[0].curves[1].point(at: 0.5) + #expect(approx(lastPoint, expected, tolerance: 1e-9)) + } + + @Test func partialZeroAndOne() { + let circle = BezierPath.circle(radius: 1) + #expect(circle.partial(upTo: 0).isEmpty) + #expect(circle.partial(upTo: 1).curveCount == circle.curveCount) + } + + @Test func alignmentEqualizesCurveCounts() { + let circle = BezierPath.circle(radius: 1) // 8 curves + let square = BezierPath.rectangle(width: 2, height: 2) // 4 curves + let (a, b) = circle.aligned(with: square) + #expect(a.curveCount == 8) + #expect(b.curveCount == 8) + #expect(a.subpaths.count == b.subpaths.count) + } + + @Test func alignmentPadsMissingSubpaths() { + let one = BezierPath.circle(radius: 1) + let two = BezierPath(subpaths: [ + BezierPath.Subpath(curves: [.line(from: Vec2(0, 0), to: Vec2(1, 0))]), + BezierPath.Subpath(curves: [.line(from: Vec2(0, 1), to: Vec2(1, 1))]) + ]) + let (a, b) = one.aligned(with: two) + #expect(a.subpaths.count == 2) + #expect(b.subpaths.count == 2) + for i in 0..<2 { + #expect(a.subpaths[i].curves.count == b.subpaths[i].curves.count) + } + } + + @Test func boundingBoxToleratesNonPositiveSampleCounts() throws { + let path = BezierPath.rectangle(width: 2, height: 2) + let box = try #require(path.boundingBox(samplesPerCurve: 0)) + #expect(approx(box.max.x, 1)) + #expect(path.boundingBox(samplesPerCurve: -3) != nil) + } + + @Test func alignmentAnchorsEmptySubpathAtCounterpart() { + let empty = BezierPath(subpaths: [BezierPath.Subpath(curves: [])]) + let line = BezierPath.line(from: Vec2(2, 3), to: Vec2(4, 3)) + let (a, b) = empty.aligned(with: line) + #expect(a.subpaths[0].curves.count == b.subpaths[0].curves.count) + // The degenerate side sits at the counterpart's start, not the origin, + // so a morph doesn't fly in from (0, 0). + #expect(a.subpaths[0].curves.allSatisfy { + approx($0.p0, Vec2(2, 3)) && approx($0.p1, Vec2(2, 3)) + }) + } + + @Test func aligningWhollyEmptyPathAnchorsAtCounterpart() { + let empty = BezierPath() + let line = BezierPath.line(from: Vec2(2, 3), to: Vec2(4, 3)) + let (a, b) = empty.aligned(with: line) + #expect(a.subpaths.count == 1) + #expect(a.subpaths[0].curves.count == b.subpaths[0].curves.count) + // The padded side sits at the counterpart's start, not the origin. + #expect(a.subpaths[0].curves.allSatisfy { + approx($0.p0, Vec2(2, 3)) && approx($0.p1, Vec2(2, 3)) + }) + } + + @Test func paddingEmptyPathAnchorsEachSubpathAtItsOwnCounterpart() { + let empty = BezierPath() + let twoLines = BezierPath(subpaths: [ + BezierPath.Subpath(curves: [.line(from: Vec2(1, 1), to: Vec2(2, 1))]), + BezierPath.Subpath(curves: [.line(from: Vec2(5, 5), to: Vec2(6, 5))]) + ]) + let (a, _) = empty.aligned(with: twoLines) + #expect(a.subpaths.count == 2) + // Each pad anchors at its own counterpart subpath's start, not at + // the previous pad's point. + #expect(a.subpaths[0].curves.allSatisfy { approx($0.p0, Vec2(1, 1)) }) + #expect(a.subpaths[1].curves.allSatisfy { approx($0.p0, Vec2(5, 5)) }) + } + + @Test func interpolationEndpointsMatchInputs() throws { + let circle = BezierPath.circle(radius: 1) + let square = BezierPath.rectangle(width: 2, height: 2) + let (a, b) = circle.aligned(with: square) + let atStart = BezierPath.interpolate(a, b, 0) + let atEnd = BezierPath.interpolate(a, b, 1) + let startBox = try #require(atStart.boundingBox()) + let aBox = try #require(a.boundingBox()) + #expect(approx(startBox.min, aBox.min)) + let endBox = try #require(atEnd.boundingBox()) + let bBox = try #require(b.boundingBox()) + #expect(approx(endBox.max, bBox.max)) + } + + @Test func transformedByOffsetMovesBounds() throws { + let path = BezierPath.circle(radius: 1) + .transformed(by: Transform2D(translation: Vec2(3, -2))) + let box = try #require(path.boundingBox()) + #expect(approx((box.min.x + box.max.x) / 2, 3, tolerance: 1e-6)) + #expect(approx((box.min.y + box.max.y) / 2, -2, tolerance: 1e-6)) + } +}