refactor(text): SDF for all text kinds; remove the legacy stroke text path

SDF is the only text renderer now — the stroke path and its LOD ladder are
gone, and the remaining text kinds render as SDF glyph quads too.

- ATTDEF / ATTRIB: carry a GlyphRun so single-line attribute text renders as
  SDF (multi-line already did via layout_mtext). Tolerance / complex-linetype
  text already SDF.
- Remove `sdf_text_enabled()` (the OCS_TEXT_SDF flag / kill switch) and take
  the SDF path unconditionally everywhere: tessellate Text arm (per-group by
  GlyphRun presence), hit_test fallbacks, render gather, complex_lt, table,
  mleader.
- Delete the text LOD ladder: tess.rs baseline/greek block and the
  block_cache greek/baseline path (emit_greeked_text / emit_text_baseline),
  plus the now-dead helpers text_baseline_points / text_greek_obb_tris /
  text_obb_corners_native / mtext_line_count and the LocalWire
  text_height_local / text_obb_local fields. All text is crisp at every zoom;
  text entities are exempt from the sub-pixel stub cull.
- Fix a latent bug: block-internal SDF text was culled because its LocalWire
  aabb_local was [0,0,0,0] (no points) — fold the glyph-quad extent into
  aabb_local and exempt text_verts-carrying wires from the sub-pixel cull.
- Clear the pre-existing dead-code warnings (TextPlacement.elevation, TabKind,
  MTextRunKind::Tab).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-07 21:07:00 +03:00
1 parent 67ce45d40b
commit 5c15091e74
12 files changed
+145 -1238

No files matched your search

