feat(shx): real SHX glyphs for text fonts and linetype shapes

The SHX reader now serves all three consumers instead of just SHAPE
entities:

- Complex-linetype shape elements stopped being skipped: a document
  linetype's Shape{number} resolves its style's shape file (shared path
  fallbacks) and draws the real glyph; `.lin` catalog references keep
  the file token and look the shape up by name. The converted-LFF
  substitute set remains the fallback for anything unresolved.

- Face::Shx renders resolvable on-disk .SHX text FONTS with their real
  stroke glyphs: character code → shape lookup, cap height normalised
  to the 9-unit space every stroke-font consumer expects, advance from
  the interpreter's final pen position, line spacing from the shape #0
  above/below header, TTF fallback per missing glyph.
  resolve_text_style resolves a style's .shx next to the drawing and
  passes the absolute path through; unresolvable names keep their LFF
  substitutes. Shapes-only files (no #0 header) are refused as fonts.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-23 06:44:48 +03:00
1 parent 1de4344280
commit bcd1fc15ea
5 files changed
+241 -23

No files matched your search

+27 -7
View File
@@ -24,14 +24,34 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
style.true_type_font.trim().to_string()
} else if !style.font_file.trim().is_empty() {
let file = style.font_file.trim();
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
let stem = basename.split('.').next().unwrap_or(basename).trim();
if !stem.is_empty() {
stem.to_string()
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
// A .shx font that resolves on disk (as stored, or next to the
// drawing) renders its REAL stroke glyphs — pass the absolute
// path through so `Face::resolve` picks the SHX face. Only an
// unresolvable file falls back to the stem's LFF substitute.
let shx_path = file
.to_ascii_lowercase()
.ends_with(".shx")
.then(|| {
let base = document
.source_path
.as_deref()
.map(std::path::Path::new)
.and_then(|p| p.parent());
crate::io::resolve_image_file(file, base)
})
.flatten();
if let Some(p) = shx_path {
p
} else {
"Standard".to_string()
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
let stem = basename.split('.').next().unwrap_or(basename).trim();
if !stem.is_empty() {
stem.to_string()
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
} else {
"Standard".to_string()
}
}
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
+45 -6
View File
@@ -22,11 +22,15 @@ pub enum LtSegment {
Space(f32),
/// Draw a dot (zero-length element, rendered as a tiny mark).
Dot,
/// Draw a shape at the current pen position. Embedded SHAPE elements are
/// no longer rendered (the LFF font set ships no shape file).
/// Draw a shape at the current pen position.
Shape {
/// Shape name (retained for round-tripping; not drawn).
/// Shape name (`.lin` catalog reference; empty for document shapes).
name: String,
/// Resolved .SHX shape-file path ("" when none resolved — the LFF
/// substitute set is the fallback for catalog names).
shx_file: String,
/// Shape number inside the file (0 = look up by name).
number: u16,
/// X offset along the linetype direction (can be negative).
x: f32,
/// Y offset perpendicular to the linetype direction.
@@ -130,9 +134,38 @@ pub fn document_lt_segments(document: &CadDocument, name: &str) -> Option<Comple
rot_deg: c.rotation as f32,
});
}
// Embedded SHAPE glyphs need a .shp set we don't ship; skip the
// draw (matches the bundled-catalog behaviour).
LineTypeComplexContent::Shape { .. } => {}
LineTypeComplexContent::Shape { shape_number } => {
// Resolve the style's shape file with the shared path
// fallbacks (as stored / next to the drawing) so the real
// SHX glyph draws; unresolved files simply skip.
let font = document
.text_styles
.iter()
.find(|s| s.handle == c.style_handle)
.map(|s| s.font_file.trim().to_string())
.unwrap_or_default();
if font.is_empty() || *shape_number <= 0 {
continue;
}
let base = document
.source_path
.as_deref()
.map(std::path::Path::new)
.and_then(|p| p.parent());
let Some(resolved) = crate::io::resolve_image_file(&font, base) else {
continue;
};
let scale = c.scale as f32;
segments.push(LtSegment::Shape {
name: String::new(),
shx_file: resolved,
number: *shape_number as u16,
x: c.offset[0] as f32,
y: c.offset[1] as f32,
scale: if scale.abs() < 1e-6 { 1.0 } else { scale },
rot_deg: c.rotation as f32,
});
}
}
}
}
@@ -447,6 +480,7 @@ fn parse_shape_element(inner: &str) -> Option<LtSegment> {
let mut y = 0.0f32;
let mut scale = 1.0f32;
let mut rot_deg = 0.0f32;
let mut shx_file = String::new();
for part in parts {
let p = part.trim();
@@ -463,11 +497,16 @@ fn parse_shape_element(inner: &str) -> Option<LtSegment> {
.or_else(|| p.strip_prefix("U="))
{
rot_deg = v.parse().unwrap_or(0.0);
} else if p.to_ascii_lowercase().ends_with(".shx") {
// `[NAME,ltypeshp.shx,…]` — the shape file reference.
shx_file = p.to_string();
}
}
Some(LtSegment::Shape {
name,
shx_file,
number: 0,
x,
y,
scale,
+47 -3
View File
@@ -229,6 +229,8 @@ pub fn apply_along(
LtSeg::Shape {
name: sh_name,
shx_file,
number,
x,
y,
scale: sh_scale,
@@ -239,7 +241,9 @@ pub fn apply_along(
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);
let shape_strokes = emit_shape(
sh_name, shx_file, *number, insert, fwd, perp, *sh_scale, *rot_deg,
);
strokes.extend(shape_strokes);
elem_idx += 1;
@@ -386,6 +390,8 @@ enum LtSeg {
Dot,
Shape {
name: String,
shx_file: String,
number: u16,
x: f32,
y: f32,
scale: f32,
@@ -409,12 +415,16 @@ fn scale_segments(segs: &[LtSegment], scale: f32) -> Vec<LtSeg> {
LtSegment::Dot => LtSeg::Dot,
LtSegment::Shape {
name,
shx_file,
number,
x,
y,
scale: sh_scale,
rot_deg,
} => LtSeg::Shape {
name: name.clone(),
shx_file: shx_file.clone(),
number: *number,
x: x * scale,
y: y * scale,
scale: *sh_scale * scale,
@@ -461,16 +471,50 @@ fn offset_pt(pt: [f32; 3], fwd: [f32; 3], perp: [f32; 3], dx: f32, dy: f32) -> [
]
}
/// Transform a named linetype shape (from the converted `ltypeshp` LFF font)
/// into world-space strokes at the pen position.
/// Transform a linetype shape into world-space strokes at the pen position:
/// the real .SHX glyph when the segment carries a resolved shape file (by
/// number for document linetypes, by name for `.lin` references), falling
/// back to the converted `ltypeshp` LFF substitute set.
fn emit_shape(
name: &str,
shx_file: &str,
number: u16,
insert: [f32; 3],
fwd: [f32; 3],
perp: [f32; 3],
scale: f32,
rot_deg: f32,
) -> Vec<Vec<[f32; 3]>> {
if !shx_file.is_empty() {
let polys = if number > 0 {
crate::scene::text::shx::shape_polylines(shx_file, number)
} else {
crate::scene::text::shx::shape_polylines_by_name(shx_file, name)
};
if let Some(polys) = polys {
let rot_r = rot_deg.to_radians();
let (cos_r, sin_r) = (rot_r.cos(), rot_r.sin());
return polys
.iter()
.map(|stroke| {
stroke
.iter()
.map(|&[lx, ly]| {
let scaled_x = lx as f32 * scale;
let scaled_y = ly as f32 * scale;
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();
}
}
let shape = match lff::shape(name) {
Some(s) => s,
None => return vec![],
+34 -3
View File
@@ -12,7 +12,7 @@ use std::ops::Deref;
use std::sync::Arc;
use crate::scene::text::lff::{self, Glyph};
use crate::scene::text::{sysfont, ttf_glyph};
use crate::scene::text::{shx, sysfont, ttf_glyph};
/// A glyph borrowed from a static LFF font or owned (cached `Arc`) from a TTF
/// face. Derefs to [`Glyph`] either way.
@@ -39,12 +39,37 @@ pub enum Face {
/// Cached space-glyph advance (9-unit), used for word spacing.
word: f32,
},
/// A real compiled .SHX stroke font on disk (glyphs keyed by char code).
Shx {
path: String,
/// (above + below) / above — baseline step relative to cap height.
line: f32,
/// Space advance (9-unit).
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 {
// A resolvable on-disk .SHX font renders its real stroke glyphs; the
// LFF substitutes only cover names we can't load.
if font_name.to_ascii_lowercase().ends_with(".shx")
&& std::path::Path::new(font_name).is_file()
{
if let Some((above, below)) = shx::font_metrics(font_name) {
let word = shx::font_glyph(font_name, ' ' as u16)
.map(|g| g.advance)
.filter(|a| *a > 0.0)
.unwrap_or(4.5);
return Face::Shx {
path: font_name.to_string(),
line: ((above + below) / above) as f32,
word,
};
}
}
let is_builtin = lff::is_builtin(font_name);
let has_sys = sysfont::has_family(font_name);
if !is_builtin && has_sys {
@@ -75,6 +100,9 @@ impl Face {
Face::Ttf { family, .. } => ttf_glyph::glyph(family, ch)
.or_else(|| ttf_glyph::fallback_glyph(ch))
.map(GlyphRef::Owned),
Face::Shx { path, .. } => shx::font_glyph(path, ch as u16)
.map(GlyphRef::Owned)
.or_else(|| ttf_glyph::fallback_glyph(ch).map(GlyphRef::Owned)),
}
}
@@ -84,6 +112,8 @@ impl Face {
match self {
Face::Lff(f) => f.letter_spacing,
Face::Ttf { .. } => 0.0,
// SHX advances already include the trailing gap by design.
Face::Shx { .. } => 0.0,
}
}
@@ -91,7 +121,7 @@ impl Face {
pub fn word_spacing(&self) -> f32 {
match self {
Face::Lff(f) => f.word_spacing,
Face::Ttf { word, .. } => *word,
Face::Ttf { word, .. } | Face::Shx { word, .. } => *word,
}
}
@@ -101,7 +131,7 @@ impl Face {
pub fn ttf_family(&self) -> Option<&str> {
match self {
Face::Ttf { family, .. } => Some(family),
Face::Lff(_) => None,
Face::Lff(_) | Face::Shx { .. } => None,
}
}
@@ -111,6 +141,7 @@ impl Face {
match self {
Face::Lff(f) => f.line_spacing,
Face::Ttf { .. } => 1.0,
Face::Shx { line, .. } => *line,
}
}
}
+88 -4
View File
@@ -60,8 +60,9 @@ fn file_cache() -> &'static Mutex<HashMap<String, Option<Arc<ShxFile>>>> {
C.get_or_init(|| Mutex::new(HashMap::new()))
}
fn shape_cache() -> &'static Mutex<HashMap<(String, u16), Option<ShapePolylines>>> {
static C: OnceLock<Mutex<HashMap<(String, u16), Option<ShapePolylines>>>> = OnceLock::new();
fn shape_cache() -> &'static Mutex<HashMap<(String, u16), Option<(ShapePolylines, f64)>>> {
static C: OnceLock<Mutex<HashMap<(String, u16), Option<(ShapePolylines, f64)>>>> =
OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
@@ -115,9 +116,26 @@ fn parse_file(path: &str) -> Option<ShxFile> {
Some(ShxFile { shapes })
}
/// Tessellate the shape named `name` (case-insensitive) from the SHX at
/// `path` — the `.lin` catalog references linetype shapes by name.
pub fn shape_polylines_by_name(path: &str, name: &str) -> Option<ShapePolylines> {
let file = load_file(path)?;
let num = file
.shapes
.iter()
.find(|(_, (n, _))| n.eq_ignore_ascii_case(name))
.map(|(&num, _)| num)?;
shape_polylines(path, num)
}
/// Tessellate `shape_number` from the SHX at `path` into unit-space
/// polylines. `None` when the file/shape is missing or not a shapes file.
pub fn shape_polylines(path: &str, shape_number: u16) -> Option<ShapePolylines> {
shape_with_advance(path, shape_number).map(|(p, _)| p)
}
/// Like [`shape_polylines`], plus the pen's final X — a font glyph's advance.
pub fn shape_with_advance(path: &str, shape_number: u16) -> Option<(ShapePolylines, f64)> {
let key = (path.to_string(), shape_number);
if let Ok(c) = shape_cache().lock() {
if let Some(hit) = c.get(&key) {
@@ -131,7 +149,7 @@ pub fn shape_polylines(path: &str, shape_number: u16) -> Option<ShapePolylines>
built
}
fn build_shape(path: &str, shape_number: u16) -> Option<ShapePolylines> {
fn build_shape(path: &str, shape_number: u16) -> Option<(ShapePolylines, f64)> {
let file = load_file(path)?;
let mut out: Vec<Vec<[f64; 2]>> = Vec::new();
let mut cur: Vec<[f64; 2]> = vec![[0.0, 0.0]];
@@ -146,10 +164,76 @@ fn build_shape(path: &str, shape_number: u16) -> Option<ShapePolylines> {
if cur.len() > 1 {
out.push(cur);
}
let advance = st.x;
if out.is_empty() {
// A pen-up-only glyph (space) still carries its advance.
if advance.abs() < 1e-12 {
return None;
}
return Some((Arc::new(Vec::new()), advance));
}
Some((Arc::new(out), advance))
}
// ── SHX text fonts ────────────────────────────────────────────────────────────
//
// A classic SHX *font* is the same container with glyphs keyed by character
// code and shape #0 holding the font header: `above, below, modes, 0` (the
// cap height above the baseline and the descender depth).
/// Font metrics: (above, below). `None` when the file has no shape #0 header.
pub fn font_metrics(path: &str) -> Option<(f64, f64)> {
let file = load_file(path)?;
let (_, spec) = file.shapes.get(&0)?;
let above = *spec.first()? as f64;
let below = *spec.get(1)? as f64;
if above <= 0.0 {
return None;
}
Some(Arc::new(out))
Some((above, below))
}
fn glyph_cache(
) -> &'static Mutex<HashMap<(String, u16), Option<Arc<crate::scene::text::lff::Glyph>>>> {
static C: OnceLock<
Mutex<HashMap<(String, u16), Option<Arc<crate::scene::text::lff::Glyph>>>>,
> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Look up character `code` as a font glyph, normalised so the font's
/// `above` (cap height) maps to 9 units — the scale every stroke-font
/// consumer in the text pipeline expects.
pub fn font_glyph(path: &str, code: u16) -> Option<Arc<crate::scene::text::lff::Glyph>> {
let key = (path.to_string(), code);
if let Ok(c) = glyph_cache().lock() {
if let Some(hit) = c.get(&key) {
return hit.clone();
}
}
let built = (|| {
let (above, _) = font_metrics(path)?;
let (polys, advance) = shape_with_advance(path, code)?;
let k = 9.0 / above;
let strokes: Vec<Vec<[f32; 2]>> = polys
.iter()
.filter(|p| p.len() > 1)
.map(|p| {
p.iter()
.map(|&[x, y]| [(x * k) as f32, (y * k) as f32])
.collect()
})
.collect();
Some(Arc::new(crate::scene::text::lff::Glyph {
strokes,
advance: (advance * k) as f32,
fill_tris: Vec::new(),
}))
})();
if let Ok(mut c) = glyph_cache().lock() {
c.insert(key, built.clone());
}
built
}
struct InterpState {