Skip to content

Phase 1 (2/5): Cubic Bézier engine — curves, paths, partials, morph alignment - #2

Merged
ronaldmannak merged 5 commits into
claude/picomanim-phase1-01-scaffoldfrom
claude/picomanim-phase1-02-bezier
Jul 5, 2026
Merged

Phase 1 (2/5): Cubic Bézier engine — curves, paths, partials, morph alignment#2
ronaldmannak merged 5 commits into
claude/picomanim-phase1-01-scaffoldfrom
claude/picomanim-phase1-02-bezier

Conversation

@ronaldmannak

Copy link
Copy Markdown
Contributor

Summary

The geometry engine, mirroring Manim's VMobject representation: all geometry — including straight edges — is stored as cubic Béziers, so any shape can morph into any other.

  • CubicCurve — evaluation, de Casteljau split(at:)/clipped(from:to:), even subdivision.
  • BezierPath — multi-subpath container: constructors (polygon, polyline, line, arc, circle, ellipse, rectangle, regular polygon), sampled bounding boxes, point mapping / Transform2D application.
  • partial(upTo:) — the leading portion of a path (splits the boundary curve at fractional progress); this is what draw-in animations consume in PR 4.
  • aligned(with:) / interpolate(_:_:_:) — restructure two paths to matching subpath/curve counts, then lerp control points; this is the morph (transform) machinery.

Arcs use the standard k = 4/3·tan(Δ/4) cubic approximation, one segment per ≤45° of sweep (negative sweeps covered by a test); circles are 8 segments so they morph smoothly.

Stack

PR 2/5 — stacked on #1 (base branch claude/picomanim-phase1-01-scaffold; will be retargeted to main when #1 merges). PRs 3–5 build on this.

Intentionally not included

Styling, identity, or mobjects (PR 3); anything time-based (PR 4). This PR is pure geometry with no rendering.

Test strategy

Algebraic identities recomputed by hand: split/clip continuity against de Casteljau (endpoints + parameter remapping), partial curve counting incl. fractional boundary splits, alignment count equalization (8-curve circle vs 4-curve square → 8/8; subpath padding), interpolation endpoints reproducing inputs, sampled circle bounds within 1e-2 of the radius.

Concurrency

Pure Sendable/Hashable value types; no shared state.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YGb3ukB5dQy4vgz7Z1hags


