Files
OneCAD-Draft/src/scene/lff.rs
T
Hakan SevenandClaude Opus 4.8 cd048165a9 feat(fonts): system TrueType support with shaping and fallback
Add a TrueType/OpenType render path alongside the LFF stroke engine so
users can pick fonts installed on their machine for text and MText.

- Discover system families via fontdb (sysfont) and list them in the
  text-style picker.
- Extract glyph outlines with ttf-parser, flattened into wire strokes
  normalized to the same 9-unit cap-height space as LFF glyphs.
- Shape runs with cosmic-text (ligatures, Arabic joining, kerning, bidi)
  plus automatic font fallback: a glyph missing from the chosen family
  is taken from another installed font.
- Unify both sources behind a single Face type; a shared run tokenizer
  drives the stroke (per-glyph) and shaped (TTF) renderers identically.
- Stroke fonts now fall back to a system TTF (cosmic-text picks it) for
  characters no LFF font covers, replacing the unicode/iso3098 chain.

Also trim the embedded LFF set from 47 to 26, keeping the AutoCAD-SHX
equivalents and dropping LibreCAD-only fonts now covered by TrueType.

LFF builtins keep priority over same-named system fonts so DXF text
renders identically across machines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:18:17 +03:00

826 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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::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::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");
}
}
}