From 627761b629481f19c509421b471cd4738da0cfc3 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Thu, 25 Jun 2026 18:17:01 +0100 Subject: [PATCH] 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. --- .cargo/config.toml | 3 + Cargo.lock | 1 + Cargo.toml | 1 + src/app/view.rs | 90 +++++----- src/entities/attribute.rs | 3 +- src/entities/mod.rs | 2 +- src/entities/multileader.rs | 45 ++++- src/entities/table.rs | 78 +++++++-- src/entities/text.rs | 3 +- src/entities/text_support.rs | 25 ++- src/entities/tolerance.rs | 3 +- src/scene/convert/acad_to_truck.rs | 1 + src/scene/convert/tessellate.rs | 260 ++++++++++++++++++++++------- src/scene/pipeline/mod.rs | 36 +++- src/scene/text/complex_lt.rs | 2 +- src/scene/text/font_face.rs | 43 ++++- src/scene/text/lff.rs | 35 ++-- src/scene/text/sysfont.rs | 52 +++++- src/scene/text/ttf_glyph.rs | 53 ++++++ src/ui/overlay.rs | 79 +++++---- 20 files changed, 624 insertions(+), 191 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 7ec6f88e..e137468b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,6 @@ +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "link-arg=-Wl,--exclude-all-symbols"] + # getrandom 0.3 (pulled by ahash via acadrust) needs this cfg to select its # browser backend on wasm32-unknown-unknown, alongside its `wasm_js` feature # (enabled in Cargo.toml for the wasm target). Without it the web build fails diff --git a/Cargo.lock b/Cargo.lock index 474869dc..17d5947a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,7 @@ dependencies = [ "image", "inventory", "js-sys", + "lyon_tessellation", "lzma-sys", "ocs_plugin_api", "open", diff --git a/Cargo.toml b/Cargo.toml index 1ed05e10..ac652098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ ttf-parser = "0.25" # obtain positioned (font, glyph-id) runs; outlines come from ttf-parser and # render through our own wire pipeline. cosmic-text = "0.15" +lyon_tessellation = "1.0.20" [target.'cfg(target_os = "windows")'.dependencies] # Win32_System_Com — COM behind "Set as default app" diff --git a/src/app/view.rs b/src/app/view.rs index dc22ab69..3f40e6a5 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -75,6 +75,46 @@ impl OpenCADStudio { .into() }; + let grid_overlay = { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let model_basis = { + let (o, ux, uy, uz) = tab.ucs_xform().axes(); + (o.as_dvec3(), (ux, uy, uz)) + }; + let grid: Vec = tab + .scene + .grid_views(vw, vh) + .into_iter() + .map(|(bounds, cam, handle)| { + let (origin, axes): (glam::DVec3, _) = if is_paper { + match tab.ucs_from_viewport(handle) { + Some(u) => { + let (o, ux, uy, uz) = + super::helpers::UcsXform::from_ucs(&u).axes(); + (o.as_dvec3(), (ux, uy, uz)) + } + None => ( + glam::DVec3::ZERO, + (glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z), + ), + } + } else { + model_basis + }; + let plane = grid_plane_from_camera(cam.pitch, cam.yaw); + overlay::GridParams { + view_rot: cam.view_proj_rte(bounds), + eye: cam.eye(), + bounds, + plane, + origin, + axes, + } + }) + .collect(); + overlay::grid_overlay(grid) + }; + let selection_overlay = { let sel = tab.scene.selection.borrow().clone(); let snap_info = tab.snap_result.map(|s| (s.screen, s.snap_type)); @@ -151,49 +191,6 @@ impl OpenCADStudio { // the correct place and scale. let vp_bounds = tab.scene.active_model_tile_bounds(vw, vh); - // Each view draws its own grid, clipped to its own bounds, so they - // stay independent: model tiles in model space; the sheet plus each - // floating viewport (clipped to its rectangle) in paper space. One - // enumeration for both, shared with the renderer. - // Align each grid to its pane's UCS (origin in wire space, world - // axis directions): model tiles to the tab's model UCS, each content - // viewport to its own per-viewport UCS, the paper sheet to plain WCS. - let model_basis = { - let (o, ux, uy, uz) = tab.ucs_xform().axes(); - (o.as_dvec3(), (ux, uy, uz)) - }; - let grid: Vec = tab - .scene - .grid_views(vw, vh) - .into_iter() - .map(|(bounds, cam, handle)| { - let (origin, axes): (glam::DVec3, _) = if is_paper { - match tab.ucs_from_viewport(handle) { - Some(u) => { - let (o, ux, uy, uz) = - super::helpers::UcsXform::from_ucs(&u).axes(); - (o.as_dvec3(), (ux, uy, uz)) - } - None => ( - glam::DVec3::ZERO, - (glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z), - ), - } - } else { - model_basis - }; - let plane = grid_plane_from_camera(cam.pitch, cam.yaw); - overlay::GridParams { - view_rot: cam.view_proj_rte(bounds), - eye: cam.eye(), - bounds, - plane, - origin, - axes, - } - }) - .collect(); - // The UCS icon shows the active pane's UCS tripod: the model view, or // (inside a floating viewport) projected through the viewport camera // at the viewport's rect so it tracks the in-viewport UCS. @@ -257,7 +254,6 @@ impl OpenCADStudio { sel, snap_info, grips, - grid, ucs_icon, ost_points, tab.last_cursor_screen, @@ -396,13 +392,14 @@ impl OpenCADStudio { a: 1.0, }; stack![ - container(viewport_3d) + container(grid_overlay) .style(move |_: &Theme| container::Style { background: Some(Background::Color(DESK)), ..Default::default() }) .width(Fill) .height(Fill), + viewport_3d, selection_overlay, viewport_mouse, ] @@ -410,13 +407,14 @@ impl OpenCADStudio { .height(Fill) } else { stack![ - container(viewport_3d) + container(grid_overlay) .style(move |_: &Theme| container::Style { background: Some(Background::Color(bg_color)), ..Default::default() }) .width(Fill) .height(Fill), + viewport_3d, selection_overlay, viewport_mouse, ] diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 561d5694..88fffc08 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -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 diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 0a1d1d93..37fe0518 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -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; diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 5c00fce1..824f2153 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -267,6 +267,7 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option Option Option = 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. diff --git a/src/entities/table.rs b/src/entities/table.rs index cfa8f959..2242c2c8 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -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| -> Option { 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| -> Option { 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 } diff --git a/src/entities/text.rs b/src/entities/text.rs index 6e6bada9..86f833a1 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -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![], diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 842ec3a3..4f971548 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -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 diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index 71f63ec1..14b6d953 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -206,7 +206,7 @@ fn tessellate_tolerance(tol: &Tolerance) -> Vec> { 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![], diff --git a/src/scene/convert/acad_to_truck.rs b/src/scene/convert/acad_to_truck.rs index 60497fc8..fcd5208d 100644 --- a/src/scene/convert/acad_to_truck.rs +++ b/src/scene/convert/acad_to_truck.rs @@ -18,6 +18,7 @@ pub struct TextStroke { pub strokes: Vec>, pub origin: [f64; 2], pub color: Option<[f32; 3]>, + pub fill_tris: Vec<[f32; 2]>, } #[allow(dead_code)] diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 528b78e2..b990304d 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -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 = Vec::new(); let mut bin_first: Vec = 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| -> usize { - if let Some(i) = bins.iter().position(|(k, _, _)| *k == key) { + |key: Option<[f32; 3]>, bins: &mut Vec, firsts: &mut Vec| -> 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 = 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 = 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) => { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 5d097382..ebd8b95d 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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, diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 9abdda8c..85e68cc9 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -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, diff --git a/src/scene/text/font_face.rs b/src/scene/text/font_face.rs index 5df7850f..59de3e81 100644 --- a/src/scene/text/font_face.rs +++ b/src/scene/text/font_face.rs @@ -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); + } + } + } + } } diff --git a/src/scene/text/lff.rs b/src/scene/text/lff.rs index 32e22da5..f10c0a52 100644 --- a/src/scene/text/lff.rs +++ b/src/scene/text/lff.rs @@ -89,6 +89,7 @@ pub struct Glyph { pub strokes: Vec>, /// 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>, 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>, 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::new(); + let mut fill_tris: Vec<[f32; 2]> = Vec::new(); let mut cursor_x: f32 = 0.0; let mut underline: Option = None; let mut overline: Option = 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>| { + let flush_ttf = |seg: &mut String, cursor_x: &mut f32, out: &mut Vec>, 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, diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index 7a6c8a92..8cbf08df 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -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 { + 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 { 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(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Optio pub fn has_family(family: &str) -> bool { face_id(family).is_some() } + diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index c17b10a6..48b5dc17 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -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> { // 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>, + pub fill_tris: Vec<[f32; 2]>, } /// A fully shaped run. @@ -289,9 +336,11 @@ fn build_fallback(ch: char) -> Option> { 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> { 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 { 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, }); } } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index fb283fb9..35a9c7ae 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -119,11 +119,60 @@ pub struct OstTrackPoint { pub screen: Point, } +pub fn grid_overlay<'a>( + grid: Vec, +) -> Element<'a, Message> { + canvas(GridCanvas { grid }) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +struct GridCanvas { + grid: Vec, +} + +impl canvas::Program for GridCanvas { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + _theme: &Theme, + bounds: iced::Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + + for g in &self.grid { + let gb = g.bounds; + let cx0 = gb.x.max(0.0); + let cy0 = gb.y.max(0.0); + let cx1 = (gb.x + gb.width).min(bounds.width); + let cy1 = (gb.y + gb.height).min(bounds.height); + if cx1 <= cx0 || cy1 <= cy0 { + continue; + } + let clip = iced::Rectangle { + x: cx0, + y: cy0, + width: cx1 - cx0, + height: cy1 - cy0, + }; + frame.with_clip(clip, |f| { + draw_grid(f, g.view_rot, g.eye, g.plane, gb, g.origin, g.axes) + }); + } + + vec![frame.into_geometry()] + } +} + pub fn selection_overlay<'a>( selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, - grid: Vec, ucs_icon: Option, ost_points: Vec, cursor_screen: Point, @@ -135,7 +184,6 @@ pub fn selection_overlay<'a>( selection, snap, grips, - grid, ucs_icon, ost_points, cursor_screen, @@ -152,7 +200,6 @@ struct SelectionCanvas { selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, - grid: Vec, ucs_icon: Option, ost_points: Vec, cursor_screen: Point, @@ -313,32 +360,6 @@ impl canvas::Program for SelectionCanvas { } } - // ── Grid display ────────────────────────────────────────────────── - // One entry per pane with its grid on. Each is clipped to its own - // rectangle so lines never spill into neighbouring panes — and the clip - // is intersected with the widget so a zoomed-in viewport whose rectangle - // overflows the canvas never paints grid outside the 3-D view. - for g in &self.grid { - let gb = g.bounds; - let cx0 = gb.x.max(0.0); - let cy0 = gb.y.max(0.0); - let cx1 = (gb.x + gb.width).min(bounds.width); - let cy1 = (gb.y + gb.height).min(bounds.height); - if cx1 <= cx0 || cy1 <= cy0 { - continue; - } - let clip = iced::Rectangle { - x: cx0, - y: cy0, - width: cx1 - cx0, - height: cy1 - cy0, - }; - // Project with the full viewport rect `gb` so the grid stays aligned; - // only the clip is clamped. - frame.with_clip(clip, |f| { - draw_grid(f, g.view_rot, g.eye, g.plane, gb, g.origin, g.axes) - }); - } if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) { let (fill, stroke) = if self.selection.box_crossing {