forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1de4344280
commit
bcd1fc15ea
5 files changed
+241
-23
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user