Skip to content
Open
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
8 changes: 8 additions & 0 deletions Sources/PicoManim/Animation/ManimScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,14 @@ public struct ManimScene: Sendable {
// No-op for a plain create (both poles share the same opacity);
// restores visibility when re-creating a faded-out object.
state.opacity = lerp(a.opacity, b.opacity, p)
// A stroke-less filled mobject (like text) would be invisible
// during the draw-in, so borrow the fill color as a temporary
// outline that fades away as the fill arrives (Manim's Write).
let targetStrokeInvisible = b.strokeWidth <= 0 || b.strokeColor.alpha <= 0
if p < 1, targetStrokeInvisible, b.fillColor.alpha > 0 {
state.strokeColor = b.fillColor.withOpacity(b.fillColor.alpha * (1 - fillProgress))
state.strokeWidth = 2
}
case .fadeIn(let shift), .fadeOut(let shift):
state.opacity = lerp(a.opacity, b.opacity, p)
// A zero-shift fade owns only opacity, so it composes with
Expand Down
171 changes: 171 additions & 0 deletions Sources/PicoManim/Mobjects/TextMobject.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#if canImport(CoreText)
import CoreGraphics
import CoreText
import Foundation

// Text mobjects: glyph outlines extracted with CoreText and stored as
// ordinary Bézier subpaths, so text draws in with `create`, morphs with
// `transform`, and styles like any other mobject.
//
// Only available where CoreText exists (Apple platforms); the rest of the
// package stays platform-neutral.
extension Mobject {
/// A filled text mobject laid out on a single line.
///
/// Glyph outlines become subpaths of one path, in font space scaled so
/// that **a font size of 48 spans one scene unit per em** (Manim's
/// familiar proportions: default text is a bit under one unit tall).
/// The path is recentered on its bounding box, so rotation and scaling
/// happen about the text's visual center.
///
/// - Parameters:
/// - string: The text to lay out (single line).
/// - fontName: A PostScript or family name; `nil` uses the system font.
/// - fontSize: Manim-style font size; 48 → one scene unit per em.
/// - center: Scene position of the text's center.
/// - color: Fill color (text has no stroke by default, like Manim).
public static func text(
_ string: String,
fontName: String? = nil,
fontSize: Double = 48,
at center: Vec2 = .zero,
color: ManimColor = .white
) -> Mobject {
let path = BezierPath.text(string, fontName: fontName, fontSize: fontSize)
let localCenter = path.boundingBoxCenter
var mobject = Mobject(
path: path.mapPoints { $0 - localCenter },
strokeColor: color.withOpacity(0),
strokeWidth: 0,
Comment on lines +38 to +39

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 Make text visible during create draw-in

When callers animate default text with .create, the first half of the animation only advances strokeEnd before the fill fade starts; checked ManimScene.apply's .create case, and it doesn't reveal fill until progress exceeds 0.5. Because text is initialized here with both zero stroke alpha and zero stroke width, Mobject.text("Hi") remains completely invisible during that draw-in phase and then just fades in, so the new text mobject does not actually draw in like the other path mobjects.

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 6d83c0aManimScene.apply's .create case now borrows the fill color as a temporary outline whenever the target has no visible stroke and a visible fill (Manim's Write behavior): the borrowed stroke is fully opaque during the draw-in phase, fades out exactly as the fill fades in, and is gone at progress 1 so the end state matches the target bit-for-bit. This benefits any stroke-less filled mobject, not just text. Test added: createShowsTemporaryOutlineForStrokelessText (visible stroke at t = 0.25, no stroke and full fill at the end).


Generated by Claude Code

fillColor: color
)
mobject.position = center
return mobject
}
}

