From 110683f41909f96ce2c068a9206f310345060f10 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 7 Jul 2026 17:01:36 +0300 Subject: [PATCH] =?UTF-8?q?refactor(text):=20per-entity=20SDF=20text=20?= =?UTF-8?q?=E2=80=94=20all=20text=20kinds,=20selection/hover=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rearchitect SDF text from a document-wide collector to per-entity production during tessellation, so each entity owns its glyph quads and composite/nested text is covered for free (no re-explosion, no per-frame document walk). - WireModel gains `text_verts`: glyph quads ride on the wire, so they are cached by the tess memo, cloned on hit, and transformed by the block expand loop exactly like `points`. - Text arm (tessellate.rs) builds the quads when SDF is on and suppresses the strokes; render gathers text_verts from the viewport wire set (`gather_text_verts`, cached on wire content id) — the old collector is removed. Covers model, dimension, table, block-internal and paper-sheet text uniformly. - MLEADER text (lays out via layout_mtext, not the Text arm) gets its own SDF branch driven by the per-run GlyphRun. - Pick box comes from the rendered glyph quads (true text extent), not entity_aabb which mis-places the box for MTEXT; composite paths no longer clobber the empty text wire's tight AABB (dim pick box hugs the text). - Selection / hover highlight via a text-highlight overlay (upload_text_ highlight, keyed on selection_generation) mirroring the selected-wire xray overlay; base glyphs stay neutral so a deselect leaves no stale tint. - Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle. Co-Authored-By: Claude Opus 4.8 --- src/app/update/viewport.rs | 2 + src/entities/dimension.rs | 5 + src/entities/leader.rs | 2 + src/entities/multileader.rs | 77 ++++++++++++- src/entities/table.rs | 1 + src/modules/annotate/aligned_dim.rs | 2 + src/modules/annotate/angular_dim.rs | 1 + src/modules/annotate/diameter_dim.rs | 1 + src/modules/annotate/dim_baseline.rs | 1 + src/modules/annotate/dim_continue.rs | 1 + src/modules/annotate/dimjogline.rs | 1 + src/modules/annotate/dimtedit.rs | 1 + src/modules/annotate/leader_cmd.rs | 1 + src/modules/annotate/linear_dim.rs | 1 + src/modules/annotate/mleader_cmd.rs | 1 + src/modules/annotate/mleader_edit.rs | 1 + src/modules/annotate/ordinate_dim.rs | 1 + src/modules/annotate/qdim.rs | 1 + src/modules/annotate/radius_dim.rs | 1 + src/modules/annotate/table_cmd.rs | 1 + src/modules/annotate/tolerance_cmd.rs | 1 + src/modules/draw/draw/attdef.rs | 1 + src/modules/draw/draw/mline.rs | 1 + src/modules/draw/draw/raster_image.rs | 1 + src/modules/draw/draw/ray.rs | 2 + src/modules/draw/draw/revcloud.rs | 1 + src/modules/draw/draw/wipeout.rs | 2 + src/modules/draw/inquiry/area.rs | 1 + src/modules/draw/inquiry/dist.rs | 1 + src/modules/draw/inquiry/measuregeom.rs | 1 + src/modules/layout/mview.rs | 1 + src/modules/manage/mview.rs | 1 + src/modules/model/primitive_cmd.rs | 1 + src/modules/view/plot_window.rs | 1 + src/modules/view/zoom_window.rs | 1 + src/scene/cache/block_cache.rs | 61 +++++++++- src/scene/convert/tess.rs | 54 +++++++-- src/scene/convert/tessellate.rs | 147 ++++++++++++++++++++++-- src/scene/mod.rs | 11 +- src/scene/model/wire_model.rs | 9 ++ src/scene/pick/hit_test.rs | 43 ++++++- src/scene/pick/xclip.rs | 1 + src/scene/pipeline/mod.rs | 72 +++++++++++- src/scene/text/complex_lt.rs | 1 + src/scene/text/sdf_atlas.rs | 8 ++ src/scene/view/render.rs | 144 ++++++----------------- src/snap.rs | 1 + 47 files changed, 529 insertions(+), 143 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index fa833b75f..5f0714d07 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1166,6 +1166,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { let far_pos = base + dir * far; let far_neg = base - dir * far; let guide = crate::scene::WireModel { + text_verts: Vec::new(), name: "__polar_guide__".into(), points: vec![ [far_neg.x, far_neg.y, far_neg.z], @@ -1199,6 +1200,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { let far_pos = h.base + h.dir * far; let far_neg = h.base - h.dir * far; previews.push(crate::scene::WireModel { + text_verts: Vec::new(), name: "__otrack_guide__".into(), points: vec![ [far_neg.x, far_neg.y, far_neg.z], diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index b142d23ea..6134bf554 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -1345,6 +1345,7 @@ fn tessellate_dimension_inner( let (ext1, ext2) = split_ext_lines(&geom.ext_lines); if !ext1.is_empty() { wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: ext1, points_low: Vec::new(), @@ -1366,6 +1367,7 @@ fn tessellate_dimension_inner( } if !ext2.is_empty() { wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: ext2, points_low: Vec::new(), @@ -1387,6 +1389,7 @@ fn tessellate_dimension_inner( } } else { wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: geom.ext_lines, points_low: Vec::new(), @@ -1409,6 +1412,7 @@ fn tessellate_dimension_inner( } wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: geom.dim_lines, points_low: Vec::new(), @@ -1447,6 +1451,7 @@ fn tessellate_dimension_inner( aci_to_rgba(&c) }; wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: vec![], points_low: Vec::new(), diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 77821f8ee..335c420e5 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -562,6 +562,7 @@ impl LeaderTess for Leader { if verts.len() < 2 { return WireModel { + text_verts: Vec::new(), name, points: vec![], points_low: Vec::new(), @@ -662,6 +663,7 @@ impl LeaderTess for Leader { } WireModel { + text_verts: Vec::new(), name, points, points_low: Vec::new(), diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 0377b0008..ebc7b94ae 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -1284,6 +1284,7 @@ impl MultiLeaderTess for MultiLeader { // WireModels so the renderer respects per-piece coloring. let mut wires: Vec = Vec::new(); wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points, points_low: Vec::new(), @@ -1525,7 +1526,75 @@ impl MultiLeaderTess for MultiLeader { ] }; - if lod_mode == 0 { + if crate::scene::text::sdf_atlas::sdf_text_enabled() { + // SDF text: emit glyph quads instead of strokes (crisp at every + // zoom, so no baseline/greek LOD). `layout_mtext` already placed + // each run at its final position/rotation with a `GlyphRun`, so + // this mirrors the top-level Text arm (anno = 1, origin as-is). + // Base colour stays neutral — selection / hover recolouring is + // done by the text-highlight overlay. + let neutral = color_or_inherit(&ctx.text_color, entity_color); + let mut sdf_verts: Vec = Vec::new(); + if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { + for ts in &layout.strokes { + let Some(run) = &ts.run else { continue }; + let quads = crate::scene::text::glyph_quads::layout_glyph_quads( + &mut atlas, + run.height, + run.rotation, + run.width_factor, + run.oblique, + run.tracking, + &run.font, + &run.text, + ); + crate::scene::pipeline::text_gpu::push_glyph_vertices( + &mut sdf_verts, + &quads, + [ts.origin[0], ts.origin[1], z as f64], + 1.0, + neutral, + 0.0, + ); + } + } + if !sdf_verts.is_empty() { + // Pick box from the glyph quads (f64 accumulate → f32). + let (mut nx, mut ny, mut xx, mut xy) = + (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for v in &sdf_verts { + let x = v.pos[0] as f64 + v.pos_low[0] as f64; + let y = v.pos[1] as f64 + v.pos_low[1] as f64; + nx = nx.min(x); + xx = xx.max(x); + ny = ny.min(y); + xy = xy.max(y); + } + wires.push(WireModel { + text_verts: sdf_verts, + name: name.clone(), + points: vec![], + points_low: Vec::new(), + color: neutral, + selected, + aci: 0, + 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, + )], + tangent_geoms: vec![], + key_vertices: vec![], + aabb: [nx as f32, ny as f32, xx as f32, xy as f32], + plinegen: true, + vp_scissor: None, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } + } else if lod_mode == 0 { // Baseline of the top line only. let line_w = line_widths.first().copied().unwrap_or(0.0); let len_px = world_per_pixel @@ -1536,6 +1605,7 @@ impl MultiLeaderTess for MultiLeader { let p0 = to_wcs(-line_w * h_anchor, line_y_local); let p1 = to_wcs(line_w * (1.0 - h_anchor), line_y_local); wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: vec![p0, p1], points_low: Vec::new(), @@ -1578,6 +1648,7 @@ impl MultiLeaderTess for MultiLeader { } if !greek_tris.is_empty() { wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: vec![], points_low: Vec::new(), @@ -1634,6 +1705,7 @@ impl MultiLeaderTess for MultiLeader { vec![] }; wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: text_points, points_low: Vec::new(), @@ -1660,6 +1732,7 @@ impl MultiLeaderTess for MultiLeader { vec![] }; wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: vec![], points_low: Vec::new(), @@ -1724,6 +1797,7 @@ impl MultiLeaderTess for MultiLeader { wcs_corners[3], ]; wires.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: vec![], points_low: Vec::new(), @@ -1754,6 +1828,7 @@ impl MultiLeaderTess for MultiLeader { wcs_corners[0], ]; wires.push(WireModel { + text_verts: Vec::new(), name, points: frame_points, points_low: Vec::new(), diff --git a/src/entities/table.rs b/src/entities/table.rs index a6b9f01d4..4992a3aed 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -739,6 +739,7 @@ pub fn tessellate_table( let mk = |color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel { WireModel { + text_verts: Vec::new(), name: name.clone(), points, points_low: Vec::new(), diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 182f8ef53..b9bd5e57b 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -183,6 +183,7 @@ impl CadCommand for AlignedDimensionCommand { } }; Some(WireModel { + text_verts: Vec::new(), name: "dimaligned_preview".into(), points: vec![[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z]], points_low: Vec::new(), @@ -224,6 +225,7 @@ fn preview_aligned(p1: Vec3, p2: Vec3, dim_pt: Vec3) -> WireModel { // Show ext lines + dim line. let (d1, d2) = dim_line_endpoints(p1, p2, dim_pt); WireModel { + text_verts: Vec::new(), name: "dimaligned_preview".into(), points: vec![ [p1.x, p1.y, p1.z], diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index cbe3ecad4..04534e994 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -124,6 +124,7 @@ fn v3(pt: Vec3) -> Vector3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + text_verts: Vec::new(), name: "dimangular_preview".to_string(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points_low: Vec::new(), diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 3d17b48d0..ba66c17a8 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -179,6 +179,7 @@ fn v3(p: Vec3) -> Vector3 { fn preview_line(a: Vec3, b: Vec3) -> WireModel { WireModel { + text_verts: Vec::new(), name: "dimdia_preview".into(), points: vec![[a.x, a.y, a.z], [b.x, b.y, b.z]], points_low: Vec::new(), diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index d36fe3be6..3850d2c80 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -169,6 +169,7 @@ impl CadCommand for DimBaselineCommand { let p1 = self.base_p1; let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(pt); Some(WireModel { + text_verts: Vec::new(), name: "dimbase_preview".into(), points: vec![ [p1.x, p1.y, p1.z], diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index 18da01b32..877a0d0e1 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -149,6 +149,7 @@ impl CadCommand for DimContinueCommand { let dim_line_pt = p1 + self.perp * (self.dim_line_perp - p1.dot(self.perp)); let dim_line_pt2 = pt + self.perp * (self.dim_line_perp - pt.dot(self.perp)); Some(WireModel { + text_verts: Vec::new(), name: "dimcont_preview".into(), points: vec![ [p1.x, p1.y, p1.z], diff --git a/src/modules/annotate/dimjogline.rs b/src/modules/annotate/dimjogline.rs index 354694d69..c10f61937 100644 --- a/src/modules/annotate/dimjogline.rs +++ b/src/modules/annotate/dimjogline.rs @@ -85,6 +85,7 @@ impl CadCommand for DimJogLineCommand { } let d = 0.3_f32; Some(WireModel { + text_verts: Vec::new(), name: "dimjog_preview".into(), // Jog zigzag in the XY drawing plane (Z is elevation, ~0). The old // marker varied Z, so in the top-down view it collapsed to a flat diff --git a/src/modules/annotate/dimtedit.rs b/src/modules/annotate/dimtedit.rs index dc5c5e200..098848d6a 100644 --- a/src/modules/annotate/dimtedit.rs +++ b/src/modules/annotate/dimtedit.rs @@ -101,6 +101,7 @@ impl CadCommand for DimTeditCommand { } let d = 0.2_f32; Some(WireModel { + text_verts: Vec::new(), name: "dimtedit_preview".into(), // Marker box in the XY drawing plane (Z is elevation, ~0). The old // box varied Z, so in the top-down view it collapsed to a flat line diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index 2fbf7f208..bc67ca596 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -163,6 +163,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel { points.push([w2.x, w2.y, w2.z]); } WireModel { + text_verts: Vec::new(), name: "leader_preview".into(), points, points_low: Vec::new(), diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index e44ce75e9..0593adaeb 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -218,6 +218,7 @@ fn v3(pt: Vec3) -> Vector3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + text_verts: Vec::new(), name: "dimlinear_preview".to_string(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points_low: Vec::new(), diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 96e5a96f9..5bb130e93 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -149,6 +149,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel { points.push([w2.x, w2.y, w2.z]); } WireModel { + text_verts: Vec::new(), name: "mleader_preview".into(), points, points_low: Vec::new(), diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index e3aae8984..2bf142885 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -456,6 +456,7 @@ impl CadCommand for MLeaderCollectCommand { fn preview_wire(pts: &[Vec3]) -> WireModel { WireModel { + text_verts: Vec::new(), name: "mleader_edit_preview".into(), points: pts.iter().map(|p| [p.x, p.y, p.z]).collect(), points_low: Vec::new(), diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 0e7a8b294..fa69d1105 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -116,6 +116,7 @@ fn ordinate_elbow(feature: Vec3, leader: Vec3, is_x: bool) -> Vec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + text_verts: Vec::new(), name: "dimordinate_preview".into(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points_low: Vec::new(), diff --git a/src/modules/annotate/qdim.rs b/src/modules/annotate/qdim.rs index afd1a81f7..316752fcc 100644 --- a/src/modules/annotate/qdim.rs +++ b/src/modules/annotate/qdim.rs @@ -88,6 +88,7 @@ impl CadCommand for QdimCommand { } let d = 0.5_f32; Some(WireModel { + text_verts: Vec::new(), name: "qdim_preview".into(), points: vec![[pt.x - d * 3.0, pt.y, pt.z], [pt.x + d * 3.0, pt.y, pt.z]], points_low: Vec::new(), diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index af74f8c2e..c86153bae 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -184,6 +184,7 @@ fn v3(pt: Vec3) -> Vector3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + text_verts: Vec::new(), name: "dimradius_preview".to_string(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points_low: Vec::new(), diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index fa77d7718..4afc93503 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -139,6 +139,7 @@ impl CadCommand for TableCommand { let y = pt.y; let z = pt.z; Some(WireModel { + text_verts: Vec::new(), name: "table_preview".into(), points: vec![ [x, y, z], diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index 770a91fb5..5c7b001f2 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -84,6 +84,7 @@ impl CadCommand for ToleranceCommand { } let d = 0.15_f32; Some(WireModel { + text_verts: Vec::new(), name: "tolerance_preview".into(), points: vec![ [pt.x - d, pt.y, pt.z], diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index 3a89ad286..9d778777c 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -160,6 +160,7 @@ impl CadCommand for AttdefCommand { // Show a small cross at the insertion point. let d = 0.15_f32; Some(WireModel { + text_verts: Vec::new(), name: "attdef_preview".into(), points: vec![ [pt.x - d, pt.y, pt.z], diff --git a/src/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index 7c753b8a1..3757d8491 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -131,6 +131,7 @@ impl CadCommand for MlineCommand { let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect(); pts.push([pt.x, pt.y, pt.z]); Some(WireModel { + text_verts: Vec::new(), name: "mline_preview".into(), points: pts, points_low: Vec::new(), diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index bb43e50cc..8766e155d 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -108,6 +108,7 @@ impl CadCommand for ImageCommand { let p3 = [origin.x, origin.y + world_height, origin.z]; Some(WireModel { + text_verts: Vec::new(), name: "image_preview".into(), points: vec![p0, p1, p2, p3, p0], points_low: Vec::new(), diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 601605884..91d0c0474 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -74,6 +74,7 @@ impl CadCommand for RayCommand { let dir = (pt - base).normalize_or_zero(); let far = base + dir * DISPLAY_EXTENT; Some(WireModel { + text_verts: Vec::new(), name: "ray_preview".into(), points: vec![[base.x, base.y, base.z], [far.x, far.y, far.z]], points_low: Vec::new(), @@ -154,6 +155,7 @@ impl CadCommand for XLineCommand { let far_pos = base + dir * DISPLAY_EXTENT; let far_neg = base - dir * DISPLAY_EXTENT; Some(WireModel { + text_verts: Vec::new(), name: "xline_preview".into(), points: vec![ [far_neg.x, far_neg.y, far_neg.z], diff --git a/src/modules/draw/draw/revcloud.rs b/src/modules/draw/draw/revcloud.rs index c2a971995..a769f5e7b 100644 --- a/src/modules/draw/draw/revcloud.rs +++ b/src/modules/draw/draw/revcloud.rs @@ -80,6 +80,7 @@ impl CadCommand for RevCloudCommand { preview_pts.push([pt.x, pt.y, pt.z]); preview_pts.push([self.points[0].x, self.points[0].y, self.points[0].z]); Some(WireModel { + text_verts: Vec::new(), name: "revcloud_preview".into(), points: preview_pts, points_low: Vec::new(), diff --git a/src/modules/draw/draw/wipeout.rs b/src/modules/draw/draw/wipeout.rs index 07eea2f51..0adafd991 100644 --- a/src/modules/draw/draw/wipeout.rs +++ b/src/modules/draw/draw/wipeout.rs @@ -114,6 +114,7 @@ impl CadCommand for WipeoutCommand { let min = p1.min(pt); let max = p1.max(pt); Some(WireModel { + text_verts: Vec::new(), name: "wipeout_preview".into(), points: vec![ [min.x, min.y, min.z], @@ -148,6 +149,7 @@ impl CadCommand for WipeoutCommand { pts.push([pt.x, pt.y, pt.z]); pts.push([self.poly_pts[0].x, self.poly_pts[0].y, self.poly_pts[0].z]); Some(WireModel { + text_verts: Vec::new(), name: "wipeout_poly_preview".into(), points: pts, points_low: Vec::new(), diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 04a8b345f..34d8e32ee 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -69,6 +69,7 @@ impl CadCommand for AreaCommand { pts.push(f(pt)); pts.push(f(self.points[0])); Some(WireModel { + text_verts: Vec::new(), name: "area_preview".into(), points: pts, points_low: Vec::new(), diff --git a/src/modules/draw/inquiry/dist.rs b/src/modules/draw/inquiry/dist.rs index 2daab8df1..da4d5e705 100644 --- a/src/modules/draw/inquiry/dist.rs +++ b/src/modules/draw/inquiry/dist.rs @@ -63,6 +63,7 @@ impl CadCommand for DistCommand { let p1 = self.first?; // The preview wire is purely visual, so f32 vertices are fine here. Some(WireModel { + text_verts: Vec::new(), name: "dist_preview".into(), points: vec![ [p1.x as f32, p1.y as f32, p1.z as f32], diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index 436f3d72b..05a22943a 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -67,6 +67,7 @@ impl MeasureGeomCommand { /// Build a cyan preview wire connecting the picked points and the cursor. fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel { WireModel { + text_verts: Vec::new(), name: name.to_string(), points: pts, points_low: Vec::new(), diff --git a/src/modules/layout/mview.rs b/src/modules/layout/mview.rs index b88c3c878..15680d7fe 100644 --- a/src/modules/layout/mview.rs +++ b/src/modules/layout/mview.rs @@ -80,6 +80,7 @@ impl CadCommand for MviewCommand { fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); let c1 = self.corner1?; Some(WireModel { + text_verts: Vec::new(), name: "mview_preview".to_string(), points: vec![ [c1.x, c1.y, c1.z], diff --git a/src/modules/manage/mview.rs b/src/modules/manage/mview.rs index b7df843bd..326357ee7 100644 --- a/src/modules/manage/mview.rs +++ b/src/modules/manage/mview.rs @@ -74,6 +74,7 @@ impl CadCommand for MviewCommand { fn on_mouse_move(&mut self, pt: Vec3) -> Option { let c1 = self.corner1?; Some(WireModel { + text_verts: Vec::new(), name: "mview_preview".to_string(), points: vec![ [c1.x, c1.y, c1.z], diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 745e5ec93..922156c00 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -305,6 +305,7 @@ inventory::submit!(crate::command::CommandRegistration { fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { WireModel { + text_verts: Vec::new(), name: name.into(), points, points_low: Vec::new(), diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index 5582329cf..ed3ca960c 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -48,6 +48,7 @@ impl CadCommand for PlotWindowCommand { let p1 = self.p1?; // Draw the selection rectangle. Some(WireModel { + text_verts: Vec::new(), name: "plotwindow_preview".into(), points: vec![ [p1.x, p1.y, p1.z], diff --git a/src/modules/view/zoom_window.rs b/src/modules/view/zoom_window.rs index 41181cd34..27590cc61 100644 --- a/src/modules/view/zoom_window.rs +++ b/src/modules/view/zoom_window.rs @@ -65,6 +65,7 @@ impl CadCommand for ZoomWindowCommand { let max = p1.max(pt); // Draw a rectangle preview Some(WireModel { + text_verts: Vec::new(), name: "zoom_window_preview".into(), points: vec![ [min.x, min.y, 0.0], diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index f8751b219..6af8cc9b9 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -38,6 +38,11 @@ pub struct LocalWire { /// Low-bit residual paired with `points` so block-instance wires keep /// sub-f32 precision once the renderer translates them to world space. pub points_low: Vec<[f32; 3]>, + /// SDF glyph quads for block-internal text, in block-local coordinates. + /// Non-empty only when SDF text is on and this sub is a TEXT/MTEXT. The + /// expand-time transform (`emit_wire`) maps each vertex to world exactly + /// like `points`, so block-instance text lands at the right place/scale. + pub text_verts: Vec, pub key_vertices: Vec<[f64; 3]>, pub snap_pts: Vec<(glam::DVec3, SnapHint)>, pub tangent_geoms: Vec, @@ -430,10 +435,18 @@ fn tessellate_sub_local( return vec![]; } - let text_height_local: Option = match sub { - EntityType::Text(t) => Some((t.height * anno_scale as f64) as f32), - EntityType::MText(m) => Some((m.height * anno_scale as f64) as f32), - _ => None, + // With SDF text on, block-internal TEXT/MTEXT renders as glyph quads + // (carried on the wire's `text_verts`), so skip the greek/baseline LOD + // ladder — leaving `text_height_local` None routes the sub to `emit_wire`, + // which transforms and emits those quads. + let text_height_local: Option = if crate::scene::text::sdf_atlas::sdf_text_enabled() { + None + } else { + match sub { + EntityType::Text(t) => Some((t.height * anno_scale as f64) as f32), + EntityType::MText(m) => Some((m.height * anno_scale as f64) as f32), + _ => None, + } }; // Pre-compute the OBB corners (absolute f64) for Text / MText so the greek @@ -470,6 +483,7 @@ fn tessellate_sub_local( result.push(LocalWire { points: wire.points, points_low: wire.points_low, + text_verts: wire.text_verts, key_vertices: wire.key_vertices, snap_pts: wire.snap_pts, tangent_geoms: wire.tangent_geoms, @@ -965,6 +979,8 @@ struct BatchEntry { /// reconstructs `high + low`). Without it absolute f32 fills quantize to /// ~0.5 m and the greek-text rectangles shear. fill_tris_low: Vec<[f32; 3]>, + /// Accumulated SDF glyph quads (world space) for block-instance text. + text_verts: Vec, min_x: f32, min_y: f32, max_x: f32, @@ -1041,6 +1057,7 @@ impl Batches { // the cached defn. let color = crate::scene::view::render::adapt_to_bg(b.color, bg_color); WireModel { + text_verts: b.text_verts, name: name.to_string(), points: b.points, points_low: b.points_low, @@ -1357,7 +1374,7 @@ fn emit_wire( ctx: &ExpandCtx, out: &mut Batches, ) { - if lw.points.is_empty() && lw.fill_tris.is_empty() { + if lw.points.is_empty() && lw.fill_tris.is_empty() && lw.text_verts.is_empty() { return; } @@ -1507,6 +1524,40 @@ fn emit_wire( entry.fill_tris.push([hx, hy, hz]); entry.fill_tris_low.push([lx, ly, lz]); } + // SDF glyph quads: reconstruct each block-local f64 position, apply the + // insert transform, re-split — same path as points/fills so block-instance + // text lands at the right world place and scale. Colour resolves to the + // batch's final colour (ByBlock / layer-0 block text follows the insert). + for tv in &lw.text_verts { + let wx = tv.pos[0] as f64 + tv.pos_low[0] as f64; + let wy = tv.pos[1] as f64 + tv.pos_low[1] as f64; + let wz = tv.pos[2] as f64 + tv.pos_low[2] as f64; + let v = accum_xform.apply(Vector3::new(wx, wy, wz)); + let (hx, lx) = WireModel::split_ds(v.x); + let (hy, ly) = WireModel::split_ds(v.y); + let (hz, lz) = WireModel::split_ds(v.z); + // Grow the batch AABB by the glyph extent so a text-only block wire + // (no points) still finalizes to a bounded pick box. + if hx < entry.min_x { + entry.min_x = hx; + } + if hy < entry.min_y { + entry.min_y = hy; + } + if hx > entry.max_x { + entry.max_x = hx; + } + if hy > entry.max_y { + entry.max_y = hy; + } + entry.text_verts.push(crate::scene::pipeline::text_gpu::TextVertex { + pos: [hx, hy, hz], + pos_low: [lx, ly, lz], + uv: tv.uv, + color: [final_color[0], final_color[1], final_color[2], tv.color[3]], + draw_depth: tv.draw_depth, + }); + } } fn transform_tangent( diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 4ed3e2514..39e3c6880 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -287,7 +287,12 @@ pub(crate) fn tessellate_entity( if !wires.is_empty() { let aabb = entity_aabb(e); for w in &mut wires { - w.aabb = aabb; + // Empty SDF-text cells keep their tight glyph-box + // AABB; only stroke/fill wires take the whole-block + // box as a broad-phase pick hint. + if !w.points.is_empty() || !w.fill_tris.is_empty() { + w.aabb = aabb; + } } return wires; } @@ -315,7 +320,14 @@ pub(crate) fn tessellate_entity( ); for w in &mut wires { w.aci = aci; - w.aabb = aabb; + // The whole-dimension box is a broad-phase hint for stroke/fill + // wires (picked by proximity). An empty SDF-text wire instead + // keeps its own tight glyph-box AABB so the text pick box hugs the + // text — clicking empty space inside the dimension selects nothing, + // only the lines or the text do. + if !w.points.is_empty() || !w.fill_tris.is_empty() { + w.aabb = aabb; + } } return wires; } @@ -334,7 +346,11 @@ pub(crate) fn tessellate_entity( ); for w in &mut wires { w.aci = aci; - w.aabb = aabb; + // As with dimensions: keep the whole-leader box only on stroke/fill + // wires; empty SDF-text wires keep their tight glyph-box AABB. + if !w.points.is_empty() || !w.fill_tris.is_empty() { + w.aabb = aabb; + } } return wires; } @@ -397,7 +413,12 @@ pub(crate) fn tessellate_entity( if !wires.is_empty() { let aabb = entity_aabb(e); for w in &mut wires { - w.aabb = aabb; + // Empty SDF-text cells keep their tight glyph-box + // AABB; only stroke/fill wires take the whole-block + // box as a broad-phase pick hint. + if !w.points.is_empty() || !w.fill_tris.is_empty() { + w.aabb = aabb; + } } return wires; } @@ -437,6 +458,7 @@ pub(crate) fn tessellate_entity( (ins.insert_point.z) as f32, ); let marker = WireModel { + text_verts: Vec::new(), name: h.value().to_string(), points: vec![], points_low: Vec::new(), @@ -614,13 +636,12 @@ pub(crate) fn tessellate_entity( _ => None, }; // SDF text renders crisply at every zoom, so bypass the baseline/greek - // LOD ladder entirely — fall through to the full path, which is itself - // suppressed to an empty (snap-only) wire while SDF is on. - let text_height = if crate::scene::text::sdf_atlas::sdf_text_enabled() { - None - } else { - text_height - }; + // LOD ladder entirely — fall through to the full path, which produces + // the SDF glyph quads (carried on the wire) plus a snap-only wire while + // SDF is on. Applies to every TEXT/MTEXT, top-level or composite. + let sdf_here = crate::scene::text::sdf_atlas::sdf_text_enabled() + && matches!(e, EntityType::Text(_) | EntityType::MText(_)); + let text_height = if sdf_here { None } else { text_height }; if let Some(h_world) = text_height { let h_px = (h_world as f32) / wpp; // Wrap-expanded line count for MText (Text = 1). @@ -658,6 +679,7 @@ pub(crate) fn tessellate_entity( )]; } return vec![WireModel { + text_verts: Vec::new(), name: h.value().to_string(), points: pts, points_low: Vec::new(), @@ -697,6 +719,7 @@ pub(crate) fn tessellate_entity( // hit_test's AABB fallback handles window / crossing. #19. let fill_color = if sel { WireModel::SELECTED } else { entity_color }; return vec![WireModel { + text_verts: Vec::new(), name: h.value().to_string(), points: vec![], points_low: Vec::new(), @@ -733,7 +756,12 @@ pub(crate) fn tessellate_entity( ); for b in &mut bases { b.aci = aci; - b.aabb = aabb; + // SDF text wires carry a glyph-bounds AABB (the true text extent) set + // in the Text arm; entity_aabb would mis-place the pick box for MTEXT, + // so don't clobber it. Every other wire takes the entity AABB. + if b.text_verts.is_empty() { + b.aabb = aabb; + } } // Complex linetypes (with embedded shapes / text) expand the *base* @@ -801,6 +829,7 @@ fn lod_stub_wire( // LOD boundary. #19. let stored_color = if selected { WireModel::SELECTED } else { color }; WireModel { + text_verts: Vec::new(), name, // Diagonal of the entity's 3D AABB so depth tests against // shaded / hidden-line geometry are correct — the stub doesn't @@ -863,6 +892,7 @@ fn lod_stub_wire_3d( } let stored_color = if selected { WireModel::SELECTED } else { color }; WireModel { + text_verts: Vec::new(), name, points, points_low: Vec::new(), diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 5bb014222..bb75a7ad6 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -198,12 +198,16 @@ pub fn tessellate( }; let anno = anno_scale as f64; - // With SDF text on, glyphs render as quads elsewhere — skip - // building the stroke geometry so text is not double-drawn. - // `bins` stays empty, so the empty-wire path below still emits - // the insertion snap point (text stays snappable, no glyph - // strokes polluting snap/pick). - let sdf_text = crate::scene::text::sdf_atlas::sdf_text_enabled(); + // With SDF text on, glyphs render as textured quads carried on + // this entity's own wire (built below) — skip the stroke + // geometry so text is not double-drawn. `bins` stays empty, so + // the empty-wire path emits the insertion snap point and the + // glyph quads. Applies to every TEXT/MTEXT the tessellator + // sees, whether a top-level entity, a dimension's text, or a + // block-internal string — each owns its glyphs, no central + // collector, no re-explosion. + let sdf_text = crate::scene::text::sdf_atlas::sdf_text_enabled() + && matches!(entity, EntityType::Text(_) | EntityType::MText(_)); for group in stroke_groups.iter().filter(|_| !sdf_text) { let lx_v = group.origin[0]; let ly_v = group.origin[1]; @@ -241,6 +245,52 @@ pub fn tessellate( } } + // ── SDF glyph quads ────────────────────────────────────── + // When SDF text is on, build this run's glyph quads here and + // carry them on the wire. They ride with the wire through the + // tess memo, hit-materialisation and (for block content) the + // block-expand transform — no separate document-wide collector. + let mut sdf_verts: Vec = Vec::new(); + if sdf_text { + if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { + // Selection tints the whole run; otherwise inline `\C` + // colours (bin key) win, falling back to entity colour. + for group in &stroke_groups { + let Some(run) = &group.run else { continue }; + let slx_v = (group.origin[0] - ref_lx_v) * anno + ref_lx_v; + let sly_v = (group.origin[1] - ref_ly_v) * anno + ref_ly_v; + // Base colour only (inline `\C` wins). Selection / + // hover recolouring is done by the text-highlight + // overlay, so the base glyphs stay neutral — else a + // deselect would leave stale-tinted glyphs until the + // next geometry rebuild (the base text buffer only + // rebuilds on geometry, not on a pick). + let gcolor = group + .color + .map(|c| [c[0], c[1], c[2], entity_color[3]]) + .unwrap_or(entity_color); + let quads = crate::scene::text::glyph_quads::layout_glyph_quads( + &mut atlas, + run.height, + run.rotation, + run.width_factor, + run.oblique, + run.tracking, + &run.font, + &run.text, + ); + crate::scene::pipeline::text_gpu::push_glyph_vertices( + &mut sdf_verts, + &quads, + [slx_v, sly_v, elev_v], + anno, + gcolor, + 0.0, + ); + } + } + } + let snap_pts = te.snap_pts; let key_vertices: Vec<[f64; 3]> = te .key_vertices @@ -249,9 +299,74 @@ pub fn tessellate( .collect(); // Empty input (no glyphs) → emit a single empty wire so the - // entity still has a hit-test target via snap_pts. + // entity still has a hit-test target via snap_pts. With SDF on + // this is the normal path (strokes suppressed) and the wire + // also carries the glyph quads built above. if bins.is_empty() { - return vec![WireModel { + let mut wires: Vec = Vec::new(); + // Pick box straight from the rendered glyph quads — the true + // text extent. entity_aabb is unreliable for MTEXT (its box + // sits beside the laid-out glyphs), so we derive the AABB + // from `sdf_verts` and stop the generic stamp from + // clobbering it (tess.rs guards on `text_verts`). + let text_aabb = if sdf_text && !sdf_verts.is_empty() { + // Accumulate in f64 (reconstruct high + low) so the box + // stays accurate at UTM scale, then cast the corners to + // f32 once for storage. + let (mut nx, mut ny, mut xx, mut xy) = + (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for v in &sdf_verts { + let x = v.pos[0] as f64 + v.pos_low[0] as f64; + let y = v.pos[1] as f64 + v.pos_low[1] as f64; + nx = nx.min(x); + xx = xx.max(x); + ny = ny.min(y); + xy = xy.max(y); + } + [nx as f32, ny as f32, xx as f32, xy as f32] + } else { + WireModel::UNBOUNDED_AABB + }; + // Debug (env OCS_TEXT_BOX): draw a rectangle around the glyph + // bounds as a separate outline wire so the text box is + // visible for testing. The empty text wire below is left + // untouched (still the SDF + pick target). + if sdf_text + && !sdf_verts.is_empty() + && crate::scene::text::sdf_atlas::text_box_debug() + { + let [nx, ny, xx, xy] = text_aabb; + let (nx, ny, xx, xy) = (nx as f64, ny as f64, xx as f64, xy as f64); + let mut pts = Vec::with_capacity(5); + let mut low = Vec::with_capacity(5); + for (x, y) in [(nx, ny), (xx, ny), (xx, xy), (nx, xy), (nx, ny)] { + let (hh, ll) = split_ds_xyz(x, y, elev_v); + pts.push(hh); + low.push(ll); + } + wires.push(WireModel { + text_verts: Vec::new(), + name: name.clone(), + points: pts, + points_low: low, + color: [1.0, 0.0, 1.0, 1.0], + selected, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + vp_scissor: None, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } + wires.push(WireModel { + text_verts: sdf_verts, name, points: Vec::new(), points_low: Vec::new(), @@ -264,12 +379,13 @@ pub fn tessellate( tangent_geoms: te.tangent_geoms, aci: 0, key_vertices, - aabb: WireModel::UNBOUNDED_AABB, + aabb: text_aabb, plinegen: true, vp_scissor: None, fill_tris: vec![], fill_tris_low: Vec::new(), - }]; + }); + return wires; } let mut out: Vec = Vec::new(); @@ -292,6 +408,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: bin.pts, points_low: bin.pts_low, @@ -324,6 +441,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: Vec::new(), points_low: Vec::new(), @@ -347,6 +465,7 @@ pub fn tessellate( if out.is_empty() { out.push(WireModel { + text_verts: Vec::new(), name, points: Vec::new(), points_low: Vec::new(), @@ -391,6 +510,7 @@ pub fn tessellate( .map(|[kx, ky, kz]| [kx, ky, kz]) .collect(); return vec![WireModel { + text_verts: Vec::new(), name, points: vec![ [x - s, y, z], @@ -434,6 +554,7 @@ pub fn tessellate( .map(|[x, y, z]| [x, y, z]) .collect(); return vec![WireModel { + text_verts: Vec::new(), name, points, points_low, @@ -466,6 +587,7 @@ pub fn tessellate( .map(|[x, y, z]| [x, y, z]) .collect(); return vec![WireModel { + text_verts: Vec::new(), name, points, points_low, @@ -517,6 +639,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: local_pts, points_low: local_pts_low, @@ -548,6 +671,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + text_verts: Vec::new(), name: name.clone(), points: Vec::new(), points_low: Vec::new(), @@ -570,6 +694,7 @@ pub fn tessellate( if out.is_empty() { out.push(WireModel { + text_verts: Vec::new(), name, points: Vec::new(), points_low: Vec::new(), @@ -602,6 +727,7 @@ pub fn tessellate( .map(|[x, y, z]| [x, y, z]) .collect(); return vec![WireModel { + text_verts: Vec::new(), name, points: local_pts, points_low: local_pts_low, @@ -665,6 +791,7 @@ pub fn tessellate( let snap_pts: Vec<(glam::DVec3, SnapHint)> = snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect(); vec![WireModel { + text_verts: Vec::new(), name, points, points_low, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e69ccd290..7ccae3e2e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -710,11 +710,14 @@ pub struct Scene { pub(crate) model_tiles: RefCell>, /// Index of the active model tile (camera input + overlays target it). pub(crate) active_model_tile: std::cell::Cell, - /// Cache of the SDF text vertex list, keyed on `(geometry_epoch, - /// anno_scale.to_bits())`, so the per-frame collector is not re-laid-out on - /// pan / zoom. See [`Scene::sdf_text_vertices`]. + /// Cache of the gathered SDF text vertex list for one viewport, keyed on + /// the wire-buffer content id. The glyph quads ride on each entity's wire + /// (built by the tessellator); this caches the per-viewport gather so it is + /// not re-walked every frame while the wire set is unchanged. Keyed on the + /// wire content id so it re-gathers exactly when the wires (geometry or + /// selection) change. See [`Scene::gather_text_verts`]. sdf_text_cache: - RefCell>)>>, + RefCell>)>>, /// pane_grid layout tree for the Model tab — the source of truth for the /// tile split layout, resize and focus. `model_tiles` (the renderer's /// per-pane data: camera / render-mode / grid) is kept in lock-step with diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 1a27ee89b..c8ae9ddcb 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -82,6 +82,13 @@ pub struct WireModel { /// Low residual paired with [`fill_tris`] (double-single). Empty = all-zero; /// tessellation from CAD f64 fills it so fills stay precise at UTM scale. pub fill_tris_low: Vec<[f32; 3]>, + /// SDF glyph quads for this entity's text (TEXT / MTEXT / dimension text / + /// block-internal text). Non-empty only when SDF text is enabled and this + /// wire carries a text run. Rides with the wire so it is cached by the + /// tess memo, cloned on hit, and transformed by the block-expand loop + /// exactly like `points` — no separate collector pass. The renderer + /// gathers these across all wires into the text vertex buffer. + pub text_verts: Vec, } impl WireModel { @@ -129,6 +136,7 @@ impl WireModel { /// Create a solid wire (no dash pattern, 1px weight). pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self { Self { + text_verts: Vec::new(), name, points, points_low: Vec::new(), @@ -261,6 +269,7 @@ impl WireModel { impl Default for WireModel { fn default() -> Self { Self { + text_verts: Vec::new(), name: String::new(), points: Vec::new(), points_low: Vec::new(), diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 345152564..f1d55f0b3 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -134,7 +134,48 @@ pub fn click_hit<'a>( } } } - best_fill.map(|(_, n)| n) + if let Some((_, n)) = best_fill { + return Some(n); + } + + // SDF text renders as glyph quads, not strokes — its only pick target is + // the empty wire that carries the quads (`text_verts`) and the text's own + // tight AABB. Fall back to AABB containment so such text stays click- + // selectable, and use `text_verts` as the exact discriminator so only real + // text boxes qualify (never some other empty wire). Lowest priority — real + // edges and fills above always win. Prefer the tightest box so a click over + // overlapping text picks the smallest. + if crate::scene::text::sdf_atlas::sdf_text_enabled() { + let mut best_area = f32::MAX; + let mut best_box: Option<&str> = None; + for wire in wires { + if wire.text_verts.is_empty() + || !wire.points.is_empty() + || !wire.fill_tris.is_empty() + || wire.aabb == WireModel::UNBOUNDED_AABB + { + continue; + } + let [minx, miny, maxx, maxy] = wire.aabb; + let (mut sx0, mut sy0, mut sx1, mut sy1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] { + let s = world_to_screen(glam::DVec3::new(cx as f64, cy as f64, 0.0), view_rot, eye, bounds); + sx0 = sx0.min(s.x); + sx1 = sx1.max(s.x); + sy0 = sy0.min(s.y); + sy1 = sy1.max(s.y); + } + if cursor.x >= sx0 && cursor.x <= sx1 && cursor.y >= sy0 && cursor.y <= sy1 { + let area = (sx1 - sx0) * (sy1 - sy0); + if area < best_area { + best_area = area; + best_box = Some(wire.name.as_str()); + } + } + } + return best_box; + } + None } /// Like `click_hit` but returns every wire within the click threshold, diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 4d0298e3f..9817676fa 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -610,6 +610,7 @@ mod tests { // A polyline half inside, half outside the 0..10 square. let mut wires = vec![WireModel { + text_verts: Vec::new(), points: vec![[5.0, 5.0, 0.0], [15.0, 5.0, 0.0]], ..Default::default() }]; diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index d0dad43a2..a40b0a8c6 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -85,6 +85,11 @@ pub struct Pipeline { /// All glyph-quad vertices for the frame, one buffer, `None` when empty. text_vbuf: Option, text_vcount: u32, + /// Tinted glyph quads of just the selected / hovered text, drawn over the + /// base text pass so a selection / rollover recolours the glyphs (the text + /// analogue of the selected-wire xray overlay). Rebuilt on selection change. + text_highlight_vbuf: Option, + text_highlight_vcount: u32, depth_texture_size: Size, depth_view: wgpu::TextureView, /// 4× MSAA color buffer for the main drawing passes. @@ -1146,6 +1151,8 @@ impl Pipeline { text_atlas_gpu: None, text_vbuf: None, text_vcount: 0, + text_highlight_vbuf: None, + text_highlight_vcount: 0, mesh_pipeline, mesh_transparent_pipeline, mesh_highlight_pipeline, @@ -1306,6 +1313,49 @@ impl Pipeline { WireGpu::from_run(device, &out, depth_map, None, false, self.wire_const_bgl.as_ref()); } + /// Build the text-highlight overlay: the glyph quads of just the selected / + /// hovered text, recoloured (selection blue, hover orange) and drawn over + /// the base text pass. Uses the same handle→wire index as the selected-wire + /// overlay, so it is O(highlighted). Empty when nothing is highlighted. + pub fn upload_text_highlight( + &mut self, + device: &wgpu::Device, + wires: &[WireModel], + selected: &rustc_hash::FxHashSet, + hover: Option, + ) { + let hover = hover.filter(|h| !selected.contains(h)); + if selected.is_empty() && hover.is_none() { + self.text_highlight_vbuf = None; + self.text_highlight_vcount = 0; + return; + } + let mut out: Vec = Vec::new(); + let push = + |handle_val: u64, tint: [f32; 4], wires: &[WireModel], out: &mut Vec| { + if let Some(idxs) = self.wire_handle_index.get(&handle_val) { + for &i in idxs { + if let Some(w) = wires.get(i as usize) { + for v in &w.text_verts { + out.push(text_gpu::TextVertex { + color: [tint[0], tint[1], tint[2], v.color[3]], + ..*v + }); + } + } + } + } + }; + for h in selected { + push(h.value(), WireModel::SELECTED, wires, &mut out); + } + if let Some(h) = hover { + push(h.value(), WireModel::HOVER, wires, &mut out); + } + self.text_highlight_vcount = out.len() as u32; + self.text_highlight_vbuf = text_gpu::upload_vertices(device, &out); + } + /// Upload the live overlay (command preview / interim / grip-drag) wires. /// Small and refreshed each frame they're present; kept separate from the /// base wire buffer so a drag never re-uploads the resident base set. @@ -2066,8 +2116,12 @@ impl Pipeline { } // ── Pass 5c: SDF text quads (drawn over wires) ──────────────────── - if let (Some(vbuf), Some(atlas)) = (&self.text_vbuf, &self.text_atlas_gpu) { - if self.text_vcount > 0 { + // The highlight buffer (selected / hovered text, tinted) is drawn in + // the same pass right after the base text so it recolours those glyphs. + if let Some(atlas) = &self.text_atlas_gpu { + let have_base = self.text_vbuf.is_some() && self.text_vcount > 0; + let have_hl = self.text_highlight_vbuf.is_some() && self.text_highlight_vcount > 0; + if have_base || have_hl { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("text.render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -2094,8 +2148,18 @@ impl Pipeline { pass.set_pipeline(&self.text_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_bind_group(1, &atlas.bind_group, &[]); - pass.set_vertex_buffer(0, vbuf.slice(..)); - pass.draw(0..self.text_vcount, 0..1); + if let Some(vbuf) = &self.text_vbuf { + if self.text_vcount > 0 { + pass.set_vertex_buffer(0, vbuf.slice(..)); + pass.draw(0..self.text_vcount, 0..1); + } + } + if let Some(hlbuf) = &self.text_highlight_vbuf { + if self.text_highlight_vcount > 0 { + pass.set_vertex_buffer(0, hlbuf.slice(..)); + pass.draw(0..self.text_highlight_vcount, 0..1); + } + } } } diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index d89ad0ed2..b9818d926 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -208,6 +208,7 @@ pub fn apply_along( .into_iter() .filter(|s| s.len() >= 2) .map(|pts| WireModel { + text_verts: Vec::new(), name: name.to_string(), points: pts, points_low: Vec::new(), diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index d8b3b7242..40b2c6b59 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -47,6 +47,14 @@ pub fn sdf_text_enabled() -> bool { *ON.get_or_init(|| std::env::var_os("OCS_TEXT_SDF").is_some()) } +/// Debug: when env `OCS_TEXT_BOX` is set, each SDF text run also emits a +/// rectangle outline around its glyph bounds so the text pick box is visible. +/// Read once. Only meaningful together with [`sdf_text_enabled`]. +pub fn text_box_debug() -> bool { + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("OCS_TEXT_BOX").is_some()) +} + // ── Bake configuration ────────────────────────────────────────────────────── /// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 75dc8f5e2..542cb7bdf 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -345,6 +345,15 @@ impl shader::Primitive for Primitive { vp.hover_handle, &vp.draw_depths, ); + // Text highlight rides the same selection key: a pick / rollover + // recolours the selected / hovered glyphs without touching the + // base text buffer. + inner.upload_text_highlight( + device, + &vp.wires[..], + &vp.selected_handles, + vp.hover_handle, + ); inner.cached_selection = sel_key; } // Batched solid meshes — geometry-only, so they ride the geometry @@ -775,120 +784,42 @@ pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { // ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ── impl Scene { - /// SDF text-quad vertices for the current document's TEXT entities. Behind - /// the `OCS_TEXT_SDF` env flag (Phase 2b bring-up) so the default render - /// path is untouched; empty otherwise. Glyphs are baked into the shared - /// atlas as a side effect. Both the stroke path and this share - /// `text_run_placement`, so quads land exactly where the strokes do. - #[allow(dead_code)] // consumed by the text render pass in the GPU integration step - pub(in crate::scene) fn sdf_text_vertices( + /// Gather the SDF glyph quads carried on a viewport's wire set into one + /// flat vertex list for the text render pass. The tessellator attaches the + /// quads to each entity's own wire (and the block-expand loop transforms + /// block-instance quads to world), so gathering is a cheap walk. Cached on + /// `wire_content_id` — the wire-buffer content id — so an unchanged wire + /// set (pan / zoom) is walked once, not every frame; the id changes when + /// geometry or selection rebuilds the wires, re-tinting selected glyphs. + /// Empty when SDF text is disabled. + pub(in crate::scene) fn gather_text_verts( &self, + wires: &[WireModel], + wire_content_id: u64, ) -> std::sync::Arc> { use std::sync::Arc; if !crate::scene::text::sdf_atlas::sdf_text_enabled() { return Arc::new(Vec::new()); } - // Cache keyed on (geometry_epoch, annotation scale): the run layout only - // changes when geometry or the scale changes, so pan / zoom reuses it - // instead of re-laying-out every glyph each frame. - let anno = if self.current_layout == "Model" { - self.annotation_scale - } else { - 1.0 - }; - let key = (self.geometry_epoch, anno.to_bits()); { let cache = self.sdf_text_cache.borrow(); - if let Some((epoch, anno_bits, verts)) = cache.as_ref() { - if *epoch == key.0 && *anno_bits == key.1 { + if let Some((id, verts)) = cache.as_ref() { + if *id == wire_content_id { return verts.clone(); } } } - let verts = Arc::new(self.sdf_text_vertices_enabled(true)); - *self.sdf_text_cache.borrow_mut() = Some((key.0, key.1, verts.clone())); + let mut out: Vec = Vec::new(); + for w in wires { + if !w.text_verts.is_empty() { + out.extend_from_slice(&w.text_verts); + } + } + let verts = Arc::new(out); + *self.sdf_text_cache.borrow_mut() = Some((wire_content_id, verts.clone())); verts } - #[allow(dead_code)] - fn sdf_text_vertices_enabled( - &self, - enabled: bool, - ) -> Vec { - use crate::scene::convert::acad_to_truck::{convert, TruckObject}; - use crate::scene::convert::tessellate::entity_z; - use crate::scene::pipeline::text_gpu; - use crate::scene::text::{glyph_quads, sdf_atlas}; - if !enabled { - return Vec::new(); - } - let Ok(mut atlas) = sdf_atlas::text_atlas().lock() else { - return Vec::new(); - }; - // Annotation scale matches the stroke path (tessellate): model-space - // text scales, paper/layout text does not. - let anno = if self.current_layout == "Model" { - self.annotation_scale as f64 - } else { - 1.0 - }; - let mut out = Vec::new(); - for e in self.document.entities() { - // Only text-producing entities — converting solids/hatches here - // would re-tessellate the whole document every frame. - if !matches!( - e, - acadrust::EntityType::Text(_) | acadrust::EntityType::MText(_) - ) { - continue; - } - let Some(te) = convert(e, &self.document) else { - continue; - }; - let TruckObject::Text(groups) = te.object else { - continue; - }; - let base_color = self.render_style(e).0; - let elev = entity_z(e) as f64; - // Annotation scale anchors at the first run's origin (matches the - // stroke path so multi-line MText spreads identically). - let ref_origin = groups.first().map(|g| g.origin).unwrap_or([0.0, 0.0]); - for g in &groups { - let Some(run) = &g.run else { - continue; - }; - let slx = (g.origin[0] - ref_origin[0]) * anno + ref_origin[0]; - let sly = (g.origin[1] - ref_origin[1]) * anno + ref_origin[1]; - let color = g - .color - .map(|c| [c[0], c[1], c[2], 1.0]) - .unwrap_or(base_color); - let quads = glyph_quads::layout_glyph_quads( - &mut atlas, - run.height, - run.rotation, - run.width_factor, - run.oblique, - run.tracking, - &run.font, - &run.text, - ); - // `anno` scales the run-local quads; `slx/sly` is the - // annotation-scaled run origin — exactly as tessellate places - // the stroke geometry. - text_gpu::push_glyph_vertices( - &mut out, - &quads, - [slx, sly, elev], - anno, - color, - 0.0, - ); - } - } - out - } - /// Build the unified multi-viewport `Primitive` for the current layout. /// Model layout → one full-window viewport (more once tiled); paper /// layout → one viewport per floating content viewport. Each entry is @@ -1170,13 +1101,14 @@ impl Scene { self.meshes_arc() }; - // SDF text quads (Phase 2b, behind OCS_TEXT_SDF). Model/content only — - // the paper sheet must not draw model-space text onto the sheet. - let text_verts = if inst.paper_sheet { - Arc::new(Vec::new()) - } else { - self.sdf_text_vertices() - }; + // SDF text quads (behind OCS_TEXT_SDF). The glyph quads ride on each + // entity's own wire (produced by the tessellator, transformed for + // block instances by the block-expand loop), so here we simply gather + // them from this viewport's wire set. This covers model text, block- + // internal text and the paper sheet's own annotation alike — each set + // draws only the text that belongs to it. Cached on the wire content + // id so an unchanged wire set is not re-walked every frame. + let text_verts = self.gather_text_verts(&all_wires, wire_content_id); Some(ViewportData { wires: all_wires, preview_wires, diff --git a/src/snap.rs b/src/snap.rs index cac31adde..9e3c8c387 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1832,6 +1832,7 @@ mod ext_tests { // A single line segment (0,0)-(10,0): its endpoints are vertices, its // midpoint and any extension foot are not. let wire = WireModel { + text_verts: Vec::new(), points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]], ..Default::default() };