-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 2-C: Text mobjects via CoreText outlines #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ronaldmannak
wants to merge
4
commits into
main
Choose a base branch
from
claude/picomanim-phase2-03-text
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
b11a607
Add text mobjects via CoreText glyph outlines
claude f699af7
Fix text tests to use worldPath bounding box API
claude 6d83c0a
Address review: Write-style outline for strokeless create, safer font…
claude a16396c
Fix macOS build: CFGetTypeID-checked cast instead of as? CTFont
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When callers animate default text with
.create, the first half of the animation only advancesstrokeEndbefore the fill fade starts; checkedManimScene.apply's.createcase, 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 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 6d83c0a —
ManimScene.apply's.createcase now borrows the fill color as a temporary outline whenever the target has no visible stroke and a visible fill (Manim'sWritebehavior): 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