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 new file mode 100644 index 0000000..50af458 --- /dev/null +++ b/Sources/PicoManim/Mobjects/TextMobject.swift @@ -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, + 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.. [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..57548d9 --- /dev/null +++ b/Tests/PicoManimTests/TextMobjectTests.swift @@ -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