//! 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. /// /// Returns the quads plus the final pen advance in run-local units /// (`cursor × scale × |width_factor|`, always ≥ 0): the exact laid-out width /// including spacing/tracking, so exporters can width-match (`Tz`) and cull /// runs without re-deriving advances from a second layout pass. Mirrored /// (negative width factor) runs collapse to a positive extent — the search /// layer reads LTR while the outlines keep the true mirror. #[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, bold: bool, text: &str, ) -> (Vec, f32) { if text.is_empty() || height <= 0.0 { return (vec![], 0.0); } 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] }; use crate::scene::text::lff::{tokenize_run, Deco, Op, Tok}; // Decoration line positions (glyph units) and half-thickness — mirror the // stroke tessellator's constants so SDF decorations align with strokes. const UNDER_Y: f32 = -1.5; const OVER_Y: f32 = 10.5; const STRIKE_Y: f32 = 4.5; const DECO_HALF: f32 = 0.35; let solid = atlas.solid_uv(); let face = Face::resolve(font_name); let mut cursor_x = 0.0f32; let mut quads = Vec::new(); // Open decoration runs (pen x where each turned on) and the spans to draw. let mut under: Option = None; let mut over: Option = None; let mut strike: Option = None; let mut decos: Vec<(f32, f32, f32)> = Vec::new(); // (start_x, end_x, y) // Same token walk as `tessellate_text_run` (LFF branch): DXF `%%` specials // resolve to glyphs, spaces/missing advance the pen, decoration toggles // open/close underline / overline / strikethrough runs. for tok in tokenize_run(text) { let ch = match tok { Tok::Glyph(c) => c, Tok::Space => { cursor_x += face.word_spacing(); continue; } Tok::Missing => { cursor_x += 6.0 + face.letter_spacing() * tracking; continue; } Tok::Deco(deco, op) => { let (slot, y) = match deco { Deco::Under => (&mut under, UNDER_Y), Deco::Over => (&mut over, OVER_Y), Deco::Strike => (&mut strike, STRIKE_Y), }; match op { Op::On => { if slot.is_none() { *slot = Some(cursor_x); } } Op::Off => { if let Some(s) = slot.take() { decos.push((s, cursor_x, y)); } } Op::Toggle => { *slot = match slot.take() { Some(s) => { decos.push((s, cursor_x, y)); None } None => Some(cursor_x), }; } } continue; } }; match atlas.get_or_insert(font_name, ch, bold) { 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.spacing_after(ch) * tracking; } None => { // No ink (whitespace glyph) or atlas full: advance only. cursor_x += match face.glyph(ch) { Some(g) => g.advance + face.spacing_after(ch) * tracking, None => 6.0 + face.letter_spacing() * tracking, }; } } } let pen_advance = (cursor_x * scale * wf).abs(); // Any decoration still open at the end runs to the final pen position. for (slot, y) in [(under, UNDER_Y), (over, OVER_Y), (strike, STRIKE_Y)] { if let Some(s) = slot { decos.push((s, cursor_x, y)); } } // Decoration lines: thin quads sampling the atlas's solid texel so the SDF // shader fills them solid in the run colour. for (s, e, y) in decos { quads.push(GlyphQuad { corners: [ xform(s, y - DECO_HALF, 0.0), xform(e, y - DECO_HALF, 0.0), xform(e, y + DECO_HALF, 0.0), xform(s, y + DECO_HALF, 0.0), ], uv_min: solid, uv_max: solid, }); } (quads, pen_advance) } // ── 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", false, "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", false, "AA"); let (a_sp_a, _) = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "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", false, "AA"); let (turned, _) = layout_glyph_quads( &mut atlas, 10.0, std::f32::consts::FRAC_PI_2, 1.0, 0.0, 1.0, "txt", false, "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", false, "A"); let (big, _) = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "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}" ); } #[test] fn underline_emits_an_extra_solid_quad() { let mut atlas = GlyphAtlas::new(512, 512); let (plain, plain_adv) = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB"); // `\L…\l` underlines the run — same glyph quads plus one decoration quad. let (under, under_adv) = layout_glyph_quads( &mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "\\LAB\\l", ); assert_eq!(under.len(), plain.len() + 1, "underline adds one quad"); // The decoration quad samples the atlas's solid texel (degenerate UV). let deco = under.last().unwrap(); assert_eq!( deco.uv_min, deco.uv_max, "decoration quad uses the solid UV" ); assert_eq!(deco.uv_min, atlas.solid_uv()); // Decorations don't move the pen: same advance either way, and the // advance covers both glyphs (positive, wider than one glyph). assert!( (under_adv - plain_adv).abs() < 1e-6, "decoration must not move the pen: {under_adv} vs {plain_adv}" ); assert!(plain_adv > 0.0, "two glyphs advance the pen"); } #[test] fn pen_advance_scales_with_height_and_width_factor() { let mut atlas = GlyphAtlas::new(512, 512); let (_, base) = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB"); let (_, tall) = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB"); assert!( (tall / base - 2.0).abs() < 0.05, "double height ~ double advance: {tall} vs {base}" ); let (_, wide) = layout_glyph_quads(&mut atlas, 10.0, 0.0, 2.0, 0.0, 1.0, "txt", false, "AB"); assert!( (wide / base - 2.0).abs() < 0.05, "double width factor ~ double advance: {wide} vs {base}" ); let (_, empty) = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, ""); assert_eq!(empty, 0.0, "empty text advances nothing"); } }