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

@ -278,7 +278,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
anchor_f64[0] - (anchor_local_x as f64 * cos_r - local_y_for_line as f64 * sin_r),
anchor_f64[1] - (anchor_local_x as f64 * sin_r + local_y_for_line as f64 * cos_r),
];
let strokes = lff::tessellate_text_ex(
let (strokes, fill_tris) = lff::tessellate_text_ex(
[0.0, 0.0],
input.height as f32,
rotation,
@ -291,6 +291,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
strokes,
origin,
color: None,
fill_tris,
});
}
let _ = input.line_count; // round-trip only — recomputed above

View file

@ -25,7 +25,7 @@ pub mod solid3d;
pub mod spline;
pub mod table;
pub mod text;
pub(crate) mod text_support;
pub mod text_support;
pub mod tolerance;
pub mod traits;
pub mod underlay;

View file

@ -267,6 +267,7 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
// Text strokes, drawn from the layout computed up front (centred on the
// text grip). The snap node is the grip itself.
let mut fill_tris = Vec::new();
if let Some(layout) = &text_layout {
snap_pts.push(node([text_loc.x, text_loc.y, text_loc.z]));
for ts in &layout.strokes {
@ -281,6 +282,9 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
points.push([ox + x as f64, oy + y as f64, text_loc.z]);
}
}
for &[x, y] in &ts.fill_tris {
fill_tris.push([ox + x as f64, oy + y as f64, text_loc.z]);
}
}
}
@ -293,7 +297,7 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
snap_pts,
tangent_geoms: tangents,
key_vertices: key_verts,
fill_tris: vec![],
fill_tris,
})
}
@ -1597,6 +1601,7 @@ impl MultiLeaderTess for MultiLeader {
// local glyph space with its world origin (already offset-
// relative because we passed local_ins_x/y) stored as f64.
let mut text_points: Vec<[f32; 3]> = Vec::new();
let mut text_fill_tris: Vec<[f32; 3]> = Vec::new();
for ts in &layout.strokes {
let ox = ts.origin[0] as f32;
let oy = ts.origin[1] as f32;
@ -1609,9 +1614,19 @@ impl MultiLeaderTess for MultiLeader {
text_points.push([x + ox, y + oy, z]);
}
}
for &[x, y] in &ts.fill_tris {
text_fill_tris.push([x + ox, y + oy, z]);
}
}
let mut is_first = true;
if !text_points.is_empty() {
let snap = if is_first {
is_first = false;
vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)]
} else {
vec![]
};
wires.push(WireModel {
name: name.clone(),
points: text_points,
@ -1622,7 +1637,7 @@ impl MultiLeaderTess for MultiLeader {
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)],
snap_pts: snap,
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
@ -1632,6 +1647,32 @@ impl MultiLeaderTess for MultiLeader {
fill_tris_low: Vec::new(),
});
}
if !text_fill_tris.is_empty() {
let snap = if is_first {
vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)]
} else {
vec![]
};
wires.push(WireModel {
name: name.clone(),
points: vec![],
points_low: Vec::new(),
color: text_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: text_fill_tris,
fill_tris_low: Vec::new(),
});
}
}
// Text frame / background-fill rectangle in local frame, then rotated to WCS.

View file

