feat(text): SDF for tolerance + complex-linetype text; per-group model; pick cycle

Extend SDF text to the remaining text kinds and make the Text arm decide SDF
per group instead of per entity type.

- Text arm: a stroke group renders as SDF glyph quads when it carries a
  GlyphRun (strokes suppressed); a run-less group keeps its strokes. TEXT /
  MTEXT / dim / block / mleader are unchanged (all run-groups). A composite
  Text object can now mix geometry + text: its run-less groups stay strokes and
  its run-groups become SDF. The glyph-bounds AABB is hoisted so both the
  bins-empty and bins-non-empty paths stamp it on the text wire.
- Tolerance: refactored to emit the feature-control frame (border, separators,
  dividers) as run-less stroke groups and each cell's text as a run-group, so
  the frame stays crisp geometry while the values/symbols render as SDF text.
- Complex linetypes: embedded LtSeg::Text renders as SDF glyph quads on an
  extra text wire (glyph-bounds AABB) instead of stroke polylines.
- click_hits_all: include SDF text boxes so selection cycling steps through
  text too, ranked after real geometry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-07 18:22:08 +03:00
1 parent 96181563fb
commit d9a2c8245c
4 files changed
+263 -89

No files matched your search

+66 -37
View File
@@ -202,17 +202,16 @@ pub fn tessellate(
};
let anno = anno_scale as f64;
// 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) {
// SDF text is decided PER GROUP by whether the group carries a
// `GlyphRun`: a run-group renders as textured quads (built
// below) so its strokes are suppressed; a run-less group keeps
// its strokes. TEXT / MTEXT / dim / block / mleader text are all
// run-groups → fully SDF; a composite object that packs geometry
// and text into one Text object (a tolerance frame: box lines =
// run-less, cell text = run-groups) keeps the geometry as
// strokes and draws only the text as SDF.
let sdf_on = crate::scene::text::sdf_atlas::sdf_text_enabled();
for group in stroke_groups.iter().filter(|g| !(sdf_on && g.run.is_some())) {
let lx_v = group.origin[0];
let ly_v = group.origin[1];
let slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v;
@@ -255,7 +254,7 @@ pub fn tessellate(
// 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 sdf_on {
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.
@@ -302,42 +301,43 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z])
.collect();
// 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 derive the AABB from
// `sdf_verts` (accumulate in f64, reconstruct high+low, then cast
// to f32 once) and stop the generic stamp clobbering it (tess.rs
// guards on `text_verts`). Computed here so both the bins-empty
// and the bins-non-empty (tolerance box + text) paths can stamp
// it on the SDF text wire.
let text_aabb = if sdf_on && !sdf_verts.is_empty() {
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
};
// Empty input (no glyphs) → emit a single empty wire so the
// 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() {
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
};
// MTEXT background fill / mask: an opaque rectangle behind
// the glyphs, emitted first so it renders under the text.
// Flag 0x01 → the entity's background-fill colour; 0x02 →
// the drawing-window (canvas) colour, which masks geometry
// behind the text like a wipeout. Box = glyph bounds padded
// by (background_scale - 1) × text height.
if sdf_text && text_aabb != WireModel::UNBOUNDED_AABB {
if sdf_on && text_aabb != WireModel::UNBOUNDED_AABB {
if let EntityType::MText(m) = entity {
let has_fill = m.background_fill_flags & 0x03 != 0;
let has_frame = m.background_fill_flags & 0x10 != 0;
@@ -429,7 +429,7 @@ pub fn tessellate(
// 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
if sdf_on
&& !sdf_verts.is_empty()
&& crate::scene::text::sdf_atlas::text_box_debug()
{
@@ -561,6 +561,35 @@ pub fn tessellate(
}
}
// Composite Text objects (e.g. a tolerance frame) keep geometry
// in `bins` (run-less groups) and text in `sdf_verts` (run
// groups). Emit the glyphs on their own wire carrying the tight
// glyph-box AABB so the text draws + picks alongside the box
// strokes. TEXT / MTEXT never reach here with SDF on (their bins
// are empty → the early-return path above).
if !sdf_verts.is_empty() {
out.push(WireModel {
text_verts: sdf_verts,
name: name.clone(),
points: Vec::new(),
points_low: Vec::new(),
color,
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: text_aabb,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
}
if out.is_empty() {
out.push(WireModel {
text_verts: Vec::new(),
+32
View File
@@ -215,6 +215,38 @@ pub fn click_hits_all<'a>(
hits.push((best_for_wire, &wire.name));
}
}
// SDF text: include a text whose box contains the cursor (an empty-stroke
// wire carrying glyph quads) so selection cycling steps through text too —
// the same discriminator/fallback `click_hit` uses. Ranked after real
// geometry (distance = the click threshold, above every proximity hit).
if crate::scene::text::sdf_atlas::sdf_text_enabled() {
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 {
hits.push((CLICK_THRESHOLD_PX, &wire.name));
}
}
}
hits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
hits.into_iter().map(|(_, name)| name).collect()
}
+82 -16
View File
@@ -69,6 +69,11 @@ pub fn apply_along(
let mut strokes: Vec<Vec<[f32; 3]>> = Vec::new();
let mut cur_stroke: Vec<[f32; 3]> = Vec::new();
// When SDF text is on, embedded linetype text (LtSeg::Text) renders as glyph
// quads collected here instead of stroke polylines; emitted as one extra
// text-carrying WireModel at the end.
let sdf_on = crate::scene::text::sdf_atlas::sdf_text_enabled();
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
let mut elem_idx: usize = 0;
let mut elem_consumed: f32 = 0.0;
@@ -175,20 +180,45 @@ pub fn apply_along(
let insert = offset_pt(insert, fwd, perp, *x, *y);
let fwd_angle = fwd[1].atan2(fwd[0]) + rot_deg.to_radians();
let resolved = resolve_dxf_special_chars(text);
let (text_strokes, _) = lff::tessellate_text_ex(
[insert[0], insert[1]],
*tx_scale,
fwd_angle,
1.0,
0.0,
style,
&resolved,
);
for stroke in &text_strokes {
if stroke.len() >= 2 {
let pts: Vec<[f32; 3]> =
stroke.iter().map(|&[sx, sy]| [sx, sy, insert[2]]).collect();
strokes.push(pts);
if sdf_on {
// SDF: glyph quads at the insert point (rotation baked in
// by layout_glyph_quads), collected for the text wire.
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
*tx_scale,
fwd_angle,
1.0,
0.0,
0.0,
style,
&resolved,
);
crate::scene::pipeline::text_gpu::push_glyph_vertices(
&mut text_verts,
&quads,
[insert[0] as f64, insert[1] as f64, insert[2] as f64],
1.0,
color,
0.0,
);
}
} else {
let (text_strokes, _) = lff::tessellate_text_ex(
[insert[0], insert[1]],
*tx_scale,
fwd_angle,
1.0,
0.0,
style,
&resolved,
);
for stroke in &text_strokes {
if stroke.len() >= 2 {
let pts: Vec<[f32; 3]> =
stroke.iter().map(|&[sx, sy]| [sx, sy, insert[2]]).collect();
strokes.push(pts);
}
}
}
@@ -204,7 +234,7 @@ pub fn apply_along(
flush(&mut cur_stroke, &mut strokes);
strokes
let mut out: Vec<WireModel> = strokes
.into_iter()
.filter(|s| s.len() >= 2)
.map(|pts| WireModel {
@@ -227,7 +257,43 @@ pub fn apply_along(
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
.collect()
.collect();
// Embedded linetype text (SDF): one extra wire carrying the glyph quads,
// with a glyph-bounds AABB (f64 accumulate → f32) so it stays precise at
// UTM scale. Empty points so it doesn't add stroke geometry.
if !text_verts.is_empty() {
let (mut nx, mut ny, mut xx, mut xy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for v in &text_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);
}
out.push(WireModel {
text_verts,
name: name.to_string(),
points: Vec::new(),
points_low: Vec::new(),
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
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(),
});
}
out
}
// ── Helpers ───────────────────────────────────────────────────────────────