forked from 1b-agent/opencadstudio-upstream
feat(text): SDF glyph-atlas foundation for shader text rendering
Groundwork to render CAD text as per-glyph SDF quads (box-quad snap, perf, no LOD) instead of stroke-polyline wires. CPU side fully tested; GPU side compiles but is not yet wired into the render loop. - scene::text::sdf_atlas — GlyphAtlas: shelf packer + R8 SDF tiles baked per (font, char). Unified field so one shader draws both kinds: TrueType = signed distance to the filled outline, LFF stroke = pen band. Uses only crates already in the tree (ttf-parser/lyon via the existing Glyph). - scene::text::glyph_quads — layout_glyph_quads: lays a run out as per-glyph quads using the exact tessellate_text_run transform, so quads land where the strokes would. Per-char advance (TTF shaping is a follow-up). - shaders/text.wgsl — SDF fragment shader (smoothstep over fwidth), RTE vertex path mirroring image.wgsl, per-vertex colour + draw-order bias. - scene::pipeline::text_gpu — TextVertex + double-single vertex builder, R8 atlas texture upload, bind group, and the image-cloned pipeline. 13 unit tests across the three CPU stages, all green. Modules carry #![allow(dead_code)] until the render-loop integration lands. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
9490c85b32
commit
374cf7e89f
6 files changed
+1065
No files matched your search
@@ -0,0 +1,199 @@
|
||||
//! Phase 2a of the text-shader initiative: lay a text run out as per-glyph
|
||||
//! quads over the SDF atlas, instead of tessellating glyph outlines into wire
|
||||
//! strokes.
|
||||
//!
|
||||
//! Each visible glyph becomes one quad whose corners are the glyph's atlas
|
||||
//! `plane` rectangle (ink bbox + SDF spread, in 9-unit glyph space) run through
|
||||
//! the exact same run transform the stroke path uses (`tessellate_text_run`):
|
||||
//! scale = height/9, width factor, oblique skew, rotation. Corners come out in
|
||||
//! run-local 2-D (origin at `[0, 0]`) — the same space `TextStroke.strokes`
|
||||
//! use — so the downstream path (annotation scale, f64 origin, double-single
|
||||
//! split) handles them identically. The paired atlas UV rect lets the fragment
|
||||
//! shader sample the glyph's SDF tile.
|
||||
//!
|
||||
//! Layout here is per-character advance (LFF exact; TTF without shaping). TTF
|
||||
//! shaping (kerning/ligatures via `shape_run`) is a later refinement — it needs
|
||||
//! per-glyph offsets within a shaped run, which this cut does not yet apply.
|
||||
|
||||
// Not yet wired into the render path (Phase 2b: the text quad pipeline).
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::scene::text::font_face::Face;
|
||||
use crate::scene::text::sdf_atlas::GlyphAtlas;
|
||||
|
||||
/// One glyph placed as a quad in run-local 2-D space (origin at `[0, 0]`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GlyphQuad {
|
||||
/// Corners, CCW: bottom-left, bottom-right, top-right, top-left.
|
||||
pub corners: [[f32; 2]; 4],
|
||||
/// Atlas UV of the tile (top-left / bottom-right).
|
||||
pub uv_min: [f32; 2],
|
||||
pub uv_max: [f32; 2],
|
||||
}
|
||||
|
||||
/// Lay `text` out as per-glyph quads. Mirrors `tessellate_text_run`'s transform
|
||||
/// and per-character advance so the quads land exactly where the strokes would.
|
||||
/// Whitespace and glyphs with no ink advance the pen but emit no quad.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn layout_glyph_quads(
|
||||
atlas: &mut GlyphAtlas,
|
||||
height: f32,
|
||||
rotation: f32,
|
||||
width_factor: f32,
|
||||
oblique_angle: f32,
|
||||
tracking: f32,
|
||||
font_name: &str,
|
||||
text: &str,
|
||||
) -> Vec<GlyphQuad> {
|
||||
if text.is_empty() || height <= 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let scale = height / 9.0;
|
||||
let wf = if width_factor < 0.0 {
|
||||
width_factor.clamp(-100.0, -0.01)
|
||||
} else {
|
||||
width_factor.clamp(0.01, 100.0)
|
||||
};
|
||||
let ob = oblique_angle.tan();
|
||||
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
|
||||
|
||||
// Glyph-space (gx, gy) at pen position cx -> run-local 2-D (origin [0, 0]).
|
||||
// Identical to `tessellate_text_run::xform` with `origin = [0, 0]`.
|
||||
let xform = |gx: f32, gy: f32, cx: f32| -> [f32; 2] {
|
||||
let sx = (cx + gx) * scale * wf + gy * scale * ob;
|
||||
let sy = gy * scale;
|
||||
[sx * cos_r - sy * sin_r, sx * sin_r + sy * cos_r]
|
||||
};
|
||||
|
||||
let face = Face::resolve(font_name);
|
||||
let mut cursor_x = 0.0f32;
|
||||
let mut quads = Vec::new();
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch == ' ' {
|
||||
cursor_x += face.word_spacing();
|
||||
continue;
|
||||
}
|
||||
match atlas.get_or_insert(font_name, ch) {
|
||||
Some(e) => {
|
||||
let (lo, hi) = (e.plane_min, e.plane_max);
|
||||
quads.push(GlyphQuad {
|
||||
corners: [
|
||||
xform(lo[0], lo[1], cursor_x),
|
||||
xform(hi[0], lo[1], cursor_x),
|
||||
xform(hi[0], hi[1], cursor_x),
|
||||
xform(lo[0], hi[1], cursor_x),
|
||||
],
|
||||
uv_min: e.uv_min,
|
||||
uv_max: e.uv_max,
|
||||
});
|
||||
cursor_x += e.advance + face.letter_spacing() * tracking;
|
||||
}
|
||||
None => {
|
||||
// No ink (whitespace glyph) or atlas full: advance only, using
|
||||
// the glyph's own advance when the font knows it.
|
||||
let adv = face.glyph(ch).map(|g| g.advance).unwrap_or(6.0);
|
||||
cursor_x += adv + face.letter_spacing() * tracking;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
quads
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn center(q: &GlyphQuad) -> [f32; 2] {
|
||||
let mut c = [0.0f32; 2];
|
||||
for p in &q.corners {
|
||||
c[0] += p[0] * 0.25;
|
||||
c[1] += p[1] * 0.25;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
// Uses the embedded "txt" stroke font (no system fonts needed).
|
||||
#[test]
|
||||
fn glyphs_advance_rightward() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
let quads = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA");
|
||||
assert_eq!(quads.len(), 2, "two inked glyphs -> two quads");
|
||||
assert!(
|
||||
center(&quads[1])[0] > center(&quads[0])[0],
|
||||
"second glyph is to the right of the first"
|
||||
);
|
||||
for q in &quads {
|
||||
for p in &q.corners {
|
||||
assert!(p[0].is_finite() && p[1].is_finite());
|
||||
}
|
||||
assert!(q.uv_min[0] >= 0.0 && q.uv_max[0] <= 1.0);
|
||||
assert!(q.uv_min[1] >= 0.0 && q.uv_max[1] <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_emits_no_quad_but_advances() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
let ab = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA");
|
||||
let a_sp_a = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "A A");
|
||||
assert_eq!(a_sp_a.len(), 2, "space produces no quad");
|
||||
assert!(
|
||||
center(&a_sp_a[1])[0] > center(&ab[1])[0],
|
||||
"the space widens the gap to the second glyph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_maps_x_advance_to_y() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
// Use the baseline advance direction (glyph0 -> glyph1), which is +x when
|
||||
// flat and swings to +y after a 90° turn — independent of where a single
|
||||
// glyph's box centre sits within the cap height.
|
||||
let flat = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA");
|
||||
let turned = layout_glyph_quads(
|
||||
&mut atlas,
|
||||
10.0,
|
||||
std::f32::consts::FRAC_PI_2,
|
||||
1.0,
|
||||
0.0,
|
||||
1.0,
|
||||
"txt",
|
||||
"AA",
|
||||
);
|
||||
let adv = |qs: &[GlyphQuad]| {
|
||||
let (a, b) = (center(&qs[0]), center(&qs[1]));
|
||||
[b[0] - a[0], b[1] - a[1]]
|
||||
};
|
||||
let df = adv(&flat);
|
||||
let dt = adv(&turned);
|
||||
assert!(df[0] > df[1].abs(), "unrotated advance is along +x");
|
||||
assert!(dt[1] > dt[0].abs(), "90° advance swings to +y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taller_text_makes_bigger_quads() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
let small = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "A");
|
||||
let big = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", "A");
|
||||
let extent = |q: &GlyphQuad| {
|
||||
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
|
||||
for p in &q.corners {
|
||||
lo[0] = lo[0].min(p[0]);
|
||||
lo[1] = lo[1].min(p[1]);
|
||||
hi[0] = hi[0].max(p[0]);
|
||||
hi[1] = hi[1].max(p[1]);
|
||||
}
|
||||
(hi[1] - lo[1]).max(hi[0] - lo[0])
|
||||
};
|
||||
let r = extent(&big[0]) / extent(&small[0]);
|
||||
assert!(
|
||||
(r - 2.0).abs() < 0.15,
|
||||
"double height ~ double quad extent, got {r}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod lff;
|
||||
pub mod font_face;
|
||||
pub mod glyph_quads;
|
||||
pub mod sdf_atlas;
|
||||
pub mod sysfont;
|
||||
pub mod ttf_glyph;
|
||||
pub mod web_font;
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
//! 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 crate::scene::text::font_face::Face;
|
||||
use crate::scene::text::lff::Glyph;
|
||||
|
||||
// ── 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;
|
||||
|
||||
// ── 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). `None` = a whitespace/empty glyph
|
||||
/// with no ink (nothing to draw), cached so we do not re-resolve it.
|
||||
entries: HashMap<(String, char), Option<AtlasEntry>>,
|
||||
// Shelf packer cursor.
|
||||
cursor_x: u32,
|
||||
cursor_y: u32,
|
||||
shelf_h: u32,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl GlyphAtlas {
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
data: vec![0u8; (width * height) as usize],
|
||||
entries: HashMap::new(),
|
||||
cursor_x: 0,
|
||||
cursor_y: 0,
|
||||
shelf_h: 0,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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) -> Option<AtlasEntry> {
|
||||
let key = (family.to_string(), ch);
|
||||
if let Some(cached) = self.entries.get(&key) {
|
||||
return *cached;
|
||||
}
|
||||
let face = Face::resolve(family);
|
||||
let entry = face
|
||||
.glyph(ch)
|
||||
.and_then(|g| bake_glyph(&g))
|
||||
.and_then(|tile| self.pack(tile));
|
||||
self.entries.insert(key, entry);
|
||||
entry
|
||||
}
|
||||
|
||||
/// Blit a baked tile into the atlas via a simple shelf packer. Returns the
|
||||
/// placement, or `None` if the tile does not fit (atlas full).
|
||||
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 || self.cursor_y + tile.h > self.height {
|
||||
return None; // atlas full
|
||||
}
|
||||
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) -> Option<BakedTile> {
|
||||
let filled = !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.
|
||||
PEN_HALF_UNITS - 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()).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()).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).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_places_tiles_without_overlap() {
|
||||
let mut atlas = GlyphAtlas::new(256, 256);
|
||||
let a = atlas.pack(bake_glyph(&filled_square()).unwrap()).unwrap();
|
||||
let b = atlas.pack(bake_glyph(&diagonal_stroke()).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.
|
||||
let mut atlas = GlyphAtlas::new(4, 4);
|
||||
assert!(atlas.pack(bake_glyph(&filled_square()).unwrap()).is_none());
|
||||
}
|
||||
|
||||
// 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').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').expect("cached");
|
||||
assert_eq!(e.uv_min, e2.uv_min);
|
||||
assert_eq!(e.uv_max, e2.uv_max);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user