@ -65,6 +65,7 @@ impl TruckConvertible for Table {
let total_h = *row_offsets.last().unwrap_or(&0.0);
let mut pts: Vec<[f32; 3]> = Vec::new();
let mut tris_pts: Vec<[f32; 3]> = Vec::new();
// Per-cell borders. When a cell carries a CellStyle, honour the
// visibility / `invisible` flag of each of its four borders so
@ -161,15 +162,28 @@ impl TruckConvertible for Table {
let font_for_handle = |handle: Option<acadrust::Handle>| -> Option<String> {
handle.and_then(|h| lookup_style(h)).and_then(|s| {
let file = s.font_file.trim();
if !file.is_empty() {
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
let stem = basename.split('.').next().unwrap_or(basename).trim();
if !stem.is_empty() {
return Some(stem.to_string());
let mut font_name = if !s.true_type_font.trim().is_empty() {
s.true_type_font.trim().to_string()
} else {
let file = s.font_file.trim();
if !file.is_empty() {
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 {
return None;
}
} else {
return None;
}
};
if !crate::scene::text::lff::is_builtin(&font_name) {
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
font_name = canonical;
}
}
None
Some(font_name)
})
};
// Build a ResolvedTextStyle for the cell — needed by the shared MText
@ -304,6 +318,9 @@ impl TruckConvertible for Table {
pts.push([x + ox, y + oy, origin.z]);
}
}
for &[x, y] in &ts.fill_tris {
tris_pts.push([x + ox, y + oy, origin.z]);
}
}
}
}
@ -321,12 +338,18 @@ impl TruckConvertible for Table {
}
})
.collect();
let fill_tris_f64: Vec<[f64; 3]> = tris_pts
.into_iter()
.map(|[x, y, z]| {
[x as f64 + base[0], y as f64 + base[1], z as f64 + base[2]]
})
.collect();
Some(TruckEntity {
object: TruckObject::Lines(pts_f64),
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
fill_tris: fill_tris_f64,
})
}
}
@ -435,10 +458,24 @@ pub fn tessellate_table(
};
let font_for_handle = |handle: Option<acadrust::Handle>| -> Option<String> {
handle.and_then(lookup_style).and_then(|s| {
let file = s.font_file.trim();
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
let stem = basename.split('.').next().unwrap_or(basename).trim();
(!stem.is_empty()).then(|| stem.to_string())
let mut font_name = if !s.true_type_font.trim().is_empty() {
s.true_type_font.trim().to_string()
} else {
let file = s.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 {
return None;
}
};
if !crate::scene::text::lff::is_builtin(&font_name) {
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
font_name = canonical;
}
}
Some(font_name)
})
};
let resolved_style_for_handle =
@ -455,7 +492,7 @@ pub fn tessellate_table(
// Accumulators keyed by quantised colour (+ weight for borders).
let mut fills: HashMap<[u8; 4], ([f32; 4], Vec<[f32; 3]>)> = HashMap::default();
let mut texts: HashMap<[u8; 4], ([f32; 4], Vec<[f32; 3]>)> = HashMap::default();
let mut texts: HashMap<[u8; 4], ([f32; 4], Vec<[f32; 3]>, Vec<[f32; 3]>)> = HashMap::default();
let mut borders: HashMap<([u8; 4], u32), ([f32; 4], f32, Vec<[f32; 3]>)> = HashMap::default();
let mut emitted: rustc_hash::FxHashSet<(i32, i32, i32, i32)> = rustc_hash::FxHashSet::default();
let sel_col = WireModel::SELECTED;
@ -672,10 +709,11 @@ pub fn tessellate_table(
} else {
entity_color
};
let buf = &mut texts
let entry = texts
.entry(key4(tcol))
.or_insert_with(|| (tcol, Vec::new()))
.1;
.or_insert_with(|| (tcol, Vec::new(), Vec::new()));
let buf = &mut entry.1;
let tris_buf = &mut entry.2;
for ts in &layout.strokes {
let sx = ts.origin[0] as f32;
let sy = ts.origin[1] as f32;
@ -690,6 +728,9 @@ pub fn tessellate_table(
buf.push([x + sx, y + sy, (to.z as f64) as f32]);
}
}
for &[x, y] in &ts.fill_tris {
tris_buf.push([x + sx, y + sy, (to.z as f64) as f32]);
}
}
}
}
@ -730,10 +771,13 @@ pub fn tessellate_table(
out.push(mk(color, pts, vec![], lw));
}
}
for (_, (color, pts)) in texts {
for (_, (color, pts, tris)) in texts {
if !pts.is_empty() {
out.push(mk(color, pts, vec![], line_weight_px));
}
if !tris.is_empty() {
out.push(mk(color, vec![], tris, line_weight_px));
}
}
out
}

View file

@ -137,7 +137,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
anchor_f64[1] - (anchor_local_x as f64 * sin_r + anchor_local_y as f64 * cos_r),
];
// Strokes are in glyph-local space (origin = [0,0]).
let strokes = lff::tessellate_text_ex(
let (strokes, fill_tris) = lff::tessellate_text_ex(
[0.0, 0.0],
t.height as f32,
rotation,
@ -151,6 +151,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
strokes,
origin,
color: None,
fill_tris,
}]),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],

View file

@ -19,22 +19,20 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
|| (style_name.trim().is_empty() && entry.name.eq_ignore_ascii_case("Standard"))
});
let font_name = if let Some(style) = style {
if !style.font_file.trim().is_empty() {
let mut font_name = if let Some(style) = style {
if !style.true_type_font.trim().is_empty() {
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.true_type_font.trim().is_empty() {
style.true_type_font.trim().to_string()
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
} else {
"Standard".to_string()
}
} else if !style.true_type_font.trim().is_empty() {
style.true_type_font.trim().to_string()
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
} else {
@ -46,8 +44,18 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
style_name.trim().to_string()
};
if !lff::is_builtin(&font_name) {
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
font_name = canonical;
}
}
ResolvedTextStyle {
font_name,
font_name: {
eprintln!("[resolve_text_style] style={:?} font_file={:?} true_type_font={:?} → font_name={:?}",
style.map(|s| &s.name), style.map(|s| &s.font_file), style.map(|s| &s.true_type_font), &font_name);
font_name
},
width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0),
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
@ -1420,7 +1428,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
ins_x + (line_base_x + world_dx) as f64,
ins_y + (line_base_y + world_dy) as f64,
];
let strokes = lff::tessellate_text_run(
let (strokes, fill_tris) = lff::tessellate_text_run(
[0.0, 0.0],
run_h,
rot,
@ -1434,6 +1442,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
strokes,
origin,
color,
fill_tris,
});
if opts.want_glyph_boxes {
// Per-character boxes, advancing exactly as

View file

@ -206,7 +206,7 @@ fn tessellate_tolerance(tol: &Tolerance) -> Vec<Vec<[f32; 2]>> {
let text_w = cell.len() as f32 * char_w;
let tx = cell_x + (cw - text_w) * 0.5;
// Tessellate text in local frame then transform
let local_strokes =
let (local_strokes, _) =
lff::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, "txt", cell);
for polyline in local_strokes {
let transformed: Vec<[f32; 2]> = polyline
@ -248,6 +248,7 @@ impl TruckConvertible for Tolerance {
strokes,
origin,
color: None,
fill_tris: vec![],
}]),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],