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

@ -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(),

View file

@ -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(),

View file

@ -1284,6 +1284,7 @@ impl MultiLeaderTess for MultiLeader {
// WireModels so the renderer respects per-piece coloring.
let mut wires: Vec<WireModel> = 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<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.
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(),

View file

@ -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(),