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 SevenandClaude Opus 4.8 committed 2026-07-07 17:01:36 +03:00
1 parent fd0b8fc1d4
commit 110683f419
47 files changed
+529 -143

No files matched your search

+2
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_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<Message> {
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],
+5
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(),
+2
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(),
+76 -1
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(),
+1
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(),
+2
View File
@@ -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],
+1
View File
@@ -124,6 +124,7 @@ fn v3(pt: Vec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> 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(),
+1
View File
@@ -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(),
+1
View File
@@ -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],
+1
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_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],
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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(),
+1
View File
@@ -218,6 +218,7 @@ fn v3(pt: Vec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> 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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -116,6 +116,7 @@ fn ordinate_elbow(feature: Vec3, leader: Vec3, is_x: bool) -> Vec3 {
fn preview_wire(points: Vec<Vec3>) -> 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(),
+1
View File
@@ -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(),
+1
View File
@@ -184,6 +184,7 @@ fn v3(pt: Vec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> 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(),
+1
View File
@@ -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],
+1
View File
@@ -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],
+1
View File
@@ -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],
+1
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();
pts.push([pt.x, pt.y, pt.z]);
Some(WireModel {
text_verts: Vec::new(),
name: "mline_preview".into(),
points: pts,
points_low: Vec::new(),
+1
View File
@@ -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(),
+2
View File
@@ -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],
+1
View File
@@ -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(),
+2
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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],
+1
View File
@@ -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(),
+1
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();
let c1 = self.corner1?;
Some(WireModel {
text_verts: Vec::new(),
name: "mview_preview".to_string(),
points: vec![
[c1.x, c1.y, c1.z],
+1
View File
@@ -74,6 +74,7 @@ impl CadCommand for MviewCommand {
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
let c1 = self.corner1?;
Some(WireModel {
text_verts: Vec::new(),
name: "mview_preview".to_string(),
points: vec![
[c1.x, c1.y, c1.z],
+1
View File
@@ -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(),
+1
View File
@@ -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],
+1
View File
@@ -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],
+56 -5
View File
@@ -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<crate::scene::pipeline::text_gpu::TextVertex>,
pub key_vertices: Vec<[f64; 3]>,
pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
pub tangent_geoms: Vec<TangentGeom>,
@@ -430,10 +435,18 @@ fn tessellate_sub_local(
return vec![];
}
let text_height_local: Option<f32> = 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<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::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<crate::scene::pipeline::text_gpu::TextVertex>,
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(
+42 -12
View File
@@ -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(),
+137 -10
View File
@@ -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<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 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<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,
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<WireModel> = 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,
+7 -4
View File
@@ -710,11 +710,14 @@ pub struct Scene {
pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
/// Index of the active model tile (camera input + overlays target it).
pub(crate) active_model_tile: std::cell::Cell<usize>,
/// 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<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
/// tile split layout, resize and focus. `model_tiles` (the renderer's
/// per-pane data: camera / render-mode / grid) is kept in lock-step with
+9
View File
@@ -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<crate::scene::pipeline::text_gpu::TextVertex>,
}
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(),
+42 -1
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,
+1
View File
@@ -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()
}];
+68 -4
View File
@@ -85,6 +85,11 @@ pub struct Pipeline {
/// All glyph-quad vertices for the frame, one buffer, `None` when empty.
text_vbuf: Option<wgpu::Buffer>,
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_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<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.
/// 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);
}
}
}
}
+1
View File
@@ -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(),
+8
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())
}
/// 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 ──────────────────────────────────────────────────────
/// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap
+38 -106
View File
@@ -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<Vec<crate::scene::pipeline::text_gpu::TextVertex>> {
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<crate::scene::pipeline::text_gpu::TextVertex> = 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<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.
/// 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,
+1
View File
@@ -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()
};