extension BezierPath {
/// The outlines of `string` laid out on one line, in scene units
/// (`fontSize` 48 → one unit per em), starting at the origin baseline.
public static func text(
_ string: String,
fontName: String? = nil,
fontSize: Double = 48
) -> BezierPath {
// Lay out in font points, then scale to scene units: 48 pt = 1 unit.
let pointSize: CGFloat = 48
let unitsPerPoint = (fontSize / Double(pointSize)) / Double(pointSize)
let font: CTFont
if let fontName {
font = CTFontCreateWithName(fontName as CFString, pointSize, nil)
} else {
font = CTFontCreateUIFontForLanguage(.system, pointSize, nil) ??
CTFontCreateWithName("Helvetica" as CFString, pointSize, nil)
}

let attributed = NSAttributedString(
string: string,
attributes: [NSAttributedString.Key(kCTFontAttributeName as String): font]
)
let line = CTLineCreateWithAttributedString(attributed)

var subpaths: [Subpath] = []
let runs = CTLineGetGlyphRuns(line) as? [CTRun] ?? []
for run in runs {
let glyphCount = CTRunGetGlyphCount(run)
guard glyphCount > 0 else { continue }
var glyphs = [CGGlyph](repeating: 0, count: glyphCount)
var positions = [CGPoint](repeating: .zero, count: glyphCount)
CTRunGetGlyphs(run, CFRange(location: 0, length: 0), &glyphs)
CTRunGetPositions(run, CFRange(location: 0, length: 0), &positions)

let attributes = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any]
let fontKey = NSAttributedString.Key(kCTFontAttributeName as String)
// `as? CTFont` is rejected by the compiler ("conditional downcast
// to CoreFoundation type will always succeed"), so type-check via
// CFGetTypeID before the unconditional cast.
let runFont: CTFont
if let value = attributes?[fontKey], CFGetTypeID(value as CFTypeRef) == CTFontGetTypeID() {
runFont = value as! CTFont
} else {
runFont = font
}

for index in 0..<glyphCount {
guard let glyphPath = CTFontCreatePathForGlyph(runFont, glyphs[index], nil) else {
continue // whitespace has no outline
}
let origin = Vec2(Double(positions[index].x), Double(positions[index].y))
subpaths.append(contentsOf: BezierPath.subpaths(
from: glyphPath,
offset: origin,
scale: unitsPerPoint
))
}
}
return BezierPath(subpaths: subpaths)
}

/// Converts a CGPath (font space, y-up) into cubic Bézier subpaths,
/// translated by `offset` (font points) and uniformly scaled.
static func subpaths(from cgPath: CGPath, offset: Vec2, scale: Double) -> [Subpath] {
var result: [Subpath] = []
var currentCurves: [CubicCurve] = []
var subpathStart = Vec2.zero
var currentPoint = Vec2.zero

func convert(_ point: CGPoint) -> Vec2 {
(Vec2(Double(point.x), Double(point.y)) + offset) * scale
}

func closeCurrent(markClosed: Bool) {
if markClosed, !currentCurves.isEmpty, (currentPoint - subpathStart).length > 1e-12 {
// Explicitly add the closing edge so the outline is complete.
currentCurves.append(.line(from: currentPoint, to: subpathStart))
}
if !currentCurves.isEmpty {
result.append(Subpath(curves: currentCurves, isClosed: markClosed))
}
currentCurves = []
}

cgPath.applyWithBlock { elementPointer in
let element = elementPointer.pointee
switch element.type {
case .moveToPoint:
closeCurrent(markClosed: false)
subpathStart = convert(element.points[0])
currentPoint = subpathStart
case .addLineToPoint:
let end = convert(element.points[0])
currentCurves.append(.line(from: currentPoint, to: end))
currentPoint = end
case .addQuadCurveToPoint:
// Elevate the quadratic to a cubic.
let control = convert(element.points[0])
let end = convert(element.points[1])
currentCurves.append(CubicCurve(
p0: currentPoint,
c1: currentPoint + (control - currentPoint) * (2.0 / 3.0),
c2: end + (control - end) * (2.0 / 3.0),
p1: end
))
currentPoint = end
case .addCurveToPoint:
let c1 = convert(element.points[0])
let c2 = convert(element.points[1])
let end = convert(element.points[2])
currentCurves.append(CubicCurve(p0: currentPoint, c1: c1, c2: c2, p1: end))
currentPoint = end
case .closeSubpath:
closeCurrent(markClosed: true)
currentPoint = subpathStart
@unknown default:
break
}
}
closeCurrent(markClosed: false)
return result
}
}
#endif
88 changes: 88 additions & 0 deletions Tests/PicoManimTests/TextMobjectTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#if canImport(CoreText)
import Testing
@testable import PicoManim