Generated by Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the core vector graphics foundation for PicoManim, adding BezierPath and CubicCurve structs along with comprehensive unit tests. These types enable path construction, bounding box calculation, path splitting, and alignment/interpolation for morphing animations. The review feedback highlights critical improvements for robustness and animation quality: clamping samplesPerCurve to prevent crashes or division-by-zero errors in boundingBox, introducing a fallbackAnchor during path subdivision to avoid visual morphing artifacts from the origin, and robustly resolving anchor points when degenerate subpaths are created near empty subpaths.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +175 to +190
public func boundingBox(samplesPerCurve: Int = 8) -> (min: Vec2, max: Vec2)? {
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...samplesPerCurve {
let p = curve.point(at: Double(i) / Double(samplesPerCurve))
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
}

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

If samplesPerCurve is negative, creating the closed range 0...samplesPerCurve will cause a runtime crash (precondition failure) in Swift. If it is 0, it will result in a division by zero, producing NaN coordinates. Clamping samplesPerCurve to at least 1 avoids these issues safely.

Suggested change
public func boundingBox(samplesPerCurve: Int = 8) -> (min: Vec2, max: Vec2)? {
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...samplesPerCurve {
let p = curve.point(at: Double(i) / Double(samplesPerCurve))
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
}
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
}

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 56d1d3b ("Address review: clamp bbox samples, anchor degenerate morph curves"). boundingBox(samplesPerCurve:) clamps to at least 1 sample, so 0 can't divide by zero and negatives can't trap on range creation. Added boundingBoxToleratesNonPositiveSampleCounts covering both cases. Codex flagged the same issue (same fix). No impact on later PRs (they call it with the default).


Generated by Claude Code

Comment on lines +251 to +255
for i in a.indices {
let target = Swift.max(a[i].curves.count, b[i].curves.count)
a[i] = a[i].subdividedEvenly(to: target)
b[i] = b[i].subdividedEvenly(to: target)
}

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

Pass the start point of the corresponding subpath as the fallbackAnchor when subdividing. This ensures that if one of the subpaths is empty, its degenerate curves are anchored at the other shape's subpath start point, preventing the 'fly-in from origin' visual artifact during morphing.

        for i in a.indices {
            let target = Swift.max(a[i].curves.count, b[i].curves.count)
            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)
        }

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 56d1d3b, exactly as suggested: aligned(with:) computes per-pair anchors (own start, else counterpart's start, else origin) and passes them to subdividedEvenly(to:fallbackAnchor:). Added alignmentAnchorsEmptySubpathAtCounterpart, which pins that the degenerate side sits at the counterpart's start point rather than the origin. This improves PR #4's transform morphs for any future multi-subpath shapes; no call-site changes needed there.


Generated by Claude Code

Comment thread Sources/PicoManim/Core/BezierPath.swift Outdated
Comment on lines +287 to +299
public func subdividedEvenly(to target: Int) -> BezierPath.Subpath {
let count = curves.count
guard target > count else { return self }
guard count > 0 else {
// An empty subpath still has to match the other path's structure:
// fill it with degenerate point-curves.
let point = Vec2.zero
let degenerate = CubicCurve(p0: point, c1: point, c2: point, p1: point)
return BezierPath.Subpath(
curves: Array(repeating: degenerate, count: target),
isClosed: isClosed
)
}

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

When subdividing an empty subpath, hardcoding the degenerate curves to Vec2.zero causes them to fly in from the origin during morphing animations. Adding a fallbackAnchor parameter allows the alignment logic to position these degenerate curves at the start of the corresponding subpath on the other shape, resulting in a much smoother morph.

    public func subdividedEvenly(to target: Int, fallbackAnchor: Vec2 = .zero) -> BezierPath.Subpath {
        let count = curves.count
        guard target > count else { return self }
        guard count > 0 else {
            // An empty subpath still has to match the other path's structure:
            // fill it with degenerate point-curves.
            let degenerate = CubicCurve(p0: fallbackAnchor, c1: fallbackAnchor, c2: fallbackAnchor, p1: fallbackAnchor)
            return BezierPath.Subpath(
                curves: Array(repeating: degenerate, count: target),
                isClosed: isClosed
            )
        }

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 56d1d3b together with the alignment-side change: subdividedEvenly now takes fallbackAnchor: Vec2 = .zero and fills an empty subpath with degenerate point-curves at that anchor. Covered by alignmentAnchorsEmptySubpathAtCounterpart.


Generated by Claude Code

Comment thread Sources/PicoManim/Core/BezierPath.swift Outdated
Comment on lines +240 to +246
func degenerateSubpath(near subpaths: [Subpath]) -> Subpath {
let anchor = subpaths.last?.curves.last?.p1 ?? .zero
return Subpath(
curves: [CubicCurve(p0: anchor, c1: anchor, c2: anchor, p1: anchor)],
isClosed: false
)
}

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

If the last subpath in subpaths is empty (has no curves), subpaths.last?.curves.last will be nil, causing the anchor to fall back to .zero. Iterating backwards to find the last non-empty subpath provides a much more robust anchor point.

Suggested change
func degenerateSubpath(near subpaths: [Subpath]) -> Subpath {
let anchor = subpaths.last?.curves.last?.p1 ?? .zero
return Subpath(
curves: [CubicCurve(p0: anchor, c1: anchor, c2: anchor, p1: anchor)],
isClosed: false
)
}
func degenerateSubpath(near subpaths: [Subpath]) -> Subpath {
let anchor = subpaths.reversed().first(where: { !$0.curves.isEmpty })?.curves.last?.p1 ?? .zero
return Subpath(
curves: [CubicCurve(p0: anchor, c1: anchor, c2: anchor, p1: anchor)],
isClosed: false
)
}

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 56d1d3b: degenerateSubpath(near:) walks backwards to the last non-empty subpath for its anchor (subpaths.reversed().first { !$0.curves.isEmpty }), falling back to the origin only when every subpath is empty.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 119a685d64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/PicoManim/Core/BezierPath.swift Outdated
for subpath in subpaths {
for curve in subpath.curves {
for i in 0...samplesPerCurve {
let p = curve.point(at: Double(i) / Double(samplesPerCurve))

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 Guard non-positive sample counts

When a caller passes samplesPerCurve as 0, this divides by zero and feeds NaN into the min/max updates, so a non-empty path returns an invalid (inf, -inf) bounding box; negative values also trap when constructing 0...samplesPerCurve. Since this is a public parameter, clamp it to at least 1 or explicitly reject invalid counts before iterating.

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 56d1d3b: samplesPerCurve is clamped to at least 1 (Swift.max(1, samplesPerCurve)), which handles both the zero (NaN bbox) and negative (range trap) cases. Regression test boundingBoxToleratesNonPositiveSampleCounts covers both. Gemini raised the same point; one fix resolves both threads.


Generated by Claude Code

@ronaldmannak
ronaldmannak force-pushed the claude/picomanim-phase1-02-bezier branch 2 times, most recently from 3e7994e to 56d1d3b Compare July 5, 2026 14:41

Copy link
Copy Markdown
Contributor Author

@codex review


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 56d1d3b360

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/PicoManim/Core/BezierPath.swift Outdated
)
}

while a.count < b.count { a.append(degenerateSubpath(near: a)) }

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 Anchor missing subpaths to the counterpart

When aligning a completely empty path with a real path, this append creates a one-curve degenerate subpath anchored at .zero because a has no non-empty subpaths yet. Since the placeholder is no longer empty, the later fallbackAnchor logic does not move it to the counterpart's start, so BezierPath().aligned(with: BezierPath.line(from: Vec2(2, 3), to: Vec2(4, 3))) still morphs in from the origin despite the surrounding code trying to avoid that behavior for empty subpaths.

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 add883e. Nice catch — the padding loop ran before subdividedEvenly's fallback could apply (the placeholder already has one curve, so the empty-subpath path never fires). degenerateSubpath now takes the counterpart and the pair index, anchoring at the counterpart subpath's start when its own path has no non-empty subpath. Your exact repro (BezierPath().aligned(with: line)) is pinned by the new aligningWhollyEmptyPathAnchorsAtCounterpart test. Stack (#3#5) rebased on top.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: add883eadf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/PicoManim/Core/BezierPath.swift Outdated
Comment on lines +246 to +247
let anchor = own.reversed().first { !$0.curves.isEmpty }?.curves.last?.p1
?? (pairIndex < counterpart.count ? counterpart[pairIndex].curves.first?.p0 : nil)

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 Anchor each empty-path pad to its counterpart

When one side starts as BezierPath() and the other side has multiple subpaths, the first while append makes own non-empty, so the next padding subpath takes own's previous degenerate endpoint here instead of counterpart[pairIndex]. For example, aligning an empty path with two separated line subpaths anchors both generated subpaths at the first line's start, so the second line morphs in from the wrong place rather than its own start; keep track of the originally empty side or prefer the counterpart while filling that side.

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 63ff304. Right again — the own-side scan saw the previous pad's degenerate subpath as "non-empty" and chained off it. The own-side anchor is now resolved once against the pre-padding subpaths (lastPointA/lastPointB captured before the while loops), so an originally empty side anchors every pad at its own counterpart subpath's start, while a non-empty side keeps the grow-from-own-end behavior. Your multi-subpath repro is pinned by paddingEmptyPathAnchorsEachSubpathAtItsOwnCounterpart (two separated lines → pads at (1,1) and (5,5) respectively). Stack (#3#5) rebased on top.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

@codex review


Generated by Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 63ff3041f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Contributor Author

Ready for human review/merge.

Checklist:

  • ✅ CI green on latest commit 63ff304 (test-macos + test-linux, swift build + swift test)
  • ✅ Codex reviewed latest commit: "Didn't find any major issues" (after three review rounds)
  • ✅ All 7 automated review comments addressed and individually answered:
    • Gemini (4): boundingBox sample clamping, fallbackAnchor for empty subpaths in subdividedEvenly + aligned(with:), robust last-non-empty anchor for padded subpaths — fixed in the "clamp bbox samples, anchor degenerate morph curves" commit
    • Codex (3): same bbox guard; then two escalating morph-anchor edge cases — a wholly empty path padding at the origin (fixed in add883e), and multi-subpath pads chaining off earlier pads instead of their own counterparts (fixed in 63ff304). Each fix pins Codex's exact repro as a test (aligningWhollyEmptyPathAnchorsAtCounterpart, paddingEmptyPathAnchorsEachSubpathAtItsOwnCounterpart)
  • ✅ Stacked PRs current: Phase 1 (3/5): Mobject model and Manim-style shape factories #3Phase 1 (5/5): ManimView SwiftUI preview, demo scene, and docs #5 rebased on this branch's latest

Remaining notes:


Generated by Claude Code

ronaldmannak and others added 5 commits July 5, 2026 15:23
Phase 1 (1/5): Package scaffold, CI, and core math layer
Everything a shape or morph needs, mirroring Manim's VMobject
representation where all geometry - including straight edges - is
stored as cubic Beziers:

- CubicCurve: evaluation, de Casteljau split/clip, even subdivision.
- BezierPath: multi-subpath container with constructors (polygon,
  polyline, line, arc, circle, ellipse, rectangle, regular polygon),
  sampled bounding boxes, point mapping/transforms.
- partial(upTo:): leading portion of a path for draw-in animations,
  splitting the boundary curve at fractional progress.
- aligned(with:)/interpolate: restructure two paths to matching
  subpath/curve counts so any shape can interpolate into any other.

Stacked on #<PR1>; part 2/5 of the Phase 1 stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGb3ukB5dQy4vgz7Z1hags
- boundingBox(samplesPerCurve:) clamps to at least 1 sample so a zero
  or negative count can't trap on range creation or divide by zero.
- aligned(with:) anchors an empty subpath's degenerate point-curves at
  the counterpart subpath's start (and pads missing subpaths at the
  last non-empty subpath's end), so morphs never fly in from the
  origin. subdividedEvenly gains a fallbackAnchor parameter for this.

Both behaviors are pinned by new tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGb3ukB5dQy4vgz7Z1hags
Aligning a path with zero subpaths against a real path padded a
placeholder subpath anchored at the origin (the fallbackAnchor logic
in subdividedEvenly only fires for empty subpaths, and the placeholder
already has one curve). The padding helper now falls back to the start
of the counterpart subpath it will pair with, so such morphs no longer
fly in from the origin. Pinned by a new test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGb3ukB5dQy4vgz7Z1hags
Padding an originally empty path against a multi-subpath counterpart
anchored the second and later pads at the first pad's degenerate point
(the own-side scan saw the earlier pad as a non-empty subpath). The
own-side anchor is now resolved once against the pre-padding subpaths,
so an originally empty side anchors every pad at its own counterpart
subpath's start. Pinned by a new test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGb3ukB5dQy4vgz7Z1hags
@ronaldmannak
ronaldmannak force-pushed the claude/picomanim-phase1-02-bezier branch from 63ff304 to 593df5e Compare July 5, 2026 22:24
@ronaldmannak
ronaldmannak merged commit d8cd893 into claude/picomanim-phase1-01-scaffold Jul 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants