From b11a607ffd0254eabd212ee65348adb75f6d652f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 01:49:28 +0000 Subject: [PATCH 1/4] Add text mobjects via CoreText glyph outlines Mobject.text(_:fontName:fontSize:at:color:) lays out a single line with CoreText and converts each glyph's outline (CGPath elements, with quadratics elevated to cubics and closing edges made explicit) into ordinary Bezier subpaths of one mobject. Text therefore draws in with create, morphs with transform, and styles like any other shape. Sizing contract: a font size of 48 spans one scene unit per em, so default text lands a bit under one unit tall, matching Manim's proportions. Text is filled (no stroke) and recentered on its visual bounding box so rotation and scaling act about the text center. The file is fenced behind #if canImport(CoreText): Apple platforms get text, the Linux build and its CI job are untouched. Tests (also gated) cover outline extraction, em sizing, linear font-size scaling, centering, whitespace advances, alignment/morph interop, and create. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YGb3ukB5dQy4vgz7Z1hags --- Sources/PicoManim/Mobjects/TextMobject.swift | 163 +++++++++++++++++++ Tests/PicoManimTests/TextMobjectTests.swift | 71 ++++++++ 2 files changed, 234 insertions(+) create mode 100644 Sources/PicoManim/Mobjects/TextMobject.swift create mode 100644 Tests/PicoManimTests/TextMobjectTests.swift diff --git a/Sources/PicoManim/Mobjects/TextMobject.swift b/Sources/PicoManim/Mobjects/TextMobject.swift new file mode 100644 index 0000000..47cc613 --- /dev/null +++ b/Sources/PicoManim/Mobjects/TextMobject.swift @@ -0,0 +1,163 @@ +#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, + 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 / 48) / 48 + 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 runFont = attributes?[NSAttributedString.Key(kCTFontAttributeName as String)] + .map { $0 as! CTFont } ?? font + + for index in 0.. [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 diff --git a/Tests/PicoManimTests/TextMobjectTests.swift b/Tests/PicoManimTests/TextMobjectTests.swift new file mode 100644 index 0000000..cfde15b --- /dev/null +++ b/Tests/PicoManimTests/TextMobjectTests.swift @@ -0,0 +1,71 @@ +#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.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.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).boundingBox) + let large = try #require(Mobject.text("X", fontSize: 96).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)) + #expect(approx(text.center.x, 2, tolerance: 1e-6)) + #expect(approx(text.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").boundingBox) + let tight = try #require(Mobject.text("aa").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 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 From f699af7cba215a8a9e46294fd2fc8cee711dc845 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 02:06:25 +0000 Subject: [PATCH 2/4] Fix text tests to use worldPath bounding box API Mobject.boundingBox and Mobject.center are introduced by the groups branch and don't exist on main, which this branch is based on. Use worldPath.boundingBox() / worldPath.boundingBoxCenter instead so the branch stays independent of the groups PR. --- Tests/PicoManimTests/TextMobjectTests.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Tests/PicoManimTests/TextMobjectTests.swift b/Tests/PicoManimTests/TextMobjectTests.swift index cfde15b..bf855f2 100644 --- a/Tests/PicoManimTests/TextMobjectTests.swift +++ b/Tests/PicoManimTests/TextMobjectTests.swift @@ -14,13 +14,13 @@ struct TextMobjectTests { // "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.boundingBox) + 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.boundingBox) + 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. @@ -28,24 +28,25 @@ struct TextMobjectTests { } @Test func fontSizeScalesLinearly() throws { - let small = try #require(Mobject.text("X", fontSize: 48).boundingBox) - let large = try #require(Mobject.text("X", fontSize: 96).boundingBox) + 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)) - #expect(approx(text.center.x, 2, tolerance: 1e-6)) - #expect(approx(text.center.y, 1, tolerance: 1e-6)) + 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").boundingBox) - let tight = try #require(Mobject.text("aa").boundingBox) + 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)) } From 6d83c0af7e2d36f94e460c3b9011ecc8874800c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 02:20:01 +0000 Subject: [PATCH 3/4] Address review: Write-style outline for strokeless create, safer font cast - create now borrows the fill color as a temporary outline while drawing in a stroke-less filled mobject (like text), fading it out as the fill arrives, so Mobject.text draws in visibly instead of staying invisible for the first half (Codex). Pinned by createShowsTemporaryOutlineForStrokelessText. - Replace the force-cast when reading the run font with a conditional cast (Gemini). - Use the pointSize constant instead of repeating 48 in the scale formula (Gemini). --- Sources/PicoManim/Animation/ManimScene.swift | 8 ++++++++ Sources/PicoManim/Mobjects/TextMobject.swift | 6 +++--- Tests/PicoManimTests/TextMobjectTests.swift | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Sources/PicoManim/Animation/ManimScene.swift b/Sources/PicoManim/Animation/ManimScene.swift index dd85477..09de183 100644 --- a/Sources/PicoManim/Animation/ManimScene.swift +++ b/Sources/PicoManim/Animation/ManimScene.swift @@ -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 diff --git a/Sources/PicoManim/Mobjects/TextMobject.swift b/Sources/PicoManim/Mobjects/TextMobject.swift index 47cc613..f0400d0 100644 --- a/Sources/PicoManim/Mobjects/TextMobject.swift +++ b/Sources/PicoManim/Mobjects/TextMobject.swift @@ -54,7 +54,7 @@ extension BezierPath { ) -> BezierPath { // Lay out in font points, then scale to scene units: 48 pt = 1 unit. let pointSize: CGFloat = 48 - let unitsPerPoint = (fontSize / 48) / 48 + let unitsPerPoint = (fontSize / Double(pointSize)) / Double(pointSize) let font: CTFont if let fontName { font = CTFontCreateWithName(fontName as CFString, pointSize, nil) @@ -80,8 +80,8 @@ extension BezierPath { CTRunGetPositions(run, CFRange(location: 0, length: 0), &positions) let attributes = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any] - let runFont = attributes?[NSAttributedString.Key(kCTFontAttributeName as String)] - .map { $0 as! CTFont } ?? font + let fontKey = NSAttributedString.Key(kCTFontAttributeName as String) + let runFont = attributes?[fontKey] as? CTFont ?? font for index in 0.. 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") From a16396c562d6cfcb8ff8aad26028b56a9efe0af3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 02:22:00 +0000 Subject: [PATCH 4/4] Fix macOS build: CFGetTypeID-checked cast instead of as? CTFont Swift 6 rejects a conditional downcast to a CoreFoundation type (error: conditional downcast to CoreFoundation type 'CTFont' will always succeed), which broke the previous commit's cast on the macOS CI job. Type-check with CFGetTypeID before the unconditional cast. --- Sources/PicoManim/Mobjects/TextMobject.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/PicoManim/Mobjects/TextMobject.swift b/Sources/PicoManim/Mobjects/TextMobject.swift index f0400d0..50af458 100644 --- a/Sources/PicoManim/Mobjects/TextMobject.swift +++ b/Sources/PicoManim/Mobjects/TextMobject.swift @@ -81,7 +81,15 @@ extension BezierPath { let attributes = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any] let fontKey = NSAttributedString.Key(kCTFontAttributeName as String) - let runFont = attributes?[fontKey] as? CTFont ?? font + // `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..