From a561b088d28397c24e924f877d50b9e9740ad789 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 7 Jul 2026 01:11:05 +0300 Subject: [PATCH] feat(text): draw SDF text decorations (underline/overline/strike) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDF glyph path skipped decoration toggles, so underline / overline / strikethrough lines were missing from OCS_TEXT_SDF text. Reserve a small solid (all-255) cell in the glyph atlas and expose solid_uv(); layout_glyph_quads now tracks the decoration runs (mirroring the stroke tessellator's UNDER/OVER/STRIKE positions) and emits a thin quad per run whose corners all sample the solid cell — the SDF shader fills it solid in the run colour, so decorations render through the same pass as glyphs, no extra pipeline. Verified in-app; SDF text now covers glyphs, %% specials, and decorations for both TEXT and MTEXT. Co-Authored-By: Claude Opus 4.8 --- src/scene/text/glyph_quads.rs | 91 ++++++++++++++++++++++++++++++++--- src/scene/text/sdf_atlas.rs | 36 ++++++++++++-- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/scene/text/glyph_quads.rs b/src/scene/text/glyph_quads.rs index 314657a68..acfdf1dff 100644 --- a/src/scene/text/glyph_quads.rs +++ b/src/scene/text/glyph_quads.rs @@ -66,25 +66,68 @@ pub fn layout_glyph_quads( [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 emit - // no glyph (they render as lines on the stroke path). - for tok in crate::scene::text::lff::tokenize_run(text) { + // 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 { - crate::scene::text::lff::Tok::Glyph(c) => c, - crate::scene::text::lff::Tok::Space => { + Tok::Glyph(c) => c, + Tok::Space => { cursor_x += face.word_spacing(); continue; } - crate::scene::text::lff::Tok::Missing => { + Tok::Missing => { cursor_x += 6.0 + face.letter_spacing() * tracking; continue; } - crate::scene::text::lff::Tok::Deco(..) => 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) { Some(e) => { @@ -109,6 +152,27 @@ pub fn layout_glyph_quads( } } + // 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 } @@ -206,4 +270,17 @@ mod tests { "double height ~ double quad extent, got {r}" ); } + + #[test] + fn underline_emits_an_extra_solid_quad() { + let mut atlas = GlyphAtlas::new(512, 512); + let plain = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AB"); + // `\L…\l` underlines the run — same glyph quads plus one decoration quad. + let under = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "\\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()); + } } diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index 05d2b64f8..dccf4f637 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -92,6 +92,11 @@ pub struct GlyphAtlas { cursor_x: u32, cursor_y: u32, shelf_h: u32, + /// UV of a fully-inside (value 255) texel, reserved at the atlas corner. + /// A quad whose corners all map here samples a constant "inside" field, so + /// the SDF shader fills it solid — used to draw decoration lines + /// (underline / overline / strikethrough) through the glyph pipeline. + solid_uv: [f32; 2], /// Set when `data` changed since the last upload; the GPU side clears it. dirty: bool, } @@ -106,20 +111,43 @@ struct BakedTile { advance: f32, } +/// Side of the reserved solid (all-255) block at the atlas corner. +const SOLID_PX: u32 = 4; + impl GlyphAtlas { pub fn new(width: u32, height: u32) -> Self { + let mut data = vec![0u8; (width * height) as usize]; + // Reserve a small solid block at (0,0) for decoration lines; the shelf + // packer starts past it so glyphs never overwrite it. + let solid = SOLID_PX.min(width).min(height); + for y in 0..solid { + for x in 0..solid { + data[(y * width + x) as usize] = 255; + } + } Self { width, height, - data: vec![0u8; (width * height) as usize], + data, entries: HashMap::new(), - cursor_x: 0, + cursor_x: solid, cursor_y: 0, - shelf_h: 0, - dirty: false, + shelf_h: solid, + // Centre of the solid block, safely inside it under bilinear filtering. + solid_uv: [ + (solid as f32 * 0.5) / width as f32, + (solid as f32 * 0.5) / height as f32, + ], + dirty: true, } } + /// UV of a fully-inside texel; a quad with all corners at this UV renders + /// solid. Used for decoration lines. + pub fn solid_uv(&self) -> [f32; 2] { + self.solid_uv + } + pub fn width(&self) -> u32 { self.width }