From bcd1fc15ea466606beea5524e04fd9e591fc28e0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 23 Jul 2026 06:44:48 +0300 Subject: [PATCH] feat(shx): real SHX glyphs for text fonts and linetype shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/entities/text_support.rs | 34 ++++++++++--- src/io/linetypes.rs | 51 +++++++++++++++++--- src/scene/text/complex_lt.rs | 50 ++++++++++++++++++-- src/scene/text/font_face.rs | 37 +++++++++++++-- src/scene/text/shx.rs | 92 ++++++++++++++++++++++++++++++++++-- 5 files changed, 241 insertions(+), 23 deletions(-) diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 10d3ae3b2..09645863c 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -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() diff --git a/src/io/linetypes.rs b/src/io/linetypes.rs index 51fe535a8..40d9ddba2 100644 --- a/src/io/linetypes.rs +++ b/src/io/linetypes.rs @@ -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 {} + 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 { 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 { .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, diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index ec8721af4..15f88d667 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -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 { 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> { + 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![], diff --git a/src/scene/text/font_face.rs b/src/scene/text/font_face.rs index 59de3e811..e4b5efee4 100644 --- a/src/scene/text/font_face.rs +++ b/src/scene/text/font_face.rs @@ -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, } } } diff --git a/src/scene/text/shx.rs b/src/scene/text/shx.rs index 59b06f482..51828b495 100644 --- a/src/scene/text/shx.rs +++ b/src/scene/text/shx.rs @@ -60,8 +60,9 @@ fn file_cache() -> &'static Mutex>>> { C.get_or_init(|| Mutex::new(HashMap::new())) } -fn shape_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); +fn shape_cache() -> &'static Mutex>> { + static C: OnceLock>>> = + OnceLock::new(); C.get_or_init(|| Mutex::new(HashMap::new())) } @@ -115,9 +116,26 @@ fn parse_file(path: &str) -> Option { 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 { + 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 { + 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 built } -fn build_shape(path: &str, shape_number: u16) -> Option { +fn build_shape(path: &str, shape_number: u16) -> Option<(ShapePolylines, f64)> { let file = load_file(path)?; let mut out: Vec> = 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 { 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>>> { + static C: OnceLock< + Mutex>>>, + > = 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> { + 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> = 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 {