diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index c0d7c51b9..5c0105144 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -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 diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 8f86dbcfc..66db05819 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -1084,7 +1084,7 @@ impl MultiLeaderTess for MultiLeader { entity_color: [f32; 4], line_weight_px: f32, anno_scale: f32, - world_per_pixel: Option, + _world_per_pixel: Option, bg_color: [f32; 4], ) -> Vec { 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. diff --git a/src/entities/table.rs b/src/entities/table.rs index e41b3f811..1085bb267 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -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 = 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. diff --git a/src/entities/text.rs b/src/entities/text.rs index 5df593e04..492bf6b76 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -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, diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 5dbea79d7..e5127bcda 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -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` entries). +/// Tab-stop alignment kind (from `\pt` 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 ¶graphs { - let mut atoms: Vec = Vec::new(); - for run in ¶.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, - ¶.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, -) -> 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 { diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index d013a37f9..acbce744f 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -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, - /// 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 = 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); } diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 8505787c2..bb0912ae3 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -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 = 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, diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index bfa532826..88bedf7a8 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -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 = 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; diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 1eff22856..4d5c8a800 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -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)); diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 159cc345f..d07c81cae 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -69,10 +69,8 @@ pub fn apply_along( let mut strokes: Vec> = 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 = 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; diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index 021678faa..c7dc3128a 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -39,18 +39,9 @@ pub fn text_atlas() -> &'static Mutex { 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 = 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 = OnceLock::new(); *ON.get_or_init(|| std::env::var_os("OCS_TEXT_BOX").is_some()) diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 542cb7bdf..e850a3be9 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -798,9 +798,6 @@ impl Scene { wire_content_id: u64, ) -> std::sync::Arc> { 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() {