Files
OneCAD-Draft/src/scene/text/sdf_atlas.rs
T
Kevin GriffinandClaude Opus 4.8 6b15884444 fix(export): render SDF text as vectors in PDF/print export (#385)
Since text became SDF-only (the legacy stroke-text path was removed), all
text — standalone TEXT/MTEXT and dimension text — rendered on-screen only as
GPU glyph quads carried on `WireModel::text_verts`. The CPU PDF/print exporter
draws only wire stroke `points` and hatch fills, so it silently dropped every
glyph: dimensions and text were missing from exported PDFs and prints
(present in v0.7.6).

Reconstruct vector text in the exporter instead of resurrecting the stroke
path, so the SDF render path and its caching are untouched:

- sdf_atlas: `GlyphAtlas::export_table()` snapshots each baked glyph's vector
  geometry (outline strokes + fill triangles, glyph space) keyed by its tile
  `uv_min`, re-resolved through `Face` so it honours the current TEXTFILL.
- pdf_export: a new `emit_text` pass walks each wire's `text_verts` (one
  6-vertex quad per glyph), recovers the glyph geometry by `uv_min`, and
  affine-maps it into the quad — `DrawLine` polylines for stroke (LFF/SHX)
  fonts, filled triangles for TrueType, plus solid decoration bars. Runs under
  the same rotation/scale/clip CTM as the wires, so it covers model space,
  paper space and windowed plots, and every SDF-text kind (text, dimension,
  multileader, table, tolerance).

Text embedded in complex linetypes is SDF-only with no stroke fallback and
stays out of scope.

Tests: a unit test asserts a stroke glyph emits vector ops within the glyph's
world bbox; an integration test drives a real TEXT + linear DIMENSION through
scene.entity_wires() -> export_pdf and checks text reaches the file. Verified
bug-first: with emit_text disabled the end-to-end test fails with the reported
symptom (text adds no content).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:49:21 -07:00

629 lines
25 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 glyph's vector geometry recovered for CPU export (PDF / print), where the
/// SDF atlas texture can't be sampled. All coordinates are in the 9-unit glyph
/// space; `plane_min`/`plane_max` is the same tile rect the render quad spans,
/// so the exporter can map the geometry into a rendered quad by affine interp.
#[derive(Clone, Debug)]
pub struct GlyphExport {
pub plane_min: [f32; 2],
pub plane_max: [f32; 2],
/// Outline / centreline polylines (glyph units).
pub strokes: Vec<Vec<[f32; 2]>>,
/// Fill triangulation (glyph units, flat triples). Non-empty only for a
/// filled TrueType glyph — the exporter fills these and ignores `strokes`;
/// empty means "stroke `strokes`" (LFF pen font, or a hollow TTF glyph).
pub fill_tris: Vec<[f32; 2]>,
}
/// Pack a glyph quad's `uv_min` corner into a stable hash key for the export
/// table below. The two f32 UVs are exact copies of the baked `AtlasEntry`
/// values (via `GlyphQuad`), so bit-equality is a reliable identity as long as
/// the atlas hasn't been re-scaled by a growth since the quads were built —
/// which, for an export of an already-viewed drawing, it hasn't.
pub fn uv_key(uv_min: [f32; 2]) -> u64 {
((uv_min[0].to_bits() as u64) << 32) | uv_min[1].to_bits() as u64
}
/// 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
}
/// Snapshot every baked glyph's vector geometry, keyed by its tile
/// `uv_min` ([`uv_key`]). CPU exporters (PDF / print) walk a text run's SDF
/// glyph quads, look each quad's `uv_min` up here, and re-emit the glyph as
/// vector strokes / fills instead of sampling the SDF texture. Re-resolves
/// each entry's outline through `Face` (the same source `get_or_insert`
/// baked from), so it reflects the current TEXTFILL mode.
pub fn export_table(&self) -> HashMap<u64, GlyphExport> {
let mut out = HashMap::with_capacity(self.entries.len());
for ((family, ch, bold), entry) in &self.entries {
let Some(entry) = entry else { continue };
let face = Face::resolve(family);
let Some(g) = face.glyph(*ch) else { continue };
// Bold LFF glyphs bake with a wider pen but the same centrelines;
// the exporter widens the pen itself, so the geometry is shared.
let _ = bold;
out.insert(
uv_key(entry.uv_min),
GlyphExport {
plane_min: entry.plane_min,
plane_max: entry.plane_max,
strokes: g.strokes.clone(),
fill_tris: g.fill_tris.clone(),
},
);
}
out
}
/// 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);
}
}