From f4ae0e77739147659f1fd837d186406c64244c29 Mon Sep 17 00:00:00 2001 From: Hakan Date: Fri, 15 May 2026 17:41:56 +0300 Subject: [PATCH] fix(scene): per-line text greek honors word-wrap subline count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `text_obb_corners_native` derived MText line count from explicit `\n` matches in `m.value`, so any MText that relied on `\P`/`\N` breaks or word-wrap (rectangle_width-driven) reported a single line. The greek emitter then split the OBB into one row and the user saw a single box on top of a 5-line wrapped paragraph. - text_support: new `mtext_line_count` — strip codes, split on \n/\P/\N, word-wrap each paragraph against `rectangle_width`, sum. Same line splitting the MText/MultiLeader renderers already perform; centralized for reuse. - text_obb_corners_native: takes `mtext_lines_override: Option`. When supplied, OBB height = h_world × n × line_spacing_factor; this also overrides a possibly stale `rectangle_height` on resaved DWGs. - Top-level greek paths (scene/mod.rs) compute the line count via `mtext_line_count` and pass it through to baseline / rect emission. - block_cache: tessellate_sub_local computes the count at defn build so the stored `text_obb_local` is wrap-aware; emit_text_baseline and emit_greeked_text recover n_lines from `ulen / h_local` as before and now produce the correct per-row split inside Inserts. Co-Authored-By: Claude Opus 4.7 --- src/entities/text_support.rs | 29 ++++++ src/scene/block_cache.rs | 189 +++++++++++++++++++++++++---------- src/scene/mod.rs | 162 +++++++++++++++++++++++------- 3 files changed, 287 insertions(+), 93 deletions(-) diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 14a524f9d..c620a3d17 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -341,6 +341,35 @@ pub fn measure_mtext_chars(text: &str, scale: f32, font: &cxf::CxfFile) -> f32 { width } +/// Total number of visible lines an MText renders to — explicit `\P` / +/// `\n` / `\N` breaks plus word-wrap induced sublines when +/// `rectangle_width > 0`. Mirrors the line-splitting `entities/mtext.rs` +/// and `tessellate_multileader` perform; lets renderers split the OBB +/// into per-row LOD primitives without re-running the wrap measurement. +pub fn mtext_line_count( + m: &acadrust::entities::MText, + document: &CadDocument, + anno_scale: f32, +) -> usize { + let resolved = resolve_text_style(&m.style, document); + let font = cxf::get_font(&resolved.font_name); + let width_factor = resolved.width_factor.max(0.01); + let height = m.height as f32 * anno_scale; + let plain = strip_mtext_codes(&m.value); + let explicit = split_mtext_lines(&plain); + let total: usize = if m.rectangle_width > 0.0 && height > 0.0 { + let scale = height / 9.0 * width_factor; + let max_w = m.rectangle_width as f32 * anno_scale; + explicit + .iter() + .map(|l| word_wrap(l, max_w, scale, font).len()) + .sum() + } else { + explicit.len() + }; + total.max(1) +} + pub fn word_wrap(text: &str, max_w: f32, scale: f32, font: &'static cxf::CxfFile) -> Vec { if max_w <= 0.0 || text.is_empty() { return vec![text.to_string()]; diff --git a/src/scene/block_cache.rs b/src/scene/block_cache.rs index 58731026c..f333c6af1 100644 --- a/src/scene/block_cache.rs +++ b/src/scene/block_cache.rs @@ -423,9 +423,17 @@ fn tessellate_sub_local( // so the greek path can emit a rect that matches the entity's rotation // instead of falling back to the axis-aligned `aabb_local`. Subtract the // defn's `local_offset` in f64 first so distant text retains precision. + // 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 [lo_x, lo_y, lo_z] = local_offset; + 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<[[f32; 3]; 4]> = - crate::scene::text_obb_corners_native(sub, anno_scale).map(|c| { + crate::scene::text_obb_corners_native(sub, anno_scale, mtext_lines).map(|c| { [ [(c[0][0] - lo_x) as f32, (c[0][1] - lo_y) as f32, (c[0][2] - lo_z) as f32], [(c[1][0] - lo_x) as f32, (c[1][1] - lo_y) as f32, (c[1][2] - lo_z) as f32], @@ -656,46 +664,69 @@ fn emit_greeked_text( [(w.x - ox) as f32, (w.y - oy) as f32, (w.z - oz) as f32] }; - let corners: Option<[[f32; 3]; 4]> = if let (Some(obb), Some(h_local)) = + // 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<[f32; 3]> = if let (Some(obb), Some(h_local)) = (lw.text_obb_local, lw.text_height_local) { if h_local <= 0.0 { - None - } else { - // OBB[3] - OBB[0] = up direction in local frame, full-block - // height for MText; normalize and rescale to a single line. - let dx = obb[3][0] - obb[0][0]; - let dy = obb[3][1] - obb[0][1]; - let dz = obb[3][2] - obb[0][2]; - let dlen = (dx * dx + dy * dy + dz * dz).sqrt(); - if dlen < 1e-9 { - None - } else { - let nx = dx / dlen * h_local; - let ny = dy / dlen * h_local; - let nz = dz / dlen * h_local; - let bl = obb[0]; - let br = obb[1]; - let tl = [bl[0] + nx, bl[1] + ny, bl[2] + nz]; - let tr = [br[0] + nx, br[1] + ny, br[2] + nz]; - Some([xf(bl), xf(br), xf(tr), xf(tl)]) - } + return; } + 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_local).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_off = (i as f32) * h_local; + let bot_off = ((i + 1) as f32) * h_local; + let top_along = (ulen - top_off).max(0.0); + let bot_along = (ulen - bot_off).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 { - // OBB missing — fall back to the axis-aligned local AABB. Already - // a tight per-entity rect (no multi-line block to clamp). let [x0, y0, x1, y1] = local_aabb; let z = 0.0_f32; - Some([ - xf([x0, y0, z]), - xf([x1, y0, z]), - xf([x1, y1, z]), - xf([x0, y1, z]), - ]) + let bl = xf([x0, y0, z]); + let br = xf([x1, y0, z]); + let tr = xf([x1, y1, z]); + let tl = xf([x0, y1, z]); + vec![bl, br, tr, bl, tr, tl] }; - let Some([bl, br, tr, tl]) = corners else { + if tris.is_empty() { return; - }; + } let final_color = if ctx.selected { WireModel::SELECTED @@ -716,7 +747,7 @@ fn emit_greeked_text( .entry(key) .or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, true)); - for p in [bl, br, tr, bl, tr, tl] { + for p in tris { if p[0] < entry.min_x { entry.min_x = p[0]; } @@ -748,6 +779,12 @@ fn emit_text_baseline( 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 [ox, oy, oz] = ctx.world_offset; let [lo_x, lo_y, lo_z] = defn_lo; let xf = |p: [f32; 3]| -> [f32; 3] { @@ -758,17 +795,39 @@ fn emit_text_baseline( )); [(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]; + // 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; 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).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 = if ctx.selected { WireModel::SELECTED } else if lw.color_is_byblock { @@ -788,25 +847,45 @@ fn emit_text_baseline( .entry(key) .or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, false)); - 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]; + 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 f32) * h_local).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]); } - if q[1] < entry.min_y { - entry.min_y = q[1]; + 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); } - 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); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0e540a0d0..19d3bbb62 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -4162,13 +4162,22 @@ fn tessellate_entity( }; 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 = text_baseline_points(e, anno_scale, world_offset); + let pts = text_baseline_points(e, anno_scale, world_offset, 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). + // 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; @@ -4194,7 +4203,7 @@ fn tessellate_entity( }]; } if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB { - let fill_tris = text_greek_obb_tris(e, anno_scale, world_offset); + let fill_tris = text_greek_obb_tris(e, anno_scale, world_offset, n_lines); if fill_tris.is_empty() { return vec![]; } @@ -4260,9 +4269,15 @@ fn tessellate_entity( /// for block-defn subs it's block-local. No offset/transform applied. /// Width is approximated from glyph height × character count (TEXT) or /// from `rectangle_width` (MTEXT). Returns `None` for non-text entities. +/// +/// `mtext_lines_override` lets the caller plug in a wrap-aware line count +/// (from `text_support::mtext_line_count`). Without it, MText's OBB +/// height collapses to a single line when the file omits `rectangle_height`, +/// which makes downstream per-line LOD math degenerate. pub(crate) fn text_obb_corners_native( e: &EntityType, anno_scale: f32, + mtext_lines_override: Option, ) -> Option<[[f64; 3]; 4]> { use acadrust::entities::{ AttachmentPoint, TextHorizontalAlignment, TextVerticalAlignment, @@ -4317,15 +4332,26 @@ pub(crate) fn text_obb_corners_native( } EntityType::MText(m) => { let h_world = m.height * anno; - let lines = (m.value.matches('\n').count() + 1) as f64; + 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 * lines.max(1.0) + 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), + ) }; - let total_h = m - .rectangle_height - .unwrap_or(h_world * 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. @@ -4374,35 +4400,18 @@ 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. +/// NaN-separated baseline segments — one per visible wrap line — for a +/// sub-pixel Text / MText, in world-offset-subtracted f32 space. `n_lines` +/// must be the actual rendered line count (1 for Text, the wrap-expanded +/// count for MText). Lines are emitted top → bottom along the OBB's up +/// direction, each at the bottom edge of its slab. fn text_baseline_points( e: &EntityType, anno_scale: f32, world_offset: [f64; 3], + n_lines: usize, ) -> 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 filled rect (2 triangles) for a greeked top-level Text / MText. -/// Clamped to a single line's height — multi-line MText's OBB spans the -/// whole block and using its full height would emit a misleadingly tall -/// box. The face3d pipeline skips its 0.45 dim for wires with empty -/// `points`, so these tris render at the literal text color. -fn text_greek_obb_tris( - e: &EntityType, - anno_scale: f32, - world_offset: [f64; 3], -) -> Vec<[f32; 3]> { - let Some(corners) = text_obb_corners_native(e, anno_scale) else { + let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else { return vec![]; }; let line_h = match e { @@ -4413,6 +4422,62 @@ fn text_greek_obb_tris( if line_h <= 0.0 { return vec![]; } + let n_lines = n_lines.max(1); + 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] + }; + 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. +fn text_greek_obb_tris( + e: &EntityType, + anno_scale: f32, + world_offset: [f64; 3], + 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, + _ => return vec![], + }; + if line_h <= 0.0 { + return vec![]; + } + let n_lines = n_lines.max(1); 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] @@ -4421,19 +4486,40 @@ fn text_greek_obb_tris( let br = cast(corners[1]); let full_tl = cast(corners[3]); - // Up direction = (bl → full_tl) normalized, then scale to a single - // line. Replaces the full-block top corners so MText with N lines - // still emits a single-line rect. 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 tl = [bl[0] + nx * line_h, bl[1] + ny * line_h, bl[2] + nz * line_h]; - let tr = [br[0] + nx * line_h, br[1] + ny * line_h, br[2] + nz * line_h]; - vec![bl, br, tr, bl, tr, tl] + 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 } fn entity_aabb(e: &acadrust::EntityType, world_offset: [f64; 3]) -> [f32; 4] {