makepad/libs/text_mesh/src/letters.rs

1255 lines
52 KiB
Rust
Raw Permalink 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.

//! 3D extruded type with one mesh per letter, for kinetic 3D typography.
//!
//! The glyph work (outlines, counters, bevels, the verified triangulation)
//! is [`crate::text_mesh`]'s builder; this module lays the text out with a
//! window-less [`Layouter`], builds it in the `Block` layout and splits the
//! stream into letters, each around its own rest centre so a document can
//! move, turn and scale every letter about itself. Each letter also keeps
//! its faces by part (front and back caps, side walls, bevel band: the
//! builder's face classes), so the caps, walls and bevel can wear different
//! materials, and the text records the rest centre of every word and line
//! so a word or a line can turn about its own middle.
use makepad_geom3d::mesh::*;
use makepad_geom3d::fx_mesh::{FxMesh, VERT_FLOATS};
use crate::text_mesh::{build_text_mesh, BevelType, TextLayout, TextMeshParams};
use makepad_draw::text::geom::Size;
use makepad_draw::text::font::FontId;
use makepad_draw::text::font_family::FontFamilyId;
use makepad_draw::text::layouter::{BorrowedLayoutParams, LaidoutText, LayoutOptions, Layouter, Settings, Style};
use makepad_draw::text::loader::{FontDefinition, FontFamilyDefinition};
use makepad_draw::SharedBytes;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
/// Where a font comes from.
#[derive(Clone, Debug, PartialEq)]
pub enum FontSource {
/// A font shipped with Makepad: "sans" (IBM Plex Sans), "bold" (Plex
/// SemiBold), "italic", "bold_italic", "mono" (Liberation Mono), "inter"
/// (Inter, variable weight), "roboto" (Roboto Flex), "noto" (Noto Sans).
Bundled(String),
/// A .ttf/.otf file.
Path(PathBuf),
/// Font file bytes (a library asset).
Bytes(Arc<Vec<u8>>),
}
impl Default for FontSource {
fn default() -> Self {
FontSource::Bundled("sans".into())
}
}
static FONTS_DIR: std::sync::RwLock<Option<PathBuf>> = std::sync::RwLock::new(None);
/// Say where Makepad's bundled fonts are (the widget library's
/// `resources`): a host calls it once, before building text.
pub fn set_fonts_dir(dir: impl Into<PathBuf>) {
*FONTS_DIR.write().unwrap_or_else(|e| e.into_inner()) = Some(dir.into());
}
/// A host's font bytes by file path, consulted before the file system: a
/// host without this machine's files (the browser player) hands over the
/// bytes it fetched; `None` reads the file.
pub type FontReader = dyn Fn(&std::path::Path) -> Option<Arc<[u8]>> + Send + Sync;
static FONT_READER: std::sync::OnceLock<Box<FontReader>> = std::sync::OnceLock::new();
/// Install the host's font reader (once; a later call is ignored: false).
pub fn set_font_reader(reader: Box<FontReader>) -> bool {
FONT_READER.set(reader).is_ok()
}
/// A font file's bytes: the host's reader, else the file.
fn read_font_file(path: &std::path::Path) -> std::io::Result<Vec<u8>> {
match FONT_READER.get().and_then(|reader| reader(path)) {
Some(bytes) => Ok(bytes.to_vec()),
None => std::fs::read(path),
}
}
/// Makepad's bundled font directory, as the host said ([`set_fonts_dir`]),
/// or `MOTION3D_FONTS_DIR` when set. Without either there is no directory
/// (the path is empty and a read of a bundled name says so).
pub fn fonts_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("MOTION3D_FONTS_DIR") {
return PathBuf::from(dir);
}
if let Some(dir) = FONTS_DIR.read().unwrap_or_else(|e| e.into_inner()).clone() {
return dir;
}
#[cfg(any(test, feature = "dev-fonts"))]
return PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../widgets/resources");
#[cfg(not(any(test, feature = "dev-fonts")))]
PathBuf::new()
}
/// The file behind a bundled font name, `None` for an unknown name.
pub fn bundled_font_file(name: &str) -> Option<&'static str> {
Some(match name.trim().to_ascii_lowercase().as_str() {
"sans" | "regular" | "plex" | "default" => "IBMPlexSans-Text.ttf",
"bold" | "semibold" => "IBMPlexSans-SemiBold.ttf",
"italic" => "IBMPlexSans-Italic.ttf",
"bold_italic" => "IBMPlexSans-BoldItalic.ttf",
"mono" => "LiberationMono-Regular.ttf",
"inter" => "Inter.ttf",
"roboto" => "RobotoFlex.ttf",
"noto" => "NotoSans-Regular.ttf",
_ => return None,
})
}
/// Every bundled font name, for messages.
pub const BUNDLED_FONTS: &[&str] = &["sans", "bold", "italic", "bold_italic", "mono", "inter", "roboto", "noto"];
impl FontSource {
/// The font file's bytes, or a message naming the fix.
pub fn load(&self) -> Result<Vec<u8>, String> {
match self {
FontSource::Bundled(name) => {
let file = bundled_font_file(name).ok_or_else(|| format!("no bundled font `{name}`; bundled fonts: {}", BUNDLED_FONTS.join(", ")))?;
let dir = fonts_dir();
if dir.as_os_str().is_empty() {
return Err(format!("bundled font `{name}`: the host has not said where the fonts are (letters::set_fonts_dir)"));
}
let path = dir.join(file);
read_font_file(&path).map_err(|e| format!("bundled font `{name}` ({}): {e}", path.display()))
}
FontSource::Path(path) => read_font_file(path).map_err(|e| format!("font {}: {e}", path.display())),
FontSource::Bytes(bytes) => Ok(bytes.as_ref().clone()),
}
}
}
/// Horizontal alignment of the lines within the block.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TextAlign {
Left,
#[default]
Center,
Right,
}
/// The profile of the edge band between the side walls and the caps.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BevelProfile {
/// No band: a hard edge (the same as `bevel: 0`).
Flat,
/// A straight 45° cut (`bevel_segments` subdivide it).
#[default]
Chamfer,
/// A convex quarter round.
Round,
/// A concave quarter round.
Cove,
/// Flat terraces.
Step,
/// An S moulding: a cove running into a round.
Ogee,
}
impl BevelProfile {
pub const NAMES: &'static [&'static str] = &["flat", "chamfer", "round", "cove", "step", "ogee"];
pub fn by_name(s: &str) -> Option<Self> {
Some(match s {
"flat" | "none" => Self::Flat,
"chamfer" => Self::Chamfer,
"round" | "rounded" => Self::Round,
"cove" => Self::Cove,
"step" | "steps" => Self::Step,
"ogee" => Self::Ogee,
_ => return None,
})
}
}
/// The faces of a letter by part.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TextPart {
/// The front cap (+z).
Front,
/// The back cap (−z).
Back,
/// The side walls.
Side,
/// The bevel band (both sides).
Bevel,
}
impl TextPart {
pub const ALL: [TextPart; 4] = [TextPart::Front, TextPart::Back, TextPart::Side, TextPart::Bevel];
fn of_class(class: f32) -> Option<Self> {
Some(match class as i32 {
0 => Self::Front,
1 => Self::Back,
2 => Self::Side,
3 => Self::Bevel,
_ => return None,
})
}
}
/// What to build.
#[derive(Clone, Debug, PartialEq)]
pub struct Text3dParams {
/// The text; `\n` breaks lines.
pub text: String,
/// The font. Default bundled "sans".
pub font: FontSource,
/// Variable-font weight (100..900) for fonts with a `wght` axis ("inter"); None = the font's own.
pub weight: Option<f32>,
/// Other variable-font axes as (tag, value), e.g. `wdth` 125 or `slnt`
/// -10 (four-letter tags packed big-endian); axes the font lacks are
/// ignored. Default none.
pub axes: Vec<(u32, f32)>,
/// Cap height of the first line, world units. Default 1.
pub size: f32,
/// Extrusion depth; 0 = flat front faces only. Default 0.3.
pub depth: f32,
/// Bevel width (clamped to 0.4 depth and 0.3 size); 0 = none (default).
pub bevel: f32,
/// Rings across the bevel band (1..8). Default 1 (4 for round profiles).
pub bevel_segments: u32,
/// The bevel band's profile. Default chamfer.
pub bevel_profile: BevelProfile,
/// Curve flattening tolerance in em (0.004..0.05). Default 0.012.
pub detail: f32,
/// Extra letter spacing in em (-0.5..2). Default 0.
pub tracking: f32,
/// Line spacing multiplier. Default 1.
pub line_height: f32,
/// Alignment of lines within the block. Default Center.
pub align: TextAlign,
/// Wrap width in world units; None = explicit lines only (default).
pub wrap: Option<f32>,
/// Also sample the letters into cubes: (cells per cap height 4..40,
/// layers 1..12) — vj_fx's voxel layout, letter cells only.
pub voxels: Option<(u32, u32)>,
}
impl Default for Text3dParams {
fn default() -> Self {
Self {
text: String::new(),
font: FontSource::default(),
weight: None,
axes: Vec::new(),
size: 1.0,
depth: 0.3,
bevel: 0.0,
bevel_segments: 1,
bevel_profile: BevelProfile::Chamfer,
detail: 0.012,
tracking: 0.0,
line_height: 1.0,
align: TextAlign::Center,
wrap: None,
voxels: None,
}
}
}
/// One letter: its solid around its own rest centre.
#[derive(Clone, Debug, PartialEq)]
pub struct Letter {
/// Vertices relative to `pivot`, every part.
pub mesh: Mesh,
/// The same faces split by part (parts without faces are left out).
pub parts: Vec<(TextPart, Mesh)>,
/// Rest centre in text space (block centred on the origin, y up, front faces +z).
pub pivot: [f32; 3],
/// Ordinal among the letters with ink (spaces are not letters).
pub index: usize,
pub word: usize,
pub line: usize,
pub ch: char,
}
/// A built text.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Text3d {
pub letters: Vec<Letter>,
pub words: usize,
pub lines: usize,
/// Rest centre of every word and every line (ink bounds), text space.
pub word_centers: Vec<[f32; 3]>,
pub line_centers: Vec<[f32; 3]>,
/// Bounds of the whole block in text space, `(min, max)`.
pub bounds: ([f32; 3], [f32; 3]),
/// What the builder had to degrade or could not do.
pub warnings: Vec<String>,
/// With `Text3dParams::voxels`: the letter cells as cubes.
pub voxels: Vec<Voxel>,
/// Half extents of one voxel cube.
pub voxel_half: [f32; 3],
}
/// One cube of a voxel letter.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Voxel {
/// Rest centre in text space.
pub center: [f32; 3],
/// The glyph ordinal it belongs to (`Letter::index`).
pub glyph: usize,
}
impl Text3d {
/// Every letter placed at its pivot, as one mesh.
pub fn merged(&self) -> Mesh {
let mut out = Mesh::new();
for l in &self.letters {
let mut m = l.mesh.clone();
m.positions.iter_mut().for_each(|p| *p = add3(*p, l.pivot));
out.append(&m);
}
out
}
}
/// 100 lpx reference size, in points (as vj_fx lays text out).
const REF_PTS: f32 = 100.0 * 72.0 / 96.0;
static RECORDING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
type GlyphRecord = (u64, std::collections::BTreeSet<u16>);
fn recorded() -> &'static (std::sync::mpsc::Sender<GlyphRecord>, std::sync::Mutex<std::sync::mpsc::Receiver<GlyphRecord>>) {
static Q: std::sync::OnceLock<(std::sync::mpsc::Sender<GlyphRecord>, std::sync::Mutex<std::sync::mpsc::Receiver<GlyphRecord>>)> = std::sync::OnceLock::new();
Q.get_or_init(|| {
let (tx, rx) = std::sync::mpsc::channel();
(tx, std::sync::Mutex::new(rx))
})
}
/// Keeps (or stops keeping) the glyphs every [`build_text3d`] from now on
/// lays out, on any thread: how a build tool learns which glyphs of which
/// font files 3D type uses (for instance to ship only those).
pub fn record_glyphs(on: bool) {
RECORDING.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// The glyphs recorded since the last call: per build, the font file's
/// content hash (FNV-1a 64 over its bytes) and the glyph ids laid out.
pub fn take_recorded_glyphs() -> Vec<(u64, std::collections::BTreeSet<u16>)> {
let Ok(rx) = recorded().1.lock() else { return Vec::new() };
rx.try_iter().collect()
}
/// The trace host's switch (`makepad-web-trace`) for 3D type's record.
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn wasm_record_text3d_glyphs(on: u32) {
record_glyphs(on != 0);
}
/// The trace host's read of 3D type's record, as draw's
/// `wasm_take_recorded_glyphs` lays it out: `[words, fonts, (file hash,
/// glyph count, glyph id...)...]`.
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn wasm_take_text3d_glyphs() -> u32 {
let mut fonts: std::collections::BTreeMap<u64, std::collections::BTreeSet<u16>> = Default::default();
for (hash, glyphs) in take_recorded_glyphs() {
fonts.entry(hash).or_default().extend(glyphs);
}
let mut words = vec![0u64, fonts.len() as u64];
for (hash, glyphs) in fonts {
words.push(hash);
words.push(glyphs.len() as u64);
words.extend(glyphs.iter().map(|&id| id as u64));
}
words[0] = words.len() as u64;
Box::leak(words.into_boxed_slice()).as_ptr() as usize as u32
}
fn fnv64(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3))
}
/// Lay out and extrude `params.text`.
pub fn build_text3d(params: &Text3dParams) -> Result<Text3d, String> {
let bytes = params.font.load()?;
let recording = RECORDING.load(std::sync::atomic::Ordering::Relaxed).then(|| fnv64(&bytes));
// Only shaping and metrics are needed: keep the rasterizer's atlas tiny
// (the default allocates a 2048² atlas per layouter).
let mut settings = Settings::default();
settings.loader.rasterizer.atlas_size = Size::new(16, 16);
settings.cache_size = 8;
let mut layouter = Layouter::new(settings);
layouter.record_glyphs(recording.is_some());
let font_id: FontId = 0x4D33_4400_0001_u64.into();
let family: FontFamilyId = 0x4D33_4400_0101_u64.into();
layouter.define_font(
font_id,
FontDefinition {
data: SharedBytes::from_vec(bytes),
index: 0,
ascender_fudge_in_ems: 0.0,
descender_fudge_in_ems: 0.0,
weight: params.weight,
variations: params.axes.clone(),
},
);
layouter.define_font_family(family, FontFamilyDefinition { font_ids: vec![font_id], expected_member_count: 1, diagnostics: Default::default() });
let align = match params.align {
TextAlign::Left => 0.0,
TextAlign::Center => 0.5,
TextAlign::Right => 1.0,
};
let line_height = if params.line_height.is_finite() && params.line_height > 0.0 { params.line_height } else { 1.0 };
let mut layout = |max: Option<f32>, wrap: bool| -> Rc<LaidoutText> {
layouter.get_or_layout(BorrowedLayoutParams {
text: &params.text,
style: Style { font_family_id: family, font_size_in_pts: REF_PTS, color: None },
options: LayoutOptions { max_width_in_lpxs: max, wrap, align, line_spacing_scale: line_height, ..LayoutOptions::default() },
})
};
let mut laidout = layout(None, false);
let size = if params.size.is_finite() && params.size > 0.0 { params.size } else { 1.0 };
if let Some(wrap) = params.wrap.filter(|w| w.is_finite() && *w > 0.0) {
let cap = laidout.rows.first().map(|r| r.cap_height_in_lpxs).filter(|c| *c > 0.0).unwrap_or(70.0);
laidout = layout(Some(wrap * cap / size), true);
} else if laidout.rows.len() > 1 && align > 0.0 {
// Explicit lines align within the widest one (the layouter aligns
// a row within the max width, which defaults to the row's own).
let widest = laidout.rows.iter().map(|r| r.width_in_lpxs).fold(0.0f32, f32::max);
laidout = layout(Some(widest), false);
}
if let Some(hash) = recording {
let glyphs = layouter.take_recorded_glyphs().into_iter().flat_map(|r| r.glyphs).collect();
recorded().0.send((hash, glyphs)).ok();
}
let mut fx = FxMesh::default();
let report = build_text_mesh(
&laidout,
&TextMeshParams {
size,
depth: params.depth.max(0.0),
bevel: if params.bevel_profile == BevelProfile::Flat { 0.0 } else { params.bevel.max(0.0) },
detail: params.detail,
layout: TextLayout::Block,
tracking: params.tracking,
bevel_type: match params.bevel_profile {
BevelProfile::Flat | BevelProfile::Chamfer => BevelType::Chamfer,
BevelProfile::Round => BevelType::Round,
BevelProfile::Cove => BevelType::Cove,
BevelProfile::Step => BevelType::Step,
BevelProfile::Ogee => BevelType::Ogee,
},
bevel_rings: params.bevel_segments.clamp(1, 8) as usize,
..TextMeshParams::default()
},
&mut fx,
);
let (voxels, voxel_half) = match params.voxels {
Some((res, layers)) => {
let mut vx = FxMesh::default();
let r = build_text_mesh(
&laidout,
&TextMeshParams {
size,
depth: params.depth.max(0.0),
detail: params.detail,
layout: TextLayout::Voxel,
tracking: params.tracking,
voxel_res: res.clamp(4, 40) as usize,
voxel_layers: layers.clamp(1, 12) as usize,
voxel_waste: false,
..TextMeshParams::default()
},
&mut vx,
);
// 24 vertices per cube; letter cubes (class 4) only.
let half = r.voxel_half;
let layers = r.voxel_layers.max(1) as f32;
let cubes = vx
.verts
.chunks_exact(VERT_FLOATS * 24)
.filter(|c| c[7].floor() == 4.0)
.map(|c| {
let layer = (c[11].max(0.0) % 1000.0).floor();
Voxel { center: [c[8], c[9], half[2] * (layers - 1.0 - 2.0 * layer)], glyph: c[3].max(0.0).round() as usize }
})
.collect();
(cubes, half)
}
None => (Vec::new(), [0.0; 3]),
};
// The characters of the ink glyphs, in the builder's order.
let mut chars = Vec::new();
for row in &laidout.rows {
let text = row.text.as_str();
for g in &row.glyphs {
if let Some(c) = text.get(g.cluster.min(text.len())..).and_then(|s| s.chars().next()) {
if !c.is_whitespace() {
chars.push(c);
}
}
}
}
let mut out = Text3d { words: report.words, lines: report.lines, warnings: report.warnings.clone(), voxels, voxel_half, ..Default::default() };
let v = |i: usize| &fx.verts[i * VERT_FLOATS..(i + 1) * VERT_FLOATS];
let n_verts = fx.verts.len() / VERT_FLOATS;
// Glyph ordinal -> (letter slot, vertex remap); per part the same.
let mut slots: Vec<Option<usize>> = Vec::new();
let mut remap = vec![u32::MAX; n_verts];
let mut part_remap = vec![u32::MAX; n_verts];
for tri in fx.idx.chunks_exact(3) {
let first = v(tri[0] as usize);
let class = first[7].floor();
if !(0.0..=3.0).contains(&class) || first[3] < 0.0 {
continue;
}
let glyph = first[3].round() as usize;
if slots.len() <= glyph {
slots.resize(glyph + 1, None);
}
let slot = *slots[glyph].get_or_insert_with(|| {
out.letters.push(Letter {
mesh: Mesh::new(),
parts: Vec::new(),
pivot: [first[8], first[9], 0.0],
index: glyph,
word: first[10].max(0.0).floor() as usize,
line: (first[11].max(0.0) % 1000.0) as usize,
ch: chars.get(glyph).copied().unwrap_or('?'),
});
out.letters.len() - 1
});
let mut ids = [0u32; 3];
for (k, &i) in tri.iter().enumerate() {
let i = i as usize;
if remap[i] == u32::MAX {
let s = v(i);
remap[i] = out.letters[slot].mesh.vertex([s[0], s[1], s[2]], normalize_or_up([s[4], s[5], s[6]]), [0.0, 0.0]);
}
ids[k] = remap[i];
}
out.letters[slot].mesh.tri(ids[0], ids[1], ids[2]);
// The same triangle in its part's mesh (a vertex belongs to one
// class: the builder never shares vertices across classes).
let Some(part) = TextPart::of_class(class) else { continue };
let parts = &mut out.letters[slot].parts;
let k = match parts.iter().position(|(p, _)| *p == part) {
Some(k) => k,
None => {
parts.push((part, Mesh::new()));
parts.len() - 1
}
};
let mut pids = [0u32; 3];
for (j, &i) in tri.iter().enumerate() {
let i = i as usize;
if part_remap[i] == u32::MAX {
let s = v(i);
part_remap[i] = parts[k].1.vertex([s[0], s[1], s[2]], normalize_or_up([s[4], s[5], s[6]]), [0.0, 0.0]);
}
pids[j] = part_remap[i];
}
parts[k].1.tri(pids[0], pids[1], pids[2]);
}
for l in &mut out.letters {
l.parts.sort_by_key(|(p, _)| TextPart::ALL.iter().position(|q| q == p));
}
out.letters.sort_by_key(|l| l.index);
// Block bounds, then planar uvs across the whole block.
let (mut min, mut max) = ([f32::MAX; 3], [f32::MIN; 3]);
for l in &out.letters {
for p in &l.mesh.positions {
let q = add3(*p, l.pivot);
for k in 0..3 {
min[k] = min[k].min(q[k]);
max[k] = max[k].max(q[k]);
}
}
}
if out.letters.is_empty() {
min = [0.0; 3];
max = [0.0; 3];
}
out.bounds = (min, max);
let (w, h) = ((max[0] - min[0]).max(1e-6), (max[1] - min[1]).max(1e-6));
for l in &mut out.letters {
let pivot = l.pivot;
let uv = |p: &[f32; 3]| [(p[0] + pivot[0] - min[0]) / w, (p[1] + pivot[1] - min[1]) / h];
l.mesh.uvs = l.mesh.positions.iter().map(uv).collect();
for (_, m) in &mut l.parts {
m.uvs = m.positions.iter().map(uv).collect();
}
}
// Word and line rest centres: the middle of their letters' ink.
let centres = |key: &dyn Fn(&Letter) -> usize, n: usize| -> Vec<[f32; 3]> {
let mut bb = vec![([f32::MAX; 3], [f32::MIN; 3]); n];
for l in &out.letters {
let Some((mn, mx)) = l.mesh.bounds() else { continue };
let b = &mut bb[key(l).min(n.saturating_sub(1))];
for k in 0..3 {
b.0[k] = b.0[k].min(mn[k] + l.pivot[k]);
b.1[k] = b.1[k].max(mx[k] + l.pivot[k]);
}
}
bb.iter().map(|(a, b)| if a[0] > b[0] { [0.0; 3] } else { [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5] }).collect()
};
let words = out.letters.iter().map(|l| l.word + 1).max().unwrap_or(0);
let lines = out.letters.iter().map(|l| l.line + 1).max().unwrap_or(0);
out.word_centers = centres(&|l| l.word, words);
out.line_centers = centres(&|l| l.line, lines);
Ok(out)
}
// ---- traced letters: centre lines, outlines, tubes, LED dots ----------
/// A letter's front cap rasterized on a square lattice (letter-local
/// coordinates, the letter's pivot at the origin).
#[derive(Clone, Debug)]
pub struct InkGrid {
pub w: usize,
pub h: usize,
/// Centre of cell (0, 0).
pub origin: [f32; 2],
pub cell: f32,
pub ink: Vec<bool>,
}
impl InkGrid {
pub fn at(&self, x: i32, y: i32) -> bool {
x >= 0 && y >= 0 && (x as usize) < self.w && (y as usize) < self.h && self.ink[y as usize * self.w + x as usize]
}
pub fn centre(&self, x: usize, y: usize) -> [f32; 2] {
[self.origin[0] + x as f32 * self.cell, self.origin[1] + y as f32 * self.cell]
}
}
/// Which lattice cells (centres) lie inside the letter's front cap.
pub fn ink_grid(letter: &Letter, cell: f32) -> InkGrid {
let cell = cell.max(1e-3);
let tris: Vec<[[f32; 2]; 3]> = letter
.parts
.iter()
.filter(|(p, _)| *p == TextPart::Front)
.flat_map(|(_, m)| m.indices.chunks_exact(3).map(move |t| [0, 1, 2].map(|k| { let q = m.positions[t[k] as usize]; [q[0], q[1]] })))
.collect();
let (mut mn, mut mx) = ([f32::MAX; 2], [f32::MIN; 2]);
for t in &tris {
for q in t {
mn = [mn[0].min(q[0]), mn[1].min(q[1])];
mx = [mx[0].max(q[0]), mx[1].max(q[1])];
}
}
if tris.is_empty() {
return InkGrid { w: 0, h: 0, origin: [0.0; 2], cell, ink: Vec::new() };
}
// One empty cell of margin all round (thinning needs a border).
let origin = [mn[0] - cell, mn[1] - cell];
let w = ((mx[0] - mn[0]) / cell).ceil() as usize + 3;
let h = ((mx[1] - mn[1]) / cell).ceil() as usize + 3;
let mut ink = vec![false; w * h];
let side = |a: [f32; 2], b: [f32; 2], p: [f32; 2]| (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
for t in &tris {
let lo = [t.iter().map(|q| q[0]).fold(f32::MAX, f32::min), t.iter().map(|q| q[1]).fold(f32::MAX, f32::min)];
let hi = [t.iter().map(|q| q[0]).fold(f32::MIN, f32::max), t.iter().map(|q| q[1]).fold(f32::MIN, f32::max)];
let x0 = (((lo[0] - origin[0]) / cell).floor().max(0.0)) as usize;
let x1 = (((hi[0] - origin[0]) / cell).ceil() as usize).min(w - 1);
let y0 = (((lo[1] - origin[1]) / cell).floor().max(0.0)) as usize;
let y1 = (((hi[1] - origin[1]) / cell).ceil() as usize).min(h - 1);
for y in y0..=y1 {
for x in x0..=x1 {
let p = [origin[0] + x as f32 * cell, origin[1] + y as f32 * cell];
let (a, b, c) = (side(t[0], t[1], p), side(t[1], t[2], p), side(t[2], t[0], p));
if (a >= 0.0 && b >= 0.0 && c >= 0.0) || (a <= 0.0 && b <= 0.0 && c <= 0.0) {
ink[y * w + x] = true;
}
}
}
}
InkGrid { w, h, origin, cell, ink }
}
/// The letter's centre lines: its ink thinned to one cell (Zhang–Suen),
/// traced into polylines between ends and junctions, spurs shorter than
/// `min_len` dropped, then smoothed. Each path says whether it is closed.
pub fn centre_lines(grid: &InkGrid, min_len: f32) -> Vec<(Vec<[f32; 2]>, bool)> {
let (w, h) = (grid.w, grid.h);
if w < 3 || h < 3 {
return Vec::new();
}
let mut on = grid.ink.clone();
let idx = |x: usize, y: usize| y * w + x;
// Zhang–Suen thinning.
loop {
let mut changed = false;
for pass in 0..2 {
let mut clear = Vec::new();
for y in 1..h - 1 {
for x in 1..w - 1 {
if !on[idx(x, y)] {
continue;
}
// p2..p9 clockwise from north.
let n = [
on[idx(x, y + 1)], on[idx(x + 1, y + 1)], on[idx(x + 1, y)], on[idx(x + 1, y - 1)],
on[idx(x, y - 1)], on[idx(x - 1, y - 1)], on[idx(x - 1, y)], on[idx(x - 1, y + 1)],
];
let b = n.iter().filter(|v| **v).count();
if !(2..=6).contains(&b) {
continue;
}
let a = (0..8).filter(|k| !n[*k] && n[(k + 1) % 8]).count();
if a != 1 {
continue;
}
let (p2, p4, p6, p8) = (n[0], n[2], n[4], n[6]);
let ok = if pass == 0 { !(p2 && p4 && p6) && !(p4 && p6 && p8) } else { !(p2 && p4 && p8) && !(p2 && p6 && p8) };
if ok {
clear.push(idx(x, y));
}
}
}
changed |= !clear.is_empty();
for i in clear {
on[i] = false;
}
}
if !changed {
break;
}
}
// Trace: nodes are cells with other than two neighbours.
const D: [(i32, i32); 8] = [(0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (-1, 1)];
let get = |on: &[bool], x: i32, y: i32| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && on[y as usize * w + x as usize];
// 8-connected, but a diagonal step only where no side step connects the
// two cells already (a staircase then reads as a line, not a fork).
let neighbours = |on: &[bool], x: i32, y: i32| -> Vec<(i32, i32)> {
D.iter()
.filter(|(dx, dy)| get(on, x + dx, y + dy) && (*dx == 0 || *dy == 0 || (!get(on, x + dx, y) && !get(on, x, y + dy))))
.map(|(dx, dy)| (x + dx, y + dy))
.collect()
};
// Prune spurs: a branch from an end cell to a junction shorter than
// `min_len` is thinning noise (a stroke's corner), not a stroke.
let spur = (min_len / grid.cell).ceil() as usize;
for _ in 0..3 {
let mut removed = false;
let ends: Vec<(i32, i32)> = (0..h).flat_map(|y| (0..w).map(move |x| (x as i32, y as i32))).filter(|(x, y)| on[*y as usize * w + *x as usize] && neighbours(&on, *x, *y).len() == 1).collect();
for e in ends {
let mut branch = vec![e];
let (mut prev, mut cur) = (e, e);
let reached_junction = loop {
let ns: Vec<(i32, i32)> = neighbours(&on, cur.0, cur.1).into_iter().filter(|n| *n != prev).collect();
if ns.len() != 1 {
break ns.len() > 1;
}
prev = cur;
cur = ns[0];
if neighbours(&on, cur.0, cur.1).len() > 2 {
break true;
}
branch.push(cur);
if branch.len() > spur {
break false;
}
};
if reached_junction && branch.len() <= spur {
for c in branch {
on[c.1 as usize * w + c.0 as usize] = false;
}
removed = true;
}
}
if !removed {
break;
}
}
let mut used_edge = std::collections::HashSet::new();
let key = |a: (i32, i32), b: (i32, i32)| if a <= b { (a, b) } else { (b, a) };
let mut paths: Vec<(Vec<(i32, i32)>, bool)> = Vec::new();
let cells: Vec<(i32, i32)> = (0..h).flat_map(|y| (0..w).map(move |x| (x as i32, y as i32))).filter(|(x, y)| on[*y as usize * w + *x as usize]).collect();
let is_node = |on: &[bool], c: (i32, i32)| neighbours(on, c.0, c.1).len() != 2;
let walk = |start: (i32, i32), first: (i32, i32), used: &mut std::collections::HashSet<((i32, i32), (i32, i32))>, on: &[bool]| -> (Vec<(i32, i32)>, bool) {
let mut path = vec![start, first];
used.insert(key(start, first));
let (mut prev, mut cur) = (start, first);
loop {
if cur == start {
return (path, true);
}
if is_node(on, cur) {
return (path, false);
}
let next = neighbours(on, cur.0, cur.1).into_iter().find(|n| *n != prev && !used.contains(&key(cur, *n)));
let Some(next) = next else { return (path, false) };
used.insert(key(cur, next));
path.push(next);
prev = cur;
cur = next;
}
};
for &c in &cells {
if is_node(&on, c) {
for n in neighbours(&on, c.0, c.1) {
if !used_edge.contains(&key(c, n)) {
paths.push(walk(c, n, &mut used_edge, &on));
}
}
}
}
// Loops with no node (an O): start anywhere on them.
for &c in &cells {
for n in neighbours(&on, c.0, c.1) {
if !used_edge.contains(&key(c, n)) {
paths.push(walk(c, n, &mut used_edge, &on));
}
}
}
let mut out = Vec::new();
for (cells, closed) in paths {
let mut pts: Vec<[f32; 2]> = cells.iter().map(|(x, y)| grid.centre(*x as usize, *y as usize)).collect();
if closed {
pts.pop();
}
let len: f32 = pts.windows(2).map(|q| ((q[1][0] - q[0][0]).powi(2) + (q[1][1] - q[0][1]).powi(2)).sqrt()).sum();
if len < min_len && !closed {
continue;
}
if pts.len() < 2 {
continue;
}
out.push((smooth(&simplify(&pts, grid.cell * 0.6, closed), closed), closed));
}
out
}
/// Douglas–Peucker.
fn simplify(pts: &[[f32; 2]], tol: f32, closed: bool) -> Vec<[f32; 2]> {
fn rec(p: &[[f32; 2]], tol: f32, out: &mut Vec<[f32; 2]>) {
let (a, b) = (p[0], p[p.len() - 1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
let (mut far, mut at) = (0.0, 0);
for (i, q) in p.iter().enumerate().take(p.len() - 1).skip(1) {
let d = ((q[0] - a[0]) * dy - (q[1] - a[1]) * dx).abs() / l;
if d > far {
far = d;
at = i;
}
}
if far > tol {
rec(&p[..=at], tol, out);
out.pop();
rec(&p[at..], tol, out);
} else {
out.push(a);
out.push(b);
}
}
if pts.len() < 3 {
return pts.to_vec();
}
let mut out = Vec::new();
if closed {
// A loop starts and ends on the same point: split it at the point
// farthest from the start and simplify the two halves.
let d2 = |q: &[f32; 2]| (q[0] - pts[0][0]).powi(2) + (q[1] - pts[0][1]).powi(2);
let far = (0..pts.len()).max_by(|a, b| d2(&pts[*a]).partial_cmp(&d2(&pts[*b])).unwrap()).unwrap_or(0).max(1);
let mut second = pts[far..].to_vec();
second.push(pts[0]);
rec(&pts[..=far], tol, &mut out);
out.pop();
rec(&second, tol, &mut out);
out.pop();
} else {
rec(pts, tol, &mut out);
}
out
}
/// Chaikin corner cutting, two rounds (ends of open paths kept).
fn smooth(pts: &[[f32; 2]], closed: bool) -> Vec<[f32; 2]> {
let mut p = pts.to_vec();
for _ in 0..2 {
if p.len() < 3 {
break;
}
let n = p.len();
let mut q = Vec::with_capacity(n * 2);
if !closed {
q.push(p[0]);
}
let segs = if closed { n } else { n - 1 };
for i in 0..segs {
let (a, b) = (p[i], p[(i + 1) % n]);
q.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
q.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
}
if !closed {
q.push(p[n - 1]);
}
p = q;
}
p
}
/// The front cap's boundary as closed loops (letter-local).
pub fn outline_loops(letter: &Letter) -> Vec<Vec<[f32; 2]>> {
use std::collections::HashMap;
let key = |p: [f32; 3]| ((p[0] * 1e4).round() as i64, (p[1] * 1e4).round() as i64);
let mut count: HashMap<((i64, i64), (i64, i64)), i32> = HashMap::new();
let mut pos: HashMap<(i64, i64), [f32; 2]> = HashMap::new();
for (part, m) in &letter.parts {
if *part != TextPart::Front {
continue;
}
for t in m.indices.chunks_exact(3) {
for k in 0..3 {
let (a, b) = (m.positions[t[k] as usize], m.positions[t[(k + 1) % 3] as usize]);
let (ka, kb) = (key(a), key(b));
pos.insert(ka, [a[0], a[1]]);
pos.insert(kb, [b[0], b[1]]);
if ka == kb {
continue;
}
*count.entry((ka, kb)).or_insert(0) += 1;
}
}
}
// Boundary edges: no twin running the other way.
let mut next: HashMap<(i64, i64), Vec<(i64, i64)>> = HashMap::new();
for (&(a, b), _) in count.iter().filter(|(e, _)| !count.contains_key(&(e.1, e.0))) {
next.entry(a).or_default().push(b);
}
let mut loops = Vec::new();
let mut starts: Vec<(i64, i64)> = next.keys().copied().collect();
starts.sort();
for s in starts {
while let Some(first) = next.get_mut(&s).and_then(|v| v.pop()) {
let mut lp = vec![pos[&s]];
let mut cur = first;
let mut guard = 0;
while cur != s && guard < 100_000 {
lp.push(pos[&cur]);
match next.get_mut(&cur).and_then(|v| v.pop()) {
Some(n) => cur = n,
None => break,
}
guard += 1;
}
if lp.len() >= 3 {
loops.push(smooth(&lp, true));
}
}
}
loops
}
/// Round tubes of `radius` along 2D paths in the z = 0 plane, with round
/// ends on open paths.
pub fn tube_mesh(paths: &[(Vec<[f32; 2]>, bool)], radius: f32, sides: u32) -> Mesh {
let sides = sides.clamp(3, 24) as usize;
let mut m = Mesh::new();
let tau = std::f32::consts::TAU;
for (pts, closed) in paths {
let n = pts.len();
if n < 2 {
continue;
}
let tangent = |i: usize| -> [f32; 2] {
let (a, b) = if *closed { (pts[(i + n - 1) % n], pts[(i + 1) % n]) } else { (pts[i.saturating_sub(1)], pts[(i + 1).min(n - 1)]) };
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
[dx / l, dy / l]
};
let base = m.positions.len() as u32;
let rings = if *closed { n + 1 } else { n };
for r in 0..rings {
let i = r % n;
let t = tangent(i);
let side = [-t[1], t[0]];
for k in 0..sides {
let a = k as f32 / sides as f32 * tau;
let nrm = [side[0] * a.cos(), side[1] * a.cos(), a.sin()];
m.vertex([pts[i][0] + nrm[0] * radius, pts[i][1] + nrm[1] * radius, nrm[2] * radius], nrm, [r as f32 / rings as f32, k as f32 / sides as f32]);
}
}
for r in 0..rings - 1 {
for k in 0..sides {
let a = base + (r * sides + k) as u32;
let b = base + (r * sides + (k + 1) % sides) as u32;
let c = base + ((r + 1) * sides + (k + 1) % sides) as u32;
let d = base + ((r + 1) * sides + k) as u32;
m.quad(a, d, c, b);
}
}
if !*closed {
// Round ends: a small half-sphere as a fan of rings.
for (end, dir) in [(0usize, -1.0f32), (n - 1, 1.0)] {
let t = tangent(end);
let side = [-t[1], t[0]];
let c = pts[end];
let cap = m.positions.len() as u32;
let steps = 3;
for s in 0..=steps {
let phi = s as f32 / steps as f32 * std::f32::consts::FRAC_PI_2;
for k in 0..sides {
let a = k as f32 / sides as f32 * tau;
let ring = [side[0] * a.cos(), side[1] * a.cos(), a.sin()];
let nrm = [ring[0] * phi.cos() + t[0] * dir * phi.sin(), ring[1] * phi.cos() + t[1] * dir * phi.sin(), ring[2] * phi.cos()];
m.vertex([c[0] + nrm[0] * radius, c[1] + nrm[1] * radius, nrm[2] * radius], nrm, [0.0, 0.0]);
}
}
for s in 0..steps {
for k in 0..sides {
let a = cap + (s * sides + k) as u32;
let b = cap + (s * sides + (k + 1) % sides) as u32;
let cc = cap + ((s + 1) * sides + (k + 1) % sides) as u32;
let d = cap + ((s + 1) * sides + k) as u32;
if dir > 0.0 { m.quad(a, d, cc, b) } else { m.quad(a, b, cc, d) }
}
}
}
}
}
m
}
/// LED dots: lattice points at `pitch` inside the letter (letter-local).
pub fn led_dots(letter: &Letter, pitch: f32) -> Vec<[f32; 2]> {
let g = ink_grid(letter, pitch);
(0..g.h).flat_map(|y| (0..g.w).map(move |x| (x, y))).filter(|(x, y)| g.ink[y * g.w + x]).map(|(x, y)| g.centre(x, y)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn build(text: &str) -> Text3d {
build_text3d(&Text3dParams { text: text.into(), ..Default::default() }).unwrap()
}
#[test]
fn letters_with_holes() {
let t = build("HOB@8");
assert_eq!(t.letters.len(), 5);
assert_eq!(t.letters.iter().map(|l| l.ch).collect::<String>(), "HOB@8");
for l in &t.letters {
l.mesh.validate().unwrap();
assert!(l.mesh.triangle_count() > 8);
}
// The O keeps its counter: no front-cap triangle covers its centre.
let o = &t.letters[1];
for tri in o.mesh.indices.chunks_exact(3) {
let ps: Vec<[f32; 3]> = tri.iter().map(|&i| o.mesh.positions[i as usize]).collect();
if ps.iter().all(|p| p[2] > 0.14) {
let g = [(ps[0][0] + ps[1][0] + ps[2][0]) / 3.0, (ps[0][1] + ps[1][1] + ps[2][1]) / 3.0];
assert!(g[0].hypot(g[1]) > 0.08, "cap triangle over the O's counter at {g:?}");
}
}
// Letters read left to right, block centred.
assert!(t.letters.windows(2).all(|w| w[0].pivot[0] < w[1].pivot[0]));
assert!((t.bounds.0[0] + t.bounds.1[0]).abs() < 0.05);
// Cap height is `size`.
let h = &t.letters[0];
let (mn, mx) = h.mesh.bounds().unwrap();
assert!(((mx[1] - mn[1]) - 1.0).abs() < 0.03, "{}", mx[1] - mn[1]);
}
#[test]
fn words_lines_and_determinism() {
let t = build("AB CD\nEF");
assert_eq!(t.letters.len(), 6);
assert_eq!(t.letters.iter().map(|l| (l.word, l.line)).collect::<Vec<_>>(), vec![(0, 0), (0, 0), (1, 0), (1, 0), (2, 1), (2, 1)]);
assert_eq!(t, build("AB CD\nEF"));
assert!(t.letters[4].pivot[1] < t.letters[0].pivot[1]);
}
#[test]
fn fonts_and_errors() {
let mono = build_text3d(&Text3dParams { text: "ii".into(), font: FontSource::Bundled("mono".into()), depth: 0.0, ..Default::default() }).unwrap();
assert_eq!(mono.letters.len(), 2);
assert!(mono.letters[0].mesh.positions.iter().all(|p| p[2].abs() < 1e-5));
let err = build_text3d(&Text3dParams { text: "x".into(), font: FontSource::Bundled("comic".into()), ..Default::default() }).unwrap_err();
assert!(err.contains("bundled fonts"));
let bytes = std::fs::read(fonts_dir().join("Inter.ttf")).unwrap();
let t = build_text3d(&Text3dParams { text: "Hi".into(), font: FontSource::Bytes(Arc::new(bytes)), weight: Some(800.0), bevel: 0.03, bevel_segments: 3, ..Default::default() }).unwrap();
assert_eq!(t.letters.len(), 2);
let m = t.merged();
m.validate().unwrap();
}
/// A host without the files (the browser) hands the bytes over by path.
#[test]
fn a_font_the_host_hands_over_needs_no_file() {
let inter: Arc<[u8]> = std::fs::read(fonts_dir().join("Inter.ttf")).unwrap().into();
set_font_reader(Box::new(move |p| p.starts_with("/not-on-this-machine").then(|| inter.clone())));
let font = FontSource::Path("/not-on-this-machine/resources/Inter.ttf".into());
let t = build_text3d(&Text3dParams { text: "Hi".into(), font, ..Default::default() }).unwrap();
assert_eq!(t.letters.len(), 2);
// Paths it does not answer for are read as before.
assert!(build_text3d(&Text3dParams { text: "x".into(), font: FontSource::Path("/nowhere/x.ttf".into()), ..Default::default() }).is_err());
}
#[test]
fn wrap_breaks_lines() {
let t = build_text3d(&Text3dParams { text: "one two three four".into(), wrap: Some(4.0), ..Default::default() }).unwrap();
assert!(t.lines >= 2, "{}", t.lines);
assert!(t.bounds.1[0] - t.bounds.0[0] <= 4.5);
}
/// Every edge of the welded solid is shared by exactly two triangles
/// running it in opposite directions (closed, consistently wound), and
/// the solid encloses a positive volume (outward normals).
fn assert_watertight(m: &Mesh, what: &str) {
use std::collections::HashMap;
let key = |p: [f32; 3]| [(p[0] * 1e4).round() as i64, (p[1] * 1e4).round() as i64, (p[2] * 1e4).round() as i64];
let mut ids: HashMap<[i64; 3], u32> = HashMap::new();
let weld: Vec<u32> = m.positions.iter().map(|p| { let n = ids.len() as u32; *ids.entry(key(*p)).or_insert(n) }).collect();
let mut edges: HashMap<(u32, u32), i32> = HashMap::new();
let mut volume = 0.0f64;
for t in m.indices.chunks_exact(3) {
let v: Vec<u32> = t.iter().map(|&i| weld[i as usize]).collect();
if v[0] == v[1] || v[1] == v[2] || v[0] == v[2] {
continue;
}
for k in 0..3 {
*edges.entry((v[k], v[(k + 1) % 3])).or_insert(0) += 1;
}
let p: Vec<[f64; 3]> = t.iter().map(|&i| { let q = m.positions[i as usize]; [q[0] as f64, q[1] as f64, q[2] as f64] }).collect();
volume += (p[0][0] * (p[1][1] * p[2][2] - p[1][2] * p[2][1]) - p[0][1] * (p[1][0] * p[2][2] - p[1][2] * p[2][0]) + p[0][2] * (p[1][0] * p[2][1] - p[1][1] * p[2][0])) / 6.0;
}
let open: Vec<_> = edges.iter().filter(|((a, b), n)| **n != 1 || edges.get(&(*b, *a)).copied() != Some(1)).take(4).collect();
assert!(open.is_empty(), "{what}: {} open or doubled edges, e.g. {open:?}", edges.iter().filter(|((a, b), n)| **n != 1 || edges.get(&(*b, *a)).copied() != Some(1)).count());
assert!(volume > 0.0, "{what}: inside out (volume {volume})");
}
#[test]
fn solids_are_watertight_with_counters() {
for profile in [BevelProfile::Flat, BevelProfile::Chamfer, BevelProfile::Round, BevelProfile::Ogee] {
let t = build_text3d(&Text3dParams { text: "oAB8".into(), bevel: 0.06, bevel_segments: 3, bevel_profile: profile, ..Default::default() }).unwrap();
assert_eq!(t.letters.len(), 4);
for l in &t.letters {
assert_watertight(&l.mesh, &format!("{:?} {}", profile, l.ch));
// The parts cover the letter exactly.
let tris: usize = l.parts.iter().map(|(_, m)| m.triangle_count()).sum();
assert_eq!(tris, l.mesh.triangle_count());
let has = |p: TextPart| l.parts.iter().any(|(q, _)| *q == p);
assert!(has(TextPart::Front) && has(TextPart::Back) && has(TextPart::Side), "{:?}", l.parts.iter().map(|p| p.0).collect::<Vec<_>>());
assert_eq!(has(TextPart::Bevel), profile != BevelProfile::Flat, "{profile:?} {}", l.ch);
// Front caps face +z, back caps -z.
for (part, m) in &l.parts {
let want = match part {
TextPart::Front => 1.0,
TextPart::Back => -1.0,
_ => continue,
};
assert!(m.normals.iter().all(|n| (n[2] - want).abs() < 1e-3), "{part:?} normals");
for tri in m.indices.chunks_exact(3) {
let p: Vec<[f32; 3]> = tri.iter().map(|&i| m.positions[i as usize]).collect();
let z = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[1][1] - p[0][1]) * (p[2][0] - p[0][0]);
assert!(z * want >= -1e-7, "{profile:?} {} {part:?} triangle wound against its normal: {z} {p:?}", l.ch);
}
}
}
// The counters of o, A, B and 8 stay open: some point inside
// each letter's box is not covered by its front cap.
}
}
/// A wide bevel on thin strokes keeps every cap triangle's winding
/// (vj_fx clamps the inset per point to the local stroke width).
#[test]
fn wide_bevels_never_fold_the_caps() {
for font in ["sans", "italic", "mono", "bold"] {
for profile in [BevelProfile::Chamfer, BevelProfile::Round] {
let t = build_text3d(&Text3dParams { text: "oAB8gR&@%ew".into(), font: FontSource::Bundled(font.into()), bevel: 0.1, bevel_profile: profile, ..Default::default() }).unwrap();
for l in &t.letters {
assert_watertight(&l.mesh, &format!("{font} {profile:?} {}", l.ch));
for (part, m) in &l.parts {
let want = match part {
TextPart::Front => 1.0,
TextPart::Back => -1.0,
_ => continue,
};
for tri in m.indices.chunks_exact(3) {
let p: Vec<[f32; 3]> = tri.iter().map(|&i| m.positions[i as usize]).collect();
let z = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[1][1] - p[0][1]) * (p[2][0] - p[0][0]);
assert!(z * want >= -1e-7, "{font} {profile:?} {} {part:?} cap folded: {z}", l.ch);
}
}
}
}
}
}
#[test]
fn unit_centres() {
let t = build("AB CD\nEF");
assert_eq!(t.word_centers.len(), 3);
assert_eq!(t.line_centers.len(), 2);
assert!(t.word_centers[0][0] < t.word_centers[1][0]);
assert!(t.line_centers[1][1] < t.line_centers[0][1]);
let mid = (t.letters[0].pivot[0] + t.letters[1].pivot[0]) * 0.5;
assert!((t.word_centers[0][0] - mid).abs() < 0.2);
}
#[test]
fn explicit_lines_align() {
let t = build("WIDE LINE\nI");
let i = t.letters.last().unwrap();
// Centred: the short line's letter sits near the middle.
assert!(i.pivot[0].abs() < 0.3, "{:?}", i.pivot);
let left = build_text3d(&Text3dParams { text: "WIDE LINE\nI".into(), align: TextAlign::Left, ..Default::default() }).unwrap();
assert!(left.letters.last().unwrap().pivot[0] < -1.0);
}
#[test]
fn voxels_fill_the_letters() {
let t = build_text3d(&Text3dParams { text: "HI".into(), voxels: Some((10, 2)), ..Default::default() }).unwrap();
assert!(t.voxels.len() > 40, "{}", t.voxels.len());
assert!(t.voxel_half[0] > 0.0);
// Every cube sits inside its glyph's ink box.
for v in &t.voxels {
let l = t.letters.iter().find(|l| l.index == v.glyph).unwrap();
let (mn, mx) = l.mesh.bounds().unwrap();
assert!(v.center[0] >= mn[0] + l.pivot[0] - 0.05 && v.center[0] <= mx[0] + l.pivot[0] + 0.05, "{v:?}");
}
assert!(t.voxels.iter().any(|v| v.glyph == 0) && t.voxels.iter().any(|v| v.glyph == 1));
}
#[test]
fn centre_lines_trace_strokes() {
// Inter: a sans I (Plex's I has serif bars, traced as three strokes).
let t = build_text3d(&Text3dParams { text: "OIL".into(), font: FontSource::Bundled("inter".into()), weight: Some(700.0), depth: 0.1, ..Default::default() }).unwrap();
// A width axis widens the letters (Roboto Flex's wdth).
let wide = |w: f32| {
let t = build_text3d(&Text3dParams { text: "OIL".into(), font: FontSource::Bundled("roboto".into()), axes: vec![(u32::from_be_bytes(*b"wdth"), w)], depth: 0.1, ..Default::default() }).unwrap();
let xs = t.letters.iter().flat_map(|l| l.mesh.positions.iter().map(move |p| p[0] + l.pivot[0]));
let (lo, hi) = xs.fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(x), hi.max(x)));
hi - lo
};
assert!(wide(151.0) > wide(25.0) * 1.2, "wdth 151 is wider: {} vs {}", wide(151.0), wide(25.0));
let o = centre_lines(&ink_grid(&t.letters[0], 0.02), 0.1);
assert_eq!(o.len(), 1, "O is one loop: {:?}", o.iter().map(|p| (p.0.len(), p.1)).collect::<Vec<_>>());
assert!(o[0].1, "closed");
assert!(o[0].0.len() > 12, "the loop keeps its shape: {}", o[0].0.len());
let i = centre_lines(&ink_grid(&t.letters[1], 0.02), 0.1);
assert_eq!(i.len(), 1);
assert!(!i[0].1);
// The I's centre line runs up its middle, about a cap height long.
let (a, b) = (i[0].0.first().unwrap(), i[0].0.last().unwrap());
assert!((a[1] - b[1]).abs() > 0.7, "{a:?} {b:?}");
assert!(a[0].abs() < 0.03 && b[0].abs() < 0.03);
let l = centre_lines(&ink_grid(&t.letters[2], 0.02), 0.1);
assert!(!l.is_empty() && l.len() <= 2, "{}", l.len());
let tube = tube_mesh(&o, 0.03, 8);
tube.validate().unwrap();
assert_eq!(outline_loops(&t.letters[0]).len(), 2, "O: outer and counter");
assert!(led_dots(&t.letters[1], 0.08).len() >= 10);
let k = build_text3d(&Text3dParams { text: "OK".into(), font: FontSource::Bundled("inter".into()), ..Default::default() }).unwrap();
for l in &k.letters {
let p = centre_lines(&ink_grid(l, 0.018), 0.09);
assert!(!p.is_empty(), "{}: no centre lines", l.ch);
}
}
}