Files
OneCAD-Draft/src/scene/text/sdf_atlas.rs
T
Hakan SevenandClaude Opus 4.8 1e4b1f23a3 fix(text): grow the SDF glyph atlas instead of dropping glyphs (#347)
Text and dimensions vanished on a text-heavy, multi-font DWG. The global
SDF glyph atlas was a fixed 1024² page shared across drawings; once full,
pack() returned None and every further (font, char, bold) glyph was
silently skipped — so late-baked glyphs (dimension text especially) never
rendered.

Initialise the atlas at 2048² (covers real multi-font/CJK drawings with
no growth, so no stale UVs) and add a grow_height() fallback that doubles
height, appends zeroed rows, rescales cached entries' V/solid_uv and marks
the texture dirty (up to MAX_ATLAS_PX = 8192) rather than dropping glyphs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:36:33 +03:00

576 lines
22 KiB
Rust

//! CPU-side signed-distance-field (SDF) glyph atlas.
//!
//! Phase 1 of the text-shader initiative: bake each glyph into a single-channel
//! SDF tile and pack the tiles into one growable atlas texture. Later phases
//! draw text as per-glyph quads that sample this atlas in an SDF fragment
//! shader, instead of tessellating glyph outlines into wire segments.
//!
//! Both glyph kinds feed one unified field so a single shader renders both:
//! * Filled (TrueType): the glyph carries closed contours (`strokes`) plus a
//! fill triangulation (`fill_tris`). The field is the **signed** distance to
//! the outline — positive inside the fill, negative outside.
//! * Stroke (LFF pen fonts): the glyph carries open polylines and no fill. The
//! field is a **band** around the strokes: `pen_half - dist_to_polyline`,
//! positive inside the nominal pen width.
//!
//! In both cases the boundary sits at field value 0, mapped to 0.5 in the u8
//! texel so the shader can threshold with `smoothstep(0.5 ± aa, value)`.
//!
//! Uses only crates already in the tree (no image/SDF crate): the distance
//! field is computed by brute-force point-to-segment distance over the glyph's
//! own polylines. Tiles are small and baked once per (font, char), then cached.
// Phase 1 lands the CPU atlas builder; it is exercised by tests but not yet
// wired into the render path — that is Phase 2 (the text quad pipeline). Drop
// this allow once `GlyphAtlas` is consumed by the renderer.
#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::scene::text::font_face::Face;
use crate::scene::text::lff::Glyph;
/// Process-wide glyph atlas, shared by the text collector (which bakes glyphs
/// while building draw data) and the GPU upload (which reads the texels).
/// Mirrors the existing global font-glyph caches (see `ttf_glyph`).
pub fn text_atlas() -> &'static Mutex<GlyphAtlas> {
static ATLAS: OnceLock<Mutex<GlyphAtlas>> = OnceLock::new();
// 2048² (~780 glyphs) covers real text-heavy drawings (many fonts + CJK /
// Vietnamese diacritics) without ever growing; `pack()` still grows as a
// fallback beyond that. See issue #347.
ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(2048, 2048)))
}
/// TEXTFILL system variable — `true` fills TrueType glyphs (default), `false`
/// draws them hollow (outline only). Global, like the AutoCAD system variable.
static TEXTFILL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
pub fn textfill() -> bool {
TEXTFILL.load(std::sync::atomic::Ordering::Relaxed)
}
/// Set TEXTFILL and drop the cached glyphs so they re-bake filled / hollow.
/// Callers must re-tessellate text (`bump_geometry`) so quads pick up the
/// re-baked tiles.
pub fn set_textfill(on: bool) {
TEXTFILL.store(on, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut a) = text_atlas().lock() {
a.reset();
}
}
/// Debug: when env `OCS_TEXT_BOX` is set, each SDF text run also emits a
/// rectangle outline around its glyph bounds so the text pick box is visible.
/// Read once.
pub fn text_box_debug() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var_os("OCS_TEXT_BOX").is_some())
}
// ── Bake configuration ──────────────────────────────────────────────────────
/// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap
/// letter is ~9 * PX_PER_UNIT texels tall. SDF is resolution-independent when
/// sampled, so this only sets the baked field's fidelity, not on-screen size.
const PX_PER_UNIT: f32 = 6.0;
/// Half-range of the distance field, in glyph units. Field values are clamped
/// to ±SPREAD_UNITS and linearly mapped to [0, 1] (0 -> 0.5). Also the tile
/// margin: the tile extends this far past the ink so the field/AA is not
/// clipped at the glyph edge.
const SPREAD_UNITS: f32 = 1.5;
/// LFF stroke half-width, in glyph units. Stroke fonts have no intrinsic width
/// (plotted at the current lineweight); this is the nominal pen the SDF band is
/// built around. Scales with text height like the strokes themselves.
const PEN_HALF_UNITS: f32 = 0.35;
/// Upper bound on a single glyph tile's dimension (texels), a guard against a
/// pathologically wide/tall glyph blowing up the atlas.
const MAX_TILE_PX: u32 = 512;
/// Largest atlas dimension the packer will grow the texture to, kept within the
/// common wgpu `max_texture_dimension_2d` limit (8192). The atlas starts at
/// 1024² and doubles its height on demand up to this bound.
const MAX_ATLAS_PX: u32 = 8192;
// ── Public types ─────────────────────────────────────────────────────────────
/// Placement of one baked glyph within the atlas.
#[derive(Clone, Copy, Debug)]
pub struct AtlasEntry {
/// Atlas UV of the tile's top-left corner (normalized 0..1).
pub uv_min: [f32; 2],
/// Atlas UV of the tile's bottom-right corner (normalized 0..1).
pub uv_max: [f32; 2],
/// Glyph-space (9-unit) rectangle the tile covers — the ink bounding box
/// expanded by the SDF spread. The render quad must span exactly this rect
/// (in glyph-local coords) so texels line up with world positions.
pub plane_min: [f32; 2],
pub plane_max: [f32; 2],
/// Glyph advance width (9-unit space).
pub advance: f32,
}
/// A single-channel SDF glyph atlas plus a shelf packer.
pub struct GlyphAtlas {
width: u32,
height: u32,
/// R8 SDF texels, row-major, row 0 = top.
data: Vec<u8>,
/// Cache keyed by (font family, char, bold). `None` = a whitespace/empty
/// glyph with no ink (nothing to draw), cached so we do not re-resolve it.
/// Bold bakes the same glyph with a wider pen (a separate atlas entry).
entries: HashMap<(String, char, bool), Option<AtlasEntry>>,
// Shelf packer cursor.
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,
}
/// A freshly baked, not-yet-packed glyph tile.
struct BakedTile {
w: u32,
h: u32,
data: Vec<u8>,
plane_min: [f32; 2],
plane_max: [f32; 2],
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,
entries: HashMap::new(),
cursor_x: solid,
cursor_y: 0,
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
}
pub fn height(&self) -> u32 {
self.height
}
/// Raw R8 texel data (row-major, row 0 = top) for GPU upload.
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn clear_dirty(&mut self) {
self.dirty = false;
}
/// Drop every cached glyph and re-init the texture, so glyphs re-bake on
/// next use (e.g. after TEXTFILL toggles between fill and outline).
pub fn reset(&mut self) {
*self = GlyphAtlas::new(self.width, self.height);
}
/// Look up a glyph's atlas placement, baking + packing it on first use.
/// Returns `None` for whitespace/empty glyphs (nothing to draw) and when the
/// atlas is full (Phase 1 is single-page; callers should treat `None` as
/// "skip / fall back").
pub fn get_or_insert(&mut self, family: &str, ch: char, bold: bool) -> Option<AtlasEntry> {
let key = (family.to_string(), ch, bold);
if let Some(cached) = self.entries.get(&key) {
return *cached;
}
// Bold stroke glyphs bake with a wider pen band (same shapes, thicker).
let pen_half = if bold {
PEN_HALF_UNITS * 1.7
} else {
PEN_HALF_UNITS
};
let face = Face::resolve(family);
let entry = face
.glyph(ch)
.and_then(|g| bake_glyph(&g, pen_half))
.and_then(|tile| self.pack(tile));
self.entries.insert(key, entry);
entry
}
/// Grow the atlas taller (keeping the width, so packed X positions and their
/// normalized U stay valid) and rescale every cached entry's V for the new
/// height. Returns false once the height hits `MAX_ATLAS_PX` — text-heavy
/// drawings would otherwise silently drop glyphs once the single page filled
/// (issue #347). Width is unchanged, so the row-major data just grows with
/// zeroed rows appended at the bottom.
fn grow_height(&mut self) -> bool {
if self.height >= MAX_ATLAS_PX {
return false;
}
let old_h = self.height;
let new_h = (self.height * 2).min(MAX_ATLAS_PX);
self.data.resize((self.width * new_h) as usize, 0);
let sy = old_h as f32 / new_h as f32;
for entry in self.entries.values_mut().flatten() {
entry.uv_min[1] *= sy;
entry.uv_max[1] *= sy;
}
self.solid_uv[1] *= sy;
self.height = new_h;
self.dirty = true;
true
}
/// Blit a baked tile into the atlas via a simple shelf packer. Grows the
/// atlas when a tile doesn't fit; returns `None` only once it can grow no
/// further (or the tile is wider than the atlas, which the tile cap prevents).
fn pack(&mut self, tile: BakedTile) -> Option<AtlasEntry> {
// Wrap to a new shelf if the tile overflows the current row.
if self.cursor_x + tile.w > self.width {
self.cursor_y += self.shelf_h;
self.cursor_x = 0;
self.shelf_h = 0;
}
if self.cursor_x + tile.w > self.width {
return None; // tile wider than the atlas (MAX_TILE_PX guards this)
}
while self.cursor_y + tile.h > self.height {
if !self.grow_height() {
return None; // atlas full and cannot grow further
}
}
let (ox, oy) = (self.cursor_x, self.cursor_y);
for row in 0..tile.h {
let dst = ((oy + row) * self.width + ox) as usize;
let src = (row * tile.w) as usize;
self.data[dst..dst + tile.w as usize]
.copy_from_slice(&tile.data[src..src + tile.w as usize]);
}
self.cursor_x += tile.w;
self.shelf_h = self.shelf_h.max(tile.h);
self.dirty = true;
let (fw, fh) = (self.width as f32, self.height as f32);
Some(AtlasEntry {
uv_min: [ox as f32 / fw, oy as f32 / fh],
uv_max: [(ox + tile.w) as f32 / fw, (oy + tile.h) as f32 / fh],
plane_min: tile.plane_min,
plane_max: tile.plane_max,
advance: tile.advance,
})
}
}
// ── Baking ─────────────────────────────────────────────────────────────────
/// Rasterize a glyph into an SDF tile. `None` if the glyph has no ink.
fn bake_glyph(g: &Glyph, pen_half: f32) -> Option<BakedTile> {
// TEXTFILL 0 renders TrueType glyphs hollow: bake them as a stroke field
// (outline) instead of a signed fill field. Stroke fonts (no fill tris) are
// unaffected.
let filled = textfill() && !g.fill_tris.is_empty();
// Ink bounding box over all strokes (and fill triangles, for safety).
let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY);
let (mut max_x, mut max_y) = (f32::NEG_INFINITY, f32::NEG_INFINITY);
let mut acc = |x: f32, y: f32| {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
};
for s in &g.strokes {
for &[x, y] in s {
acc(x, y);
}
}
for &[x, y] in &g.fill_tris {
acc(x, y);
}
if !min_x.is_finite() || max_x < min_x || max_y < min_y {
return None; // no geometry (whitespace)
}
// Expand by the spread so the field is not clipped at the ink edge.
let plane_min = [min_x - SPREAD_UNITS, min_y - SPREAD_UNITS];
let plane_max = [max_x + SPREAD_UNITS, max_y + SPREAD_UNITS];
let w = (((plane_max[0] - plane_min[0]) * PX_PER_UNIT).ceil() as u32)
.clamp(1, MAX_TILE_PX);
let h = (((plane_max[1] - plane_min[1]) * PX_PER_UNIT).ceil() as u32)
.clamp(1, MAX_TILE_PX);
let mut data = vec![0u8; (w * h) as usize];
let inv = 1.0 / PX_PER_UNIT;
for py in 0..h {
// Row 0 = top of the tile; glyph Y is up, so flip.
let gy = plane_max[1] - (py as f32 + 0.5) * inv;
for px in 0..w {
let gx = plane_min[0] + (px as f32 + 0.5) * inv;
let d = min_dist_to_strokes(gx, gy, &g.strokes);
let sd = if filled {
// Signed: positive inside the fill.
if point_in_fill(gx, gy, &g.fill_tris) {
d
} else {
-d
}
} else {
// Band around the pen path (wider for bold).
pen_half - d
};
let t = (0.5 + sd / (2.0 * SPREAD_UNITS)).clamp(0.0, 1.0);
data[(py * w + px) as usize] = (t * 255.0).round() as u8;
}
}
Some(BakedTile {
w,
h,
data,
plane_min,
plane_max,
advance: g.advance,
})
}
/// Minimum distance from `(x, y)` to any segment of any polyline. Treats a
/// single-point polyline as a point. Returns `+inf` if there is no geometry.
fn min_dist_to_strokes(x: f32, y: f32, strokes: &[Vec<[f32; 2]>]) -> f32 {
let mut best = f32::INFINITY;
for s in strokes {
if s.len() == 1 {
best = best.min(dist(x, y, s[0][0], s[0][1]));
continue;
}
for w in s.windows(2) {
best = best.min(dist_point_seg(x, y, w[0], w[1]));
}
}
best
}
/// True if `(x, y)` lies in any triangle of a flat triangle-vertex list
/// (3 vertices per triangle).
fn point_in_fill(x: f32, y: f32, tris: &[[f32; 2]]) -> bool {
for t in tris.chunks_exact(3) {
if point_in_tri(x, y, t[0], t[1], t[2]) {
return true;
}
}
false
}
fn dist(px: f32, py: f32, ax: f32, ay: f32) -> f32 {
((px - ax) * (px - ax) + (py - ay) * (py - ay)).sqrt()
}
fn dist_point_seg(px: f32, py: f32, a: [f32; 2], b: [f32; 2]) -> f32 {
let (abx, aby) = (b[0] - a[0], b[1] - a[1]);
let (apx, apy) = (px - a[0], py - a[1]);
let ab2 = abx * abx + aby * aby;
let t = if ab2 <= 1e-12 {
0.0
} else {
((apx * abx + apy * aby) / ab2).clamp(0.0, 1.0)
};
let (cx, cy) = (a[0] + abx * t, a[1] + aby * t);
dist(px, py, cx, cy)
}
fn point_in_tri(px: f32, py: f32, a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
// Sign of the cross products; inside if all the same sign (allowing edges).
let d1 = edge_sign(px, py, a, b);
let d2 = edge_sign(px, py, b, c);
let d3 = edge_sign(px, py, c, a);
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(has_neg && has_pos)
}
fn edge_sign(px: f32, py: f32, a: [f32; 2], b: [f32; 2]) -> f32 {
(px - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (py - b[1])
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Sample the SDF value at a glyph-space point of a baked tile (0..255).
fn sample(tile: &BakedTile, gx: f32, gy: f32) -> u8 {
let px = (((gx - tile.plane_min[0]) * PX_PER_UNIT) as i32)
.clamp(0, tile.w as i32 - 1) as u32;
let py = (((tile.plane_max[1] - gy) * PX_PER_UNIT) as i32)
.clamp(0, tile.h as i32 - 1) as u32;
tile.data[(py * tile.w + px) as usize]
}
/// A filled 4x4 square (fill_tris + closed contour).
fn filled_square() -> Glyph {
let sq = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]];
Glyph {
strokes: vec![sq.to_vec()],
advance: 4.0,
fill_tris: vec![
[0.0, 0.0],
[4.0, 0.0],
[4.0, 4.0],
[0.0, 0.0],
[4.0, 4.0],
[0.0, 4.0],
],
}
}
/// A single diagonal stroke, no fill (LFF-style).
fn diagonal_stroke() -> Glyph {
Glyph {
strokes: vec![vec![[0.0, 0.0], [4.0, 4.0]]],
advance: 4.0,
fill_tris: vec![],
}
}
#[test]
fn filled_glyph_inside_is_high_outside_is_low() {
let tile = bake_glyph(&filled_square(), PEN_HALF_UNITS).expect("square bakes");
// Deep inside the fill -> well above the 0.5 (128) edge.
assert!(sample(&tile, 2.0, 2.0) > 200, "center should read as inside");
// Well outside the fill -> below the edge.
assert!(
sample(&tile, -1.0, -1.0) < 60,
"far corner should read as outside"
);
}
#[test]
fn stroke_glyph_on_line_is_high_off_line_is_low() {
let tile = bake_glyph(&diagonal_stroke(), PEN_HALF_UNITS).expect("stroke bakes");
// On the diagonal -> inside the pen band.
assert!(sample(&tile, 2.0, 2.0) > 128, "on-stroke should be inside band");
// Off the diagonal by more than the pen half-width -> outside.
assert!(
sample(&tile, 3.0, 0.5) < 128,
"off-stroke should be outside band"
);
}
#[test]
fn empty_glyph_has_no_tile() {
let space = Glyph {
strokes: vec![],
advance: 4.5,
fill_tris: vec![],
};
assert!(bake_glyph(&space, PEN_HALF_UNITS).is_none());
}
#[test]
fn packing_places_tiles_without_overlap() {
let mut atlas = GlyphAtlas::new(256, 256);
let a = atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()).unwrap();
let b = atlas.pack(bake_glyph(&diagonal_stroke(), PEN_HALF_UNITS).unwrap()).unwrap();
assert!(atlas.is_dirty());
// UVs are within the atlas.
for e in [&a, &b] {
assert!(e.uv_min[0] >= 0.0 && e.uv_max[0] <= 1.0);
assert!(e.uv_min[1] >= 0.0 && e.uv_max[1] <= 1.0);
}
// Two tiles on the same shelf must not overlap in U.
let overlap_u = a.uv_min[0] < b.uv_max[0] && b.uv_min[0] < a.uv_max[0];
let overlap_v = a.uv_min[1] < b.uv_max[1] && b.uv_min[1] < a.uv_max[1];
assert!(!(overlap_u && overlap_v), "tiles overlap in the atlas");
}
#[test]
fn full_atlas_returns_none() {
// A tiny atlas cannot fit even one padded tile (wider than the atlas —
// the width the packer does not grow).
let mut atlas = GlyphAtlas::new(4, 4);
assert!(atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()).is_none());
}
#[test]
fn atlas_grows_instead_of_dropping_glyphs() {
// Text-heavy drawings need more unique glyphs than one 1024² page holds;
// the packer must grow taller rather than start returning None, which
// silently dropped text and dimensions (issue #347).
let mut atlas = GlyphAtlas::new(1024, 1024);
let start_h = atlas.height();
let mut packed = 0;
for _ in 0..1000 {
match atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()) {
Some(e) => {
// UVs stay normalized after any growth-driven rescale.
assert!(e.uv_min[1] >= 0.0 && e.uv_max[1] <= 1.0);
packed += 1;
}
None => break,
}
}
assert_eq!(packed, 1000, "every tile must fit once the atlas grows");
assert!(atlas.height() > start_h, "atlas should have grown taller");
assert!(atlas.height() <= MAX_ATLAS_PX);
}
// Full path over a real, embedded stroke font (no system fonts needed):
// Face::resolve -> glyph -> bake -> pack, plus the (font, char) cache.
#[test]
fn real_lff_glyph_bakes_and_packs() {
let mut atlas = GlyphAtlas::new(512, 512);
let e = atlas.get_or_insert("txt", 'A', false).expect("LFF 'A' bakes");
assert!(atlas.is_dirty());
assert!(
e.plane_max[0] > e.plane_min[0] && e.plane_max[1] > e.plane_min[1],
"tile covers a positive-area glyph rect"
);
assert!(
e.uv_max[0] > e.uv_min[0]
&& e.uv_max[1] > e.uv_min[1]
&& e.uv_max[0] <= 1.0
&& e.uv_max[1] <= 1.0,
"uv rect non-degenerate and within the atlas"
);
assert!(e.advance > 0.0);
// Second lookup is cached: identical placement, no re-pack.
let e2 = atlas.get_or_insert("txt", 'A', false).expect("cached");
assert_eq!(e.uv_min, e2.uv_min);
assert_eq!(e.uv_max, e2.uv_max);
}
}