private func approx(_ a: Double, _ b: Double, tolerance: Double = 1e-6) -> Bool {
abs(a - b) <= tolerance
}

@Suite("Text mobjects")
struct TextMobjectTests {
@Test func textProducesGlyphOutlines() throws {
let text = Mobject.text("Hi")
#expect(!text.path.isEmpty)
// "H" is one outline; "i" is a stem plus a dot.
#expect(text.path.subpaths.count >= 3)
#expect(text.path.subpaths.allSatisfy { !$0.curves.isEmpty })
let box = try #require(text.worldPath.boundingBox())
#expect(box.max.x > box.min.x)
}

@Test func defaultSizeSpansAboutOneUnitPerEm() throws {
let text = Mobject.text("Xg") // cap height + descender
let box = try #require(text.worldPath.boundingBox())
let height = box.max.y - box.min.y
// Cap-to-descender extent of a 48-point em should land well within
// a unit but be clearly visible.
#expect(height > 0.3 && height < 1.3)
}

@Test func fontSizeScalesLinearly() throws {
let small = try #require(Mobject.text("X", fontSize: 48).worldPath.boundingBox())
let large = try #require(Mobject.text("X", fontSize: 96).worldPath.boundingBox())
let ratio = (large.max.y - large.min.y) / (small.max.y - small.min.y)
#expect(approx(ratio, 2, tolerance: 0.05))
}

@Test func textIsCenteredFilledAndStrokeless() {
let text = Mobject.text("Center", at: Vec2(2, 1))
let center = text.worldPath.boundingBoxCenter
#expect(approx(center.x, 2, tolerance: 1e-6))
#expect(approx(center.y, 1, tolerance: 1e-6))
#expect(text.strokeWidth == 0)
#expect(text.effectiveFillAlpha == 1)
#expect(text.effectiveStrokeAlpha == 0)
}

@Test func whitespaceAdvancesWithoutOutlines() throws {
let spaced = try #require(Mobject.text("a a").worldPath.boundingBox())
let tight = try #require(Mobject.text("aa").worldPath.boundingBox())
#expect((spaced.max.x - spaced.min.x) > (tight.max.x - tight.min.x))
}

@Test func textAlignsAndMorphsLikeAnyPath() {
let text = Mobject.text("O")
let circle = BezierPath.circle(radius: 1)
let (a, b) = text.path.aligned(with: circle)
#expect(a.subpaths.count == b.subpaths.count)
let mid = BezierPath.interpolate(a, b, 0.5)
#expect(!mid.isEmpty)
}

@Test func createShowsTemporaryOutlineForStrokelessText() throws {
var scene = ManimScene()
let text = Mobject.text("Hi")
scene.play(.create(text, duration: 1, rate: .linear))
// While the outline draws in, a borrowed fill-colored stroke keeps
// the (stroke-less) text visible.
let quarter = try #require(scene.snapshot(at: 0.25).first)
#expect(quarter.effectiveStrokeAlpha > 0)
#expect(quarter.strokeWidth > 0)
// The temporary outline is fully gone at the end.
let end = try #require(scene.snapshot(at: 1).first)
#expect(end.strokeWidth == 0)
#expect(end.effectiveStrokeAlpha == 0)
#expect(end.effectiveFillAlpha == 1)
}

@Test func textDrawsInWithCreate() throws {
var scene = ManimScene()
let text = Mobject.text("Hi")
scene.play(.create(text, duration: 1, rate: .linear))
let mid = try #require(scene.snapshot(at: 0.5).first)
#expect(approx(mid.strokeEnd, 0.5, tolerance: 1e-9))
let end = try #require(scene.snapshot(at: 1).first)
#expect(end.fillOpacityFactor == 1)
}
}
#endif