refactor(scene): group modules into role-based subfolders

Move the 30 flat modules under src/scene/ into six folders by role:
convert (entity→geometry), text (fonts/shaping), model (render models),
pick (hit-test/selection/grips/xclip), view (camera/transform/render),
cache (block + property caches). pipeline/ is unchanged.

Full path migration — all `scene::X` references become
`scene::<group>::X`, no compatibility re-exports. render.rs items that
were `pub(super)` (parent was scene) become `pub(in crate::scene)` to
keep their original scene-wide visibility.

No behavior change; build and tests are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-16 11:33:46 +03:00
1 parent cd048165a9
commit 7fe7e0230d
160 files changed
+732 -715

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
//! CPU-side complex linetype tessellation.
//!
//! Walks the entity path and applies LT elements (dashes, gaps, dots, shapes),
//! returning one `WireModel` per continuous stroke.
//!
//! Coordinate convention: 2D entities live in the **XZ** plane (Y = elevation).
//! Shape X → along the linetype direction; Shape Y → perpendicular in XZ.
use crate::entities::text_support::resolve_dxf_special_chars;
use crate::linetypes::{ComplexLt, LtSegment};
use crate::scene::text::lff;
use crate::scene::model::wire_model::WireModel;
// ── Public entry point ────────────────────────────────────────────────────
/// Apply `lt` along `path_pts` (the entity's tessellated world-space strip).
///
/// Returns one `WireModel` per continuous stroke. Pass the entity's `name`,
/// `color`, `selected` flag, and `line_weight_px` so the WireModels inherit
/// the entity's visual properties.
pub fn apply_along(
name: &str,
path_pts: &[[f32; 3]],
lt: &ComplexLt,
scale: f32,
color: [f32; 4],
selected: bool,
line_weight_px: f32,
) -> Vec<WireModel> {
if path_pts.len() < 2 || lt.segments.is_empty() {
return vec![];
}
let scaled: Vec<LtSeg> = scale_segments(&lt.segments, scale);
if scaled.is_empty() {
return vec![];
}
let pattern_len: f32 = scaled
.iter()
.map(|s| match s {
LtSeg::Dash(l) | LtSeg::Space(l) => *l,
LtSeg::Dot | LtSeg::Shape { .. } | LtSeg::Text { .. } => 0.0,
})
.sum();
if pattern_len < 1e-10 {
return vec![];
}
// Guard against pattern blow-up. The per-segment walk below emits at least
// one stroke vertex per pattern element, so the total vertex count scales
// with `path_len / pattern_len`. On a large-extent drawing (e.g. a city/
// street map) carrying a finely-scaled complex linetype, that ratio can
// reach billions — the strokes Vec grows until the process is OOM-killed,
// single-threaded, before the drawing ever finishes loading. Past a sane
// repeat count the dashes are sub-pixel anyway, so render the base wire
// solid: returning empty here makes the caller fall back to the solid base.
const MAX_PATTERN_REPEATS: f32 = 1_000_000.0;
let path_len: f32 = path_pts
.windows(2)
.map(|w| {
let d = [w[1][0] - w[0][0], w[1][1] - w[0][1], w[1][2] - w[0][2]];
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
})
.sum();
if path_len / pattern_len > MAX_PATTERN_REPEATS {
return vec![];
}
let mut strokes: Vec<Vec<[f32; 3]>> = Vec::new();
let mut cur_stroke: Vec<[f32; 3]> = Vec::new();
let mut elem_idx: usize = 0;
let mut elem_consumed: f32 = 0.0;
for i in 0..path_pts.len() - 1 {
let ps = path_pts[i];
let pe = path_pts[i + 1];
let dx = pe[0] - ps[0];
let dy = pe[1] - ps[1];
let dz = pe[2] - ps[2];
let seg_len = (dx * dx + dy * dy + dz * dz).sqrt();
if seg_len < 1e-10 {
continue;
}
let fwd = [dx / seg_len, dy / seg_len, dz / seg_len];
let perp = [-fwd[1], fwd[0], fwd[2]];
let mut pos = 0.0f32;
while pos < seg_len - 1e-6 {
let idx = elem_idx % scaled.len();
match &scaled[idx] {
LtSeg::Dash(dash_len) => {
let remaining_dash = dash_len - elem_consumed;
let remaining_seg = seg_len - pos;
let advance = remaining_dash.min(remaining_seg);
let p_start = lerp(ps, pe, pos / seg_len);
let p_end = lerp(ps, pe, (pos + advance) / seg_len);
if cur_stroke.is_empty() {
cur_stroke.push(p_start);
}
cur_stroke.push(p_end);
pos += advance;
elem_consumed += advance;
if (elem_consumed - dash_len).abs() < 1e-6 {
elem_idx += 1;
elem_consumed = 0.0;
}
}
LtSeg::Space(space_len) => {
let remaining = space_len - elem_consumed;
let advance = remaining.min(seg_len - pos);
flush(&mut cur_stroke, &mut strokes);
pos += advance;
elem_consumed += advance;
if (elem_consumed - space_len).abs() < 1e-6 {
elem_idx += 1;
elem_consumed = 0.0;
}
}
LtSeg::Dot => {
flush(&mut cur_stroke, &mut strokes);
let p = lerp(ps, pe, pos / seg_len);
strokes.push(vec![p, p]);
elem_idx += 1;
elem_consumed = 0.0;
if pos < 1e-6 {
break;
}
}
LtSeg::Shape {
name: sh_name,
x,
y,
scale: sh_scale,
rot_deg,
} => {
flush(&mut cur_stroke, &mut strokes);
let insert = lerp(ps, pe, pos / seg_len);
let insert = offset_pt(insert, fwd, perp, *x, *y);
let shape_strokes = emit_shape(sh_name, insert, fwd, perp, *sh_scale, *rot_deg);
strokes.extend(shape_strokes);
elem_idx += 1;
elem_consumed = 0.0;
if pos < 1e-6 {
break;
}
}
LtSeg::Text {
text,
style,
x,
y,
scale: tx_scale,
rot_deg,
} => {
flush(&mut cur_stroke, &mut strokes);
let insert = lerp(ps, pe, pos / seg_len);
let insert = offset_pt(insert, fwd, perp, *x, *y);
let fwd_angle = fwd[1].atan2(fwd[0]) + rot_deg.to_radians();
let resolved = resolve_dxf_special_chars(text);
let text_strokes = lff::tessellate_text_ex(
[insert[0], insert[1]],
*tx_scale,
fwd_angle,
1.0,
0.0,
style,
&resolved,
);
for stroke in &text_strokes {
if stroke.len() >= 2 {
let pts: Vec<[f32; 3]> =
stroke.iter().map(|&[sx, sy]| [sx, sy, insert[2]]).collect();
strokes.push(pts);
}
}
elem_idx += 1;
elem_consumed = 0.0;
if pos < 1e-6 {
break;
}
}
}
}
}
flush(&mut cur_stroke, &mut strokes);
strokes
.into_iter()
.filter(|s| s.len() >= 2)
.map(|pts| WireModel {
name: name.to_string(),
points: pts,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
})
.collect()
}
// ── Helpers ───────────────────────────────────────────────────────────────
#[derive(Clone)]
enum LtSeg {
Dash(f32),
Space(f32),
Dot,
Shape {
name: String,
x: f32,
y: f32,
scale: f32,
rot_deg: f32,
},
Text {
text: String,
style: String,
x: f32,
y: f32,
scale: f32,
rot_deg: f32,
},
}
fn scale_segments(segs: &[LtSegment], scale: f32) -> Vec<LtSeg> {
segs.iter()
.map(|s| match s {
LtSegment::Dash(l) => LtSeg::Dash(l * scale),
LtSegment::Space(l) => LtSeg::Space(l * scale),
LtSegment::Dot => LtSeg::Dot,
LtSegment::Shape {
name,
x,
y,
scale: sh_scale,
rot_deg,
} => LtSeg::Shape {
name: name.clone(),
x: x * scale,
y: y * scale,
scale: *sh_scale * scale,
rot_deg: *rot_deg,
},
LtSegment::Text {
text,
style,
x,
y,
scale: tx_scale,
rot_deg,
} => LtSeg::Text {
text: text.clone(),
style: style.clone(),
x: x * scale,
y: y * scale,
scale: *tx_scale * scale,
rot_deg: *rot_deg,
},
})
.collect()
}
fn flush(cur: &mut Vec<[f32; 3]>, strokes: &mut Vec<Vec<[f32; 3]>>) {
if !cur.is_empty() {
strokes.push(std::mem::take(cur));
}
}
fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
]
}
fn offset_pt(pt: [f32; 3], fwd: [f32; 3], perp: [f32; 3], dx: f32, dy: f32) -> [f32; 3] {
[
pt[0] + fwd[0] * dx + perp[0] * dy,
pt[1] + fwd[1] * dx + perp[1] * dy,
pt[2] + fwd[2] * dx + perp[2] * dy,
]
}
/// Transform a named linetype shape (from the converted `ltypeshp` LFF font)
/// into world-space strokes at the pen position.
fn emit_shape(
name: &str,
insert: [f32; 3],
fwd: [f32; 3],
perp: [f32; 3],
scale: f32,
rot_deg: f32,
) -> Vec<Vec<[f32; 3]>> {
let shape = match lff::shape(name) {
Some(s) => s,
None => return vec![],
};
let rot_r = rot_deg.to_radians();
let (cos_r, sin_r) = (rot_r.cos(), rot_r.sin());
shape
.strokes
.iter()
.map(|stroke| {
stroke
.iter()
.map(|&[lx, ly]| {
let scaled_x = lx * scale;
let scaled_y = ly * scale;
// Rotate in the shape's local frame, then place along the
// line tangent (fwd) and perpendicular (perp).
let along_fwd = cos_r * scaled_x - sin_r * scaled_y;
let along_perp = sin_r * scaled_x + cos_r * scaled_y;
[
insert[0] + fwd[0] * along_fwd + perp[0] * along_perp,
insert[1] + fwd[1] * along_fwd + perp[1] * along_perp,
insert[2] + fwd[2] * along_fwd + perp[2] * along_perp,
]
})
.collect()
})
.collect()
}
+180
View File
@@ -0,0 +1,180 @@
// Unified font face over the two glyph sources: embedded LFF stroke fonts and
// the user's installed TrueType/OpenType fonts. Both deliver glyphs in the same
// 9-unit cap-height space (see `ttf_glyph`), so the text layout code resolves a
// `Face` once per run and asks it for glyphs without caring which kind it is.
//
// Dispatch rule: a name that matches an embedded LFF font wins (so DXF stroke
// fonts like `txt`/`romans` keep rendering as before); otherwise, if it matches
// an installed system family, the TrueType path is used; otherwise we fall back
// to LFF's own `STANDARD` resolution.
use std::ops::Deref;
use std::sync::Arc;
use crate::scene::text::lff::{self, Glyph};
use crate::scene::text::{sysfont, ttf_glyph};
/// A glyph borrowed from a static LFF font or owned (cached `Arc`) from a TTF
/// face. Derefs to [`Glyph`] either way.
pub enum GlyphRef<'a> {
Borrowed(&'a Glyph),
Owned(Arc<Glyph>),
}
impl Deref for GlyphRef<'_> {
type Target = Glyph;
fn deref(&self) -> &Glyph {
match self {
GlyphRef::Borrowed(g) => g,
GlyphRef::Owned(g) => g,
}
}
}
/// Resolved font for a text run.
pub enum Face {
Lff(&'static lff::Font),
Ttf {
family: String,
/// Cached space-glyph advance (9-unit), used for word spacing.
word: f32,
},
}
impl Face {
/// Resolve a style's font name to a concrete face. Embedded stroke fonts
/// take priority; only otherwise-unknown names try the system fonts.
pub fn resolve(font_name: &str) -> Face {
if !lff::is_builtin(font_name) && sysfont::has_family(font_name) {
let word = ttf_glyph::glyph(font_name, ' ')
.map(|g| g.advance)
// Fall back to a sensible blank-width if the font has no space.
.filter(|w| *w > 0.0)
.unwrap_or(4.5);
return Face::Ttf {
family: font_name.to_string(),
word,
};
}
Face::Lff(lff::get_font(font_name))
}
/// Look up a glyph. A stroke (LFF) font uses its own glyphs; anything it
/// lacks falls back to a system TrueType font chosen by cosmic-text (filled
/// outline, so covers scripts no stroke font provides). The TTF path mirrors
/// this for its single-glyph lookups.
pub fn glyph(&self, ch: char) -> Option<GlyphRef<'_>> {
match self {
Face::Lff(f) => match f.glyph(ch) {
Some(g) => Some(GlyphRef::Borrowed(g)),
None => ttf_glyph::fallback_glyph(ch).map(GlyphRef::Owned),
},
Face::Ttf { family, .. } => ttf_glyph::glyph(family, ch)
.or_else(|| ttf_glyph::fallback_glyph(ch))
.map(GlyphRef::Owned),
}
}
/// Extra spacing added after every glyph (9-unit). TTF advances already
/// include side bearings, so no extra tracking is added there.
pub fn letter_spacing(&self) -> f32 {
match self {
Face::Lff(f) => f.letter_spacing,
Face::Ttf { .. } => 0.0,
}
}
/// Width of a space (9-unit).
pub fn word_spacing(&self) -> f32 {
match self {
Face::Lff(f) => f.word_spacing,
Face::Ttf { word, .. } => *word,
}
}
/// The system family name when this is a TrueType face, else `None`. Lets
/// the tessellator shape TTF runs while keeping the per-glyph stroke path
/// for LFF.
pub fn ttf_family(&self) -> Option<&str> {
match self {
Face::Ttf { family, .. } => Some(family),
Face::Lff(_) => None,
}
}
/// Line-spacing factor (1.0 = one cap height between baselines as scaled by
/// the caller). TTF uses the neutral 1.0; LFF carries its font's value.
pub fn line_spacing(&self) -> f32 {
match self {
Face::Lff(f) => f.line_spacing,
Face::Ttf { .. } => 1.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ttf_dispatches_through_tessellation() {
let fams = sysfont::families();
if fams.is_empty() {
eprintln!("no system fonts; skipping");
return;
}
// Pick a family that actually yields an 'A' outline, then render a run
// through the real LFF tessellation entry to prove the Face dispatch.
let fam = fams
.iter()
.find(|f| ttf_glyph::glyph(f, 'A').is_some())
.expect("a system family with an 'A'");
assert!(matches!(Face::resolve(fam), Face::Ttf { .. }));
let strokes =
lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "ABC");
assert!(!strokes.is_empty(), "TTF run produced no strokes");
}
#[test]
fn shx_name_stays_lff() {
assert!(matches!(Face::resolve("txt"), Face::Lff(_)));
assert!(matches!(Face::resolve("romans.shx"), Face::Lff(_)));
}
#[test]
fn lff_falls_back_to_ttf_for_uncovered_glyph() {
// A CJK ideograph is in no stroke font; with a stroke style selected it
// must now resolve through the system-font (cosmic-text) fallback rather
// than coming back empty. Tolerated if the machine has no CJK font.
let txt = Face::resolve("txt");
assert!(matches!(txt, Face::Lff(_)));
assert!(txt.glyph('A').is_some(), "primary stroke glyph still works");
match txt.glyph('中') {
Some(g) => assert!(!g.strokes.is_empty(), "fallback glyph has contours"),
None => eprintln!("no CJK system font; fallback skipped"),
}
}
#[test]
fn ttf_run_keeps_decorations() {
let fams = sysfont::families();
if fams.is_empty() {
eprintln!("no system fonts; skipping");
return;
}
let fam = fams
.iter()
.find(|f| ttf_glyph::glyph(f, 'A').is_some())
.expect("a system family with an 'A'");
// Underlined shaped text: glyph contours plus exactly one underline
// segment (a 2-point polyline) emitted on the \l toggle.
let plain = lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "AB");
let deco = lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "\\LAB\\l");
assert!(!plain.is_empty());
assert_eq!(
deco.len(),
plain.len() + 1,
"underline should add exactly one segment on the TTF path"
);
}
}
+825
View File
@@ -0,0 +1,825 @@
// LibreCAD LFF stroke-font engine.
//
// Parses LibreCAD Font Format (`.lff`) files and tessellates text into
// world-space 2-D polyline strokes. Replaces the former QCAD CXF engine.
//
// LFF layout:
// - `# Key: value` headers (Name / LetterSpacing / WordSpacing /
// LineSpacingFactor).
// - Glyph blocks `[<hex>] <char>` followed by stroke lines. Each stroke
// line is a `;`-separated polyline of `x,y` vertices; a vertex written
// `x,y,A<bulge>` makes the segment to the NEXT vertex a bulge arc
// (DXF bulge = tan(included_angle / 4)).
// - A line `C<hex>` inside a glyph includes another glyph's strokes
// (used to compose accented characters).
//
// Cap height is 9 glyph units, matching the `height / 9.0` text scale used
// throughout the renderer.
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::sync::{Mutex, OnceLock};
// ── Embedded fonts ─────────────────────────────────────────────────────────
/// Every LibreCAD LFF font, keyed by its file stem (lower-case). Registered
/// under the upper-cased stem and the font's `# Name:` header.
const FONTS_SRC: &[(&str, &str)] = &[
("cyrillic_ii", include_str!("../../../assets/fonts/cyrillic_ii.lff")),
("gothgbt", include_str!("../../../assets/fonts/gothgbt.lff")),
("gothgrt", include_str!("../../../assets/fonts/gothgrt.lff")),
("gothitt", include_str!("../../../assets/fonts/gothitt.lff")),
("greekc", include_str!("../../../assets/fonts/greekc.lff")),
("greeks", include_str!("../../../assets/fonts/greeks.lff")),
("iso3098", include_str!("../../../assets/fonts/iso3098.lff")),
("iso", include_str!("../../../assets/fonts/iso.lff")),
("italicc", include_str!("../../../assets/fonts/italicc.lff")),
("italict", include_str!("../../../assets/fonts/italict.lff")),
("ltypeshp", include_str!("../../../assets/fonts/ltypeshp.lff")),
("romanc", include_str!("../../../assets/fonts/romanc.lff")),
("romand", include_str!("../../../assets/fonts/romand.lff")),
("romans", include_str!("../../../assets/fonts/romans.lff")),
("romant", include_str!("../../../assets/fonts/romant.lff")),
("scriptc", include_str!("../../../assets/fonts/scriptc.lff")),
("scripts", include_str!("../../../assets/fonts/scripts.lff")),
("simplex", include_str!("../../../assets/fonts/simplex.lff")),
("standard", include_str!("../../../assets/fonts/standard.lff")),
("syastro", include_str!("../../../assets/fonts/syastro.lff")),
("symap", include_str!("../../../assets/fonts/symap.lff")),
("symath", include_str!("../../../assets/fonts/symath.lff")),
("symbol", include_str!("../../../assets/fonts/symbol.lff")),
("symeteo", include_str!("../../../assets/fonts/symeteo.lff")),
("symusic", include_str!("../../../assets/fonts/symusic.lff")),
("unicode", include_str!("../../../assets/fonts/unicode.lff")),
];
/// AutoCAD / DXF SHX font names → LFF stem. Names that already match a stem
/// (romans, italicc, scripts, symap, …) resolve directly and need no entry.
const ALIASES: &[(&str, &str)] = &[
// AutoCAD/DXF SHX font names → the nearest LFF stem. Names that already
// match a stem (romans, italicc, scripts, iso, symap, …) resolve directly
// and need no entry. Unknown names fall back to `standard` (as LibreCAD's
// `requestFont` does).
("TXT", "standard"),
("MONOTXT", "standard"),
("ISOCP", "iso"),
("ISOCP2", "iso"),
("ISOCP3", "iso"),
("ISOCPEUR", "iso"),
("ISOCT", "iso3098"),
("ISOCT2", "iso3098"),
("ISOCT3", "iso3098"),
("ISOCTEUR", "iso3098"),
("COMPLEX", "romanc"),
("ITALIC", "italicc"),
("GOTHICE", "gothgbt"),
("GOTHICG", "gothgrt"),
("GOTHICI", "gothitt"),
("CYRILLIC", "cyrillic_ii"),
("CYRILTLC", "cyrillic_ii"),
("GREEK", "greekc"),
("BIGFONT", "unicode"),
("EXTFONT", "unicode"),
];
// ── Public types ──────────────────────────────────────────────────────────
/// One glyph: a list of open 2-D polyline strokes in glyph units.
#[derive(Clone, Default)]
pub struct Glyph {
pub strokes: Vec<Vec<[f32; 2]>>,
/// Advance width in glyph units (rightmost X of all strokes).
pub advance: f32,
}
/// A parsed LFF font.
#[derive(Clone)]
pub struct Font {
pub name: String,
pub letter_spacing: f32,
pub word_spacing: f32,
pub line_spacing: f32,
glyphs: HashMap<char, Glyph>,
/// Named shapes — blocks whose `[hex] LABEL` label is a word rather than
/// the single codepoint character (used by `ltypeshp` for complex
/// linetype shapes, keyed by name since codepoints collide).
shapes: HashMap<String, Glyph>,
}
impl Font {
/// Look up a glyph by Unicode character.
pub fn glyph(&self, c: char) -> Option<&Glyph> {
self.glyphs.get(&c)
}
/// Look up a named shape (case-insensitive).
pub fn shape(&self, name: &str) -> Option<&Glyph> {
self.shapes.get(&name.to_ascii_uppercase())
}
}
/// Look up a complex-linetype shape by name in the `ltypeshp` font.
pub fn shape(name: &str) -> Option<&'static Glyph> {
fonts_map().get("LTYPESHP").and_then(|f| f.shape(name))
}
// ── Registry ───────────────────────────────────────────────────────────────
static FONTS: OnceLock<HashMap<String, Font>> = OnceLock::new();
static WARNED_GLYPHS: OnceLock<Mutex<HashSet<(String, char)>>> = OnceLock::new();
fn warn_missing_glyph(font_name: &str, ch: char) {
if ch.is_ascii() {
return;
}
let set = WARNED_GLYPHS.get_or_init(|| Mutex::new(HashSet::default()));
if let Ok(mut guard) = set.lock() {
if guard.insert((font_name.to_string(), ch)) {
eprintln!(
"lff: glyph U+{:04X} ('{}') not found in font '{font_name}'",
ch as u32, ch
);
}
}
}
fn fonts_map() -> &'static HashMap<String, Font> {
FONTS.get_or_init(|| {
let mut map = HashMap::default();
// Register every font under its stem and its `# Name:` header.
for (stem, src) in FONTS_SRC {
let f = parse_lff(src);
map.insert(stem.to_ascii_uppercase(), f.clone());
map.entry(f.name.to_ascii_uppercase()).or_insert_with(|| f.clone());
}
// AutoCAD/DXF SHX names → the matching LFF stem.
for (alias, stem) in ALIASES {
if let Some(f) = map.get(&stem.to_ascii_uppercase()).cloned() {
map.insert(alias.to_ascii_uppercase(), f);
}
}
map
})
}
/// Whether `name` resolves to an actual embedded LFF font (after stripping a
/// trailing extension) — i.e. `get_font` would return a real match rather than
/// the `STANDARD` fallback. Lets the font dispatcher prefer a system TrueType
/// face only for names that are *not* one of our stroke fonts.
pub fn is_builtin(name: &str) -> bool {
let map = fonts_map();
let key = name.trim().to_ascii_uppercase();
map.contains_key(&key)
|| key
.rsplit_once('.')
.is_some_and(|(stem, _)| map.contains_key(stem))
}
pub fn get_font(name: &str) -> &'static Font {
let map = fonts_map();
let key = name.trim().to_ascii_uppercase();
map.get(&key)
.or_else(|| {
// Strip a trailing ".SHX" / extension and retry.
key.rsplit_once('.').and_then(|(stem, _)| map.get(stem))
})
// LibreCAD's `requestFont` falls back to `standard` for unknown names.
.or_else(|| map.get("STANDARD"))
.or_else(|| map.get("UNICODE"))
.or_else(|| map.values().next())
.expect("at least one LFF font must be embedded")
}
// ── Run tokenizer ────────────────────────────────────────────────────────────
/// Decoration line a token toggles.
#[derive(Clone, Copy)]
enum Deco {
Under,
Over,
Strike,
}
/// How a decoration token changes state. `\L`/`\O`/`\K` turn on (idempotent),
/// `\l`/`\o`/`\k` turn off; `%%u`/`%%o` flip.
#[derive(Clone, Copy)]
enum Op {
On,
Off,
Toggle,
}
/// One unit of a text run after inline-code resolution. Shared by the stroke
/// (LFF) and shaped (TTF) renderers so both interpret the DXF inline grammar
/// — `\L…\l` decorations and `%%` specials — identically.
enum Tok {
/// A renderable character (DXF `%%d`/`%%p`/`%%c`/`%%nnn` already resolved).
Glyph(char),
/// A literal space.
Space,
/// A `%%nnn` escape with fewer than three digits — advances like a missing
/// glyph without drawing anything.
Missing,
/// A decoration toggle.
Deco(Deco, Op),
}
/// Resolve a run's inline codes into a flat token stream. Mirrors the original
/// inline parser in `tessellate_text_run` exactly (verified by the golden test).
fn tokenize_run(text: &str) -> Vec<Tok> {
let mut toks = Vec::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.peek().copied() {
Some('L') => {
chars.next();
toks.push(Tok::Deco(Deco::Under, Op::On));
continue;
}
Some('l') => {
chars.next();
toks.push(Tok::Deco(Deco::Under, Op::Off));
continue;
}
Some('O') => {
chars.next();
toks.push(Tok::Deco(Deco::Over, Op::On));
continue;
}
Some('o') => {
chars.next();
toks.push(Tok::Deco(Deco::Over, Op::Off));
continue;
}
Some('K') => {
chars.next();
toks.push(Tok::Deco(Deco::Strike, Op::On));
continue;
}
Some('k') => {
chars.next();
toks.push(Tok::Deco(Deco::Strike, Op::Off));
continue;
}
_ => {}
}
}
if ch == '%' && chars.peek() == Some(&'%') {
chars.next();
match chars.peek().map(|c| c.to_ascii_lowercase()) {
Some('d') => {
chars.next();
toks.push(Tok::Glyph('°'));
}
Some('p') => {
chars.next();
toks.push(Tok::Glyph('±'));
}
Some('c') => {
chars.next();
toks.push(Tok::Glyph('⌀'));
}
Some('%') => {
chars.next();
toks.push(Tok::Glyph('%'));
}
Some('u') => {
chars.next();
toks.push(Tok::Deco(Deco::Under, Op::Toggle));
}
Some('o') => {
chars.next();
toks.push(Tok::Deco(Deco::Over, Op::Toggle));
}
Some(d) if d.is_ascii_digit() => {
let mut digits = String::with_capacity(3);
for _ in 0..3 {
match chars.peek() {
Some(&c) if c.is_ascii_digit() => digits.push(chars.next().unwrap()),
_ => break,
}
}
if digits.len() == 3 {
if let Some(c) = digits.parse::<u32>().ok().and_then(char::from_u32) {
toks.push(Tok::Glyph(c));
}
} else {
toks.push(Tok::Missing);
}
}
_ => {}
}
continue;
}
toks.push(if ch == ' ' { Tok::Space } else { Tok::Glyph(ch) });
}
toks
}
// ── Text tessellation ───────────────────────────────────────────────────────
/// Tessellate a text run into world-space 2-D strokes. Same semantics as the
/// former CXF engine; the `tracking` multiplier scales `letter_spacing`.
pub fn tessellate_text_ex(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
tessellate_text_run(
origin,
height,
rotation,
width_factor,
oblique_angle,
1.0,
font_name,
text,
)
}
#[allow(clippy::too_many_arguments)]
pub fn tessellate_text_run(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
tracking: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
if text.is_empty() || height <= 0.0 {
return vec![];
}
let face = crate::scene::text::font_face::Face::resolve(font_name);
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());
let xform = |gx: f32, gy: f32, cx: f32| -> [f32; 2] {
let sx = (cx + gx) * scale * wf + gy * scale * ob;
let sy = gy * scale;
[
origin[0] + sx * cos_r - sy * sin_r,
origin[1] + sx * sin_r + sy * cos_r,
]
};
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cursor_x: f32 = 0.0;
let mut underline: Option<f32> = None;
let mut overline: Option<f32> = None;
let mut strikethrough: Option<f32> = None;
const UNDER_Y: f32 = -1.5;
const OVER_Y: f32 = 10.5;
const STRIKE_Y: f32 = 4.5;
let toks = tokenize_run(text);
let ttf_family = face.ttf_family();
// Render a stroke directly (LFF glyph) or per shaped contour (TTF), applying
// the run transform at the current pen position.
let emit_glyph = |out: &mut Vec<Vec<[f32; 2]>>, strokes: &[Vec<[f32; 2]>], cx: f32| {
for stroke in strokes {
if stroke.len() < 2 {
continue;
}
out.push(stroke.iter().map(|&[gx, gy]| xform(gx, gy, cx)).collect());
}
};
// Flush a buffered TTF segment: shape it, emit the positioned glyph
// contours, and advance the pen by the shaped run width. Falls back to
// per-glyph outlines if shaping is unavailable.
let flush_ttf = |seg: &mut String, cursor_x: &mut f32, out: &mut Vec<Vec<[f32; 2]>>| {
if seg.is_empty() {
return;
}
let family = ttf_family.unwrap_or("");
if let Some(run) = crate::scene::text::ttf_glyph::shape_run(family, seg) {
for g in &run.glyphs {
emit_glyph(out, &g.strokes, *cursor_x);
}
*cursor_x += run.advance * wf;
} else {
for ch in seg.chars() {
match face.glyph(ch) {
Some(glyph) => {
emit_glyph(out, &glyph.strokes, *cursor_x);
*cursor_x += (glyph.advance + face.letter_spacing() * tracking) * wf;
}
None => {
warn_missing_glyph(font_name, ch);
*cursor_x += (6.0 + face.letter_spacing() * tracking) * wf;
}
}
}
}
seg.clear();
};
let mut seg = String::new();
for tok in &toks {
// TTF shaping batches consecutive glyphs; any break (space, missing,
// decoration toggle, end of run) flushes the buffer first so pen
// positions stay correct for decorations.
if ttf_family.is_some() && !matches!(tok, Tok::Glyph(_)) {
flush_ttf(&mut seg, &mut cursor_x, &mut out);
}
match tok {
Tok::Glyph(c) => {
if ttf_family.is_some() {
seg.push(*c);
} else {
match face.glyph(*c) {
Some(glyph) => {
emit_glyph(&mut out, &glyph.strokes, cursor_x);
cursor_x += (glyph.advance + face.letter_spacing() * tracking) * wf;
}
None => {
warn_missing_glyph(font_name, *c);
cursor_x += (6.0 + face.letter_spacing() * tracking) * wf;
}
}
}
}
Tok::Space => cursor_x += face.word_spacing(),
Tok::Missing => cursor_x += (6.0 + face.letter_spacing() * tracking) * wf,
Tok::Deco(deco, op) => {
let (slot, y) = match deco {
Deco::Under => (&mut underline, UNDER_Y),
Deco::Over => (&mut overline, OVER_Y),
Deco::Strike => (&mut strikethrough, STRIKE_Y),
};
match op {
Op::On => {
if slot.is_none() {
*slot = Some(cursor_x);
}
}
Op::Off => {
if let Some(s) = slot.take() {
out.push(vec![xform(s, y, 0.0), xform(cursor_x, y, 0.0)]);
}
}
Op::Toggle => {
*slot = match slot.take() {
Some(s) => {
out.push(vec![xform(s, y, 0.0), xform(cursor_x, y, 0.0)]);
None
}
None => Some(cursor_x),
};
}
}
}
}
}
if ttf_family.is_some() {
flush_ttf(&mut seg, &mut cursor_x, &mut out);
}
if let Some(start) = underline {
out.push(vec![xform(start, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]);
}
if let Some(start) = overline {
out.push(vec![xform(start, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
}
if let Some(start) = strikethrough {
out.push(vec![xform(start, STRIKE_Y, 0.0), xform(cursor_x, STRIKE_Y, 0.0)]);
}
out
}
// ── Parser ────────────────────────────────────────────────────────────────
/// Intermediate glyph carrying unresolved `C<hex>` references.
#[derive(Default, Clone)]
struct RawGlyph {
strokes: Vec<Vec<[f32; 2]>>,
refs: Vec<char>,
}
fn parse_lff(src: &str) -> Font {
let mut font = Font {
name: String::from("Unknown"),
letter_spacing: 3.0,
word_spacing: 6.75,
line_spacing: 1.0,
glyphs: HashMap::default(),
shapes: HashMap::default(),
};
let mut raw: HashMap<char, RawGlyph> = HashMap::default();
let mut raw_shapes: HashMap<String, RawGlyph> = HashMap::default();
let mut cur: Option<char> = None;
let mut cur_name: Option<String> = None;
let mut cur_glyph = RawGlyph::default();
// Route the just-finished block to the glyph map (by char) or, when it
// carried a shape name, to the shape map (by name).
macro_rules! flush {
() => {{
if let Some(c) = cur.take() {
raw.insert(c, std::mem::take(&mut cur_glyph));
} else if let Some(n) = cur_name.take() {
raw_shapes.insert(n, std::mem::take(&mut cur_glyph));
} else {
// Discard any strokes seen before the first header.
let _ = std::mem::take(&mut cur_glyph);
}
}};
}
for line in src.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if let Some(rest) = t.strip_prefix('#') {
if let Some(v) = rest.trim().strip_prefix("Name:") {
font.name = v.trim().to_string();
} else if let Some(v) = rest.trim().strip_prefix("LetterSpacing:") {
if let Ok(x) = v.trim().parse() {
font.letter_spacing = x;
}
} else if let Some(v) = rest.trim().strip_prefix("WordSpacing:") {
if let Ok(x) = v.trim().parse() {
font.word_spacing = x;
}
} else if let Some(v) = rest.trim().strip_prefix("LineSpacingFactor:") {
if let Ok(x) = v.trim().parse() {
font.line_spacing = x;
}
}
continue;
}
if t.starts_with('[') {
flush!();
if let Some(end) = t.find(']') {
let hex = t[1..end].trim();
let label = t[end + 1..].trim();
let cp = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32);
// A 0/1-char label is a normal glyph (keyed by codepoint); a
// word label (BOX, CIRC1, …) is a named shape.
if label.chars().count() > 1 {
cur_name = Some(label.to_ascii_uppercase());
} else {
cur = cp;
}
}
continue;
}
if cur.is_none() && cur_name.is_none() {
continue;
}
// `C<hex>` — reference another glyph's strokes.
if let Some(hex) = t.strip_prefix(['C', 'c']) {
if hex.chars().all(|c| c.is_ascii_hexdigit()) && !hex.is_empty() {
if let Some(c) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) {
cur_glyph.refs.push(c);
}
continue;
}
}
// Stroke polyline: `x,y;x,y;x,y,A<bulge>;…`
if let Some(stroke) = parse_stroke_line(t) {
if stroke.len() >= 2 {
cur_glyph.strokes.push(stroke);
}
}
}
flush!();
// Resolve `C<hex>` references. Each pass folds in targets that are
// themselves already reference-free; repeat so ref-to-ref chains settle.
for _ in 0..4 {
let keys: Vec<char> = raw.keys().copied().collect();
let mut changed = false;
for k in keys {
let refs = raw.get(&k).map(|g| g.refs.clone()).unwrap_or_default();
if refs.is_empty() {
continue;
}
let mut add: Vec<Vec<[f32; 2]>> = Vec::new();
let mut remaining: Vec<char> = Vec::new();
for r in refs {
match raw.get(&r) {
Some(rg) if rg.refs.is_empty() => add.extend(rg.strokes.iter().cloned()),
_ => remaining.push(r),
}
}
if let Some(g) = raw.get_mut(&k) {
g.strokes.extend(add);
g.refs = remaining;
changed = true;
}
}
if !changed {
break;
}
}
let advance_of = |strokes: &[Vec<[f32; 2]>]| -> f32 {
strokes
.iter()
.flat_map(|s| s.iter())
.map(|&[x, _]| x)
.fold(0.0_f32, f32::max)
};
for (c, g) in raw {
let advance = advance_of(&g.strokes);
font.glyphs.insert(c, Glyph { strokes: g.strokes, advance });
}
for (n, g) in raw_shapes {
let advance = advance_of(&g.strokes);
font.shapes.insert(n, Glyph { strokes: g.strokes, advance });
}
font
}
/// Parse one LFF stroke line into a polyline, tessellating bulge arcs.
///
/// A vertex's `A<bulge>` describes the arc from the PREVIOUS vertex to this
/// one (LibreCAD's `RS_Polyline::addVertex(v, bulge)` convention), so the
/// bulge curves the segment ending at the vertex that carries it.
fn parse_stroke_line(line: &str) -> Option<Vec<[f32; 2]>> {
let mut pts: Vec<[f32; 2]> = Vec::new();
for tok in line.split(';').filter(|s| !s.trim().is_empty()) {
let parts: Vec<&str> = tok.split(',').map(|s| s.trim()).collect();
if parts.len() < 2 {
continue;
}
let x: f32 = parts[0].parse().ok()?;
let y: f32 = parts[1].parse().ok()?;
let p = [x, y];
let bulge = if parts.len() >= 3 {
parts[2]
.strip_prefix(['A', 'a'])
.and_then(|b| b.parse::<f32>().ok())
.unwrap_or(0.0)
} else {
0.0
};
if pts.is_empty() || bulge.abs() < 1e-6 {
pts.push(p);
} else {
let from = *pts.last().unwrap();
for q in bulge_to_points(from, p, bulge) {
pts.push(q);
}
}
}
if pts.is_empty() {
None
} else {
Some(pts)
}
}
/// Tessellate a DXF bulge arc from `p0` to `p1`. Returns the intermediate
/// points plus the end point (the start point is already in the polyline).
fn bulge_to_points(p0: [f32; 2], p1: [f32; 2], bulge: f32) -> Vec<[f32; 2]> {
let dx = p1[0] - p0[0];
let dy = p1[1] - p0[1];
let chord = (dx * dx + dy * dy).sqrt();
if bulge.abs() < 1e-6 || chord < 1e-9 {
return vec![p1];
}
let theta = 4.0 * bulge.atan(); // signed included angle
let radius = (chord * 0.5) / (theta * 0.5).sin(); // signed
let mx = (p0[0] + p1[0]) * 0.5;
let my = (p0[1] + p1[1]) * 0.5;
let ux = dx / chord;
let uy = dy / chord;
// Left normal of the chord.
let nx = -uy;
let ny = ux;
let off = radius * (theta * 0.5).cos();
let cx = mx + nx * off;
let cy = my + ny * off;
let r = radius.abs();
let a0 = (p0[1] - cy).atan2(p0[0] - cx);
let n = ((theta.abs() / (std::f32::consts::FRAC_PI_8)).ceil() as usize).clamp(2, 64);
let mut pts = Vec::with_capacity(n);
for i in 1..=n {
let a = a0 + theta * (i as f32 / n as f32);
pts.push([cx + r * a.cos(), cy + r * a.sin()]);
}
pts
}
#[cfg(test)]
mod tests {
/// Locks the LFF `tessellate_text_run` output (segment count, vertex count,
/// coordinate checksum) so the shared-tokenizer refactor that adds the TTF
/// shaping path cannot silently change stroke-font rendering.
#[test]
fn lff_tessellation_golden() {
let cases: &[(&str, usize, usize, f64)] = &[
("ABC 123", 8, 80, 2853.5141),
("A\\LB\\lC", 6, 43, 825.5505),
("10%%d Ö", 6, 60, 1479.1397),
("Hello, World!", 15, 113, 5078.5248),
];
for &(t, segs, verts, sum_ref) in cases {
let st = tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, "txt", t);
let nv: usize = st.iter().map(|s| s.len()).sum();
let sum: f64 = st
.iter()
.flatten()
.map(|p| p[0] as f64 + p[1] as f64)
.sum();
assert_eq!(st.len(), segs, "segment count drift for {t:?}");
assert_eq!(nv, verts, "vertex count drift for {t:?}");
assert!(
(sum - sum_ref).abs() < 0.01,
"coordinate checksum drift for {t:?}: {sum:.4} vs {sum_ref:.4}"
);
}
}
use super::*;
#[test]
fn fonts_parse_and_resolve() {
// Straight-stroke glyph.
let a = get_font("Standard").glyph('A').expect("A glyph");
assert!(!a.strokes.is_empty() && a.advance > 0.0);
// Bulge-arc glyph tessellates into a multi-point polyline.
let zero = get_font("Standard").glyph('0').expect("0 glyph");
assert!(zero.strokes.iter().any(|s| s.len() > 4));
// Alias + fallback resolve to a real font.
assert!(get_font("txt").glyph('A').is_some());
assert!(get_font("RomanS").glyph('B').is_some());
// After curation, every embedded stem and AutoCAD SHX alias must still
// resolve to a glyph-bearing font (no alias points at a deleted file).
for name in [
"MONOTXT", "ISOCP", "ISOCP3", "ISOCT", "ISOCT3", "COMPLEX", "ITALIC", "GOTHICE",
"GOTHICG", "GOTHICI", "CYRILLIC", "GREEK", "BIGFONT", "Simplex", "ScriptC", "Symbol",
] {
assert!(
get_font(name).glyph('A').is_some() || is_builtin(name),
"alias/stem {name} failed to resolve"
);
}
// A name from a deleted LibreCAD-only font is no longer a builtin and
// falls back to Standard rather than panicking.
assert!(!is_builtin("amiri-regular"));
assert!(get_font("kochigothic").glyph('A').is_some());
// Unicode fallback covers a non-ASCII letter via the renderer path.
let strokes = tessellate_text_run([0.0, 0.0], 2.5, 0.0, 1.0, 0.0, 1.0, "Standard", "Aб");
assert!(!strokes.is_empty());
// The bulge belongs to the segment ENDING at the vertex (LibreCAD
// convention): the standard/iso/unicode 'O' must come out as an
// upright oval (taller than wide), not a sideways capsule.
for name in ["standard", "iso", "unicode", "txt"] {
let o = get_font(name).glyph('O').expect("O glyph");
let (mut minx, mut miny, mut maxx, mut maxy) =
(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for s in &o.strokes {
for &[x, y] in s {
minx = minx.min(x);
miny = miny.min(y);
maxx = maxx.max(x);
maxy = maxy.max(y);
}
}
let (w, h) = (maxx - minx, maxy - miny);
assert!(h > w, "{name} O should be upright (h {h:.1} > w {w:.1})");
}
// Turkish letters absent from simplex/unicode still render via the
// iso3098 fallback (ı/U+0131 is the one exception iso3098 lacks).
for ch in ['Ğ', 'ş', 'İ', 'Ş', 'ğ'] {
let s = tessellate_text_run(
[0.0, 0.0],
2.5,
0.0,
1.0,
0.0,
1.0,
"simplex",
&ch.to_string(),
);
assert!(!s.is_empty(), "Turkish '{ch}' should render via fallback");
}
// Complex-linetype shapes load by name (codepoints collide, so they
// must be keyed by label).
for sh in ["BOX", "CIRC1", "ZIG", "TRACK1"] {
let g = shape(sh).unwrap_or_else(|| panic!("shape {sh} missing"));
assert!(!g.strokes.is_empty(), "shape {sh} has no strokes");
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod lff;
pub mod font_face;
pub mod sysfont;
pub mod ttf_glyph;
pub mod complex_lt;
+63
View File
@@ -0,0 +1,63 @@
// System TrueType/OpenType font discovery.
//
// Wraps a `fontdb` database loaded with the user's installed system fonts so
// the rest of the app can (a) list available font families for the text-style
// picker and (b) borrow a face's raw bytes to extract glyph outlines (see the
// TTF glyph engine). LFF stroke fonts stay separate — this is purely the
// TrueType side of the renderer.
use std::sync::OnceLock;
struct SysFonts {
db: fontdb::Database,
/// Sorted, de-duplicated family names for the picker.
families: Vec<String>,
}
static FONTS: OnceLock<SysFonts> = OnceLock::new();
fn fonts() -> &'static SysFonts {
FONTS.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
let mut families: Vec<String> = db
.faces()
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
.collect();
families.sort_by_key(|n| n.to_lowercase());
families.dedup();
SysFonts { db, families }
})
}
/// All installed system font families, sorted case-insensitively, de-duped.
pub fn families() -> &'static [String] {
&fonts().families
}
/// Resolve a family name to a concrete face id (regular weight/style).
fn face_id(family: &str) -> Option<fontdb::ID> {
let db = &fonts().db;
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
..Default::default()
};
db.query(&query)
}
/// Borrow the raw face bytes for `family` and run `f` over them. The byte slice
/// is only valid inside the closure, so callers extract everything they need
/// (e.g. flattened glyph outlines) before returning. `index` is the face index
/// within a TrueType collection. Returns `None` if the family is unknown.
pub fn with_face_data<T>(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Option<T> {
let id = face_id(family)?;
fonts().db.with_face_data(id, f)
}
/// Whether `family` matches an installed system font (case-insensitive via
/// fontdb's own matching).
pub fn has_family(family: &str) -> bool {
face_id(family).is_some()
}
+428
View File
@@ -0,0 +1,428 @@
// TrueType/OpenType glyph → CAD wire strokes.
//
// Pulls a system font's bytes from `sysfont`, extracts a glyph's outline with
// `ttf-parser`, and flattens the quadratic/cubic Bézier contours into polyline
// strokes — the same `lff::Glyph` shape the LFF stroke engine produces, so the
// text layout code can consume either without caring which font kind it is.
//
// Coordinates are normalized to the **same 9-unit cap-height space the LFF
// engine uses**: a capital letter is 9 units tall with the baseline at y = 0.
// That makes a TTF glyph a drop-in for an LFF one — the existing text layout
// scales both by `height / 9.0`, so TTF and stroke text share one pipeline.
// Cap height comes from the font's OS/2 table; if absent we approximate it as
// 0.7 × units-per-em.
//
// Unlike LFF single-stroke glyphs, TTF contours are closed outlines (the glyph
// boundary). Counters — the hole in "O" or "A" — come through as separate
// closed contours; the wire renderer simply draws every contour.
use crate::scene::text::lff::Glyph;
use crate::scene::text::sysfont;
use rustc_hash::FxHashMap as HashMap;
use std::sync::{Arc, Mutex, OnceLock};
/// Bézier flattening step counts. Outlines are small on screen most of the
/// time; these are a fixed budget that keeps curves smooth without exploding
/// vertex counts. Cubic gets more steps because OTF/CFF curves swing wider.
const QUAD_STEPS: usize = 8;
const CUBIC_STEPS: usize = 12;
/// Glyph-unit cap height the layout scales by `height / 9.0`.
const CAP_UNITS: f32 = 9.0;
/// Collects `ttf-parser` outline callbacks into closed contours, normalized so
/// the font's cap height equals [`CAP_UNITS`].
struct OutlineFlattener {
/// Font-unit → 9-unit-cap-height scale factor.
k: f32,
/// Pen position (9-unit) added to every vertex — lets a shaped glyph carry
/// its run offset. Zero for a standalone glyph.
offset: [f32; 2],
contours: Vec<Vec<[f32; 2]>>,
cur: Vec<[f32; 2]>,
start: [f32; 2],
pos: [f32; 2],
}
impl OutlineFlattener {
fn new(k: f32) -> Self {
OutlineFlattener {
k,
offset: [0.0, 0.0],
contours: Vec::new(),
cur: Vec::new(),
start: [0.0, 0.0],
pos: [0.0, 0.0],
}
}
fn n(&self, x: f32, y: f32) -> [f32; 2] {
[x * self.k + self.offset[0], y * self.k + self.offset[1]]
}
fn flush(&mut self) {
if self.cur.len() >= 2 {
self.contours.push(std::mem::take(&mut self.cur));
} else {
self.cur.clear();
}
}
}
impl ttf_parser::OutlineBuilder for OutlineFlattener {
fn move_to(&mut self, x: f32, y: f32) {
self.flush();
let p = self.n(x, y);
self.start = p;
self.pos = p;
self.cur.push(p);
}
fn line_to(&mut self, x: f32, y: f32) {
let p = self.n(x, y);
self.pos = p;
self.cur.push(p);
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let p0 = self.pos;
let c = self.n(cx, cy);
let p = self.n(x, y);
for i in 1..=QUAD_STEPS {
let t = i as f32 / QUAD_STEPS as f32;
let u = 1.0 - t;
let bx = u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p[0];
let by = u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p[1];
self.cur.push([bx, by]);
}
self.pos = p;
}
fn curve_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) {
let p0 = self.pos;
let c1 = self.n(c1x, c1y);
let c2 = self.n(c2x, c2y);
let p = self.n(x, y);
for i in 1..=CUBIC_STEPS {
let t = i as f32 / CUBIC_STEPS as f32;
let u = 1.0 - t;
let bx = u * u * u * p0[0]
+ 3.0 * u * u * t * c1[0]
+ 3.0 * u * t * t * c2[0]
+ t * t * t * p[0];
let by = u * u * u * p0[1]
+ 3.0 * u * u * t * c1[1]
+ 3.0 * u * t * t * c2[1]
+ t * t * t * p[1];
self.cur.push([bx, by]);
}
self.pos = p;
}
fn close(&mut self) {
// Close the ring back to its start so the wire forms a loop.
if self.cur.first().map_or(false, |f| *f != self.pos) {
self.cur.push(self.start);
}
self.flush();
}
}
// ── Cache ────────────────────────────────────────────────────────────────────
type GlyphCache = HashMap<(String, char), Option<Arc<Glyph>>>;
fn cache() -> &'static Mutex<GlyphCache> {
static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Em-normalized outline for one character of a system font family. Returns
/// `None` if the family is not installed or the glyph is missing. Result is
/// cached per `(family, char)`.
pub fn glyph(family: &str, ch: char) -> Option<Arc<Glyph>> {
let key = (family.to_string(), ch);
if let Some(hit) = cache().lock().unwrap().get(&key) {
return hit.clone();
}
let built = sysfont::with_face_data(family, |data, index| {
let face = ttf_parser::Face::parse(data, index).ok()?;
let gid = face.glyph_index(ch)?;
let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
// A glyph with no outline (e.g. space) still has a valid advance.
face.outline_glyph(gid, &mut fl);
fl.flush();
Some(Arc::new(Glyph {
strokes: fl.contours,
advance,
}))
})
.flatten();
cache().lock().unwrap().insert(key, built.clone());
built
}
/// Font-unit → 9-unit-cap-height factor for a parsed face. Cap height comes
/// from the OS/2 table; absent, we approximate it as 0.7 × units-per-em.
fn cap_scale(face: &ttf_parser::Face) -> f32 {
let upem = face.units_per_em() as f32;
let cap = face
.capital_height()
.filter(|&c| c > 0)
.map(|c| c as f32)
.unwrap_or(0.7 * upem);
CAP_UNITS / cap
}
// ── Shaping ────────────────────────────────────────────────────────────────
/// One shaped glyph, positioned within its run. Strokes are in 9-unit space and
/// already carry the glyph's pen position (cumulative advance + shaping offset),
/// so the caller only applies the run's own transform.
pub struct PlacedGlyph {
pub strokes: Vec<Vec<[f32; 2]>>,
}
/// A fully shaped run.
pub struct ShapedRun {
pub glyphs: Vec<PlacedGlyph>,
/// Total pen advance of the run (9-unit).
pub advance: f32,
}
type ShapeCache = HashMap<(String, String), Option<Arc<ShapedRun>>>;
fn shape_cache() -> &'static Mutex<ShapeCache> {
static CACHE: OnceLock<Mutex<ShapeCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Pixel font size the layout runs at. Positions and outlines are normalized
/// out of pixel space afterwards, so the exact value only affects rounding.
const SHAPE_FS: f32 = 1000.0;
/// Shared cosmic-text layout engine, loaded once with the system fonts. Held
/// behind a mutex because `set_text`/`shape` need `&mut FontSystem`.
fn font_system() -> &'static Mutex<cosmic_text::FontSystem> {
static FS: OnceLock<Mutex<cosmic_text::FontSystem>> = OnceLock::new();
FS.get_or_init(|| Mutex::new(cosmic_text::FontSystem::new()))
}
/// Lay out and shape `text` in `family` with cosmic-text — ligatures, Arabic
/// joining, kerning, bidi reordering, and automatic font fallback (a glyph the
/// chosen family lacks is taken from another installed font). Each resulting
/// glyph is outlined from its *resolved* font and normalized into 9-unit
/// cap-height space (cap height of the primary family). Cached per
/// `(family, text)`; `None` for a non-system family.
pub fn shape_run(family: &str, text: &str) -> Option<Arc<ShapedRun>> {
if text.is_empty() {
return None;
}
let key = (family.to_string(), text.to_string());
if let Some(hit) = shape_cache().lock().unwrap().get(&key) {
return hit.clone();
}
let built = build_shaped(family, text).map(Arc::new);
shape_cache().lock().unwrap().insert(key, built.clone());
built
}
type FallbackCache = HashMap<char, Option<Arc<Glyph>>>;
fn fallback_cache() -> &'static Mutex<FallbackCache> {
static CACHE: OnceLock<Mutex<FallbackCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Last-resort glyph for a character missing from a stroke (LFF) font: let
/// cosmic-text pick whichever installed font covers it, then outline it from
/// that font normalized to 9-unit cap height. Returns `None` if no system font
/// has the character. Cached per character.
///
/// The result is a filled-outline glyph, so it visually differs from the
/// surrounding single-stroke text — accepted as the price of covering scripts
/// no stroke font provides.
pub fn fallback_glyph(ch: char) -> Option<Arc<Glyph>> {
if let Some(hit) = fallback_cache().lock().unwrap().get(&ch) {
return hit.clone();
}
let built = build_fallback(ch);
fallback_cache().lock().unwrap().insert(ch, built.clone());
built
}
fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
use cosmic_text::{Attrs, Buffer, Metrics, Shaping};
let mut fs = font_system().lock().unwrap();
// Default family → cosmic's own fallback search chooses a covering font.
let attrs = Attrs::new();
let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS));
buf.set_size(&mut fs, None, None);
let s = ch.to_string();
buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false);
for run in buf.layout_runs() {
for g in run.glyphs.iter() {
if g.glyph_id == 0 {
continue; // .notdef — this font doesn't really cover it
}
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let font = fs.get_font(g.font_id, g.font_weight)?;
let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
let k = cap_scale(&face);
let gid = ttf_parser::GlyphId(g.glyph_id);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
face.outline_glyph(gid, &mut fl);
fl.flush();
return Some(Arc::new(Glyph {
strokes: fl.contours,
advance,
}));
}
}
None
}
fn build_shaped(family: &str, text: &str) -> Option<ShapedRun> {
use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping};
// Cap height / units-per-em of the requested family set the normalization:
// its capital letters become CAP_UNITS tall, and every fallback glyph is
// scaled into the same pixel-per-unit so sizes stay consistent.
let (upem_p, cap_p) = sysfont::with_face_data(family, |data, idx| {
let f = ttf_parser::Face::parse(data, idx).ok()?;
let upem = f.units_per_em() as f32;
let cap = f
.capital_height()
.filter(|&c| c > 0)
.map(|c| c as f32)
.unwrap_or(0.7 * upem);
Some((upem, cap))
})
.flatten()?;
// Pixel (at SHAPE_FS) → 9-unit cap-height factor.
let px_to_9 = CAP_UNITS * upem_p / (SHAPE_FS * cap_p);
let mut fs = font_system().lock().unwrap();
let attrs = Attrs::new().family(Family::Name(family));
let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS));
// No wrapping: a run is a single line.
buf.set_size(&mut fs, None, None);
buf.set_text(&mut fs, text, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false);
let mut glyphs: Vec<PlacedGlyph> = Vec::new();
let mut advance = 0.0_f32;
for run in buf.layout_runs() {
advance = advance.max(run.line_w * px_to_9);
for g in run.glyphs.iter() {
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let Some(font) = fs.get_font(g.font_id, g.font_weight) else {
continue;
};
let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else {
continue;
};
let upem_g = face.units_per_em() as f32;
// Glyph font-units → 9-unit: to pixels (at SHAPE_FS) then to units.
let scale_g = (SHAPE_FS / upem_g) * px_to_9;
// Absolute pen position (px) → 9-unit. `physical()` shows x_offset /
// y_offset are fractions of font size.
let pen_x = (g.x + SHAPE_FS * g.x_offset) * px_to_9;
let pen_y = -(SHAPE_FS * g.y_offset) * px_to_9; // outlines are y-up
let mut fl = OutlineFlattener::new(scale_g);
fl.offset = [pen_x, pen_y];
face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut fl);
fl.flush();
if !fl.contours.is_empty() {
glyphs.push(PlacedGlyph {
strokes: fl.contours,
});
}
}
}
if advance <= 0.0 && glyphs.is_empty() {
return None;
}
Some(ShapedRun { glyphs, advance })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shape_run_falls_back_for_missing_script() {
let fams = sysfont::families();
if fams.is_empty() {
eprintln!("no system fonts; skipping");
return;
}
// A typical Latin family lacks CJK; cosmic-text should still resolve the
// ideograph from a fallback font. If the machine has no CJK font at all
// the count stays 1 — tolerated, but never a panic or empty run.
let fam = fams
.iter()
.find(|f| glyph(f, 'A').is_some())
.expect("family");
let run = shape_run(fam, "A中").expect("shaped");
eprintln!("fallback run glyphs={}", run.glyphs.len());
assert!(!run.glyphs.is_empty());
}
#[test]
fn shape_run_positions_glyphs() {
let fams = sysfont::families();
if fams.is_empty() { eprintln!("no fonts; skip"); return; }
let fam = fams.iter().find(|f| glyph(f, 'A').is_some()).expect("family");
let run = shape_run(fam, "AVA").expect("shaped");
eprintln!("glyphs={} advance={:.3}", run.glyphs.len(), run.advance);
assert_eq!(run.glyphs.len(), 3);
assert!(run.advance > 0.0);
// glyphs must be laid out left-to-right: each glyph's strokes sit at a
// greater x than the previous glyph's start.
let xs: Vec<f32> = run.glyphs.iter()
.filter_map(|g| g.strokes.iter().flatten().map(|p| p[0]).fold(None, |m,x| Some(m.map_or(x, |mm:f32| mm.min(x)))))
.collect();
assert!(xs.windows(2).all(|w| w[1] >= w[0] - 1.0), "glyphs not L->R: {:?}", xs);
}
#[test]
fn smoke_outline_first_family() {
let fams = sysfont::families();
if fams.is_empty() {
eprintln!("no system fonts; skipping");
return;
}
// Find any family that yields an 'A' outline.
let mut ok = false;
for fam in fams.iter().take(20) {
if let Some(g) = glyph(fam, 'A') {
eprintln!(
"{}: contours={} advance={:.4} verts={}",
fam,
g.strokes.len(),
g.advance,
g.strokes.iter().map(|s| s.len()).sum::<usize>()
);
assert!(g.advance > 0.0);
assert!(!g.strokes.is_empty());
ok = true;
break;
}
}
assert!(ok, "no family produced an 'A' outline");
}
}