fix(text): render TTF glyphs as solid fills, fix font fallback, correct Z-order

Text elements previously rendered as hollow wireframe outlines and sometimes
fell back to the wrong system font due to case-sensitive exact matching. Also,
the viewport background grid drew on top of solid entities.

Case-insensitive and partial-name fallback matching (e.g., mapping "arialn"
to "Arial Narrow") is now added to `sysfont::canonical_family_name`, ensuring
text resolves to the correct installed system font.

TrueType font contours are now triangulated into solid fills (`fill_tris`)
using `lyon_tessellation` in `ttf_glyph.rs`. To prevent the GPU from misclassifying
this 2D text as a 3D mesh (which broke flat shading), text tessellation splits
the output into separate outline and fill `WireModel`s.

The background grid is extracted from `SelectionCanvas` into `GridCanvas` and
moved beneath the 3D viewport to ensure proper layering. Finally, `DepthBiasState`
is enabled on all face/fill pipelines to apply a polygon offset, perfectly
resolving Z-fighting between coplanar wireframe lines and solid faces.
This commit is contained in:
Karim Jerbi 2026-06-25 18:17:01 +01:00
commit 627761b629
20 changed files with 624 additions and 191 deletions

View file

@ -18,6 +18,7 @@ pub struct TextStroke {
pub strokes: Vec<Vec<[f32; 2]>>,
pub origin: [f64; 2],
pub color: Option<[f32; 3]>,
pub fill_tris: Vec<[f32; 2]>,
}
#[allow(dead_code)]

View file

