Files
OneCAD-Draft/src/scene/text/sdf_atlas.rs
T
Hakan SevenandClaude Opus 4.8 5018963d3d fix(export): project viewport text into paper space
Review follow-ups to #390, which fixed PDF/print text for model space but
left the layout-plot path — the one issue #385 was filed from — broken.

viewport_content_wires rewrote points/snap_pts/key_vertices/aabb and then
cloned text_verts through untouched, so paper-space viewport text stayed at
model (UTM) coordinates: a dimension at model (25000, 12000) plotted its
lines onto the sheet and its glyphs kilometres off the page. A text-only
wire fared worse — TEXT/MTEXT carry no stroke points since text went
SDF-only, so it was dropped outright by the empty-clip and AABB rejections.
Project the glyphs through the same proj_abs the snap points use (via the
existing map_text_verts helper), cull per glyph on the quad centroid, count
them towards the paper AABB, and dim them like the wire colour.

Also in emit_text, which diverged from the wire pass it mirrors:
- divide the pen by `scale`, or a Fit/windowed plot prints text as
  near-invisible hairlines while its own lines stay correct;
- apply the CTB plot style, or a monochrome.ctb plot renders the lines
  black and leaves the text on its screen colour;
- reset the dash pattern, which is persistent PDF state the wire pass
  leaves set — a HIDDEN-linetype last wire printed glyph outlines dashed.

export_table's doc claimed it honoured TEXTFILL and widened the bold pen
itself; it did neither. Gate fill_tris on textfill() so hollow-on-screen
text exports hollow, carry the bold flag and widen the pen by the 1.7x the
bake uses, and resolve Face once per family instead of once per glyph (the
atlas mutex is held for that whole walk).

uv_key identifies a glyph by its tile's uv_min, but grow_height rescales
every entry's V and reset rewinds the packer, leaving already-built quads
addressing the wrong tile — garbage on screen and a silent miss in the
export table, i.e. #385 again under #347's grow-the-atlas conditions. Bump
an atlas generation on both and fold it into the tessellation memo guard so
the text re-lays-out instead. Quads built earlier in the same pass that
grows are still stale for that pass; the guard heals them on the next one.

Tests: the integration test's "some wire has text_verts" assert passed on
pre-fix code too (the exporter ignored them; the scene always carried
them), and its dimension half was satisfied by the TEXT alone — assert per
entity handle instead, and stop colliding on fixed names in the shared temp
root. The unit test's bbox came from the same quads the mapping reads, so
it held by construction; pin the run's own world box instead. That still
cannot catch a mirrored corner assignment — verified by mirroring it — so
say so rather than claim coverage the assert doesn't have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00

670 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)))
}
/// Bumped whenever already-baked entries' UVs stop addressing their tile:
/// `grow_height` rescales every entry's V and `reset` rewinds the packer, but
/// glyph quads built earlier keep the UVs they were laid out with. Those stale
/// quads sample the wrong tile on screen and miss the export table entirely
/// (silently dropping text from the PDF — issue #385 all over again, under
/// issue #347's grow-the-atlas conditions). The tessellation memo guard folds
/// this in, so a growth re-lays-out the text instead of leaving stale quads.
static ATLAS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Current atlas UV generation — see [`ATLAS_GENERATION`]. Anything that caches
/// glyph quads must treat a change here as "re-lay-out the text".
pub fn generation() -> u64 {
ATLAS_GENERATION.load(std::sync::atomic::Ordering::Relaxed)
}
fn bump_generation() {
ATLAS_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
/// 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]>,
/// Baked with the 1.7× bold pen; the exporter widens its own pen to match.
pub bold: bool,
}
/// 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);
// The packer rewound, so every previously handed-out UV is now stale.
bump_generation();
}
/// 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), gated on TEXTFILL so a hollow-on-screen glyph exports hollow.
pub fn export_table(&self) -> HashMap<u64, GlyphExport> {
let fill_on = textfill();
let mut out = HashMap::with_capacity(self.entries.len());
// `Face::resolve` reparses a font, so resolve once per family rather than
// once per baked glyph — a CJK drawing holds thousands of entries over a
// handful of families, and the atlas mutex is held for this whole walk.
let mut faces: HashMap<&str, Face> = HashMap::new();
for ((family, ch, bold), entry) in &self.entries {
let Some(entry) = entry else { continue };
let face = faces
.entry(family.as_str())
.or_insert_with(|| Face::resolve(family));
let Some(g) = face.glyph(*ch) else { continue };
out.insert(
uv_key(entry.uv_min),
GlyphExport {
plane_min: entry.plane_min,
plane_max: entry.plane_max,
strokes: g.strokes.clone(),
// `bake_glyph` gates the texture fill on TEXTFILL the same way
// (`textfill() && !g.fill_tris.is_empty()`); `Face::glyph` does
// not, so without this the export ignores TEXTFILL 0 and prints
// solid glyphs where the screen shows outlines.
fill_tris: if fill_on {
g.fill_tris.clone()
} else {
Vec::new()
},
// Bold stroke glyphs bake at a 1.7× pen (`get_or_insert`) but
// share the centrelines, so carry the flag and let the exporter
// widen its own pen by the same factor.
bold: *bold,
},
);
}
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;
// Every already-emitted quad's V now addresses the wrong tile.
bump_generation();
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);
}
}