feat(text): draw SDF text decorations (underline/overline/strike)

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-07 01:11:05 +03:00
1 parent d528a3e2a5
commit a561b088d2
2 files changed
+116 -11

No files matched your search

+84 -7
View File
@@ -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<f32> = None;
let mut over: Option<f32> = None;
let mut strike: Option<f32> = 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());
}
}
+32 -4
View File
@@ -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
}