@ -160,17 +160,28 @@ pub fn tessellate(
// Selection forces a single uniform colour — never split.
let split_by_color = !selected;
// Bins: key = Some(rgb), parallel high/low f32 buffers — the
// low buffer is index-for-index with high so the renderer's
// double-single RTE shader survives at UTM-scale anchors.
let mut bins: Vec<(Option<[f32; 3]>, Vec<[f32; 3]>, Vec<[f32; 3]>)> = Vec::new();
// Bins: key = Some(rgb)
struct TextBin {
color: Option<[f32; 3]>,
pts: Vec<[f32; 3]>,
pts_low: Vec<[f32; 3]>,
fill_tris: Vec<[f32; 3]>,
fill_tris_low: Vec<[f32; 3]>,
}
let mut bins: Vec<TextBin> = Vec::new();
let mut bin_first: Vec<bool> = Vec::new();
let find_or_make =
|key: Option<[f32; 3]>, bins: &mut Vec<(Option<[f32; 3]>, Vec<[f32; 3]>, Vec<[f32; 3]>)>, firsts: &mut Vec<bool>| -> usize {
if let Some(i) = bins.iter().position(|(k, _, _)| *k == key) {
|key: Option<[f32; 3]>, bins: &mut Vec<TextBin>, firsts: &mut Vec<bool>| -> usize {
if let Some(i) = bins.iter().position(|b| b.color == key) {
i
} else {
bins.push((key, Vec::new(), Vec::new()));
bins.push(TextBin {
color: key,
pts: Vec::new(),
pts_low: Vec::new(),
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
});
firsts.push(true);
bins.len() - 1
}
@ -184,28 +195,34 @@ pub fn tessellate(
let sly_v = (ly_v - ref_ly_v) * anno + ref_ly_v;
let bin_key = if split_by_color { group.color } else { None };
let bi = find_or_make(bin_key, &mut bins, &mut bin_first);
let (_k, pts, pts_low) = {
let b = &mut bins[bi];
(&b.0, &mut b.1, &mut b.2)
};
let _ = _k;
// 1. Process outline strokes
for stroke in &group.strokes {
if stroke.len() < 2 {
continue;
}
if !bin_first[bi] && !pts.is_empty() {
pts.push([f32::NAN, f32::NAN, f32::NAN]);
pts_low.push([0.0; 3]);
if !bin_first[bi] && !bins[bi].pts.is_empty() {
bins[bi].pts.push([f32::NAN, f32::NAN, f32::NAN]);
bins[bi].pts_low.push([0.0; 3]);
}
bin_first[bi] = false;
for &[x, y] in stroke {
let xv = x as f64 * anno + slx_v;
let yv = y as f64 * anno + sly_v;
let (h, l) = split_ds_xyz(xv, yv, elev_v);
pts.push(h);
pts_low.push(l);
bins[bi].pts.push(h);
bins[bi].pts_low.push(l);
}
}
// 2. Process fill triangles
for &[x, y] in &group.fill_tris {
let xv = x as f64 * anno + slx_v;
let yv = y as f64 * anno + sly_v;
let (h, l) = split_ds_xyz(xv, yv, elev_v);
bins[bi].fill_tris.push(h);
bins[bi].fill_tris_low.push(l);
}
}
let snap_pts = te.snap_pts;
@ -239,38 +256,93 @@ pub fn tessellate(
}];
}
let bin_count = bins.len();
let mut out: Vec<WireModel> = Vec::with_capacity(bin_count);
for (idx, (override_rgb, pts, pts_low)) in bins.into_iter().enumerate() {
let wire_color = match override_rgb {
let mut out: Vec<WireModel> = Vec::new();
let mut is_first = true;
for bin in bins {
let wire_color = match bin.color {
Some([r, g, b]) => [r, g, b, color[3]],
None => color,
};
// Snap points and key vertices belong to the entity as a
// whole — attach them only to the first emitted wire so
// pickers / hover don't double-count.
let (snap, keys, tangents) = if idx == 0 {
(
snap_pts.clone(),
key_vertices.clone(),
te.tangent_geoms.clone(),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
if !bin.pts.is_empty() {
let (snap, keys, tangents) = if is_first {
is_first = false;
(
snap_pts.clone(),
key_vertices.clone(),
te.tangent_geoms.clone(),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
name: name.clone(),
points: bin.pts,
points_low: bin.pts_low,
color: wire_color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: tangents,
aci: 0,
key_vertices: keys,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
}
if !bin.fill_tris.is_empty() {
let (snap, keys, tangents) = if is_first {
is_first = false;
(
snap_pts.clone(),
key_vertices.clone(),
te.tangent_geoms.clone(),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
name: name.clone(),
points: Vec::new(),
points_low: Vec::new(),
color: wire_color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: tangents,
aci: 0,
key_vertices: keys,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: bin.fill_tris,
fill_tris_low: bin.fill_tris_low,
});
}
}
if out.is_empty() {
out.push(WireModel {
name: name.clone(),
points: pts,
points_low: pts_low,
color: wire_color,
name,
points: Vec::new(),
points_low: Vec::new(),
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: tangents,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices: keys,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
@ -414,25 +486,95 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z])
.collect();
let (fill_tris, fill_tris_low) = points_to_ds(te.fill_tris);
return vec![WireModel {
name,
points: local_pts,
points_low: local_pts_low,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris,
fill_tris_low,
}];
let mut out = Vec::new();
let mut is_first = true;
if !local_pts.is_empty() {
let (snap, keys, tangents) = if is_first {
is_first = false;
(
snap_pts.clone(),
key_vertices.clone(),
te.tangent_geoms.clone(),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
name: name.clone(),
points: local_pts,
points_low: local_pts_low,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: tangents,
aci: 0,
key_vertices: keys,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
}
if !fill_tris.is_empty() {
let (snap, keys, tangents) = if is_first {
(
snap_pts.clone(),
key_vertices.clone(),
te.tangent_geoms.clone(),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
name: name.clone(),
points: Vec::new(),
points_low: Vec::new(),
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: tangents,
aci: 0,
key_vertices: keys,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris,
fill_tris_low,
});
}
if out.is_empty() {
out.push(WireModel {
name,
points: Vec::new(),
points_low: Vec::new(),
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
}
return out;
}
TruckObject::SegmentedLines(points) => {

View file

@ -338,7 +338,11 @@ impl Pipeline {
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
@ -401,7 +405,11 @@ impl Pipeline {
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
@ -457,7 +465,11 @@ impl Pipeline {
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
@ -546,7 +558,11 @@ impl Pipeline {
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
@ -600,7 +616,11 @@ impl Pipeline {
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
@ -643,7 +663,11 @@ impl Pipeline {
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,

View file

@ -175,7 +175,7 @@ pub fn apply_along(
let insert = offset_pt(insert, fwd, perp, *x, *y);
let fwd_angle = fwd[1].atan2(fwd[0]) + rot_deg.to_radians();
let resolved = resolve_dxf_special_chars(text);
let text_strokes = lff::tessellate_text_ex(
let (text_strokes, _) = lff::tessellate_text_ex(
[insert[0], insert[1]],
*tx_scale,
fwd_angle,

View file

@ -45,14 +45,17 @@ 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 {
if !lff::is_builtin(font_name) && sysfont::has_family(font_name) {
let word = ttf_glyph::glyph(font_name, ' ')
let is_builtin = lff::is_builtin(font_name);
let has_sys = sysfont::has_family(font_name);
if !is_builtin && has_sys {
let canonical = sysfont::canonical_family_name(font_name).unwrap_or_else(|| font_name.to_string());
let word = ttf_glyph::glyph(&canonical, ' ')
.map(|g| g.advance)
// Fall back to a sensible blank-width if the font has no space.
.filter(|w| *w > 0.0)
.unwrap_or(4.5);
return Face::Ttf {
family: font_name.to_string(),
family: canonical,
word,
};
}
@ -130,7 +133,7 @@ mod tests {
.find(|f| ttf_glyph::glyph(f, 'A').is_some())
.expect("a system family with an 'A'");
assert!(matches!(Face::resolve(fam), Face::Ttf { .. }));
let strokes =
let (strokes, _) =
lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "ABC");
assert!(!strokes.is_empty(), "TTF run produced no strokes");
}
@ -168,8 +171,8 @@ mod tests {
.expect("a system family with an 'A'");
// Underlined shaped text: glyph contours plus exactly one underline
// segment (a 2-point polyline) emitted on the \l toggle.
let plain = lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "AB");
let deco = lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "\\LAB\\l");
let (plain, _) = lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "AB");
let (deco, _) = lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "\\LAB\\l");
assert!(!plain.is_empty());
assert_eq!(
deco.len(),
@ -177,4 +180,32 @@ mod tests {
"underline should add exactly one segment on the TTF path"
);
}
#[test]
fn case_insensitive_ttf_resolution() {
let fams = sysfont::families();
if fams.is_empty() {
eprintln!("no system fonts; skipping");
return;
}
// Arial and Cambria are typical on Windows/Mac/Linux
for test_name in &["arial", "ARIAL", "ARIALN", "cambria"] {
let resolved = Face::resolve(test_name);
match resolved {
Face::Ttf { family, .. } => {
assert!(
family == "Arial" || family == "Arial Narrow" || family == "Cambria",
"Resolved to unexpected family name: {}", family
);
}
Face::Lff(_) => {
// It's possible some test environments don't have Arial or Cambria,
// but if they are present in sysfont::families() (or we can fallback),
// they should resolve to Ttf. If they aren't installed, Lff fallback is accepted.
eprintln!("Font {} resolved to Lff (probably not installed)", test_name);
}
}
}
}
}

View file

@ -89,6 +89,7 @@ pub struct Glyph {
pub strokes: Vec<Vec<[f32; 2]>>,
/// Advance width in glyph units (rightmost X of all strokes).
pub advance: f32,
pub fill_tris: Vec<[f32; 2]>,
}
/// A parsed LFF font.
@ -329,7 +330,7 @@ pub fn tessellate_text_ex(
oblique_angle: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
) -> (Vec<Vec<[f32; 2]>>, Vec<[f32; 2]>) {
tessellate_text_run(
origin,
height,
@ -352,9 +353,9 @@ pub fn tessellate_text_run(
tracking: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
) -> (Vec<Vec<[f32; 2]>>, Vec<[f32; 2]>) {
if text.is_empty() || height <= 0.0 {
return vec![];
return (vec![], vec![]);
}
let face = crate::scene::text::font_face::Face::resolve(font_name);
@ -377,6 +378,7 @@ pub fn tessellate_text_run(
};
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut fill_tris: Vec<[f32; 2]> = Vec::new();
let mut cursor_x: f32 = 0.0;
let mut underline: Option<f32> = None;
let mut overline: Option<f32> = None;
@ -399,10 +401,16 @@ pub fn tessellate_text_run(
}
};
let emit_fill = |fill_tris: &mut Vec<[f32; 2]>, tris: &[[f32; 2]], cx: f32| {
for &v in tris {
fill_tris.push(xform(v[0], v[1], cx));
}
};
// Flush a buffered TTF segment: shape it, emit the positioned glyph
// contours, and advance the pen by the shaped run width. Falls back to
// per-glyph outlines if shaping is unavailable.
let flush_ttf = |seg: &mut String, cursor_x: &mut f32, out: &mut Vec<Vec<[f32; 2]>>| {
let flush_ttf = |seg: &mut String, cursor_x: &mut f32, out: &mut Vec<Vec<[f32; 2]>>, fill_tris: &mut Vec<[f32; 2]>| {
if seg.is_empty() {
return;
}
@ -410,6 +418,7 @@ pub fn tessellate_text_run(
if let Some(run) = crate::scene::text::ttf_glyph::shape_run(family, seg) {
for g in &run.glyphs {
emit_glyph(out, &g.strokes, *cursor_x);
emit_fill(fill_tris, &g.fill_tris, *cursor_x);
}
*cursor_x += run.advance * wf;
} else {
@ -417,6 +426,7 @@ pub fn tessellate_text_run(
match face.glyph(ch) {
Some(glyph) => {
emit_glyph(out, &glyph.strokes, *cursor_x);
emit_fill(fill_tris, &glyph.fill_tris, *cursor_x);
*cursor_x += (glyph.advance + face.letter_spacing() * tracking) * wf;
}
None => {
@ -435,7 +445,7 @@ pub fn tessellate_text_run(
// decoration toggle, end of run) flushes the buffer first so pen
// positions stay correct for decorations.
if ttf_family.is_some() && !matches!(tok, Tok::Glyph(_)) {
flush_ttf(&mut seg, &mut cursor_x, &mut out);
flush_ttf(&mut seg, &mut cursor_x, &mut out, &mut fill_tris);
}
match tok {
Tok::Glyph(c) => {
@ -445,6 +455,7 @@ pub fn tessellate_text_run(
match face.glyph(*c) {
Some(glyph) => {
emit_glyph(&mut out, &glyph.strokes, cursor_x);
emit_fill(&mut fill_tris, &glyph.fill_tris, cursor_x);
cursor_x += (glyph.advance + face.letter_spacing() * tracking) * wf;
}
None => {
@ -487,7 +498,7 @@ pub fn tessellate_text_run(
}
}
if ttf_family.is_some() {
flush_ttf(&mut seg, &mut cursor_x, &mut out);
flush_ttf(&mut seg, &mut cursor_x, &mut out, &mut fill_tris);
}
if let Some(start) = underline {
@ -500,7 +511,7 @@ pub fn tessellate_text_run(
out.push(vec![xform(start, STRIKE_Y, 0.0), xform(cursor_x, STRIKE_Y, 0.0)]);
}
out
(out, fill_tris)
}
// ── Parser ────────────────────────────────────────────────────────────────
@ -641,11 +652,11 @@ fn parse_lff(src: &str) -> Font {
};
for (c, g) in raw {
let advance = advance_of(&g.strokes);
font.glyphs.insert(c, Glyph { strokes: g.strokes, advance });
font.glyphs.insert(c, Glyph { strokes: g.strokes, advance, fill_tris: Vec::new() });
}
for (n, g) in raw_shapes {
let advance = advance_of(&g.strokes);
font.shapes.insert(n, Glyph { strokes: g.strokes, advance });
font.shapes.insert(n, Glyph { strokes: g.strokes, advance, fill_tris: Vec::new() });
}
font
}
@ -735,7 +746,7 @@ mod tests {
("Hello, World!", 15, 113, 5078.5248),
];
for &(t, segs, verts, sum_ref) in cases {
let st = tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, "txt", t);
let (st, _) = tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, "txt", t);
let nv: usize = st.iter().map(|s| s.len()).sum();
let sum: f64 = st
.iter()
@ -780,7 +791,7 @@ mod tests {
assert!(!is_builtin("amiri-regular"));
assert!(get_font("kochigothic").glyph('A').is_some());
// Unicode fallback covers a non-ASCII letter via the renderer path.
let strokes = tessellate_text_run([0.0, 0.0], 2.5, 0.0, 1.0, 0.0, 1.0, "Standard", "Aб");
let (strokes, _) = tessellate_text_run([0.0, 0.0], 2.5, 0.0, 1.0, 0.0, 1.0, "Standard", "Aб");
assert!(!strokes.is_empty());
// The bulge belongs to the segment ENDING at the vertex (LibreCAD
// convention): the standard/iso/unicode 'O' must come out as an
@ -803,7 +814,7 @@ mod tests {
// 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(
let (s, _) = tessellate_text_run(
[0.0, 0.0],
2.5,
0.0,

View file

@ -37,11 +37,60 @@ pub fn families() -> &'static [String] {
&fonts().families
}
/// Resolve a requested family name to the canonical installed system family name (with exact case).
pub fn canonical_family_name(family: &str) -> Option<String> {
let db = &fonts().db;
// 1. Try exact match first
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
..Default::default()
};
if db.query(&query).is_some() {
if let Some(canonical) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(family)) {
return Some(canonical.clone());
}
return Some(family.to_string());
}
// 2. Try case-insensitive match on the families we have
if let Some(matched) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(family)) {
return Some(matched.clone());
}
// 3. Match common prefixes / variations
let family_lower = family.to_lowercase();
let alias = match family_lower.as_str() {
"arialn" => Some("Arial Narrow"),
"gothic" => Some("Century Gothic"),
"times" => Some("Times New Roman"),
"cour" => Some("Courier New"),
_ => None,
};
if let Some(alias_name) = alias {
if let Some(matched) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(alias_name)) {
return Some(matched.clone());
}
}
// 4. Try matching prefix/subset case-insensitively
if let Some(matched) = fonts().families.iter().find(|&f| {
let f_low = f.to_lowercase();
f_low.starts_with(&family_lower) || family_lower.starts_with(&f_low)
}) {
return Some(matched.clone());
}
None
}
/// Resolve a family name to a concrete face id (regular weight/style).
fn face_id(family: &str) -> Option<fontdb::ID> {
let db = &fonts().db;
let canonical = canonical_family_name(family)?;
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
families: &[fontdb::Family::Name(&canonical)],
..Default::default()
};
db.query(&query)
@ -61,3 +110,4 @@ pub fn with_face_data<T>(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Optio
pub fn has_family(family: &str) -> bool {
face_id(family).is_some()
}

View file

@ -21,6 +21,10 @@ use crate::scene::text::sysfont;
use rustc_hash::FxHashMap as HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use lyon_tessellation::math::point;
use lyon_tessellation::path::Path;
use lyon_tessellation::{FillTessellator, FillOptions, BuffersBuilder, VertexBuffers, FillVertex};
/// Bézier flattening step counts. Outlines are small on screen most of the
/// time; these are a fixed budget that keeps curves smooth without exploding
/// vertex counts. Cubic gets more steps because OTF/CFF curves swing wider.
@ -156,10 +160,12 @@ pub fn glyph(family: &str, ch: char) -> Option<Arc<Glyph>> {
// A glyph with no outline (e.g. space) still has a valid advance.
face.outline_glyph(gid, &mut fl);
fl.flush();
let fill_tris = triangulate_contours(&fl.contours);
Some(Arc::new(Glyph {
strokes: fl.contours,
advance,
fill_tris,
}))
})
.flatten();
@ -180,6 +186,46 @@ fn cap_scale(face: &ttf_parser::Face) -> f32 {
CAP_UNITS / cap
}
fn triangulate_contours(contours: &[Vec<[f32; 2]>]) -> Vec<[f32; 2]> {
if contours.is_empty() {
return Vec::new();
}
let mut builder = Path::builder();
for contour in contours {
if contour.len() < 3 {
continue;
}
builder.begin(point(contour[0][0], contour[0][1]));
for p in &contour[1..] {
builder.line_to(point(p[0], p[1]));
}
builder.end(true);
}
let path = builder.build();
let mut geometry: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
let mut tessellator = FillTessellator::new();
if let Err(e) = tessellator.tessellate_path(
&path,
&FillOptions::default(),
&mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| {
vertex.position().to_array()
}),
) {
eprintln!("[ttf_glyph] Tessellation error: {:?}", e);
return Vec::new();
}
let mut tris = Vec::with_capacity(geometry.indices.len());
for &idx in &geometry.indices {
if let Some(&p) = geometry.vertices.get(idx as usize) {
tris.push(p);
}
}
tris
}
// ── Shaping ────────────────────────────────────────────────────────────────
/// One shaped glyph, positioned within its run. Strokes are in 9-unit space and
@ -187,6 +233,7 @@ fn cap_scale(face: &ttf_parser::Face) -> f32 {
/// so the caller only applies the run's own transform.
pub struct PlacedGlyph {
pub strokes: Vec<Vec<[f32; 2]>>,
pub fill_tris: Vec<[f32; 2]>,
}
/// A fully shaped run.
@ -289,9 +336,11 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
if fl.contours.is_empty() {
return None;
}
let fill_tris = triangulate_contours(&fl.contours);
Some(Arc::new(Glyph {
strokes: fl.contours,
advance,
fill_tris,
}))
}
@ -321,9 +370,11 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
let mut fl = OutlineFlattener::new(k);
face.outline_glyph(gid, &mut fl);
fl.flush();
let fill_tris = triangulate_contours(&fl.contours);
return Some(Arc::new(Glyph {
strokes: fl.contours,
advance,
fill_tris,
}));
}
}
@ -391,8 +442,10 @@ fn build_shaped(family: &str, text: &str) -> Option<ShapedRun> {
face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut fl);
fl.flush();
if !fl.contours.is_empty() {
let fill_tris = triangulate_contours(&fl.contours);
glyphs.push(PlacedGlyph {
strokes: fl.contours,
fill_tris,
});
}
}