refactor(text): per-entity SDF text — all text kinds, selection/hover highlight

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-07 17:01:36 +03:00
commit 110683f419
47 changed files with 528 additions and 142 deletions

View file

@ -1166,6 +1166,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let far_pos = base + dir * far; let far_pos = base + dir * far;
let far_neg = base - dir * far; let far_neg = base - dir * far;
let guide = crate::scene::WireModel { let guide = crate::scene::WireModel {
text_verts: Vec::new(),
name: "__polar_guide__".into(), name: "__polar_guide__".into(),
points: vec![ points: vec![
[far_neg.x, far_neg.y, far_neg.z], [far_neg.x, far_neg.y, far_neg.z],
@ -1199,6 +1200,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let far_pos = h.base + h.dir * far; let far_pos = h.base + h.dir * far;
let far_neg = h.base - h.dir * far; let far_neg = h.base - h.dir * far;
previews.push(crate::scene::WireModel { previews.push(crate::scene::WireModel {
text_verts: Vec::new(),
name: "__otrack_guide__".into(), name: "__otrack_guide__".into(),
points: vec![ points: vec![
[far_neg.x, far_neg.y, far_neg.z], [far_neg.x, far_neg.y, far_neg.z],

View file

@ -1345,6 +1345,7 @@ fn tessellate_dimension_inner(
let (ext1, ext2) = split_ext_lines(&geom.ext_lines); let (ext1, ext2) = split_ext_lines(&geom.ext_lines);
if !ext1.is_empty() { if !ext1.is_empty() {
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: ext1, points: ext1,
points_low: Vec::new(), points_low: Vec::new(),
@ -1366,6 +1367,7 @@ fn tessellate_dimension_inner(
} }
if !ext2.is_empty() { if !ext2.is_empty() {
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: ext2, points: ext2,
points_low: Vec::new(), points_low: Vec::new(),
@ -1387,6 +1389,7 @@ fn tessellate_dimension_inner(
} }
} else { } else {
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: geom.ext_lines, points: geom.ext_lines,
points_low: Vec::new(), points_low: Vec::new(),
@ -1409,6 +1412,7 @@ fn tessellate_dimension_inner(
} }
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: geom.dim_lines, points: geom.dim_lines,
points_low: Vec::new(), points_low: Vec::new(),
@ -1447,6 +1451,7 @@ fn tessellate_dimension_inner(
aci_to_rgba(&c) aci_to_rgba(&c)
}; };
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: vec![], points: vec![],
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -562,6 +562,7 @@ impl LeaderTess for Leader {
if verts.len() < 2 { if verts.len() < 2 {
return WireModel { return WireModel {
text_verts: Vec::new(),
name, name,
points: vec![], points: vec![],
points_low: Vec::new(), points_low: Vec::new(),
@ -662,6 +663,7 @@ impl LeaderTess for Leader {
} }
WireModel { WireModel {
text_verts: Vec::new(),
name, name,
points, points,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -1284,6 +1284,7 @@ impl MultiLeaderTess for MultiLeader {
// WireModels so the renderer respects per-piece coloring. // WireModels so the renderer respects per-piece coloring.
let mut wires: Vec<WireModel> = Vec::new(); let mut wires: Vec<WireModel> = Vec::new();
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points, points,
points_low: Vec::new(), 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<crate::scene::pipeline::text_gpu::TextVertex> = 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. // Baseline of the top line only.
let line_w = line_widths.first().copied().unwrap_or(0.0); let line_w = line_widths.first().copied().unwrap_or(0.0);
let len_px = world_per_pixel 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 p0 = to_wcs(-line_w * h_anchor, line_y_local);
let p1 = to_wcs(line_w * (1.0 - h_anchor), line_y_local); let p1 = to_wcs(line_w * (1.0 - h_anchor), line_y_local);
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: vec![p0, p1], points: vec![p0, p1],
points_low: Vec::new(), points_low: Vec::new(),
@ -1578,6 +1648,7 @@ impl MultiLeaderTess for MultiLeader {
} }
if !greek_tris.is_empty() { if !greek_tris.is_empty() {
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: vec![], points: vec![],
points_low: Vec::new(), points_low: Vec::new(),
@ -1634,6 +1705,7 @@ impl MultiLeaderTess for MultiLeader {
vec![] vec![]
}; };
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: text_points, points: text_points,
points_low: Vec::new(), points_low: Vec::new(),
@ -1660,6 +1732,7 @@ impl MultiLeaderTess for MultiLeader {
vec![] vec![]
}; };
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: vec![], points: vec![],
points_low: Vec::new(), points_low: Vec::new(),
@ -1724,6 +1797,7 @@ impl MultiLeaderTess for MultiLeader {
wcs_corners[3], wcs_corners[3],
]; ];
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: vec![], points: vec![],
points_low: Vec::new(), points_low: Vec::new(),
@ -1754,6 +1828,7 @@ impl MultiLeaderTess for MultiLeader {
wcs_corners[0], wcs_corners[0],
]; ];
wires.push(WireModel { wires.push(WireModel {
text_verts: Vec::new(),
name, name,
points: frame_points, points: frame_points,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -739,6 +739,7 @@ pub fn tessellate_table(
let mk = let mk =
|color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel { |color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points, points,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -183,6 +183,7 @@ impl CadCommand for AlignedDimensionCommand {
} }
}; };
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "dimaligned_preview".into(), name: "dimaligned_preview".into(),
points: vec![[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z]], points: vec![[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z]],
points_low: Vec::new(), points_low: Vec::new(),
@ -224,6 +225,7 @@ fn preview_aligned(p1: Vec3, p2: Vec3, dim_pt: Vec3) -> WireModel {
// Show ext lines + dim line. // Show ext lines + dim line.
let (d1, d2) = dim_line_endpoints(p1, p2, dim_pt); let (d1, d2) = dim_line_endpoints(p1, p2, dim_pt);
WireModel { WireModel {
text_verts: Vec::new(),
name: "dimaligned_preview".into(), name: "dimaligned_preview".into(),
points: vec![ points: vec![
[p1.x, p1.y, p1.z], [p1.x, p1.y, p1.z],

View file

@ -124,6 +124,7 @@ fn v3(pt: Vec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel { fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: "dimangular_preview".to_string(), name: "dimangular_preview".to_string(),
points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(),
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -179,6 +179,7 @@ fn v3(p: Vec3) -> Vector3 {
fn preview_line(a: Vec3, b: Vec3) -> WireModel { fn preview_line(a: Vec3, b: Vec3) -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: "dimdia_preview".into(), name: "dimdia_preview".into(),
points: vec![[a.x, a.y, a.z], [b.x, b.y, b.z]], points: vec![[a.x, a.y, a.z], [b.x, b.y, b.z]],
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -169,6 +169,7 @@ impl CadCommand for DimBaselineCommand {
let p1 = self.base_p1; let p1 = self.base_p1;
let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(pt); let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(pt);
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "dimbase_preview".into(), name: "dimbase_preview".into(),
points: vec![ points: vec![
[p1.x, p1.y, p1.z], [p1.x, p1.y, p1.z],

View file

@ -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_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)); let dim_line_pt2 = pt + self.perp * (self.dim_line_perp - pt.dot(self.perp));
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "dimcont_preview".into(), name: "dimcont_preview".into(),
points: vec![ points: vec![
[p1.x, p1.y, p1.z], [p1.x, p1.y, p1.z],

View file

@ -85,6 +85,7 @@ impl CadCommand for DimJogLineCommand {
} }
let d = 0.3_f32; let d = 0.3_f32;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "dimjog_preview".into(), name: "dimjog_preview".into(),
// Jog zigzag in the XY drawing plane (Z is elevation, ~0). The old // 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 // marker varied Z, so in the top-down view it collapsed to a flat

View file

@ -101,6 +101,7 @@ impl CadCommand for DimTeditCommand {
} }
let d = 0.2_f32; let d = 0.2_f32;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "dimtedit_preview".into(), name: "dimtedit_preview".into(),
// Marker box in the XY drawing plane (Z is elevation, ~0). The old // 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 // box varied Z, so in the top-down view it collapsed to a flat line

View file

@ -163,6 +163,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
points.push([w2.x, w2.y, w2.z]); points.push([w2.x, w2.y, w2.z]);
} }
WireModel { WireModel {
text_verts: Vec::new(),
name: "leader_preview".into(), name: "leader_preview".into(),
points, points,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -218,6 +218,7 @@ fn v3(pt: Vec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel { fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: "dimlinear_preview".to_string(), name: "dimlinear_preview".to_string(),
points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(),
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -149,6 +149,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
points.push([w2.x, w2.y, w2.z]); points.push([w2.x, w2.y, w2.z]);
} }
WireModel { WireModel {
text_verts: Vec::new(),
name: "mleader_preview".into(), name: "mleader_preview".into(),
points, points,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -456,6 +456,7 @@ impl CadCommand for MLeaderCollectCommand {
fn preview_wire(pts: &[Vec3]) -> WireModel { fn preview_wire(pts: &[Vec3]) -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: "mleader_edit_preview".into(), name: "mleader_edit_preview".into(),
points: pts.iter().map(|p| [p.x, p.y, p.z]).collect(), points: pts.iter().map(|p| [p.x, p.y, p.z]).collect(),
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -116,6 +116,7 @@ fn ordinate_elbow(feature: Vec3, leader: Vec3, is_x: bool) -> Vec3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel { fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: "dimordinate_preview".into(), name: "dimordinate_preview".into(),
points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(),
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -88,6 +88,7 @@ impl CadCommand for QdimCommand {
} }
let d = 0.5_f32; let d = 0.5_f32;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "qdim_preview".into(), 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: vec![[pt.x - d * 3.0, pt.y, pt.z], [pt.x + d * 3.0, pt.y, pt.z]],
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -184,6 +184,7 @@ fn v3(pt: Vec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel { fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: "dimradius_preview".to_string(), name: "dimradius_preview".to_string(),
points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(),
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -139,6 +139,7 @@ impl CadCommand for TableCommand {
let y = pt.y; let y = pt.y;
let z = pt.z; let z = pt.z;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "table_preview".into(), name: "table_preview".into(),
points: vec![ points: vec![
[x, y, z], [x, y, z],

View file

@ -84,6 +84,7 @@ impl CadCommand for ToleranceCommand {
} }
let d = 0.15_f32; let d = 0.15_f32;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "tolerance_preview".into(), name: "tolerance_preview".into(),
points: vec![ points: vec![
[pt.x - d, pt.y, pt.z], [pt.x - d, pt.y, pt.z],

View file

@ -160,6 +160,7 @@ impl CadCommand for AttdefCommand {
// Show a small cross at the insertion point. // Show a small cross at the insertion point.
let d = 0.15_f32; let d = 0.15_f32;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "attdef_preview".into(), name: "attdef_preview".into(),
points: vec![ points: vec![
[pt.x - d, pt.y, pt.z], [pt.x - d, pt.y, pt.z],

View file

@ -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(); 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]); pts.push([pt.x, pt.y, pt.z]);
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "mline_preview".into(), name: "mline_preview".into(),
points: pts, points: pts,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -108,6 +108,7 @@ impl CadCommand for ImageCommand {
let p3 = [origin.x, origin.y + world_height, origin.z]; let p3 = [origin.x, origin.y + world_height, origin.z];
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "image_preview".into(), name: "image_preview".into(),
points: vec![p0, p1, p2, p3, p0], points: vec![p0, p1, p2, p3, p0],
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -74,6 +74,7 @@ impl CadCommand for RayCommand {
let dir = (pt - base).normalize_or_zero(); let dir = (pt - base).normalize_or_zero();
let far = base + dir * DISPLAY_EXTENT; let far = base + dir * DISPLAY_EXTENT;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "ray_preview".into(), name: "ray_preview".into(),
points: vec![[base.x, base.y, base.z], [far.x, far.y, far.z]], points: vec![[base.x, base.y, base.z], [far.x, far.y, far.z]],
points_low: Vec::new(), points_low: Vec::new(),
@ -154,6 +155,7 @@ impl CadCommand for XLineCommand {
let far_pos = base + dir * DISPLAY_EXTENT; let far_pos = base + dir * DISPLAY_EXTENT;
let far_neg = base - dir * DISPLAY_EXTENT; let far_neg = base - dir * DISPLAY_EXTENT;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "xline_preview".into(), name: "xline_preview".into(),
points: vec![ points: vec![
[far_neg.x, far_neg.y, far_neg.z], [far_neg.x, far_neg.y, far_neg.z],

View file

@ -80,6 +80,7 @@ impl CadCommand for RevCloudCommand {
preview_pts.push([pt.x, pt.y, pt.z]); preview_pts.push([pt.x, pt.y, pt.z]);
preview_pts.push([self.points[0].x, self.points[0].y, self.points[0].z]); preview_pts.push([self.points[0].x, self.points[0].y, self.points[0].z]);
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "revcloud_preview".into(), name: "revcloud_preview".into(),
points: preview_pts, points: preview_pts,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -114,6 +114,7 @@ impl CadCommand for WipeoutCommand {
let min = p1.min(pt); let min = p1.min(pt);
let max = p1.max(pt); let max = p1.max(pt);
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "wipeout_preview".into(), name: "wipeout_preview".into(),
points: vec![ points: vec![
[min.x, min.y, min.z], [min.x, min.y, min.z],
@ -148,6 +149,7 @@ impl CadCommand for WipeoutCommand {
pts.push([pt.x, pt.y, pt.z]); 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]); pts.push([self.poly_pts[0].x, self.poly_pts[0].y, self.poly_pts[0].z]);
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "wipeout_poly_preview".into(), name: "wipeout_poly_preview".into(),
points: pts, points: pts,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -69,6 +69,7 @@ impl CadCommand for AreaCommand {
pts.push(f(pt)); pts.push(f(pt));
pts.push(f(self.points[0])); pts.push(f(self.points[0]));
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "area_preview".into(), name: "area_preview".into(),
points: pts, points: pts,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -63,6 +63,7 @@ impl CadCommand for DistCommand {
let p1 = self.first?; let p1 = self.first?;
// The preview wire is purely visual, so f32 vertices are fine here. // The preview wire is purely visual, so f32 vertices are fine here.
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "dist_preview".into(), name: "dist_preview".into(),
points: vec![ points: vec![
[p1.x as f32, p1.y as f32, p1.z as f32], [p1.x as f32, p1.y as f32, p1.z as f32],

View file

@ -67,6 +67,7 @@ impl MeasureGeomCommand {
/// Build a cyan preview wire connecting the picked points and the cursor. /// Build a cyan preview wire connecting the picked points and the cursor.
fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel { fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: name.to_string(), name: name.to_string(),
points: pts, points: pts,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -80,6 +80,7 @@ impl CadCommand for MviewCommand {
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> { let pt = pt.as_vec3(); fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> { let pt = pt.as_vec3();
let c1 = self.corner1?; let c1 = self.corner1?;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "mview_preview".to_string(), name: "mview_preview".to_string(),
points: vec![ points: vec![
[c1.x, c1.y, c1.z], [c1.x, c1.y, c1.z],

View file

@ -74,6 +74,7 @@ impl CadCommand for MviewCommand {
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> { fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
let c1 = self.corner1?; let c1 = self.corner1?;
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "mview_preview".to_string(), name: "mview_preview".to_string(),
points: vec![ points: vec![
[c1.x, c1.y, c1.z], [c1.x, c1.y, c1.z],

View file

@ -305,6 +305,7 @@ inventory::submit!(crate::command::CommandRegistration {
fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
WireModel { WireModel {
text_verts: Vec::new(),
name: name.into(), name: name.into(),
points, points,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -48,6 +48,7 @@ impl CadCommand for PlotWindowCommand {
let p1 = self.p1?; let p1 = self.p1?;
// Draw the selection rectangle. // Draw the selection rectangle.
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "plotwindow_preview".into(), name: "plotwindow_preview".into(),
points: vec![ points: vec![
[p1.x, p1.y, p1.z], [p1.x, p1.y, p1.z],

View file

@ -65,6 +65,7 @@ impl CadCommand for ZoomWindowCommand {
let max = p1.max(pt); let max = p1.max(pt);
// Draw a rectangle preview // Draw a rectangle preview
Some(WireModel { Some(WireModel {
text_verts: Vec::new(),
name: "zoom_window_preview".into(), name: "zoom_window_preview".into(),
points: vec![ points: vec![
[min.x, min.y, 0.0], [min.x, min.y, 0.0],

View file

@ -38,6 +38,11 @@ pub struct LocalWire {
/// Low-bit residual paired with `points` so block-instance wires keep /// Low-bit residual paired with `points` so block-instance wires keep
/// sub-f32 precision once the renderer translates them to world space. /// sub-f32 precision once the renderer translates them to world space.
pub points_low: Vec<[f32; 3]>, 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<crate::scene::pipeline::text_gpu::TextVertex>,
pub key_vertices: Vec<[f64; 3]>, pub key_vertices: Vec<[f64; 3]>,
pub snap_pts: Vec<(glam::DVec3, SnapHint)>, pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
pub tangent_geoms: Vec<TangentGeom>, pub tangent_geoms: Vec<TangentGeom>,
@ -430,10 +435,18 @@ fn tessellate_sub_local(
return vec![]; return vec![];
} }
let text_height_local: Option<f32> = match sub { // 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<f32> = 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::Text(t) => Some((t.height * anno_scale as f64) as f32),
EntityType::MText(m) => Some((m.height * anno_scale as f64) as f32), EntityType::MText(m) => Some((m.height * anno_scale as f64) as f32),
_ => None, _ => None,
}
}; };
// Pre-compute the OBB corners (absolute f64) for Text / MText so the greek // Pre-compute the OBB corners (absolute f64) for Text / MText so the greek
@ -470,6 +483,7 @@ fn tessellate_sub_local(
result.push(LocalWire { result.push(LocalWire {
points: wire.points, points: wire.points,
points_low: wire.points_low, points_low: wire.points_low,
text_verts: wire.text_verts,
key_vertices: wire.key_vertices, key_vertices: wire.key_vertices,
snap_pts: wire.snap_pts, snap_pts: wire.snap_pts,
tangent_geoms: wire.tangent_geoms, tangent_geoms: wire.tangent_geoms,
@ -965,6 +979,8 @@ struct BatchEntry {
/// reconstructs `high + low`). Without it absolute f32 fills quantize to /// reconstructs `high + low`). Without it absolute f32 fills quantize to
/// ~0.5 m and the greek-text rectangles shear. /// ~0.5 m and the greek-text rectangles shear.
fill_tris_low: Vec<[f32; 3]>, fill_tris_low: Vec<[f32; 3]>,
/// Accumulated SDF glyph quads (world space) for block-instance text.
text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
min_x: f32, min_x: f32,
min_y: f32, min_y: f32,
max_x: f32, max_x: f32,
@ -1041,6 +1057,7 @@ impl Batches {
// the cached defn. // the cached defn.
let color = crate::scene::view::render::adapt_to_bg(b.color, bg_color); let color = crate::scene::view::render::adapt_to_bg(b.color, bg_color);
WireModel { WireModel {
text_verts: b.text_verts,
name: name.to_string(), name: name.to_string(),
points: b.points, points: b.points,
points_low: b.points_low, points_low: b.points_low,
@ -1357,7 +1374,7 @@ fn emit_wire(
ctx: &ExpandCtx, ctx: &ExpandCtx,
out: &mut Batches, 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; return;
} }
@ -1507,6 +1524,40 @@ fn emit_wire(
entry.fill_tris.push([hx, hy, hz]); entry.fill_tris.push([hx, hy, hz]);
entry.fill_tris_low.push([lx, ly, lz]); 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( fn transform_tangent(

View file

@ -287,8 +287,13 @@ pub(crate) fn tessellate_entity(
if !wires.is_empty() { if !wires.is_empty() {
let aabb = entity_aabb(e); let aabb = entity_aabb(e);
for w in &mut wires { for w in &mut wires {
// 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; w.aabb = aabb;
} }
}
return wires; return wires;
} }
} }
@ -315,8 +320,15 @@ pub(crate) fn tessellate_entity(
); );
for w in &mut wires { for w in &mut wires {
w.aci = aci; w.aci = aci;
// 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; w.aabb = aabb;
} }
}
return wires; return wires;
} }
@ -334,8 +346,12 @@ pub(crate) fn tessellate_entity(
); );
for w in &mut wires { for w in &mut wires {
w.aci = aci; w.aci = aci;
// 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; w.aabb = aabb;
} }
}
return wires; return wires;
} }
@ -397,8 +413,13 @@ pub(crate) fn tessellate_entity(
if !wires.is_empty() { if !wires.is_empty() {
let aabb = entity_aabb(e); let aabb = entity_aabb(e);
for w in &mut wires { for w in &mut wires {
// 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; w.aabb = aabb;
} }
}
return wires; return wires;
} }
} }
@ -437,6 +458,7 @@ pub(crate) fn tessellate_entity(
(ins.insert_point.z) as f32, (ins.insert_point.z) as f32,
); );
let marker = WireModel { let marker = WireModel {
text_verts: Vec::new(),
name: h.value().to_string(), name: h.value().to_string(),
points: vec![], points: vec![],
points_low: Vec::new(), points_low: Vec::new(),
@ -614,13 +636,12 @@ pub(crate) fn tessellate_entity(
_ => None, _ => None,
}; };
// SDF text renders crisply at every zoom, so bypass the baseline/greek // SDF text renders crisply at every zoom, so bypass the baseline/greek
// LOD ladder entirely — fall through to the full path, which is itself // LOD ladder entirely — fall through to the full path, which produces
// suppressed to an empty (snap-only) wire while SDF is on. // the SDF glyph quads (carried on the wire) plus a snap-only wire while
let text_height = if crate::scene::text::sdf_atlas::sdf_text_enabled() { // SDF is on. Applies to every TEXT/MTEXT, top-level or composite.
None let sdf_here = crate::scene::text::sdf_atlas::sdf_text_enabled()
} else { && matches!(e, EntityType::Text(_) | EntityType::MText(_));
text_height let text_height = if sdf_here { None } else { text_height };
};
if let Some(h_world) = text_height { if let Some(h_world) = text_height {
let h_px = (h_world as f32) / wpp; let h_px = (h_world as f32) / wpp;
// Wrap-expanded line count for MText (Text = 1). // Wrap-expanded line count for MText (Text = 1).
@ -658,6 +679,7 @@ pub(crate) fn tessellate_entity(
)]; )];
} }
return vec![WireModel { return vec![WireModel {
text_verts: Vec::new(),
name: h.value().to_string(), name: h.value().to_string(),
points: pts, points: pts,
points_low: Vec::new(), points_low: Vec::new(),
@ -697,6 +719,7 @@ pub(crate) fn tessellate_entity(
// hit_test's AABB fallback handles window / crossing. #19. // hit_test's AABB fallback handles window / crossing. #19.
let fill_color = if sel { WireModel::SELECTED } else { entity_color }; let fill_color = if sel { WireModel::SELECTED } else { entity_color };
return vec![WireModel { return vec![WireModel {
text_verts: Vec::new(),
name: h.value().to_string(), name: h.value().to_string(),
points: vec![], points: vec![],
points_low: Vec::new(), points_low: Vec::new(),
@ -733,8 +756,13 @@ pub(crate) fn tessellate_entity(
); );
for b in &mut bases { for b in &mut bases {
b.aci = aci; b.aci = aci;
// 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; b.aabb = aabb;
} }
}
// Complex linetypes (with embedded shapes / text) expand the *base* // Complex linetypes (with embedded shapes / text) expand the *base*
// polyline along its tangent. Text-type entities never have a complex // polyline along its tangent. Text-type entities never have a complex
@ -801,6 +829,7 @@ fn lod_stub_wire(
// LOD boundary. #19. // LOD boundary. #19.
let stored_color = if selected { WireModel::SELECTED } else { color }; let stored_color = if selected { WireModel::SELECTED } else { color };
WireModel { WireModel {
text_verts: Vec::new(),
name, name,
// Diagonal of the entity's 3D AABB so depth tests against // Diagonal of the entity's 3D AABB so depth tests against
// shaded / hidden-line geometry are correct — the stub doesn't // 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 }; let stored_color = if selected { WireModel::SELECTED } else { color };
WireModel { WireModel {
text_verts: Vec::new(),
name, name,
points, points,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -198,12 +198,16 @@ pub fn tessellate(
}; };
let anno = anno_scale as f64; let anno = anno_scale as f64;
// With SDF text on, glyphs render as quads elsewhere — skip // With SDF text on, glyphs render as textured quads carried on
// building the stroke geometry so text is not double-drawn. // this entity's own wire (built below) — skip the stroke
// `bins` stays empty, so the empty-wire path below still emits // geometry so text is not double-drawn. `bins` stays empty, so
// the insertion snap point (text stays snappable, no glyph // the empty-wire path emits the insertion snap point and the
// strokes polluting snap/pick). // glyph quads. Applies to every TEXT/MTEXT the tessellator
let sdf_text = crate::scene::text::sdf_atlas::sdf_text_enabled(); // 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) { for group in stroke_groups.iter().filter(|_| !sdf_text) {
let lx_v = group.origin[0]; let lx_v = group.origin[0];
let ly_v = group.origin[1]; 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<crate::scene::pipeline::text_gpu::TextVertex> = 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 snap_pts = te.snap_pts;
let key_vertices: Vec<[f64; 3]> = te let key_vertices: Vec<[f64; 3]> = te
.key_vertices .key_vertices
@ -249,9 +299,74 @@ pub fn tessellate(
.collect(); .collect();
// Empty input (no glyphs) → emit a single empty wire so the // 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() { if bins.is_empty() {
return vec![WireModel { let mut wires: Vec<WireModel> = 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, name,
points: Vec::new(), points: Vec::new(),
points_low: Vec::new(), points_low: Vec::new(),
@ -264,12 +379,13 @@ pub fn tessellate(
tangent_geoms: te.tangent_geoms, tangent_geoms: te.tangent_geoms,
aci: 0, aci: 0,
key_vertices, key_vertices,
aabb: WireModel::UNBOUNDED_AABB, aabb: text_aabb,
plinegen: true, plinegen: true,
vp_scissor: None, vp_scissor: None,
fill_tris: vec![], fill_tris: vec![],
fill_tris_low: Vec::new(), fill_tris_low: Vec::new(),
}]; });
return wires;
} }
let mut out: Vec<WireModel> = Vec::new(); let mut out: Vec<WireModel> = Vec::new();
@ -292,6 +408,7 @@ pub fn tessellate(
(Vec::new(), Vec::new(), Vec::new()) (Vec::new(), Vec::new(), Vec::new())
}; };
out.push(WireModel { out.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: bin.pts, points: bin.pts,
points_low: bin.pts_low, points_low: bin.pts_low,
@ -324,6 +441,7 @@ pub fn tessellate(
(Vec::new(), Vec::new(), Vec::new()) (Vec::new(), Vec::new(), Vec::new())
}; };
out.push(WireModel { out.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: Vec::new(), points: Vec::new(),
points_low: Vec::new(), points_low: Vec::new(),
@ -347,6 +465,7 @@ pub fn tessellate(
if out.is_empty() { if out.is_empty() {
out.push(WireModel { out.push(WireModel {
text_verts: Vec::new(),
name, name,
points: Vec::new(), points: Vec::new(),
points_low: Vec::new(), points_low: Vec::new(),
@ -391,6 +510,7 @@ pub fn tessellate(
.map(|[kx, ky, kz]| [kx, ky, kz]) .map(|[kx, ky, kz]| [kx, ky, kz])
.collect(); .collect();
return vec![WireModel { return vec![WireModel {
text_verts: Vec::new(),
name, name,
points: vec![ points: vec![
[x - s, y, z], [x - s, y, z],
@ -434,6 +554,7 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z]) .map(|[x, y, z]| [x, y, z])
.collect(); .collect();
return vec![WireModel { return vec![WireModel {
text_verts: Vec::new(),
name, name,
points, points,
points_low, points_low,
@ -466,6 +587,7 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z]) .map(|[x, y, z]| [x, y, z])
.collect(); .collect();
return vec![WireModel { return vec![WireModel {
text_verts: Vec::new(),
name, name,
points, points,
points_low, points_low,
@ -517,6 +639,7 @@ pub fn tessellate(
(Vec::new(), Vec::new(), Vec::new()) (Vec::new(), Vec::new(), Vec::new())
}; };
out.push(WireModel { out.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: local_pts, points: local_pts,
points_low: local_pts_low, points_low: local_pts_low,
@ -548,6 +671,7 @@ pub fn tessellate(
(Vec::new(), Vec::new(), Vec::new()) (Vec::new(), Vec::new(), Vec::new())
}; };
out.push(WireModel { out.push(WireModel {
text_verts: Vec::new(),
name: name.clone(), name: name.clone(),
points: Vec::new(), points: Vec::new(),
points_low: Vec::new(), points_low: Vec::new(),
@ -570,6 +694,7 @@ pub fn tessellate(
if out.is_empty() { if out.is_empty() {
out.push(WireModel { out.push(WireModel {
text_verts: Vec::new(),
name, name,
points: Vec::new(), points: Vec::new(),
points_low: Vec::new(), points_low: Vec::new(),
@ -602,6 +727,7 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z]) .map(|[x, y, z]| [x, y, z])
.collect(); .collect();
return vec![WireModel { return vec![WireModel {
text_verts: Vec::new(),
name, name,
points: local_pts, points: local_pts,
points_low: local_pts_low, points_low: local_pts_low,
@ -665,6 +791,7 @@ pub fn tessellate(
let snap_pts: Vec<(glam::DVec3, SnapHint)> = let snap_pts: Vec<(glam::DVec3, SnapHint)> =
snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect(); snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect();
vec![WireModel { vec![WireModel {
text_verts: Vec::new(),
name, name,
points, points,
points_low, points_low,

View file

@ -710,11 +710,14 @@ pub struct Scene {
pub(crate) model_tiles: RefCell<Vec<ModelTile>>, pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
/// Index of the active model tile (camera input + overlays target it). /// Index of the active model tile (camera input + overlays target it).
pub(crate) active_model_tile: std::cell::Cell<usize>, pub(crate) active_model_tile: std::cell::Cell<usize>,
/// Cache of the SDF text vertex list, keyed on `(geometry_epoch, /// Cache of the gathered SDF text vertex list for one viewport, keyed on
/// anno_scale.to_bits())`, so the per-frame collector is not re-laid-out on /// the wire-buffer content id. The glyph quads ride on each entity's wire
/// pan / zoom. See [`Scene::sdf_text_vertices`]. /// (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: sdf_text_cache:
RefCell<Option<(u64, u32, std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>>)>>, RefCell<Option<(u64, std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>>)>>,
/// pane_grid layout tree for the Model tab — the source of truth for the /// 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 /// tile split layout, resize and focus. `model_tiles` (the renderer's
/// per-pane data: camera / render-mode / grid) is kept in lock-step with /// per-pane data: camera / render-mode / grid) is kept in lock-step with

View file

@ -82,6 +82,13 @@ pub struct WireModel {
/// Low residual paired with [`fill_tris`] (double-single). Empty = all-zero; /// Low residual paired with [`fill_tris`] (double-single). Empty = all-zero;
/// tessellation from CAD f64 fills it so fills stay precise at UTM scale. /// tessellation from CAD f64 fills it so fills stay precise at UTM scale.
pub fill_tris_low: Vec<[f32; 3]>, 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<crate::scene::pipeline::text_gpu::TextVertex>,
} }
impl WireModel { impl WireModel {
@ -129,6 +136,7 @@ impl WireModel {
/// Create a solid wire (no dash pattern, 1px weight). /// Create a solid wire (no dash pattern, 1px weight).
pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self { pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self {
Self { Self {
text_verts: Vec::new(),
name, name,
points, points,
points_low: Vec::new(), points_low: Vec::new(),
@ -261,6 +269,7 @@ impl WireModel {
impl Default for WireModel { impl Default for WireModel {
fn default() -> Self { fn default() -> Self {
Self { Self {
text_verts: Vec::new(),
name: String::new(), name: String::new(),
points: Vec::new(), points: Vec::new(),
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -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, /// Like `click_hit` but returns every wire within the click threshold,

View file

@ -610,6 +610,7 @@ mod tests {
// A polyline half inside, half outside the 0..10 square. // A polyline half inside, half outside the 0..10 square.
let mut wires = vec![WireModel { let mut wires = vec![WireModel {
text_verts: Vec::new(),
points: vec![[5.0, 5.0, 0.0], [15.0, 5.0, 0.0]], points: vec![[5.0, 5.0, 0.0], [15.0, 5.0, 0.0]],
..Default::default() ..Default::default()
}]; }];

View file

@ -85,6 +85,11 @@ pub struct Pipeline {
/// All glyph-quad vertices for the frame, one buffer, `None` when empty. /// All glyph-quad vertices for the frame, one buffer, `None` when empty.
text_vbuf: Option<wgpu::Buffer>, text_vbuf: Option<wgpu::Buffer>,
text_vcount: u32, 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<wgpu::Buffer>,
text_highlight_vcount: u32,
depth_texture_size: Size<u32>, depth_texture_size: Size<u32>,
depth_view: wgpu::TextureView, depth_view: wgpu::TextureView,
/// 4× MSAA color buffer for the main drawing passes. /// 4× MSAA color buffer for the main drawing passes.
@ -1146,6 +1151,8 @@ impl Pipeline {
text_atlas_gpu: None, text_atlas_gpu: None,
text_vbuf: None, text_vbuf: None,
text_vcount: 0, text_vcount: 0,
text_highlight_vbuf: None,
text_highlight_vcount: 0,
mesh_pipeline, mesh_pipeline,
mesh_transparent_pipeline, mesh_transparent_pipeline,
mesh_highlight_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()); 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<acadrust::Handle>,
hover: Option<acadrust::Handle>,
) {
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<text_gpu::TextVertex> = Vec::new();
let push =
|handle_val: u64, tint: [f32; 4], wires: &[WireModel], out: &mut Vec<text_gpu::TextVertex>| {
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. /// Upload the live overlay (command preview / interim / grip-drag) wires.
/// Small and refreshed each frame they're present; kept separate from the /// 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. /// 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) ──────────────────── // ── Pass 5c: SDF text quads (drawn over wires) ────────────────────
if let (Some(vbuf), Some(atlas)) = (&self.text_vbuf, &self.text_atlas_gpu) { // The highlight buffer (selected / hovered text, tinted) is drawn in
if self.text_vcount > 0 { // 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 { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("text.render_pass"), label: Some("text.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { color_attachments: &[Some(wgpu::RenderPassColorAttachment {
@ -2094,10 +2148,20 @@ impl Pipeline {
pass.set_pipeline(&self.text_pipeline); pass.set_pipeline(&self.text_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_bind_group(0, &self.uniform_bind_group, &[]);
pass.set_bind_group(1, &atlas.bind_group, &[]); pass.set_bind_group(1, &atlas.bind_group, &[]);
if let Some(vbuf) = &self.text_vbuf {
if self.text_vcount > 0 {
pass.set_vertex_buffer(0, vbuf.slice(..)); pass.set_vertex_buffer(0, vbuf.slice(..));
pass.draw(0..self.text_vcount, 0..1); 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);
}
}
}
}
// ── Pass 6: wipeout fills (drawn after wires to mask them) ──────── // ── Pass 6: wipeout fills (drawn after wires to mask them) ────────
if !self.gpu_wipeouts.is_empty() { if !self.gpu_wipeouts.is_empty() {

View file

@ -208,6 +208,7 @@ pub fn apply_along(
.into_iter() .into_iter()
.filter(|s| s.len() >= 2) .filter(|s| s.len() >= 2)
.map(|pts| WireModel { .map(|pts| WireModel {
text_verts: Vec::new(),
name: name.to_string(), name: name.to_string(),
points: pts, points: pts,
points_low: Vec::new(), points_low: Vec::new(),

View file

@ -47,6 +47,14 @@ pub fn sdf_text_enabled() -> bool {
*ON.get_or_init(|| std::env::var_os("OCS_TEXT_SDF").is_some()) *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<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var_os("OCS_TEXT_BOX").is_some())
}
// ── Bake configuration ────────────────────────────────────────────────────── // ── Bake configuration ──────────────────────────────────────────────────────
/// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap /// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap

View file

@ -345,6 +345,15 @@ impl shader::Primitive for Primitive {
vp.hover_handle, vp.hover_handle,
&vp.draw_depths, &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; inner.cached_selection = sel_key;
} }
// Batched solid meshes — geometry-only, so they ride the geometry // 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) ── // ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ──
impl Scene { impl Scene {
/// SDF text-quad vertices for the current document's TEXT entities. Behind /// Gather the SDF glyph quads carried on a viewport's wire set into one
/// the `OCS_TEXT_SDF` env flag (Phase 2b bring-up) so the default render /// flat vertex list for the text render pass. The tessellator attaches the
/// path is untouched; empty otherwise. Glyphs are baked into the shared /// quads to each entity's own wire (and the block-expand loop transforms
/// atlas as a side effect. Both the stroke path and this share /// block-instance quads to world), so gathering is a cheap walk. Cached on
/// `text_run_placement`, so quads land exactly where the strokes do. /// `wire_content_id` — the wire-buffer content id — so an unchanged wire
#[allow(dead_code)] // consumed by the text render pass in the GPU integration step /// set (pan / zoom) is walked once, not every frame; the id changes when
pub(in crate::scene) fn sdf_text_vertices( /// 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, &self,
wires: &[WireModel],
wire_content_id: u64,
) -> std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>> { ) -> std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>> {
use std::sync::Arc; use std::sync::Arc;
if !crate::scene::text::sdf_atlas::sdf_text_enabled() { if !crate::scene::text::sdf_atlas::sdf_text_enabled() {
return Arc::new(Vec::new()); 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(); let cache = self.sdf_text_cache.borrow();
if let Some((epoch, anno_bits, verts)) = cache.as_ref() { if let Some((id, verts)) = cache.as_ref() {
if *epoch == key.0 && *anno_bits == key.1 { if *id == wire_content_id {
return verts.clone(); return verts.clone();
} }
} }
} }
let verts = Arc::new(self.sdf_text_vertices_enabled(true)); let mut out: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
*self.sdf_text_cache.borrow_mut() = Some((key.0, key.1, verts.clone())); 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 verts
} }
#[allow(dead_code)]
fn sdf_text_vertices_enabled(
&self,
enabled: bool,
) -> Vec<crate::scene::pipeline::text_gpu::TextVertex> {
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. /// Build the unified multi-viewport `Primitive` for the current layout.
/// Model layout → one full-window viewport (more once tiled); paper /// Model layout → one full-window viewport (more once tiled); paper
/// layout → one viewport per floating content viewport. Each entry is /// layout → one viewport per floating content viewport. Each entry is
@ -1170,13 +1101,14 @@ impl Scene {
self.meshes_arc() self.meshes_arc()
}; };
// SDF text quads (Phase 2b, behind OCS_TEXT_SDF). Model/content only — // SDF text quads (behind OCS_TEXT_SDF). The glyph quads ride on each
// the paper sheet must not draw model-space text onto the sheet. // entity's own wire (produced by the tessellator, transformed for
let text_verts = if inst.paper_sheet { // block instances by the block-expand loop), so here we simply gather
Arc::new(Vec::new()) // them from this viewport's wire set. This covers model text, block-
} else { // internal text and the paper sheet's own annotation alike — each set
self.sdf_text_vertices() // 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 { Some(ViewportData {
wires: all_wires, wires: all_wires,
preview_wires, preview_wires,

View file

@ -1832,6 +1832,7 @@ mod ext_tests {
// A single line segment (0,0)-(10,0): its endpoints are vertices, its // A single line segment (0,0)-(10,0): its endpoints are vertices, its
// midpoint and any extension foot are not. // midpoint and any extension foot are not.
let wire = WireModel { let wire = WireModel {
text_verts: Vec::new(),
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]], points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
..Default::default() ..Default::default()
}; };