feat(fonts): complex-linetype shapes via LFF + Turkish glyph fallback
Restore complex-linetype embedded shapes: ltypeshp is converted from the old CXF shape file to LFF (assets/fonts/ltypeshp.lff), with the bulge moved to the LibreCAD preceding-segment convention. The LFF parser now keeps named shapes (blocks whose label is a word, e.g. BOX/CIRC1/ZIG) in a separate map since their codepoints collide, exposed via lff::shape(); complex_lt::emit_shape draws them again at the pen position. Missing glyphs now fall back through the chosen family -> unicode -> iso3098, so Turkish letters (Ğ ğ Ş ş İ) render in any font (dotless ı is the lone gap iso3098 lacks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
aba71f8797
commit
50115c55d8
3 changed files with 167 additions and 35 deletions
45
assets/fonts/ltypeshp.lff
Normal file
45
assets/fonts/ltypeshp.lff
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Format: LibreCAD Font 1
|
||||
# Name: ltypeshp
|
||||
# LetterSpacing: 3
|
||||
# WordSpacing: 6.75
|
||||
# LineSpacingFactor: 1
|
||||
|
||||
|
||||
[0042] BAT
|
||||
0,0;-1,2;3,2,A-1;2,0
|
||||
|
||||
[0042] BOX
|
||||
2,1;2,-1;0,-1;0,1;2,1
|
||||
|
||||
[0043] CIRC1
|
||||
1,1;1,-1,A1;1,1,A1
|
||||
|
||||
[0054] TRACK1
|
||||
0,-1;0,1
|
||||
|
||||
[005a] ZIG
|
||||
0,0;1,1;2,0
|
||||
|
||||
[0099] PT1
|
||||
0,0;0.577,1;1.154,0
|
||||
|
||||
[009A] PT2
|
||||
0,0;0.577,-1;1.154,0
|
||||
|
||||
[009B] PLANT1
|
||||
0,0;1.82,1.14;2.27,-0.71;3,0
|
||||
|
||||
[009C] PLANT2
|
||||
0,0;1.41,1.61;2.63,-0.59;3.15,0
|
||||
|
||||
[009C] PLANT3
|
||||
0,0;2.42,0.53;3.05,1.24;4.23,0
|
||||
|
||||
[009D] PLANT4
|
||||
0,0;1.76,-0.55;3.64,1;4.63,0
|
||||
|
||||
[009E] PLANT5
|
||||
0,0;0.42,-0.4;3.45,-0.81;2.5,0
|
||||
|
||||
[009F] PLANT6
|
||||
0,0;2.58,-0.5;3.81,1.37;4.42,0
|
||||
|
|
@ -291,16 +291,44 @@ fn offset_pt(pt: [f32; 3], fwd: [f32; 3], perp: [f32; 3], dx: f32, dy: f32) -> [
|
|||
]
|
||||
}
|
||||
|
||||
/// Embedded SHAPE elements in complex linetypes are no longer supported:
|
||||
/// the LFF font set ships no shape file (matching LibreCAD). Shape elements
|
||||
/// are skipped; text-in-linetype still renders via the LFF fonts.
|
||||
/// 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,
|
||||
name: &str,
|
||||
insert: [f32; 3],
|
||||
fwd: [f32; 3],
|
||||
perp: [f32; 3],
|
||||
scale: f32,
|
||||
rot_deg: f32,
|
||||
) -> Vec<Vec<[f32; 3]>> {
|
||||
Vec::new()
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
109
src/scene/lff.rs
109
src/scene/lff.rs
|
|
@ -46,6 +46,7 @@ const FONTS_SRC: &[(&str, &str)] = &[
|
|||
("kochigothic", include_str!("../../assets/fonts/kochigothic.lff")),
|
||||
("kochimincho", include_str!("../../assets/fonts/kochimincho.lff")),
|
||||
("kst32b", include_str!("../../assets/fonts/kst32b.lff")),
|
||||
("ltypeshp", include_str!("../../assets/fonts/ltypeshp.lff")),
|
||||
("lc_opengost-ar", include_str!("../../assets/fonts/lc_opengost-ar.lff")),
|
||||
("lc_opengost-br", include_str!("../../assets/fonts/lc_opengost-br.lff")),
|
||||
("opengosttypea-regular", include_str!("../../assets/fonts/opengosttypea-regular.lff")),
|
||||
|
|
@ -122,6 +123,10 @@ pub struct Font {
|
|||
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 {
|
||||
|
|
@ -129,6 +134,15 @@ impl Font {
|
|||
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 ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -179,6 +193,12 @@ fn unicode_font() -> &'static Font {
|
|||
.expect("at least one LFF font must be embedded")
|
||||
}
|
||||
|
||||
/// Secondary fallback covering Latin-extended / Turkish letters (ğ Ş İ …)
|
||||
/// that `unicode` lacks.
|
||||
fn latin_ext_font() -> Option<&'static Font> {
|
||||
fonts_map().get("ISO3098")
|
||||
}
|
||||
|
||||
/// Return a font by name (case-insensitive). `.shx` suffix is stripped.
|
||||
/// Falls back to Standard → Unicode → any embedded font.
|
||||
pub fn get_font(name: &str) -> &'static Font {
|
||||
|
|
@ -390,10 +410,12 @@ pub fn tessellate_text_run(
|
|||
cursor_x += font.word_spacing;
|
||||
continue;
|
||||
}
|
||||
// Selected family first, then the broad Unicode fallback.
|
||||
// Selected family first, then the broad Unicode fallback, then
|
||||
// iso3098 for Latin-extended/Turkish letters Unicode omits.
|
||||
let glyph = font
|
||||
.glyph(render_ch)
|
||||
.or_else(|| unicode_font().glyph(render_ch));
|
||||
.or_else(|| unicode_font().glyph(render_ch))
|
||||
.or_else(|| latin_ext_font().and_then(|f| f.glyph(render_ch)));
|
||||
match glyph {
|
||||
Some(glyph) => {
|
||||
for stroke in &glyph.strokes {
|
||||
|
|
@ -440,19 +462,29 @@ fn parse_lff(src: &str) -> Font {
|
|||
word_spacing: 6.75,
|
||||
line_spacing: 1.0,
|
||||
glyphs: HashMap::new(),
|
||||
shapes: HashMap::new(),
|
||||
};
|
||||
|
||||
let mut raw: HashMap<char, RawGlyph> = HashMap::new();
|
||||
let mut raw_shapes: HashMap<String, RawGlyph> = HashMap::new();
|
||||
let mut cur: Option<char> = None;
|
||||
let mut cur_name: Option<String> = None;
|
||||
let mut cur_glyph = RawGlyph::default();
|
||||
|
||||
let flush = |cur: &mut Option<char>, g: &mut RawGlyph, raw: &mut HashMap<char, RawGlyph>| {
|
||||
if let Some(c) = cur.take() {
|
||||
raw.insert(c, std::mem::take(g));
|
||||
} else {
|
||||
*g = 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();
|
||||
|
|
@ -478,16 +510,22 @@ fn parse_lff(src: &str) -> Font {
|
|||
continue;
|
||||
}
|
||||
if t.starts_with('[') {
|
||||
flush(&mut cur, &mut cur_glyph, &mut raw);
|
||||
flush!();
|
||||
if let Some(end) = t.find(']') {
|
||||
let hex = t[1..end].trim();
|
||||
if let Some(c) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) {
|
||||
cur = Some(c);
|
||||
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() {
|
||||
if cur.is_none() && cur_name.is_none() {
|
||||
continue;
|
||||
}
|
||||
// `C<hex>` — reference another glyph's strokes.
|
||||
|
|
@ -506,7 +544,7 @@ fn parse_lff(src: &str) -> Font {
|
|||
}
|
||||
}
|
||||
}
|
||||
flush(&mut cur, &mut cur_glyph, &mut raw);
|
||||
flush!();
|
||||
|
||||
// Resolve `C<hex>` references. Each pass folds in targets that are
|
||||
// themselves already reference-free; repeat so ref-to-ref chains settle.
|
||||
|
|
@ -537,20 +575,20 @@ fn parse_lff(src: &str) -> Font {
|
|||
}
|
||||
}
|
||||
|
||||
for (c, g) in raw {
|
||||
let advance = g
|
||||
.strokes
|
||||
let advance_of = |strokes: &[Vec<[f32; 2]>]| -> f32 {
|
||||
strokes
|
||||
.iter()
|
||||
.flat_map(|s| s.iter())
|
||||
.map(|&[x, _]| x)
|
||||
.fold(0.0_f32, f32::max);
|
||||
font.glyphs.insert(
|
||||
c,
|
||||
Glyph {
|
||||
strokes: g.strokes,
|
||||
advance,
|
||||
},
|
||||
);
|
||||
.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
|
||||
}
|
||||
|
|
@ -662,6 +700,27 @@ mod tests {
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue