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:
|
/// Transform a named linetype shape (from the converted `ltypeshp` LFF font)
|
||||||
/// the LFF font set ships no shape file (matching LibreCAD). Shape elements
|
/// into world-space strokes at the pen position.
|
||||||
/// are skipped; text-in-linetype still renders via the LFF fonts.
|
|
||||||
fn emit_shape(
|
fn emit_shape(
|
||||||
_name: &str,
|
name: &str,
|
||||||
_insert: [f32; 3],
|
insert: [f32; 3],
|
||||||
_fwd: [f32; 3],
|
fwd: [f32; 3],
|
||||||
_perp: [f32; 3],
|
perp: [f32; 3],
|
||||||
_scale: f32,
|
scale: f32,
|
||||||
_rot_deg: f32,
|
rot_deg: f32,
|
||||||
) -> Vec<Vec<[f32; 3]>> {
|
) -> 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")),
|
("kochigothic", include_str!("../../assets/fonts/kochigothic.lff")),
|
||||||
("kochimincho", include_str!("../../assets/fonts/kochimincho.lff")),
|
("kochimincho", include_str!("../../assets/fonts/kochimincho.lff")),
|
||||||
("kst32b", include_str!("../../assets/fonts/kst32b.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-ar", include_str!("../../assets/fonts/lc_opengost-ar.lff")),
|
||||||
("lc_opengost-br", include_str!("../../assets/fonts/lc_opengost-br.lff")),
|
("lc_opengost-br", include_str!("../../assets/fonts/lc_opengost-br.lff")),
|
||||||
("opengosttypea-regular", include_str!("../../assets/fonts/opengosttypea-regular.lff")),
|
("opengosttypea-regular", include_str!("../../assets/fonts/opengosttypea-regular.lff")),
|
||||||
|
|
@ -122,6 +123,10 @@ pub struct Font {
|
||||||
pub word_spacing: f32,
|
pub word_spacing: f32,
|
||||||
pub line_spacing: f32,
|
pub line_spacing: f32,
|
||||||
glyphs: HashMap<char, Glyph>,
|
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 {
|
impl Font {
|
||||||
|
|
@ -129,6 +134,15 @@ impl Font {
|
||||||
pub fn glyph(&self, c: char) -> Option<&Glyph> {
|
pub fn glyph(&self, c: char) -> Option<&Glyph> {
|
||||||
self.glyphs.get(&c)
|
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 ───────────────────────────────────────────────────────────────
|
// ── Registry ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -179,6 +193,12 @@ fn unicode_font() -> &'static Font {
|
||||||
.expect("at least one LFF font must be embedded")
|
.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.
|
/// Return a font by name (case-insensitive). `.shx` suffix is stripped.
|
||||||
/// Falls back to Standard → Unicode → any embedded font.
|
/// Falls back to Standard → Unicode → any embedded font.
|
||||||
pub fn get_font(name: &str) -> &'static Font {
|
pub fn get_font(name: &str) -> &'static Font {
|
||||||
|
|
@ -390,10 +410,12 @@ pub fn tessellate_text_run(
|
||||||
cursor_x += font.word_spacing;
|
cursor_x += font.word_spacing;
|
||||||
continue;
|
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
|
let glyph = font
|
||||||
.glyph(render_ch)
|
.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 {
|
match glyph {
|
||||||
Some(glyph) => {
|
Some(glyph) => {
|
||||||
for stroke in &glyph.strokes {
|
for stroke in &glyph.strokes {
|
||||||
|
|
@ -440,19 +462,29 @@ fn parse_lff(src: &str) -> Font {
|
||||||
word_spacing: 6.75,
|
word_spacing: 6.75,
|
||||||
line_spacing: 1.0,
|
line_spacing: 1.0,
|
||||||
glyphs: HashMap::new(),
|
glyphs: HashMap::new(),
|
||||||
|
shapes: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut raw: HashMap<char, RawGlyph> = 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: Option<char> = None;
|
||||||
|
let mut cur_name: Option<String> = None;
|
||||||
let mut cur_glyph = RawGlyph::default();
|
let mut cur_glyph = RawGlyph::default();
|
||||||
|
|
||||||
let flush = |cur: &mut Option<char>, g: &mut RawGlyph, raw: &mut HashMap<char, RawGlyph>| {
|
// Route the just-finished block to the glyph map (by char) or, when it
|
||||||
if let Some(c) = cur.take() {
|
// carried a shape name, to the shape map (by name).
|
||||||
raw.insert(c, std::mem::take(g));
|
macro_rules! flush {
|
||||||
} else {
|
() => {{
|
||||||
*g = RawGlyph::default();
|
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() {
|
for line in src.lines() {
|
||||||
let t = line.trim();
|
let t = line.trim();
|
||||||
|
|
@ -478,16 +510,22 @@ fn parse_lff(src: &str) -> Font {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if t.starts_with('[') {
|
if t.starts_with('[') {
|
||||||
flush(&mut cur, &mut cur_glyph, &mut raw);
|
flush!();
|
||||||
if let Some(end) = t.find(']') {
|
if let Some(end) = t.find(']') {
|
||||||
let hex = t[1..end].trim();
|
let hex = t[1..end].trim();
|
||||||
if let Some(c) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) {
|
let label = t[end + 1..].trim();
|
||||||
cur = Some(c);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if cur.is_none() {
|
if cur.is_none() && cur_name.is_none() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// `C<hex>` — reference another glyph's strokes.
|
// `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
|
// Resolve `C<hex>` references. Each pass folds in targets that are
|
||||||
// themselves already reference-free; repeat so ref-to-ref chains settle.
|
// 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_of = |strokes: &[Vec<[f32; 2]>]| -> f32 {
|
||||||
let advance = g
|
strokes
|
||||||
.strokes
|
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|s| s.iter())
|
.flat_map(|s| s.iter())
|
||||||
.map(|&[x, _]| x)
|
.map(|&[x, _]| x)
|
||||||
.fold(0.0_f32, f32::max);
|
.fold(0.0_f32, f32::max)
|
||||||
font.glyphs.insert(
|
};
|
||||||
c,
|
for (c, g) in raw {
|
||||||
Glyph {
|
let advance = advance_of(&g.strokes);
|
||||||
strokes: g.strokes,
|
font.glyphs.insert(c, Glyph { strokes: g.strokes, advance });
|
||||||
advance,
|
}
|
||||||
},
|
for (n, g) in raw_shapes {
|
||||||
);
|
let advance = advance_of(&g.strokes);
|
||||||
|
font.shapes.insert(n, Glyph { strokes: g.strokes, advance });
|
||||||
}
|
}
|
||||||
font
|
font
|
||||||
}
|
}
|
||||||
|
|
@ -662,6 +700,27 @@ mod tests {
|
||||||
let (w, h) = (maxx - minx, maxy - miny);
|
let (w, h) = (maxx - minx, maxy - miny);
|
||||||
assert!(h > w, "{name} O should be upright (h {h:.1} > w {w:.1})");
|
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