forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
fd0b8fc1d4
commit
110683f419
47 files changed
+529
-143
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
Vendored
+56
-5
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user