feat(text): SDF glyph-atlas foundation for shader text rendering

Groundwork to render CAD text as per-glyph SDF quads (box-quad snap,
perf, no LOD) instead of stroke-polyline wires. CPU side fully tested;
GPU side compiles but is not yet wired into the render loop.

- scene::text::sdf_atlas — GlyphAtlas: shelf packer + R8 SDF tiles baked
  per (font, char). Unified field so one shader draws both kinds: TrueType
  = signed distance to the filled outline, LFF stroke = pen band. Uses only
  crates already in the tree (ttf-parser/lyon via the existing Glyph).
- scene::text::glyph_quads — layout_glyph_quads: lays a run out as per-glyph
  quads using the exact tessellate_text_run transform, so quads land where
  the strokes would. Per-char advance (TTF shaping is a follow-up).
- shaders/text.wgsl — SDF fragment shader (smoothstep over fwidth), RTE
  vertex path mirroring image.wgsl, per-vertex colour + draw-order bias.
- scene::pipeline::text_gpu — TextVertex + double-single vertex builder,
  R8 atlas texture upload, bind group, and the image-cloned pipeline.

13 unit tests across the three CPU stages, all green. Modules carry
#![allow(dead_code)] until the render-loop integration lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-06 22:03:36 +03:00
1 parent 9490c85b32
commit 374cf7e89f
6 files changed
+1065

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
//! Phase 2a of the text-shader initiative: lay a text run out as per-glyph
//! quads over the SDF atlas, instead of tessellating glyph outlines into wire
//! strokes.
//!
//! Each visible glyph becomes one quad whose corners are the glyph's atlas
//! `plane` rectangle (ink bbox + SDF spread, in 9-unit glyph space) run through
//! the exact same run transform the stroke path uses (`tessellate_text_run`):
//! scale = height/9, width factor, oblique skew, rotation. Corners come out in
//! run-local 2-D (origin at `[0, 0]`) — the same space `TextStroke.strokes`
//! use — so the downstream path (annotation scale, f64 origin, double-single
//! split) handles them identically. The paired atlas UV rect lets the fragment
//! shader sample the glyph's SDF tile.
//!
//! Layout here is per-character advance (LFF exact; TTF without shaping). TTF
//! shaping (kerning/ligatures via `shape_run`) is a later refinement — it needs
//! per-glyph offsets within a shaped run, which this cut does not yet apply.
// Not yet wired into the render path (Phase 2b: the text quad pipeline).
#![allow(dead_code)]
use crate::scene::text::font_face::Face;
use crate::scene::text::sdf_atlas::GlyphAtlas;
/// One glyph placed as a quad in run-local 2-D space (origin at `[0, 0]`).
#[derive(Clone, Copy, Debug)]
pub struct GlyphQuad {
/// Corners, CCW: bottom-left, bottom-right, top-right, top-left.
pub corners: [[f32; 2]; 4],
/// Atlas UV of the tile (top-left / bottom-right).
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
}
/// Lay `text` out as per-glyph quads. Mirrors `tessellate_text_run`'s transform
/// and per-character advance so the quads land exactly where the strokes would.
/// Whitespace and glyphs with no ink advance the pen but emit no quad.
#[allow(clippy::too_many_arguments)]
pub fn layout_glyph_quads(
atlas: &mut GlyphAtlas,
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
tracking: f32,
font_name: &str,
text: &str,
) -> Vec<GlyphQuad> {
if text.is_empty() || height <= 0.0 {
return vec![];
}
let scale = height / 9.0;
let wf = if width_factor < 0.0 {
width_factor.clamp(-100.0, -0.01)
} else {
width_factor.clamp(0.01, 100.0)
};
let ob = oblique_angle.tan();
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
// Glyph-space (gx, gy) at pen position cx -> run-local 2-D (origin [0, 0]).
// Identical to `tessellate_text_run::xform` with `origin = [0, 0]`.
let xform = |gx: f32, gy: f32, cx: f32| -> [f32; 2] {
let sx = (cx + gx) * scale * wf + gy * scale * ob;
let sy = gy * scale;
[sx * cos_r - sy * sin_r, sx * sin_r + sy * cos_r]
};
let face = Face::resolve(font_name);
let mut cursor_x = 0.0f32;
let mut quads = Vec::new();
for ch in text.chars() {
if ch == ' ' {
cursor_x += face.word_spacing();
continue;
}
match atlas.get_or_insert(font_name, ch) {
Some(e) => {
let (lo, hi) = (e.plane_min, e.plane_max);
quads.push(GlyphQuad {
corners: [
xform(lo[0], lo[1], cursor_x),
xform(hi[0], lo[1], cursor_x),
xform(hi[0], hi[1], cursor_x),
xform(lo[0], hi[1], cursor_x),
],
uv_min: e.uv_min,
uv_max: e.uv_max,
});
cursor_x += e.advance + face.letter_spacing() * tracking;
}
None => {
// No ink (whitespace glyph) or atlas full: advance only, using
// the glyph's own advance when the font knows it.
let adv = face.glyph(ch).map(|g| g.advance).unwrap_or(6.0);
cursor_x += adv + face.letter_spacing() * tracking;
}
}
}
quads
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn center(q: &GlyphQuad) -> [f32; 2] {
let mut c = [0.0f32; 2];
for p in &q.corners {
c[0] += p[0] * 0.25;
c[1] += p[1] * 0.25;
}
c
}
// Uses the embedded "txt" stroke font (no system fonts needed).
#[test]
fn glyphs_advance_rightward() {
let mut atlas = GlyphAtlas::new(512, 512);
let quads = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA");
assert_eq!(quads.len(), 2, "two inked glyphs -> two quads");
assert!(
center(&quads[1])[0] > center(&quads[0])[0],
"second glyph is to the right of the first"
);
for q in &quads {
for p in &q.corners {
assert!(p[0].is_finite() && p[1].is_finite());
}
assert!(q.uv_min[0] >= 0.0 && q.uv_max[0] <= 1.0);
assert!(q.uv_min[1] >= 0.0 && q.uv_max[1] <= 1.0);
}
}
#[test]
fn space_emits_no_quad_but_advances() {
let mut atlas = GlyphAtlas::new(512, 512);
let ab = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA");
let a_sp_a = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "A A");
assert_eq!(a_sp_a.len(), 2, "space produces no quad");
assert!(
center(&a_sp_a[1])[0] > center(&ab[1])[0],
"the space widens the gap to the second glyph"
);
}
#[test]
fn rotation_maps_x_advance_to_y() {
let mut atlas = GlyphAtlas::new(512, 512);
// Use the baseline advance direction (glyph0 -> glyph1), which is +x when
// flat and swings to +y after a 90° turn — independent of where a single
// glyph's box centre sits within the cap height.
let flat = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA");
let turned = layout_glyph_quads(
&mut atlas,
10.0,
std::f32::consts::FRAC_PI_2,
1.0,
0.0,
1.0,
"txt",
"AA",
);
let adv = |qs: &[GlyphQuad]| {
let (a, b) = (center(&qs[0]), center(&qs[1]));
[b[0] - a[0], b[1] - a[1]]
};
let df = adv(&flat);
let dt = adv(&turned);
assert!(df[0] > df[1].abs(), "unrotated advance is along +x");
assert!(dt[1] > dt[0].abs(), "90° advance swings to +y");
}
#[test]
fn taller_text_makes_bigger_quads() {
let mut atlas = GlyphAtlas::new(512, 512);
let small = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "A");
let big = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", "A");
let extent = |q: &GlyphQuad| {
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
for p in &q.corners {
lo[0] = lo[0].min(p[0]);
lo[1] = lo[1].min(p[1]);
hi[0] = hi[0].max(p[0]);
hi[1] = hi[1].max(p[1]);
}
(hi[1] - lo[1]).max(hi[0] - lo[0])
};
let r = extent(&big[0]) / extent(&small[0]);
assert!(
(r - 2.0).abs() < 0.15,
"double height ~ double quad extent, got {r}"
);
}
}