+13 -2
View File
@@ -11,7 +11,7 @@ use crate::entities::text_support::{
MTextRenderOpts, MTextVAnchor, ResolvedTextStyle,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::convert::acad_to_truck::{TextStroke, TruckEntity, TruckObject};
use crate::scene::convert::acad_to_truck::{GlyphRun, TextStroke, TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::SnapHint;
use crate::scene::text::lff;
@@ -295,7 +295,18 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
origin,
color: None,
fill_tris,
run: None,
// Carry a GlyphRun so the attribute renders as SDF glyph quads
// (same as TEXT); the strokes above are the historical fallback and
// are suppressed by the per-group SDF path.
run: Some(GlyphRun {
text: encoded,
font: resolved.font_name.clone(),
height: input.height as f32,
rotation,
width_factor,
oblique: oblique_angle,
tracking: 1.0,
}),
});
}
let _ = input.line_count; // round-trip only — recomputed above
+2 -186
View File
@@ -1084,7 +1084,7 @@ impl MultiLeaderTess for MultiLeader {
entity_color: [f32; 4],
line_weight_px: f32,
anno_scale: f32,
world_per_pixel: Option<f32>,
_world_per_pixel: Option<f32>,
bg_color: [f32; 4],
) -> Vec<crate::scene::model::wire_model::WireModel> {
use crate::scene::convert::tessellate::{
@@ -1508,28 +1508,7 @@ impl MultiLeaderTess for MultiLeader {
color_or_inherit(&ctx.text_color, entity_color)
};
// Same LOD ladder used for top-level Text / MText (see scene/mod.rs):
// h_px < 1 → baseline line (skip glyphs)
// 1 ≤ h < 5 → greeked rect in text color (skip glyphs)
// h_px ≥ 5 → full per-glyph stroke tessellation
let lod_h_px = world_per_pixel.map(|wpp| height / wpp);
let lod_mode = match lod_h_px {
Some(h) if h < 1.0 => 0,
Some(h) if h < 5.0 => 1,
_ => 2,
};
// Helper: map a (local_x, local_y) in the text's pre-rotation frame
// (origin at the insertion point) into WCS render space.
let to_wcs = |lx: f32, ly: f32| -> [f32; 3] {
[
local_ins_x + lx * cos_r - ly * sin_r,
local_ins_y + lx * sin_r + ly * cos_r,
z,
]
};
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
@@ -1597,169 +1576,6 @@ impl MultiLeaderTess for MultiLeader {
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
.map(|wpp| line_w / wpp)
.unwrap_or(f32::INFINITY);
if len_px >= 2.0 {
let line_y_local = v_offset;
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(),
color: text_color,
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: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
}
} else if lod_mode == 1 {
// One filled rect per line — keeps the visual "text lives here
// per row" hint that multi-line MText carries, in the text's
// own color. Empty `points` opts out of the face3d 0.45 dim so
// the fill renders at full intensity.
let mut greek_tris: Vec<[f32; 3]> = Vec::with_capacity(line_widths.len() * 6);
for (i, &line_w) in line_widths.iter().enumerate() {
let li = i as f32;
let line_y_bottom = -li * line_h + v_offset;
let line_y_top = line_y_bottom + height;
if line_w <= 0.0 {
continue;
}
let left = -line_w * h_anchor;
let right = line_w * (1.0 - h_anchor);
let bl = to_wcs(left, line_y_bottom);
let br = to_wcs(right, line_y_bottom);
let tr = to_wcs(right, line_y_top);
let tl = to_wcs(left, line_y_top);
greek_tris.extend_from_slice(&[bl, br, tr, bl, tr, tl]);
}
if !greek_tris.is_empty() {
wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(),
points: vec![],
points_low: Vec::new(),
color: text_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
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: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: greek_tris,
// fill_tris_low intentionally empty: this fill renders on
// the top-level path, where consumers treat a short low
// half as all-zero, so it draws at f32 precision (sub-
// metre error at UTM scale) — not a crash. Follow-up:
// double-single-split via points_to_ds to match emit_wire.
fill_tris_low: Vec::new(),
});
}
} else {
// Pre-tessellated by `layout_mtext`. Each TextStroke is in
// local glyph space with its world origin (already offset-
// relative because we passed local_ins_x/y) stored as f64.
let mut text_points: Vec<[f32; 3]> = Vec::new();
let mut text_fill_tris: Vec<[f32; 3]> = Vec::new();
for ts in &layout.strokes {
let ox = ts.origin[0] as f32;
let oy = ts.origin[1] as f32;
for stroke in &ts.strokes {
if stroke.len() < 2 {
continue;
}
text_points.push(nan);
for &[x, y] in stroke {
text_points.push([x + ox, y + oy, z]);
}
}
for &[x, y] in &ts.fill_tris {
text_fill_tris.push([x + ox, y + oy, z]);
}
}
let mut is_first = true;
if !text_points.is_empty() {
let snap = if is_first {
is_first = false;
vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)]
} else {
vec![]
};
wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(),
points: text_points,
points_low: Vec::new(),
color: text_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
}
if !text_fill_tris.is_empty() {
let snap = if is_first {
vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)]
} else {
vec![]
};
wires.push(WireModel {
text_verts: Vec::new(),
name: name.clone(),
points: vec![],
points_low: Vec::new(),
color: text_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: text_fill_tris,
// fill_tris_low intentionally empty: this fill renders on
// the top-level path, where consumers treat a short low
// half as all-zero, so it draws at f32 precision (sub-
// metre error at UTM scale) — not a crash. Follow-up:
// double-single-split via points_to_ds to match emit_wire.
fill_tris_low: Vec::new(),
});
}
}
// Text frame / background-fill rectangle in local frame, then rotated to WCS.
+23 -59
View File
@@ -492,10 +492,8 @@ pub fn tessellate_table(
// Accumulators keyed by quantised colour (+ weight for borders).
let mut fills: HashMap<[u8; 4], ([f32; 4], Vec<[f32; 3]>)> = HashMap::default();
let mut texts: HashMap<[u8; 4], ([f32; 4], Vec<[f32; 3]>, Vec<[f32; 3]>)> = HashMap::default();
// SDF cell text: glyph quads (per-vertex coloured) collected across all
// cells; emitted as one text-carrying wire at the end. Empty when SDF off.
let sdf_on = crate::scene::text::sdf_atlas::sdf_text_enabled();
// cells; emitted as one text-carrying wire at the end.
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
let mut borders: HashMap<([u8; 4], u32), ([f32; 4], f32, Vec<[f32; 3]>)> = HashMap::default();
let mut emitted: rustc_hash::FxHashSet<(i32, i32, i32, i32)> = rustc_hash::FxHashSet::default();
@@ -713,55 +711,29 @@ pub fn tessellate_table(
} else {
entity_color
};
if sdf_on {
// SDF: glyph quads per run at its placed origin (rotation baked
// in by layout_glyph_quads), coloured by the cell's text colour.
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 text_verts,
&quads,
[ts.origin[0], ts.origin[1], to.z as f64],
1.0,
tcol,
0.0,
);
}
}
} else {
let entry = texts
.entry(key4(tcol))
.or_insert_with(|| (tcol, Vec::new(), Vec::new()));
let buf = &mut entry.1;
let tris_buf = &mut entry.2;
// SDF glyph quads per run at its placed origin (rotation baked in by
// layout_glyph_quads), coloured by the cell's text colour.
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
for ts in &layout.strokes {
let sx = ts.origin[0] as f32;
let sy = ts.origin[1] as f32;
for stroke in &ts.strokes {
if stroke.len() < 2 {
continue;
}
if !buf.is_empty() {
buf.push([f32::NAN; 3]);
}
for &[x, y] in stroke {
buf.push([x + sx, y + sy, (to.z as f64) as f32]);
}
}
for &[x, y] in &ts.fill_tris {
tris_buf.push([x + sx, y + sy, (to.z as f64) as f32]);
}
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 text_verts,
&quads,
[ts.origin[0], ts.origin[1], to.z as f64],
1.0,
tcol,
0.0,
);
}
}
}
@@ -809,14 +781,6 @@ pub fn tessellate_table(
out.push(mk(color, pts, vec![], lw));
}
}
for (_, (color, pts, tris)) in texts {
if !pts.is_empty() {
out.push(mk(color, pts, vec![], line_weight_px));
}
if !tris.is_empty() {
out.push(mk(color, vec![], tris, line_weight_px));
}
}
// SDF cell text: one wire carrying the glyph quads (per-vertex coloured) +
// a glyph-bounds AABB (f64 accumulate → f32) so the text draws + picks;
// empty points so it adds no stroke geometry.
-3
View File
@@ -53,8 +53,6 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) {
pub struct TextPlacement {
/// Run-local origin (glyph space `[0,0]` maps here), WCS xy, kept f64.
pub origin: [f64; 2],
/// Entity elevation (WCS z).
pub elevation: f64,
pub height: f32,
pub rotation: f32,
pub width_factor: f32,
@@ -232,7 +230,6 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla
];
TextPlacement {
origin,
elevation: wsz,
height: t.height as f32,
rotation,
width_factor,
+6 -409
View File
@@ -1,5 +1,5 @@
use acadrust::types::aci_table::aci_to_rgb;
use acadrust::{CadDocument, EntityType};
use acadrust::CadDocument;
use crate::scene::convert::acad_to_truck::{GlyphRun, TextStroke};
use crate::scene::text::font_face::Face;
@@ -236,7 +236,9 @@ pub enum InlineColor {
True([f32; 3]),
}
/// Tab-stop alignment kind (from `\pt<L|C|R><pos>` entries).
/// Tab-stop alignment kind (from `\pt<L|C|R><pos>` entries). `Center` / `Right`
/// are DXF-spec kinds the parser does not emit yet (only `Left` is produced).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabKind {
Left,
@@ -297,6 +299,8 @@ pub enum MTextRunKind {
/// Renderable glyph text (DXF specials resolved, decoration markers stripped).
Glyphs(String),
/// `\t` — jump the cursor to the next tab stop (or default tab interval).
/// Handled in the layout matches but not emitted by the parser yet.
#[allow(dead_code)]
Tab,
}
@@ -482,87 +486,6 @@ pub fn adapt_mtext_paragraphs(
// `adapt_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`,
// `text_local_bounds`, and `resolve_dxf_special_chars`.
/// Total number of visible lines an MText renders to — explicit `\P` /
/// `\n` / `\N` breaks plus word-wrap induced sublines when
/// `rectangle_width > 0`. Drives LOD splits (greek-rect per row, baseline
/// counts) without re-running the full stroke tessellation; routes through
/// the same parse + atomise + wrap pipeline as `layout_mtext` so the LOD
/// row count and the rendered row count never disagree.
pub fn mtext_line_count(
m: &acadrust::entities::MText,
document: &CadDocument,
anno_scale: f32,
) -> usize {
let resolved = resolve_text_style(&m.style, document);
let entity_h = (m.height as f32) * anno_scale;
let base_wf_abs = resolved.width_factor.max(0.01);
let base_wf = if resolved.is_backward { -base_wf_abs } else { base_wf_abs };
let base_font_name = resolved.font_name.clone();
let rect_w = (m.rectangle_width as f32) * anno_scale;
let paragraphs = adapt_mtext_paragraphs(&m.value, entity_h, true);
let mut total = 0usize;
for para in &paragraphs {
let mut atoms: Vec<LayoutAtom> = Vec::new();
for run in &para.runs {
match &run.kind {
MTextRunKind::Glyphs(text) => {
let mut word = String::new();
for ch in text.chars() {
if ch == ' ' || ch == '\u{00A0}' {
if !word.is_empty() {
atoms.push(LayoutAtom {
kind: AtomKind::Word(std::mem::take(&mut word)),
state: run.state.clone(),
});
}
atoms.push(LayoutAtom {
kind: AtomKind::Space,
state: run.state.clone(),
});
} else {
word.push(ch);
}
}
if !word.is_empty() {
atoms.push(LayoutAtom {
kind: AtomKind::Word(word),
state: run.state.clone(),
});
}
}
MTextRunKind::Tab => atoms.push(LayoutAtom {
kind: AtomKind::Tab,
state: run.state.clone(),
}),
}
}
// Same edge-trim the renderer applies — otherwise a trailing space
// can inflate the wrap result by one extra sub-line.
let first_word = atoms
.iter()
.position(|a| !matches!(a.kind, AtomKind::Space))
.unwrap_or(atoms.len());
atoms.drain(..first_word);
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
atoms.pop();
}
let wrapped = wrap_paragraph(
atoms,
rect_w,
para.indent_first,
para.indent_left,
para.indent_right,
&para.tab_stops,
entity_h,
base_wf,
&base_font_name,
);
total += wrapped.len().max(1);
}
total.max(1)
}
// ──────────────────────────────────────────────────────────────────────────────
// Shared MText layout / render pipeline
// ──────────────────────────────────────────────────────────────────────────────
@@ -1255,332 +1178,6 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
glyph_boxes,
}
}
pub(crate) fn text_obb_corners_native(
e: &EntityType,
anno_scale: f32,
mtext_lines_override: Option<usize>,
) -> Option<[[f64; 3]; 4]> {
use acadrust::entities::attribute_definition::{
HorizontalAlignment as AttrHA, VerticalAlignment as AttrVA,
};
use acadrust::entities::{AttachmentPoint, TextHorizontalAlignment, TextVerticalAlignment};
let anno = anno_scale as f64;
// Map Attribute alignment enums to the same h/v anchor fractions used by
// Text. Kept local because the Attribute enum is distinct from Text's.
let attr_h_anchor = |ha: AttrHA| -> f64 {
match ha {
AttrHA::Left => 0.0,
AttrHA::Center | AttrHA::Middle | AttrHA::Aligned | AttrHA::Fit => 0.5,
AttrHA::Right => 1.0,
}
};
let attr_v_anchor = |va: AttrVA| -> f64 {
match va {
AttrVA::Baseline | AttrVA::Bottom => 0.0,
AttrVA::Middle => 0.5,
AttrVA::Top => 1.0,
}
};
let attr_use_align_pt = |ha: AttrHA, va: AttrVA| -> bool {
!matches!((ha, va), (AttrHA::Left, AttrVA::Baseline))
};
let (ix, iy, iz, w, h, rot, h_anchor, v_anchor) = match e {
EntityType::Text(t) => {
let h_world = t.height * anno;
let w_factor = if t.width_factor > 0.0 { t.width_factor } else { 1.0 };
let n = t.value.chars().count().max(1) as f64;
// Approximate glyph width: AutoCAD's stroke fonts average ~0.6 em.
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = match t.horizontal_alignment {
TextHorizontalAlignment::Left => 0.0,
TextHorizontalAlignment::Center
| TextHorizontalAlignment::Middle
| TextHorizontalAlignment::Aligned
| TextHorizontalAlignment::Fit => 0.5,
TextHorizontalAlignment::Right => 1.0,
};
let v_anchor = match t.vertical_alignment {
TextVerticalAlignment::Baseline | TextVerticalAlignment::Bottom => 0.0,
TextVerticalAlignment::Middle => 0.5,
TextVerticalAlignment::Top => 1.0,
};
// AutoCAD writes `alignment_point` (DXF 11) whenever the text
// isn't simple Left+Baseline; in that case it — not the
// insertion_point — is the anchor the alignment fractions map to.
let use_alignment_pt = !matches!(
(t.horizontal_alignment, t.vertical_alignment),
(
TextHorizontalAlignment::Left,
TextVerticalAlignment::Baseline,
)
);
let anchor = match (use_alignment_pt, t.alignment_point) {
(true, Some(p)) => p,
_ => t.insertion_point,
};
(
anchor.x,
anchor.y,
anchor.z,
w_world,
h_world,
t.rotation,
h_anchor,
v_anchor,
)
}
EntityType::AttributeDefinition(a) => {
let h_world = a.height * anno;
let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 };
// Render the tag in preview when no default; matches `attribute.rs`.
let display = if a.default_value.is_empty() { &a.tag } else { &a.default_value };
let n = display.chars().count().max(1) as f64;
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = attr_h_anchor(a.horizontal_alignment);
let v_anchor = attr_v_anchor(a.vertical_alignment);
let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) {
a.alignment_point
} else {
a.insertion_point
};
(
anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor,
)
}
EntityType::AttributeEntity(a) => {
let h_world = a.height * anno;
let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 };
let n = a.value.chars().count().max(1) as f64;
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = attr_h_anchor(a.horizontal_alignment);
let v_anchor = attr_v_anchor(a.vertical_alignment);
let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) {
a.alignment_point
} else {
a.insertion_point
};
(
anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor,
)
}
EntityType::Tolerance(t) => {
// Approximate: a feature control frame is roughly 1 line tall;
// width comes from char-count of the (already-stripped) text.
// GD&T symbols all advance one cell, so plain char count is close.
let raw_h = if t.text_height > 0.0 { t.text_height } else { 2.5 };
let h_world = raw_h * anno;
// Each cell ≈ 1.4 × height (matches `tolerance.rs` min_cell_w).
let n = t.text.chars().filter(|c| *c != '\n').count().max(1) as f64;
let w_world = h_world * 1.4 * n * 0.5; // rough — fine for LOD greek
// direction encodes rotation as a vector.
let rot = (t.direction.y).atan2(t.direction.x);
(
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
w_world,
h_world * 1.5, // frame is taller than glyph cap by ~0.5 h
rot,
0.0, // anchored at insertion point (bottom-left)
0.0,
)
}
EntityType::MText(m) => {
let h_world = m.height * anno;
let raw_lines = (m.value.matches('\n').count() + 1) as f64;
let effective_lines = match mtext_lines_override {
Some(n) => n.max(1) as f64,
None => raw_lines,
};
let w_world = if m.rectangle_width > 0.0 {
m.rectangle_width
} else {
h_world * 8.0 * effective_lines.max(1.0)
};
// Wrap-aware override beats `rectangle_height` — the stored
// height can be stale on DWGs that were re-saved without
// updating the bounds.
let total_h = if mtext_lines_override.is_some() {
h_world * effective_lines.max(1.0) * m.line_spacing_factor.max(0.5)
} else {
m.rectangle_height.unwrap_or(
h_world * raw_lines.max(1.0) * m.line_spacing_factor.max(0.5),
)
};
// MText `attachment_point` puts `insertion_point` at one of the
// 9 corners/midpoints of the text bbox. h_anchor / v_anchor are
// fractions from (left, bottom) of the bbox.
let (h_anchor, v_anchor) = match m.attachment_point {
AttachmentPoint::TopLeft => (0.0, 1.0),
AttachmentPoint::TopCenter => (0.5, 1.0),
AttachmentPoint::TopRight => (1.0, 1.0),
AttachmentPoint::MiddleLeft => (0.0, 0.5),
AttachmentPoint::MiddleCenter => (0.5, 0.5),
AttachmentPoint::MiddleRight => (1.0, 0.5),
AttachmentPoint::BottomLeft => (0.0, 0.0),
AttachmentPoint::BottomCenter => (0.5, 0.0),
AttachmentPoint::BottomRight => (1.0, 0.0),
};
(
m.insertion_point.x,
m.insertion_point.y,
m.insertion_point.z,
w_world,
total_h,
m.rotation,
h_anchor,
v_anchor,
)
}
_ => return None,
};
let x0 = -h_anchor * w;
let x1 = (1.0 - h_anchor) * w;
let y0 = -v_anchor * h;
let y1 = (1.0 - v_anchor) * h;
let (s, c) = (rot.sin(), rot.cos());
let rot_pt = |lx: f64, ly: f64| -> [f64; 3] {
let rx = lx * c - ly * s;
let ry = lx * s + ly * c;
[ix + rx, iy + ry, iz]
};
Some([
rot_pt(x0, y0),
rot_pt(x1, y0),
rot_pt(x1, y1),
rot_pt(x0, y1),
])
}
pub(crate) fn text_baseline_points(
e: &EntityType,
anno_scale: f32,
n_lines: usize,
) -> Vec<[f32; 3]> {
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
return vec![];
};
let line_h = match e {
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32,
EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32,
EntityType::Tolerance(t) => {
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
(raw * anno_scale as f64) as f32
}
_ => return vec![],
};
if line_h <= 0.0 {
return vec![];
}
let n_lines = n_lines.max(1);
let cast = |p: [f64; 3]| -> [f32; 3] {
[(p[0]) as f32, (p[1]) as f32, (p[2]) as f32]
};
let bl = cast(corners[0]);
let br = cast(corners[1]);
let full_tl = cast(corners[3]);
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
if ulen < 1e-9 {
return vec![bl, br];
}
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
let mut pts = Vec::with_capacity(n_lines * 3);
for i in 0..n_lines {
// i = 0 is the topmost line — its bottom sits one line_h below
// the OBB top (≈ `ulen`). For n_lines > ulen/line_h the deepest
// baselines clamp to the OBB bottom.
let bot_off = ((i + 1) as f32) * line_h;
let along = (ulen - bot_off).max(0.0);
let p0 = [bl[0] + nx * along, bl[1] + ny * along, bl[2] + nz * along];
let p1 = [br[0] + nx * along, br[1] + ny * along, br[2] + nz * along];
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
pts.extend_from_slice(&[p0, p1]);
}
pts
}
/// Filled tris for a greeked top-level Text / MText. One 2-triangle rect
/// per visible line — `n_lines` is the actual rendered line count from
/// `mtext_line_count` (1 for Text). Stacked top → bottom along the OBB's
/// up direction. The face3d pipeline skips its 0.45 dim for wires with
/// empty `points`, so these tris render at the literal text color.
pub(crate) fn text_greek_obb_tris(
e: &EntityType,
anno_scale: f32,
n_lines: usize,
) -> Vec<[f32; 3]> {
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
return vec![];
};
let line_h = match e {
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32,
EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32,
EntityType::Tolerance(t) => {
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
(raw * anno_scale as f64) as f32
}
_ => return vec![],
};
if line_h <= 0.0 {
return vec![];
}
let n_lines = n_lines.max(1);
let cast = |p: [f64; 3]| -> [f32; 3] {
[(p[0]) as f32, (p[1]) as f32, (p[2]) as f32]
};
let bl = cast(corners[0]);
let br = cast(corners[1]);
let full_tl = cast(corners[3]);
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
if ulen < 1e-9 {
return vec![];
}
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
let mut tris = Vec::with_capacity(n_lines * 6);
for i in 0..n_lines {
let top_along = (ulen - (i as f32) * line_h).max(0.0);
let bot_along = (ulen - ((i + 1) as f32) * line_h).max(0.0);
let tl = [
bl[0] + nx * top_along,
bl[1] + ny * top_along,
bl[2] + nz * top_along,
];
let tr = [
br[0] + nx * top_along,
br[1] + ny * top_along,
br[2] + nz * top_along,
];
let lbl = [
bl[0] + nx * bot_along,
bl[1] + ny * bot_along,
bl[2] + nz * bot_along,
];
let lbr = [
br[0] + nx * bot_along,
br[1] + ny * bot_along,
br[2] + nz * bot_along,
];
tris.extend_from_slice(&[lbl, lbr, tr, lbl, tr, tl]);
}
tris
}
#[cfg(test)]
mod tests {
+12 -329
View File
@@ -76,16 +76,6 @@ pub struct LocalWire {
/// expand-time: transform corners by the Insert transform → world AABB
/// → test against the camera's world-space view rect.
pub aabb_local: [f32; 4],
/// For Text / MText subs: the entity's anno-scaled glyph height in
/// local units. Lets `emit_wire` apply the same LOD ladder used for
/// top-level text (cull / greek / full) to text that's been baked into
/// a block defn. `None` for non-text entities.
pub text_height_local: Option<f32>,
/// For Text / MText subs: the 4 OBB corners in block-local coords
/// (rotation, anchor offsets and width-approximation already applied).
/// Emitted at greek time so the rect matches the text's orientation
/// instead of falling back to the axis-aligned bbox.
pub text_obb_local: Option<[[f64; 3]; 4]>,
}
#[derive(Clone, Debug)]
@@ -435,35 +425,6 @@ fn tessellate_sub_local(
return vec![];
}
// 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
// path can emit a rect that matches the entity's rotation instead of
// falling back to the axis-aligned `aabb_local`. Kept in f64 so it stays
// precise at UTM scale (the emit path splits to double-single). For MText
// the OBB height is built from the wrap-expanded line count so
// emit_greeked_text can split it into per-line rows.
let mtext_lines = match sub {
EntityType::MText(m) => Some(
crate::entities::text_support::mtext_line_count(m, doc, anno_scale),
),
_ => None,
};
let text_obb_local: Option<[[f64; 3]; 4]> =
crate::entities::text_support::text_obb_corners_native(sub, anno_scale, mtext_lines);
let mut result = Vec::with_capacity(wires_out.len());
for wire in wires_out {
// Per-wire point-count cap: a single wire that exceeds this is skipped
@@ -475,8 +436,15 @@ fn tessellate_sub_local(
// Geometry is stored absolute; the double-single (high/low) render path
// keeps it precise at UTM scale, so no per-defn offset is subtracted.
// SDF text wires have no points/fills — fold in the glyph-quad positions
// so the view-frustum cull uses the text's real box, not a degenerate
// point at the block origin (which would drop the text entirely).
let aabb_local = aabb_from_points_iter(
wire.points.iter().copied().chain(wire.fill_tris.iter().copied()),
wire.points
.iter()
.copied()
.chain(wire.fill_tris.iter().copied())
.chain(wire.text_verts.iter().map(|v| v.pos)),
);
let is_fill_only = wire.points.is_empty() && !wire.fill_tris.is_empty();
@@ -503,8 +471,6 @@ fn tessellate_sub_local(
lt_l0,
lw_l0,
aabb_local,
text_height_local,
text_obb_local,
});
}
result
@@ -678,230 +644,6 @@ pub fn expand_insert(
Some(batches.finalize(&name, selected, bg_color))
}
/// Emit a greeked filled rect (2 triangles) for a text LocalWire. Stays in
/// the text's own color — the face3d pipeline skips its 0.45 dim for wires
/// whose `points` are empty, so the rect lands at full intensity. Clamped
/// to a single line's height so multi-line MText doesn't blow the box up
/// to the full block. Mirrors `text_greek_obb_tris` in scene/mod.rs.
/// Falls back to the axis-aligned `local_aabb` when no OBB was cached;
/// xref fade still applies via the shared color resolution.
fn emit_greeked_text(
lw: &LocalWire,
local_aabb: [f32; 4],
accum_xform: &Transform,
ctx: &ExpandCtx,
out: &mut Batches,
) {
// Transform absolute f64 corners and keep the result in f64 so the
// per-vertex double-single split below stays precise at UTM-scale coords.
let xf = |p: [f64; 3]| -> [f64; 3] {
let w = accum_xform.apply(Vector3::new(p[0], p[1], p[2]));
[w.x, w.y, w.z]
};
// Per-line rects: split the OBB by `h_local` so each visible wrap
// line gets its own box. Falls back to the axis-aligned AABB when no
// OBB / height was cached.
let tris: Vec<[f64; 3]> = if let (Some(obb), Some(h_local)) =
(lw.text_obb_local, lw.text_height_local)
{
if h_local <= 0.0 {
return;
}
let h = h_local as f64;
let (ux, uy, uz) = (
obb[3][0] - obb[0][0],
obb[3][1] - obb[0][1],
obb[3][2] - obb[0][2],
);
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
if ulen < 1e-9 {
return;
}
let n_lines = ((ulen / h).round() as usize).max(1);
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
let bl_local = obb[0];
let br_local = obb[1];
let mut acc = Vec::with_capacity(n_lines * 6);
for i in 0..n_lines {
let top_along = (ulen - (i as f64) * h).max(0.0);
let bot_along = (ulen - ((i + 1) as f64) * h).max(0.0);
let tl = xf([
bl_local[0] + nx * top_along,
bl_local[1] + ny * top_along,
bl_local[2] + nz * top_along,
]);
let tr = xf([
br_local[0] + nx * top_along,
br_local[1] + ny * top_along,
br_local[2] + nz * top_along,
]);
let bl = xf([
bl_local[0] + nx * bot_along,
bl_local[1] + ny * bot_along,
bl_local[2] + nz * bot_along,
]);
let br = xf([
br_local[0] + nx * bot_along,
br_local[1] + ny * bot_along,
br_local[2] + nz * bot_along,
]);
acc.extend_from_slice(&[bl, br, tr, bl, tr, tl]);
}
acc
} else {
let [x0, y0, x1, y1] = local_aabb;
let bl = xf([x0 as f64, y0 as f64, 0.0]);
let br = xf([x1 as f64, y0 as f64, 0.0]);
let tr = xf([x1 as f64, y1 as f64, 0.0]);
let tl = xf([x0 as f64, y1 as f64, 0.0]);
vec![bl, br, tr, bl, tr, tl]
};
if tris.is_empty() {
return;
}
let final_color = resolve_wire_color(lw, ctx);
let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, true);
let entry = out
.by_style
.entry(key)
.or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, true));
for p in tris {
let (hx, lx) = WireModel::split_ds(p[0]);
let (hy, ly) = WireModel::split_ds(p[1]);
let (hz, lz) = WireModel::split_ds(p[2]);
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.fill_tris.push([hx, hy, hz]);
entry.fill_tris_low.push([lx, ly, lz]);
}
}
/// Emit a 2-point baseline line for a sub-pixel text LocalWire — same color
/// resolution as `emit_wire`, but the wire's stored glyph points are
/// replaced with the OBB's baseline edge (`obb[0] → obb[1]`). Falls back to
/// a no-op when no OBB is cached (rotation/attachment unknown).
fn emit_text_baseline(
lw: &LocalWire,
accum_xform: &Transform,
ctx: &ExpandCtx,
out: &mut Batches,
wpp: f32,
) {
let Some(obb) = lw.text_obb_local else {
return;
};
let Some(h_local) = lw.text_height_local else {
return;
};
if h_local <= 0.0 {
return;
}
let xf = |p: [f64; 3]| -> [f64; 3] {
let w = accum_xform.apply(Vector3::new(p[0], p[1], p[2]));
[w.x, w.y, w.z]
};
// Single line: just obb[0] → obb[1]. Skip when the projected length
// falls under 2 px (single-char text seen edge-on). All wrap lines
// share the same baseline length, so checking once is enough.
let p0_world = xf(obb[0]);
let p1_world = xf(obb[1]);
let dx = p1_world[0] - p0_world[0];
let dy = p1_world[1] - p0_world[1];
let len_px = (dx * dx + dy * dy).sqrt() / wpp as f64;
if len_px < 2.0 {
return;
}
// Compute the per-line baseline endpoints in *local* frame, then
// transform — keeps precision for distant text and matches the
// greek/baseline math used by the rect path.
let (ux, uy, uz) = (
obb[3][0] - obb[0][0],
obb[3][1] - obb[0][1],
obb[3][2] - obb[0][2],
);
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
let n_lines = if ulen < 1e-9 {
1
} else {
((ulen / h_local as f64).round() as usize).max(1)
};
let (nx, ny, nz) = if ulen < 1e-9 {
(0.0, 0.0, 0.0)
} else {
(ux / ulen, uy / ulen, uz / ulen)
};
let final_color = resolve_wire_color(lw, ctx);
let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, false);
let entry = out
.by_style
.entry(key)
.or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, false));
let bl_local = obb[0];
let br_local = obb[1];
for i in 0..n_lines {
let along = if ulen < 1e-9 {
0.0
} else {
(ulen - ((i + 1) as f64) * h_local as f64).max(0.0)
};
let p0 = xf([
bl_local[0] + nx * along,
bl_local[1] + ny * along,
bl_local[2] + nz * along,
]);
let p1 = xf([
br_local[0] + nx * along,
br_local[1] + ny * along,
br_local[2] + nz * along,
]);
let needs_sep = !entry.points.is_empty()
&& !entry.points.last().map(|p| p[0].is_nan()).unwrap_or(false);
if needs_sep {
entry.points.push([f32::NAN; 3]);
entry.points_low.push([0.0; 3]);
}
for q in [p0, p1] {
let (hx, lx) = WireModel::split_ds(q[0]);
let (hy, ly) = WireModel::split_ds(q[1]);
let (hz, lz) = WireModel::split_ds(q[2]);
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.points.push([hx, hy, hz]);
entry.points_low.push([lx, ly, lz]);
}
}
}
fn aabb_pixel_size(local_aabb: [f32; 4], world_per_pixel: f32) -> f32 {
let w = (local_aabb[2] - local_aabb[0]).abs();
let h = (local_aabb[3] - local_aabb[1]).abs();
@@ -1135,12 +877,6 @@ fn expand_defn(
eprintln!("block_cache: nested-block depth > {MAX_NESTING_DEPTH}, truncating");
return;
}
// Track the last text OBB that received a LOD substitute (baseline line
// or greeked box). Colour-split MTEXT produces one outline wire per
// \C/\c segment; only the first outline from a source entity should
// emit the substitute — subsequent segments use the same full-document
// OBB and would draw overlapping rectangles with the wrong colour.
let mut last_lod_obb: Option<[[f64; 3]; 4]> = None;
for sub in &defn.subs {
match sub {
LocalSub::Wire(lw) => {
@@ -1160,66 +896,13 @@ fn expand_defn(
}
}
if let Some(wpp) = ctx.world_per_pixel {
// Text/MText follows its own ladder below (baseline-line
// / greek / full) — must reach it even when the wire's
// AABB falls under MIN_PIXEL_SIZE.
let is_text = lw.text_height_local.is_some();
// SDF text wires carry glyph quads but no points; they
// render at every zoom (no text LOD), so exempt them from
// the sub-pixel cull that drops tiny stroke / fill geometry.
let is_text = !lw.text_verts.is_empty();
if !is_text && aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
continue;
}
// Text LOD ladder: text inside a block follows the same
// 1 / 5 px baseline/greek/full rules as top-level text.
// We apply the Insert's transform scale to the stored
// local glyph height to get the screen height.
if let Some(h_local) = lw.text_height_local {
let m = &accum_xform.matrix.m;
let sy = ((m[1][0] * m[1][0]
+ m[1][1] * m[1][1]
+ m[1][2] * m[1][2]) as f64)
.sqrt() as f32;
let h_world = h_local * sy;
let h_px = h_world / wpp;
if lw.is_fill_only {
// Fill-only wire: suppress at text LOD thresholds.
// The outline sibling carries the LOD substitute
// (baseline line or greeked box). Emitting every
// glyph triangle at sub-pixel zoom defeats the
// purpose of the LOD ladder.
if h_px < 5.0 {
continue;
}
} else {
if h_px < 1.0 {
// Colour-split MTEXT: only the first outline
// wire from a source entity emits the baseline
// substitute. Siblings share the same
// text_obb_local and would stack identical
// overlapping lines (last colour wins) — the
// same dedup the greek branch does, one tier
// down.
if lw.text_obb_local == last_lod_obb {
continue;
}
last_lod_obb = lw.text_obb_local;
emit_text_baseline(lw, accum_xform, ctx, out, wpp);
continue;
}
if h_px < 5.0 {
// Colour-split MTEXT: only the first outline
// wire from a source entity emits the greek
// substitute. Subsequent segments share the
// same text_obb_local and would draw
// overlapping rectangles with the wrong
// (last-wins) colour.
if lw.text_obb_local == last_lod_obb {
continue;
}
last_lod_obb = lw.text_obb_local;
emit_greeked_text(lw, local, accum_xform, ctx, out);
continue;
}
}
}
}
emit_wire(lw, accum_xform, ctx, out);
}
+14 -135
View File
@@ -93,10 +93,17 @@ pub(crate) fn tessellate_entity(
let w_px = (ab[2] - ab[0]).abs();
let h_px = (ab[3] - ab[1]).abs();
// Keep in sync with `cache::block_cache::MIN_PIXEL_SIZE`.
// Text/MText have their own LOD ladder below
// (baseline-line / greek / full) and must reach it
// even when projected size is sub-5 px.
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
// Text entities render as SDF glyph quads (crisp at every
// zoom, no LOD), so they must reach the full path even at
// sub-5 px — never substitute the stub.
let is_text = matches!(
e,
EntityType::Text(_)
| EntityType::MText(_)
| EntityType::AttributeDefinition(_)
| EntityType::AttributeEntity(_)
| EntityType::Tolerance(_)
);
// Face3D is exempt from the sub-pixel stub: it is trivially
// cheap to tessellate (4 corners → 2 tris), so there is no
// cost to draw it full at any zoom, and the cube-stub
@@ -618,137 +625,9 @@ pub(crate) fn tessellate_entity(
let aabb = entity_aabb(e);
// Text-specific LOD ladder, keyed off the entity's glyph height in
// pixels (anno-scaled):
// < 1 px → baseline line in the text's color (text-here hint)
// 1–5 px → greeked OBB rect in the text's color
// ≥ 5 px → full per-glyph stroke tessellation
//
// Applies to every entity that is "primarily a piece of text" — Text,
// MText, ATTDEF, ATTRIB, Tolerance — so far-out drawings don't pay the
// full glyph-tessellation cost. Composite entities (Dimension, Table,
// MultiLeader) carry non-text geometry and have their own LOD paths.
if let Some(wpp) = world_per_pixel {
let text_height: Option<f64> = match e {
EntityType::Text(t) => Some(t.height * anno_scale as f64),
EntityType::MText(m) => Some(m.height * anno_scale as f64),
EntityType::AttributeDefinition(a) => Some(a.height * anno_scale as f64),
EntityType::AttributeEntity(a) => Some(a.height * anno_scale as f64),
EntityType::Tolerance(t) => {
// Tolerance text_height defaults to 0.18 from creation; treat
// 0 as missing and fall back to the AutoCAD default so the
// pixel check still kicks in for legitimately tiny dimensions.
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
Some(raw * anno_scale as f64)
}
_ => None,
};
// SDF text renders crisply at every zoom, so bypass the baseline/greek
// 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).
let n_lines = match e {
EntityType::MText(m) => {
crate::entities::text_support::mtext_line_count(m, document, anno_scale)
}
_ => 1,
};
if h_px < 1.0 {
let pts = crate::entities::text_support::text_baseline_points(e, anno_scale, n_lines);
if pts.len() < 2 {
return vec![];
}
// Skip the baseline too if the line itself projects under
// 2 px (e.g. a 1-char text seen edge-on). All wrap lines
// share the same baseline length, so the first segment is
// a representative sample.
let dx = pts[1][0] - pts[0][0];
let dy = pts[1][1] - pts[0][1];
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
if len_px < 2.0 {
// Text projects to under 2 px — fall back to the
// generic LOD stub so the entity stays visible /
// selectable. #19. Text is 2-D in the XY plane so
// z_min = z_max = 0 keeps the historical behaviour.
return vec![lod_stub_wire(
h.value().to_string(),
entity_color,
sel,
aci,
aabb,
0.0,
0.0,
)];
}
return vec![WireModel {
text_verts: Vec::new(),
name: h.value().to_string(),
points: pts,
points_low: Vec::new(),
color: entity_color,
selected: sel,
aci,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}];
}
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
let fill_tris = crate::entities::text_support::text_greek_obb_tris(e, anno_scale, n_lines);
if fill_tris.is_empty() {
// Text greek fallback: also 2-D, keep stub at z=0.
return vec![lod_stub_wire(
h.value().to_string(),
entity_color,
sel,
aci,
aabb,
0.0,
0.0,
)];
}
// Greek text renders via the face3d fill batch, which colours
// each tri with `wire.color`. Bake the selected colour in so
// a selected text stays highlighted across the LOD boundary.
// 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(),
color: fill_color,
selected: sel,
aci,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb,
plinegen: true,
vp_scissor: None,
fill_tris,
fill_tris_low: Vec::new(),
}];
}
}
}
// TEXT / MTEXT / ATTDEF / ATTRIB / Tolerance all render as SDF glyph quads
// (crisp at every zoom), so there is no text LOD ladder — they fall through
// to the full tessellation path below.
let mut bases = convert::tessellate::tessellate(
document,
+9 -11
View File
@@ -210,8 +210,7 @@ pub fn tessellate(
// 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())) {
for group in stroke_groups.iter().filter(|g| g.run.is_none()) {
let lx_v = group.origin[0];
let ly_v = group.origin[1];
let slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v;
@@ -249,12 +248,12 @@ 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.
// Build each 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_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.
@@ -309,7 +308,7 @@ pub fn tessellate(
// 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 text_aabb = if !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 {
@@ -337,7 +336,7 @@ pub fn tessellate(
// 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_on && text_aabb != WireModel::UNBOUNDED_AABB {
if 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,8 +428,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_on
&& !sdf_verts.is_empty()
if !sdf_verts.is_empty()
&& crate::scene::text::sdf_atlas::text_box_debug()
{
let [nx, ny, xx, xy] = text_aabb;
+49 -54
View File
@@ -145,37 +145,34 @@ pub fn click_hit<'a>(
// 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());
}
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
best_box
}
/// Like `click_hit` but returns every wire within the click threshold,
@@ -219,32 +216,30 @@ pub fn click_hits_all<'a>(
// 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));
}
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));
+16 -37
View File
@@ -69,10 +69,8 @@ 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();
// Embedded linetype text (LtSeg::Text) renders as SDF glyph quads collected
// here; emitted as one extra text-carrying WireModel at the end.
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
let mut elem_idx: usize = 0;
@@ -180,46 +178,27 @@ 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);
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]],
// 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,
);
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);
}
}
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,
);
}
elem_idx += 1;
+1 -10
View File
@@ -39,18 +39,9 @@ pub fn text_atlas() -> &'static Mutex<GlyphAtlas> {
ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(1024, 1024)))
}
/// Whether SDF text rendering is enabled, read once. Default ON — text draws
/// as SDF glyph quads (crisp at every zoom, no LOD; the wire path suppresses
/// the glyph strokes and keeps the insertion snap). Set `OCS_TEXT_SDF=0` to
/// fall back to the legacy stroke path (kill switch for the stroke renderer).
pub fn sdf_text_enabled() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var("OCS_TEXT_SDF").map_or(true, |v| v != "0"))
}
/// 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`].
/// Read once.
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())
-3
View File
@@ -798,9 +798,6 @@ impl Scene {
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());
}
{
let cache = self.sdf_text_cache.borrow();
if let Some((id, verts)) = cache.as_ref() {