From 0584e3c862e2724b589b7316489bb2e803ed4b95 Mon Sep 17 00:00:00 2001 From: Hakan Date: Fri, 15 May 2026 13:19:43 +0300 Subject: [PATCH] perf(scene): text LOD ladder 1/5 px with baseline-line tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 2/4 drop/greek/full ladder with 1/5 baseline/greek/full so zoomed-out text still leaves a visual hint instead of disappearing: h_px < 1 → OBB baseline line in the text color 1 ≤ h < 5 → greeked OBB rect h_px ≥ 5 → full per-glyph stroke Extra guard: in the baseline tier, the line itself is dropped when its projected length is under 2 px (single-char text seen edge-on would otherwise emit a sub-pixel segment). Text/MText is exempted from the generic 5 px AABB cull (top-level and block-defn paths) so it can reach this ladder even when its bounding box projects below the threshold. Co-Authored-By: Claude Opus 4.7 --- src/scene/block_cache.rs | 94 +++++++++++++++++++++++++++++++++++++--- src/scene/mod.rs | 64 ++++++++++++++++++++++++--- 2 files changed, 145 insertions(+), 13 deletions(-) diff --git a/src/scene/block_cache.rs b/src/scene/block_cache.rs index d5f1e427e..634840279 100644 --- a/src/scene/block_cache.rs +++ b/src/scene/block_cache.rs @@ -718,6 +718,83 @@ fn emit_greeked_text( } } +/// 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, + defn_lo: [f64; 3], + ctx: &ExpandCtx, + out: &mut Batches, + wpp: f32, +) { + let Some(obb) = lw.text_obb_local else { + return; + }; + let [ox, oy, oz] = ctx.world_offset; + let [lo_x, lo_y, lo_z] = defn_lo; + let xf = |p: [f32; 3]| -> [f32; 3] { + let w = accum_xform.apply(Vector3::new( + p[0] as f64 + lo_x, + p[1] as f64 + lo_y, + p[2] as f64 + lo_z, + )); + [(w.x - ox) as f32, (w.y - oy) as f32, (w.z - oz) as f32] + }; + let p0 = xf(obb[0]); + let p1 = xf(obb[1]); + + // Skip the baseline too if the line itself projects under 2 px on screen. + let dx = p1[0] - p0[0]; + let dy = p1[1] - p0[1]; + let len_px = (dx * dx + dy * dy).sqrt() / wpp; + if len_px < 2.0 { + return; + } + + let final_color = if ctx.selected { + WireModel::SELECTED + } else if lw.color_is_byblock { + ctx.ins_color + } else { + lw.color + }; + let final_color = if ctx.is_xref && !ctx.selected { + fade_toward_bg(final_color, ctx.bg_color) + } else { + final_color + }; + + let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, 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)); + + 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]); + } + for q in [p0, p1] { + if q[0] < entry.min_x { + entry.min_x = q[0]; + } + if q[1] < entry.min_y { + entry.min_y = q[1]; + } + if q[0] > entry.max_x { + entry.max_x = q[0]; + } + if q[1] > entry.max_y { + entry.max_y = q[1]; + } + entry.points.push(q); + } +} + 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(); @@ -918,13 +995,17 @@ fn expand_defn( } } if let Some(wpp) = ctx.world_per_pixel { - if aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE { + // 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(); + if !is_text && aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE { continue; } // Text LOD ladder: text inside a block follows the same - // 5 / 10 px cull/greek/full rules as top-level text. We - // have to apply the Insert's transform scale to the - // stored local glyph height to get the screen height. + // 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] @@ -933,10 +1014,11 @@ fn expand_defn( .sqrt() as f32; let h_world = h_local * sy; let h_px = h_world / wpp; - if h_px < 2.0 { + if h_px < 1.0 { + emit_text_baseline(lw, accum_xform, defn_lo, ctx, out, wpp); continue; } - if h_px < 4.0 { + if h_px < 5.0 { emit_greeked_text(lw, local, accum_xform, defn_lo, ctx, out); continue; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 4f237a5d0..af29fa66b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3913,7 +3913,11 @@ fn tessellate_entity( let w = (ab[2] - ab[0]).abs(); let h = (ab[3] - ab[1]).abs(); // Keep in sync with `block_cache::MIN_PIXEL_SIZE`. - if w.max(h) / wpp < 5.0 { + // 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(_)); + if !is_text && w.max(h) / wpp < 5.0 { return vec![]; } } @@ -4131,9 +4135,9 @@ fn tessellate_entity( // Text-specific LOD ladder, keyed off the entity's glyph height in // pixels (anno-scaled): - // < 2 px → drop entirely (illegible, no point even greeking) - // 2–4 px → emit a greeked rect in the text's color - // ≥ 4 px → full per-glyph stroke tessellation + // < 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 if let Some(wpp) = world_per_pixel { let text_height: Option = match e { EntityType::Text(t) => Some(t.height * anno_scale as f64), @@ -4142,10 +4146,38 @@ fn tessellate_entity( }; if let Some(h_world) = text_height { let h_px = (h_world as f32) / wpp; - if h_px < 2.0 { - return vec![]; + if h_px < 1.0 { + let pts = text_baseline_points(e, anno_scale, world_offset); + 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). + 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 { + return vec![]; + } + return vec![WireModel { + name: h.value().to_string(), + points: pts, + 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![], + }]; } - if h_px < 4.0 && aabb != WireModel::UNBOUNDED_AABB { + if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB { let fill_tris = text_greek_obb_tris(e, anno_scale, world_offset); // The face3d pipeline dims fill_tris colors to 45% to fake // ambient occlusion on PolyfaceMesh / 3DFACE solids. Greeked @@ -4335,6 +4367,24 @@ pub(crate) fn text_obb_corners_native( ]) } +/// Baseline endpoints (`p00 → p10` of the OBB) for a sub-pixel Text / MText, +/// in world-offset-subtracted f32 space. Used as the lowest LOD step before +/// the entity is dropped entirely. +fn text_baseline_points( + e: &EntityType, + anno_scale: f32, + world_offset: [f64; 3], +) -> Vec<[f32; 3]> { + let Some(corners) = text_obb_corners_native(e, anno_scale) else { + return vec![]; + }; + let [ox, oy, oz] = world_offset; + let cast = |p: [f64; 3]| -> [f32; 3] { + [(p[0] - ox) as f32, (p[1] - oy) as f32, (p[2] - oz) as f32] + }; + vec![cast(corners[0]), cast(corners[1])] +} + /// 6-vertex (2-triangle) fill for a greeked top-level Text / MText, in /// world-offset-subtracted f32 space. fn text_greek_obb_tris(