From 4e5d2c8d04e0815a0cfc5956950631d83508cd48 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 22 May 2026 05:53:01 +0300 Subject: [PATCH] feat(text): route every text-bearing entity through the run-aware MText pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the MText parser + renderer into a reusable `layout_mtext` helper in `text_support` and re-point the remaining text-bearing entities at it, so every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked fractions, paragraph indents, tab stops, …) reaches the stroke output uniformly. Per-entity changes: - TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds `layout_mtext`; `\C7;Red`-style codes no longer render as literal glyph noise. - MLEADER MText content (both entity path in [entities/multileader.rs] and the LOD-aware path in [scene/tessellate.rs]) — drops the `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0 baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame / bg-fill rectangle now read line widths from the layout. - ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine the value goes through the MText pipeline (SingleLine stays on the Text-style anchor maths because the DXF spec disallows codes there). - DIMENSION text_override — `dimension_text_entity` emits a synthetic MText (instead of Text) whenever the value contains MText escapes; the LOD ladder via `tessellate_entity_dim_text` handles both kinds. Infrastructure: - `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`, `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly. - `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so callers that draw chrome (frames, fills, baselines, greek rects) share the same numbers as the rendered strokes. - `mtext_line_count` re-routed through the new parser so LOD row counts and rendered row counts can never disagree. - Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`, `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`) deleted — all callers migrated. Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented here. Co-Authored-By: Claude Opus 4.7 --- TEXT_FORMAT_CODE_INTEGRATION.md | 109 ---- src/entities/attribute.rs | 79 ++- src/entities/mtext.rs | 528 ++----------------- src/entities/multileader.rs | 144 ++---- src/entities/table.rs | 86 ++-- src/entities/text_support.rs | 863 ++++++++++++++++++++++++-------- src/scene/cxf.rs | 26 +- src/scene/tessellate.rs | 262 ++++++---- 8 files changed, 1027 insertions(+), 1070 deletions(-) delete mode 100644 TEXT_FORMAT_CODE_INTEGRATION.md diff --git a/TEXT_FORMAT_CODE_INTEGRATION.md b/TEXT_FORMAT_CODE_INTEGRATION.md deleted file mode 100644 index f7280d343..000000000 --- a/TEXT_FORMAT_CODE_INTEGRATION.md +++ /dev/null @@ -1,109 +0,0 @@ -# Text-bearing Entity Format-Code Integration - -Audit of which entities carry MText-style inline format codes (`\H`, `\W`, -`\Q`, `\T`, `\A`, `\C`, `\c`, `\f`, `\F`, `\p…q[lcrjd]…;`, `\L\l`, `\O\o`, -`\K\k`, `\S…;`, `\U+XXXX`, `\M`, `\X`, `{ }`) and whether their renderer -currently honours them. - -The MTEXT entity itself was upgraded to a run-aware parser + renderer in -commit `f12e864`; this file tracks the follow-up work for the other -text-bearing entities. - -## Status matrix - -| Entity | Content format | Current renderer | Needs integration | -| ----------------------------------- | ---------------------- | ----------------------------------------------- | ----------------- | -| **MTEXT** | MText codes | Run-aware parser + run renderer | Done | -| **TABLE** cell | MText codes (`\f` `\C` `\H` standard in real DXF) | Raw string → `cxf::tessellate_text_ex`; codes are rendered as literal glyphs | **Yes — high priority** | -| **MLEADER** (`LeaderContentType::MText`) | MText codes | `strip_mtext_codes` + `split_mtext_lines` (legacy strip path); codes are dropped silently | **Yes — high priority** | -| **ATTRIB / ATTDEF** with `mtext_flag = MultiLine / ConstantMultiLine` | MText codes | Text-style render (single-line layout) | **Yes — conditional on flag** | -| **DIMENSION** `text_override` | MText codes (rare) | Usually rendered via the baked `block_name` sub-entity path (which goes through MTEXT) | Optional — only matters for unbaked dim text | -| **TEXT** | `%%x` specials only | `cxf::tessellate_text_ex` handles specials | No | -| **TOLERANCE** | Feature Control Frame (structured) | Dedicated FCF pipeline | No | -| **LEADER** (legacy single-leader) | Plain text | Dedicated pipeline | No | - -## What each entity loses today - -### TABLE cell - -Real-world AutoCAD tables emit every cell as an MText-formatted string — -font changes, per-column colour, height overrides, paragraph alignment. The -current call site at [`src/entities/table.rs:245-253`](src/entities/table.rs#L245-L253) -passes the raw string to `cxf::tessellate_text_ex`, which only recognises -the `%%x` specials and the `\L\l \O\o \K\k` decoration markers. Every other -`\…;` block falls through and gets drawn as a literal backslash + -letter sequence, so a cell like `\C7;Red` shows up as the visible text -`\C7;Red` on screen. - -This is the most visible regression to fix. - -### MLEADER (MText content) - -Two parallel call sites strip codes via `strip_mtext_codes` + -`split_mtext_lines`: - -- [`src/entities/multileader.rs:241-313`](src/entities/multileader.rs#L241-L313) -- [`src/scene/tessellate.rs:1720-1920`](src/scene/tessellate.rs#L1720-L1920) - -The codes are silently dropped rather than rendered as glyph noise, so the -text looks plausible — but every inline override (font / colour / height / -paragraph align) is invisible. The two copies should be folded into the -shared run renderer at the same time as the integration. - -### ATTRIB / ATTDEF (when `mtext_flag` is set) - -`AttributeDefinition::mtext_flag` and `AttributeEntity::mtext_flag` carry -`MTextFlag::MultiLine` / `ConstantMultiLine` when the attribute value is -authored as MText. Current attribute rendering treats the value as a single -Text line regardless of the flag. When the flag is set, the value should -flow through the MText pipeline. - -### DIMENSION `text_override` - -Modern AutoCAD typically bakes dimensions into a `*D###` block whose -sub-entities include an MTEXT carrying any inline formatting. That path -already goes through the run renderer via the block-defn cache. The -`text_override` field on the unbaked Dimension *can* contain MText codes, -but in practice the codes appear in the baked block. Lowest priority. - -## Refactor strategy - -Extract a shared helper: - -```rust -fn render_mtext_string( - value: &str, - entity_height: f32, - base_wf: f32, - base_oblique: f32, - base_font: &str, - rect_w: f32, - attach_h_anchor: f32, - attach_v_anchor: f32, - rotation: f32, - insertion: Vec3, - line_spacing_factor: f32, - is_upside_down: bool, - is_vertical: bool, -) -> Vec; -``` - -Then have MTEXT, TABLE cell, MLEADER (and MText-flag ATTRIB) all call it. -This collapses the four nearly-identical layout loops into one and makes -the inline-code coverage uniform across entities. - -## Suggested order - -1. **TABLE** — restores visibly broken rendering in any DXF/DWG that uses - inline cell formatting (which is most of them). -2. **MLEADER** — restores the silent-fail formatting. Touches two call - sites; fold them into the shared helper at the same time. -3. **ATTRIB / ATTDEF** — gated on `mtext_flag`. Touches one call site. -4. **DIMENSION `text_override`** — optional; only matters for unbaked dims. - -## Out of scope - -- **TEXT** entity does not accept MText codes (DXF spec), only `%%x` - specials, which `cxf::tessellate_text_ex` already handles. -- **TOLERANCE** uses a structured FCF representation; no MText codes apply. -- Legacy **LEADER** (single-leader, pre-MLEADER) carries plain text only. diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 33f0720c5..0143c1637 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -7,7 +7,10 @@ use glam::Vec3; use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, parse_f64, ro_prop as ro, square_grip}; -use crate::entities::text_support::{resolve_dxf_special_chars, resolve_text_style, text_local_bounds}; +use crate::entities::text_support::{ + layout_mtext, resolve_dxf_special_chars, resolve_text_style, text_local_bounds, MTextRenderOpts, + MTextVAnchor, ResolvedTextStyle, +}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject}; use crate::scene::object::{GripApply, GripDef, PropSection, PropValue, Property}; @@ -153,13 +156,73 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument) [input.insertion_point.x, input.insertion_point.y] }; - // Multi-line: split on \\P or \n. line_count is informational and we - // recompute lines from the actual value. - let raw_value = if input.is_multiline || !matches!(input.mtext_flag, MTextFlag::SingleLine) { - input.value.to_string() - } else { - input.value.to_string() - }; + // MText-flag attributes (`mtext_flag = MultiLine | ConstantMultiLine`) + // route through the shared MText pipeline so every inline format code + // (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, + // stacked fractions, …) reaches the stroke output. SingleLine attribs + // keep the Text-style anchor math below — they don't accept MText codes + // in the DXF spec. + if matches!(input.mtext_flag, MTextFlag::MultiLine | MTextFlag::ConstantMultiLine) { + let display_value = if input.value.is_empty() { + String::new() + } else { + input.value.to_string() + }; + let attach_h_anchor: f32 = match input.horizontal_alignment { + AHA::Left => 0.0, + AHA::Center | AHA::Middle => 0.5, + AHA::Right | AHA::Aligned | AHA::Fit => 1.0, + }; + let v_anchor = match input.vertical_alignment { + AVA::Top => MTextVAnchor::Top, + AVA::Middle => MTextVAnchor::Middle, + AVA::Baseline | AVA::Bottom => MTextVAnchor::Bottom, + }; + let needs_align_pt = !(matches!(input.horizontal_alignment, AHA::Left) + && matches!(input.vertical_alignment, AVA::Baseline)); + let anchor_pt = if needs_align_pt { + input.alignment_point + } else { + input.insertion_point + }; + // Compose a ResolvedTextStyle that carries the merged width-factor + // sign (entity backward XOR style backward) and the + // entity-overridden oblique. is_upside_down is false because the + // backwards / upside-down flips are already folded into `rotation` + // and `width_factor`. + let style_for_mtext = ResolvedTextStyle { + font_name: resolved.font_name.clone(), + width_factor: width_factor.abs(), + oblique_angle, + is_backward: width_factor < 0.0, + is_upside_down: false, + }; + let layout = layout_mtext(&MTextRenderOpts { + value: &display_value, + insertion: [anchor_pt.x, anchor_pt.y, anchor_pt.z], + height: input.height as f32, + rect_w: 0.0, + rotation, + style: &style_for_mtext, + attach_h_anchor, + v_anchor, + line_spacing_factor: 1.0, + vertical_text: false, + }); + let _ = input.line_count; + let _ = input.is_multiline; + return TruckEntity { + object: TruckObject::Text(layout.strokes), + snap_pts: vec![(snap_pt, SnapHint::Insertion)], + tangent_geoms: vec![], + key_vertices: vec![], + fill_tris: vec![], + }; + } + + // SingleLine path — anchor maths uses glyph bounds for accurate + // horizontal / vertical positioning against alignment_point. + let raw_value = input.value.to_string(); let plain: Vec = raw_value .replace("\\P", "\n") .split('\n') diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index aeddd2be3..a2e64a4cb 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -1,16 +1,13 @@ use acadrust::entities::{AttachmentPoint, DrawingDirection, MText}; -use acadrust::types::aci_table::aci_to_rgb; use glam::Vec3; use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip}; use crate::entities::text_support::{ - parse_mtext_paragraphs, resolve_text_style, InlineColor, MTextRunKind, ParagraphAlign, - RunState, TabStop, + layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; -use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject}; -use crate::scene::cxf; +use crate::scene::acad_to_truck::{TruckEntity, TruckObject}; use crate::scene::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::wire_model::SnapHint; @@ -77,358 +74,8 @@ fn drawing_dir_str(d: &DrawingDirection) -> &'static str { } } -// ── Run-based MTEXT layout ────────────────────────────────────────────────── -// -// Layout proceeds in three passes: -// 1. Atomise — turn each MTextLine.runs into a flat sequence of atoms -// (Word / Space / Tab) so the wrapper can operate at break boundaries -// while keeping per-character formatting state. -// 2. Wrap — accumulate atoms into sub-lines using paragraph indents -// (`indent_first` for the first sub-line, `indent_left` for -// continuations, `indent_right` shrinks the right edge). Each Tab -// jumps the cursor to the next tab stop (or the next 4-em default -// stop when none is defined). -// 3. Render — for every sub-line: pick paragraph alignment + indent, -// walk atoms left → right, emit one TextStroke per Word using the -// atom's RunState (height / width / oblique / tracking / font / -// colour / decorations / valign). - -#[derive(Clone)] -enum AtomKind { - Word(String), - Space, - Tab, -} - -#[derive(Clone)] -struct LayoutAtom { - kind: AtomKind, - state: RunState, -} - -fn run_scale(state: &RunState, entity_h: f32, base_wf: f32) -> f32 { - (state.height_mul * entity_h / 9.0) * (state.width_mul * base_wf.abs()) -} - -fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> &'a str { - state.font.as_deref().unwrap_or(base) -} - -fn measure_word(text: &str, state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 { - let scale = run_scale(state, entity_h, base_wf); - let font_name = resolve_font(state, base_font); - let font = cxf::get_font(font_name); - let mut w = 0.0_f32; - for ch in text.chars() { - w += match font.glyph(ch) { - Some(g) => (g.advance + font.letter_spacing * state.tracking) * scale, - None => (6.0 + font.letter_spacing * state.tracking) * scale, - }; - } - w -} - -fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 { - let scale = run_scale(state, entity_h, base_wf); - let font_name = resolve_font(state, base_font); - cxf::get_font(font_name).word_spacing * scale -} - -fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 { - match &atom.kind { - AtomKind::Word(t) => measure_word(t, &atom.state, entity_h, base_wf, base_font), - AtomKind::Space => measure_space(&atom.state, entity_h, base_wf, base_font), - AtomKind::Tab => 0.0, // tabs jump the cursor explicitly during wrap / render - } -} - -/// Cursor position after a `\t` atom: advance to the next user-defined tab -/// stop that lies past `cur_x`, falling back to a 4-em default grid when no -/// stop is reached. -fn next_tab_position(cur_x: f32, tab_stops: &[TabStop], indent_left: f32, entity_h: f32) -> f32 { - let local = cur_x - indent_left; // tabs are measured from the content area's left edge - for ts in tab_stops { - if ts.position > local + 1e-4 { - return indent_left + ts.position; - } - } - let default_interval = entity_h * 4.0; // 4 em fallback, matches AutoCAD's "no tab stops" - let n = (local / default_interval).floor() + 1.0; - indent_left + n * default_interval -} - -/// Break a flat MText paragraph atom stream into wrap-fit sub-lines. -fn wrap_paragraph( - atoms: Vec, - rect_w: f32, - indent_first: f32, - indent_left: f32, - indent_right: f32, - tab_stops: &[TabStop], - entity_h: f32, - base_wf: f32, - base_font: &str, -) -> Vec> { - if rect_w <= 0.0 { - return vec![atoms]; - } - let mut sublines: Vec> = Vec::new(); - let mut cur: Vec = Vec::new(); - let mut cur_w = 0.0_f32; - let mut subline_idx: usize = 0; - let line_start_x = |idx: usize| if idx == 0 { indent_first } else { indent_left }; - let line_max_w = |idx: usize| (rect_w - indent_right - line_start_x(idx)).max(0.0); - - for atom in atoms { - match &atom.kind { - AtomKind::Word(_) => { - let w = atom_width(&atom, entity_h, base_wf, base_font); - let max_w = line_max_w(subline_idx); - if !cur.is_empty() && cur_w + w > max_w { - while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) { - cur.pop(); - } - sublines.push(std::mem::take(&mut cur)); - cur_w = 0.0; - subline_idx += 1; - } - cur.push(atom); - cur_w += w; - } - AtomKind::Space => { - if cur.is_empty() { - continue; - } - cur_w += atom_width(&atom, entity_h, base_wf, base_font); - cur.push(atom); - } - AtomKind::Tab => { - let start_x = line_start_x(subline_idx); - let new_w = next_tab_position(cur_w + start_x, tab_stops, indent_left, entity_h) - - start_x; - let max_w = line_max_w(subline_idx); - if new_w > max_w && !cur.is_empty() { - sublines.push(std::mem::take(&mut cur)); - cur_w = 0.0; - subline_idx += 1; - } else { - cur.push(atom); - cur_w = new_w.min(max_w); - } - } - } - } - if !cur.is_empty() { - sublines.push(cur); - } - if sublines.is_empty() { - sublines.push(Vec::new()); - } - sublines -} - -fn line_total_width( - atoms: &[LayoutAtom], - entity_h: f32, - base_wf: f32, - base_font: &str, - line_start_x: f32, - indent_left: f32, - tab_stops: &[TabStop], -) -> f32 { - let mut x = line_start_x; - for atom in atoms { - match atom.kind { - AtomKind::Tab => { - x = next_tab_position(x, tab_stops, indent_left, entity_h); - } - _ => x += atom_width(atom, entity_h, base_wf, base_font), - } - } - x - line_start_x -} - -fn resolve_inline_color(c: &InlineColor) -> Option<[f32; 3]> { - match c { - InlineColor::Aci(idx) => { - aci_to_rgb(*idx).map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]) - } - InlineColor::True(rgb) => Some(*rgb), - } -} - -/// Wrap a run's glyph text with MTEXT decoration markers so cxf's -/// `tessellate_text_run` emits the underline / overline / strikethrough -/// strokes for us — keeps decoration geometry in one place rather than -/// duplicating the y-position constants. -fn decorated(text: &str, state: &RunState) -> String { - if !(state.underline || state.overline || state.strike) { - return text.to_string(); - } - let mut s = String::with_capacity(text.len() + 6); - if state.underline { - s.push_str("\\L"); - } - if state.overline { - s.push_str("\\O"); - } - if state.strike { - s.push_str("\\K"); - } - s.push_str(text); - if state.underline { - s.push_str("\\l"); - } - if state.overline { - s.push_str("\\o"); - } - if state.strike { - s.push_str("\\k"); - } - s -} - fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity { let resolved_style = resolve_text_style(&t.style, document); - let base_font_name = resolved_style.font_name.clone(); - let base_font = cxf::get_font(&base_font_name); - let base_wf_abs = resolved_style.width_factor.max(0.01); - let base_wf = if resolved_style.is_backward { -base_wf_abs } else { base_wf_abs }; - let base_oblique = resolved_style.oblique_angle; - let entity_h = t.height as f32; - let rect_w = t.rectangle_width as f32; - - // ── 1. Parse ──────────────────────────────────────────────────────────── - let paragraphs = parse_mtext_paragraphs(&t.value, entity_h); - - // ── 2. Atomise + wrap each paragraph into sub-lines ───────────────────── - struct SubLine { - atoms: Vec, - align: Option, - indent_first: f32, - indent_left: f32, - indent_right: f32, - tab_stops: Vec, - is_first_in_paragraph: bool, - } - - let mut sub_lines: Vec = Vec::new(); - for para in ¶graphs { - // Flatten runs into atoms. - 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(), - }); - } - } - } - - // Trim leading + trailing Space atoms — the legacy renderer dropped - // edge whitespace via `String::trim()` after `strip_mtext_codes`, so - // line_w / cursor_start agree on what counts as the paragraph's - // visible content. Without this, a paragraph that ends with a stray - // space measures wider than it draws and centering / right-alignment - // appears off by half a space-width. - 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, - ); - for (idx, atoms) in wrapped.into_iter().enumerate() { - sub_lines.push(SubLine { - atoms, - align: para.align, - indent_first: para.indent_first, - indent_left: para.indent_left, - indent_right: para.indent_right, - tab_stops: para.tab_stops.clone(), - is_first_in_paragraph: idx == 0, - }); - } - } - if sub_lines.is_empty() { - sub_lines.push(SubLine { - atoms: Vec::new(), - align: None, - indent_first: 0.0, - indent_left: 0.0, - indent_right: 0.0, - tab_stops: Vec::new(), - is_first_in_paragraph: true, - }); - } - - // ── 3. Block geometry (line spacing, attachment, rotation) ────────────── - let n_lines = sub_lines.len().max(1) as f32; - let ls_factor = if t.line_spacing_factor > 0.0 { - t.line_spacing_factor as f32 - } else { - 1.0 - }; - // DXF code 44 — multiplier on the *default* baseline-to-baseline gap, - // which AutoCAD defines as 5/3 × text height (≈ 1.667). 1.0 → single - // spacing, 2.0 → double, etc. - let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing; - let h = entity_h; - // CXF glyphs sit on the baseline (y=0) and extend UP by `h`. v_offset - // is the Y of the first sub-line's baseline relative to the insertion - // point; pick it from the attachment-point's vertical anchor: - // Top → block top at insertion → v_offset = −h - // Bottom → block bottom at insertion → v_offset = (n−1) · line_h - // Middle → midpoint of the two above - let v_offset = match t.attachment_point { - AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => -h, - AttachmentPoint::MiddleLeft - | AttachmentPoint::MiddleCenter - | AttachmentPoint::MiddleRight => ((n_lines - 1.0) * line_h - h) * 0.5, - AttachmentPoint::BottomLeft - | AttachmentPoint::BottomCenter - | AttachmentPoint::BottomRight => (n_lines - 1.0) * line_h, - }; let attach_h_anchor: f32 = match t.attachment_point { AttachmentPoint::TopCenter | AttachmentPoint::MiddleCenter @@ -438,152 +85,41 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity { } _ => 0.0, }; - let box_left = -attach_h_anchor * rect_w; - let vertical_text = matches!(t.drawing_direction, DrawingDirection::TopToBottom); - let rot = if resolved_style.is_upside_down { + let v_anchor = match t.attachment_point { + AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => { + MTextVAnchor::Top + } + AttachmentPoint::MiddleLeft + | AttachmentPoint::MiddleCenter + | AttachmentPoint::MiddleRight => MTextVAnchor::Middle, + AttachmentPoint::BottomLeft + | AttachmentPoint::BottomCenter + | AttachmentPoint::BottomRight => MTextVAnchor::Bottom, + }; + let rotation = if resolved_style.is_upside_down { t.rotation as f32 + std::f32::consts::PI } else { t.rotation as f32 }; - let (cos_r, sin_r) = (rot.cos(), rot.sin()); - let ins_x = t.insertion_point.x; - let ins_y = t.insertion_point.y; - let insertion = Vec3::new(ins_x as f32, ins_y as f32, t.insertion_point.z as f32); - let mut all_strokes: Vec = Vec::new(); - - // ── 4. Render each sub-line ───────────────────────────────────────────── - for (i, sub) in sub_lines.iter().enumerate() { - let li = i as f32; - let (line_base_x, line_base_y) = if vertical_text { - let col_offset = li * entity_h * 1.2; - ( - col_offset * cos_r + v_offset * (-sin_r), - col_offset * sin_r + v_offset * cos_r, - ) - } else { - let ly = -(li * line_h) + v_offset; - (ly * (-sin_r), ly * cos_r) - }; - - // Paragraph-content area: [content_left, content_right] relative to - // insertion point. With rect_w == 0 we have no box, so fall back to - // anchoring at the insertion point (legacy behaviour). - let content_left = if rect_w > 0.0 { - box_left + if sub.is_first_in_paragraph { sub.indent_first } else { sub.indent_left } - } else { - 0.0 - }; - let content_right = if rect_w > 0.0 { - box_left + rect_w - sub.indent_right - } else { - 0.0 - }; - - // Effective horizontal anchor for this sub-line: paragraph alignment - // (when explicitly set inline) wins, otherwise inherit from the - // entity attachment. - let line_anchor: f32 = match sub.align { - Some(ParagraphAlign::Left) - | Some(ParagraphAlign::Justify) - | Some(ParagraphAlign::Distribute) => 0.0, - Some(ParagraphAlign::Center) => 0.5, - Some(ParagraphAlign::Right) => 1.0, - None => attach_h_anchor, - }; - - let line_w = line_total_width( - &sub.atoms, - entity_h, - base_wf, - &base_font_name, - 0.0, - sub.indent_left, - &sub.tab_stops, - ); - - // Cursor X (relative to insertion point, pre-rotation) where the - // first atom starts. With a box: lay the line inside the paragraph - // content area at `line_anchor`. Without a box: anchor the line at - // the insertion point using the entity's attachment. - let cursor_start = if rect_w > 0.0 { - let content_w = (content_right - content_left).max(0.0); - content_left + (content_w - line_w) * line_anchor - } else if line_anchor > 0.0 { - -line_w * line_anchor - } else { - 0.0 - }; - - // Line's tallest run height (for valign offsets). - let line_max_h = sub - .atoms - .iter() - .map(|a| a.state.height_mul * entity_h) - .fold(entity_h, f32::max); - - // Walk atoms left → right, emitting one TextStroke per Word atom. - let mut cursor_x = cursor_start; - for atom in &sub.atoms { - match &atom.kind { - AtomKind::Word(text) => { - // Per-atom render parameters (composed with style baseline). - let run_h = atom.state.height_mul * entity_h; - let signed_wf = base_wf.signum() * atom.state.width_mul * base_wf.abs(); - let oblique = base_oblique + atom.state.oblique_rad; - let font_name = resolve_font(&atom.state, &base_font_name); - let tracking = atom.state.tracking; - let valign_dy = match atom.state.valign { - 1 => (line_max_h - run_h) * 0.5, - 2 => line_max_h - run_h, - _ => 0.0, - }; - let color = atom.state.color.as_ref().and_then(resolve_inline_color); - let body = decorated(text, &atom.state); - - // Translate (cursor_x, valign_dy) into world space at this - // sub-line's baseline. - let lx = cursor_x; - let ly = valign_dy; - let world_dx = lx * cos_r - ly * sin_r; - let world_dy = lx * sin_r + ly * cos_r; - let origin: [f64; 2] = [ - ins_x + (line_base_x + world_dx) as f64, - ins_y + (line_base_y + world_dy) as f64, - ]; - let strokes = cxf::tessellate_text_run( - [0.0, 0.0], - run_h, - rot, - signed_wf, - oblique, - tracking, - font_name, - &body, - ); - all_strokes.push(TextStroke { - strokes, - origin, - color, - }); - cursor_x += measure_word(text, &atom.state, entity_h, base_wf, &base_font_name); - } - AtomKind::Space => { - cursor_x += measure_space(&atom.state, entity_h, base_wf, &base_font_name); - } - AtomKind::Tab => { - cursor_x = next_tab_position( - cursor_x, - &sub.tab_stops, - sub.indent_left, - entity_h, - ); - } - } - } - } - + let layout = layout_mtext(&MTextRenderOpts { + value: &t.value, + insertion: [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z], + height: t.height as f32, + rect_w: t.rectangle_width as f32, + rotation, + style: &resolved_style, + attach_h_anchor, + v_anchor, + line_spacing_factor: t.line_spacing_factor as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + }); + let insertion = Vec3::new( + t.insertion_point.x as f32, + t.insertion_point.y as f32, + t.insertion_point.z as f32, + ); TruckEntity { - object: TruckObject::Text(all_strokes), + object: TruckObject::Text(layout.strokes), snap_pts: vec![(insertion, SnapHint::Insertion)], tangent_geoms: vec![], key_vertices: vec![], diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 77dbc8a79..e7ab8cca8 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2,29 +2,28 @@ use acadrust::entities::{ LeaderContentType, MultiLeader, MultiLeaderPathType, TextAttachmentPointType, TextAttachmentType, }; -/// Local-frame vertical baseline offset for line 0 given a vertical attachment -/// and the overall block geometry. The baseline is at y=0 for `BottomOfTopLine*` -/// (i.e. text_location coincides with line-0 baseline); other variants offset -/// the block above/below/around text_location. -pub(crate) fn v_offset_for_attachment( - attach: TextAttachmentType, - n_lines: f32, - h: f32, - line_h: f32, -) -> f32 { +use crate::entities::text_support::{ + layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, +}; + +/// Map MLEADER's vertical attachment enum onto the shared `MTextVAnchor` +/// used by `layout_mtext`. Replaces the old `v_offset_for_attachment` +/// (which inlined the offset math); the shared pipeline now derives the +/// offset from the variant + n_lines / line_h. +pub(crate) fn mleader_v_anchor(attach: TextAttachmentType) -> MTextVAnchor { match attach { - TextAttachmentType::TopOfTopLine => -h, - TextAttachmentType::MiddleOfTopLine => -h * 0.5, + TextAttachmentType::TopOfTopLine => MTextVAnchor::Top, + TextAttachmentType::MiddleOfTopLine => MTextVAnchor::MiddleOfTopLine, TextAttachmentType::MiddleOfText | TextAttachmentType::CenterOfText - | TextAttachmentType::CenterOfTextOverline => ((n_lines - 1.0) * line_h - h) * 0.5, - TextAttachmentType::MiddleOfBottomLine => (n_lines - 1.0) * line_h - h * 0.5, + | TextAttachmentType::CenterOfTextOverline => MTextVAnchor::Middle, + TextAttachmentType::MiddleOfBottomLine => MTextVAnchor::MiddleOfBottomLine, TextAttachmentType::BottomOfBottomLine | TextAttachmentType::BottomLine => { - (n_lines - 1.0) * line_h + MTextVAnchor::Bottom } TextAttachmentType::BottomOfTopLineUnderlineBottomLine | TextAttachmentType::BottomOfTopLineUnderlineTopLine - | TextAttachmentType::BottomOfTopLineUnderlineAll => 0.0, + | TextAttachmentType::BottomOfTopLineUnderlineAll => MTextVAnchor::BottomOfTopLine, } } use glam::Vec3; @@ -222,92 +221,53 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option = if ctx.text_width > 0.0 { - // Width-factor sign is a render-time mirror flag; measurement uses - // the magnitude only. - let scale = height / 9.0 * width_factor.abs(); - // ctx.text_width is in the same WCS frame as ctx.text_height — - // do not re-multiply by scale_factor. - let max_w = ctx.text_width as f32; - explicit_lines - .iter() - .flat_map(|line| { - crate::entities::text_support::word_wrap(line, max_w, scale, font) - }) - .collect() - } else { - explicit_lines + // ml.text_alignment overrides ctx.text_attachment_point when not + // Left (which is the default zero); otherwise honour the stored + // attachment. + use acadrust::entities::multileader::TextAlignmentType; + let attach_h_anchor: f32 = match (ml.text_alignment, ctx.text_attachment_point) { + (TextAlignmentType::Center, _) => 0.5, + (TextAlignmentType::Right, _) => 1.0, + (TextAlignmentType::Left, TextAttachmentPointType::Center) => 0.5, + (TextAlignmentType::Left, TextAttachmentPointType::Right) => 1.0, + _ => 0.0, }; + let v_anchor = mleader_v_anchor(ctx.text_left_attachment); - let ls_factor = if ctx.line_spacing_factor > 0.0 { - ctx.line_spacing_factor as f32 - } else { - 1.0 - }; - let line_h = height * ls_factor * (5.0 / 3.0) * font.line_spacing; - let n_lines = lines.len().max(1) as f32; - - let h_anchor = match ctx.text_attachment_point { - TextAttachmentPointType::Left => 0.0_f32, - TextAttachmentPointType::Center => 0.5, - TextAttachmentPointType::Right => 1.0, - }; - let v_offset = - v_offset_for_attachment(ctx.text_left_attachment, n_lines, height, line_h); - - // Line-width measurement uses positive magnitude; mirror sign feeds - // tessellate_text_ex below. - let scale = height / 9.0 * width_factor.abs(); - for (i, line) in lines.iter().enumerate() { - let li = i as f32; - let line_y_local = -li * line_h + v_offset; - let line_w = if h_anchor > 0.0 { - crate::entities::text_support::measure_mtext_chars(line, scale, font) - } else { - 0.0 - }; - let h_shift_local = -line_w * h_anchor; - let wcs_dx = h_shift_local * cos_r - line_y_local * sin_r; - let wcs_dy = h_shift_local * sin_r + line_y_local * cos_r; - // tessellate_text_ex emits f32 glyph points around `origin`. - // For entity-level WCS output, we accept the f32 cast here since - // glyph offsets are small relative to the insertion point and the - // entity path doesn't apply a world_offset before render. - let origin = [ins_x as f32 + wcs_dx, ins_y as f32 + wcs_dy]; - let strokes = crate::scene::cxf::tessellate_text_ex( - origin, - height, - rot, - width_factor, - oblique, - &font_name, - line, - ); - for stroke in &strokes { + // Shared MText pipeline — handles every inline format code (font, + // colour, height, width factor, oblique, tracking, paragraph align, + // tabs, indents, decorations, stacked fractions, …). Inline per-run + // colour (`\C` / `\c`) is dropped here because the entity-level + // MultiLeader output is a single `TruckObject::Lines` (leader + + // arrowheads + text in one polyline blob); preserving the override + // would require emitting a separate object per colour. + let layout = layout_mtext(&MTextRenderOpts { + value: &ctx.text_string, + insertion: [ins_x, ins_y, z], + height, + rect_w: ctx.text_width as f32, + rotation: rot, + style: &resolved, + attach_h_anchor, + v_anchor, + line_spacing_factor: ctx.line_spacing_factor as f32, + vertical_text: false, + }); + for ts in &layout.strokes { + let ox = ts.origin[0]; + let oy = ts.origin[1]; + for stroke in &ts.strokes { if stroke.len() < 2 { continue; } points.push(nan); for &[x, y] in stroke { - points.push([x as f64, y as f64, z]); + points.push([ox + x as f64, oy + y as f64, z]); } } } diff --git a/src/entities/table.rs b/src/entities/table.rs index b11f5e1ff..b4ac6cfba 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -3,11 +3,12 @@ use glam::Vec3; use crate::command::EntityTransform; use crate::entities::common::{ro_prop as ro, square_grip}; +use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; use crate::scene::acad_to_truck::{TruckEntity, TruckObject}; use crate::scene::object::{GripApply, GripDef, PropSection}; use crate::scene::wire_model::SnapHint; -use crate::scene::{cxf, transform}; +use crate::scene::transform; fn v3(v: &acadrust::types::Vector3) -> Vec3 { Vec3::new(v.x as f32, v.y as f32, v.z as f32) @@ -157,6 +158,20 @@ impl TruckConvertible for Table { None }) }; + // Build a ResolvedTextStyle for the cell — needed by the shared MText + // pipeline so inline `\W`, `\Q`, etc. compose with the style baseline. + let resolved_style_for_handle = |handle: Option, + font_name: String| + -> ResolvedTextStyle { + let style = handle.and_then(|h| lookup_style(h)); + ResolvedTextStyle { + font_name, + width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0), + oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), + is_backward: style.map(|s| s.is_backward()).unwrap_or(false), + is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + } + }; for (ri, row) in self.rows.iter().enumerate() { let row_top = row_offsets[ri]; @@ -210,7 +225,7 @@ impl TruckConvertible for Table { .or_else(|| row_style.and_then(|s| s.text_style_handle)); let font_owned = font_for_handle(style_handle).unwrap_or_else(|| "txt".to_string()); - let font_name: &str = &font_owned; + let resolved = resolved_style_for_handle(style_handle, font_owned); // Alignment resolution: cell.style.alignment (1-9) overrides; // otherwise fall back to row_style.alignment, then MiddleCenter. @@ -224,39 +239,54 @@ impl TruckConvertible for Table { let horiz = ((align - 1).rem_euclid(3)) + 1; // 1=left, 2=center, 3=right let vert = ((align - 1) / 3) + 1; // 1=top, 2=middle, 3=bottom - let text_w = cxf::measure_text(text, cell_h, 1.0, font_name); - - let x_offset = match horiz { - 1 => col_left + margin, // left - 3 => col_right - margin - text_w, // right - _ => col_left + (col_width - text_w) * 0.5, // center (default) + // Position the cell's MText block anchor at the requested + // alignment corner / midpoint of the cell's content area. + let (x_offset, attach_h_anchor) = match horiz { + 1 => (col_left + margin, 0.0_f32), + 3 => (col_right - margin, 1.0_f32), + _ => (col_left + col_width * 0.5, 0.5_f32), }; - let y_offset = match vert { - 1 => row_top + margin, // top - 3 => row_bot - margin - cell_h, // bottom - _ => row_mid - cell_h * 0.5, // middle (default) + let (y_offset, v_anchor) = match vert { + 1 => (row_top + margin, MTextVAnchor::Top), + 3 => (row_bot - margin, MTextVAnchor::Bottom), + _ => (row_mid, MTextVAnchor::Middle), }; - let text_origin = origin + h * x_offset + v_down * y_offset; // Content rotation (radians) on top of table cell rotation. let rot = content.map(|c| c.rotation as f32).unwrap_or(0.0) + cell.rotation as f32; - let strokes = cxf::tessellate_text_ex( - [text_origin.x, text_origin.y], - cell_h, - rot, - 1.0, - 0.0, - font_name, - text, - ); - for stroke in strokes { - if !pts.is_empty() { - pts.push([f32::NAN; 3]); - } - for [x, y] in stroke { - pts.push([x, y, origin.z]); + let layout = layout_mtext(&MTextRenderOpts { + value: text, + insertion: [text_origin.x as f64, text_origin.y as f64, origin.z as f64], + height: cell_h, + rect_w: 0.0, + rotation: rot, + style: &resolved, + attach_h_anchor, + v_anchor, + line_spacing_factor: 1.0, + vertical_text: false, + }); + // Flatten TextStroke groups into the table's Lines buffer. + // Per-run inline `\C` / `\c` colour is dropped here because the + // table emits a single TruckObject::Lines for borders + text; + // tracking it would require splitting the table into multiple + // WireModels per cell colour. Borders + uniform-coloured runs + // honour the entity's outer colour. + 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; + } + if !pts.is_empty() { + pts.push([f32::NAN; 3]); + } + for &[x, y] in stroke { + pts.push([x + ox, y + oy, origin.z]); + } } } } diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index d858fc50e..f77aa3c12 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -1,5 +1,7 @@ +use acadrust::types::aci_table::aci_to_rgb; use acadrust::CadDocument; +use crate::scene::acad_to_truck::TextStroke; use crate::scene::cxf; pub struct ResolvedTextStyle { @@ -295,19 +297,6 @@ pub struct MTextLine { } impl MTextLine { - /// Concatenated glyph text across all runs (decorations / tabs ignored). - /// Useful for callers that only need an unstyled string (search, hit-test - /// previews, fallback paths). - pub fn plain_text(&self) -> String { - let mut s = String::new(); - for r in &self.runs { - if let MTextRunKind::Glyphs(t) = &r.kind { - s.push_str(t); - } - } - s - } - pub fn is_blank(&self) -> bool { self.runs.iter().all(|r| match &r.kind { MTextRunKind::Glyphs(t) => t.trim().is_empty(), @@ -712,226 +701,666 @@ pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec { lines[start..end].to_vec() } -pub fn strip_mtext_codes(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - - while let Some(ch) = chars.next() { - match ch { - '\\' => match chars.peek().copied() { - // Paragraph / line breaks - Some('P') | Some('n') | Some('N') => { - chars.next(); - out.push('\n'); - } - // Non-breaking space → regular space (preserves spacing, simpler for word-wrap) - Some('~') => { - chars.next(); - out.push(' '); - } - // Tab - Some('t') => { - chars.next(); - out.push_str(" "); - } - // \U+XXXX or \u+XXXX — Unicode character by hex code point. - // Up to 6 hex digits; optional trailing semicolon. - // NOTE: 'U'/'u' were previously in the "strip until ;" list — that was wrong. - Some('U') | Some('u') => { - chars.next(); - if chars.peek() == Some(&'+') { - chars.next(); // consume '+' - let mut hex = String::with_capacity(6); - for _ in 0..6 { - match chars.peek() { - Some(&c) if c.is_ascii_hexdigit() => { - hex.push(chars.next().unwrap()); - } - _ => break, - } - } - if chars.peek() == Some(&';') { - chars.next(); // consume optional trailing semicolon - } - if let Ok(n) = u32::from_str_radix(&hex, 16) { - if let Some(decoded) = char::from_u32(n) { - out.push(decoded); - continue; - } - } - // Undecodable — silently drop - } else { - // \U or \u without '+' — strip until semicolon - for c in chars.by_ref() { - if c == ';' { - break; - } - } - } - } - // \S; — stacked text (fraction / tolerance). - // sep is '/' (diagonal fraction), '^' (stacked with bar), '#' (horizontal bar). - // Render as "upper/lower" or "upper^lower" since stroke fonts can't stack. - Some('S') | Some('s') => { - chars.next(); - let mut upper = String::new(); - let mut lower = String::new(); - let mut sep = '/'; - let mut in_lower = false; - for c in chars.by_ref() { - if c == ';' { - break; - } - if !in_lower && (c == '/' || c == '^' || c == '#') { - sep = c; - in_lower = true; - } else if in_lower { - lower.push(c); - } else { - upper.push(c); - } - } - out.push_str(&upper); - if !lower.is_empty() { - out.push(if sep == '#' { '/' } else { sep }); - out.push_str(&lower); - } - } - // Decoration toggles — keep as \X markers so the tessellator can - // emit underline / overline / strikethrough strokes. - Some('L') | Some('l') | Some('O') | Some('o') | Some('K') | Some('k') => { - out.push('\\'); - out.push(chars.next().unwrap()); - } - // Literal backslash - Some('\\') => { - chars.next(); - out.push('\\'); - } - // Literal braces - Some('{') | Some('}') => { - out.push(chars.next().unwrap()); - } - // In-line codes with semicolon-terminated arguments — strip entirely. - // p = paragraph format H = height W = width factor - // Q = oblique angle T = tracking A = alignment - // C = ACI color c = true color f/F = font change - // M = DBCS multibyte X = paragraph-align end (arg-less but safe to strip) - Some(c) if "pHWQTACcfFMX".contains(c) => { - chars.next(); - for c in chars.by_ref() { - if c == ';' { - break; - } - } - } - // Unknown escape — consume and silently discard the code character so - // it does not appear as a literal in the output. - Some(_) => { - chars.next(); - } - None => {} - }, - // Strip brace grouping markers (scope delimiters for in-line formatting) - '{' | '}' => {} - '\r' => {} - other => out.push(other), - } - } - - resolve_dxf_special_chars(&out) -} - -pub fn split_mtext_lines(s: &str) -> Vec { - let lines: Vec = s.split('\n').map(|l| l.trim().to_string()).collect(); - // Drop leading and trailing blank lines, but preserve blank lines in the - // middle — they are intentional paragraph separators (\\P\\P in MTEXT). - let start = lines.iter().position(|l| !l.is_empty()).unwrap_or(0); - let end = lines - .iter() - .rposition(|l| !l.is_empty()) - .map(|i| i + 1) - .unwrap_or(0); - lines[start..end].to_vec() -} - -/// Measure the advance width of an MText line (after strip_mtext_codes), correctly -/// skipping decoration toggle markers (`\L`, `\l`, `\O`, `\o`, `\K`, `\k`). -pub fn measure_mtext_chars(text: &str, scale: f32, font: &cxf::CxfFile) -> f32 { - let mut width = 0.0_f32; - let mut chars = text.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\\' && matches!(chars.peek(), Some('L' | 'l' | 'O' | 'o' | 'K' | 'k')) { - chars.next(); - continue; - } - if c == ' ' { - width += font.word_spacing * scale; - } else { - width += font - .glyph(c) - .map(|g| (g.advance + font.letter_spacing) * scale) - .unwrap_or(scale * 6.0); - } - } - width -} +// Legacy MText helpers (`strip_mtext_codes`, `split_mtext_lines`, +// `measure_mtext_chars`, `word_wrap`) were removed when every text-bearing +// entity switched to the run-aware pipeline below. The pipeline now owns +// stripping, paragraph splitting, per-run width measurement and word-wrap; +// keep `parse_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`, +// `text_local_bounds`, and `resolve_dxf_special_chars` as the supported +// surface for callers. /// 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. +/// `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 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 + 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 = parse_mtext_paragraphs(&m.value, entity_h); + 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() - .map(|l| word_wrap(l, max_w, scale, font).len()) - .sum() - } else { - explicit.len() - }; + .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) } -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()]; - } +// ────────────────────────────────────────────────────────────────────────────── +// Shared MText layout / render pipeline +// ────────────────────────────────────────────────────────────────────────────── +// +// `layout_mtext` is the entry point used by every text-bearing entity that +// stores MText-formatted content (MTEXT, MLEADER text content, TABLE cell, +// ATTRIB / ATTDEF with `mtext_flag` set, and DIMENSION `text_override` when +// it carries inline codes). +// +// The pipeline mirrors the MTEXT renderer: +// 1. Parse — via `parse_mtext_paragraphs`. +// 2. Atomise — turn each MTextLine.runs into a flat sequence of atoms +// (Word / Space / Tab) so the wrapper operates at break boundaries +// while keeping per-character formatting state. +// 3. Wrap — accumulate atoms into sub-lines using paragraph indents and +// tab stops; each Tab jumps the cursor to the next user-defined stop +// (or a 4-em default grid). +// 4. Render — for each sub-line: pick paragraph alignment + indent, walk +// atoms left → right, emit one TextStroke per Word using the atom's +// RunState (height / width / oblique / tracking / font / colour / +// decorations / valign). +// +// In addition to the strokes, the helper returns enough geometry (line +// widths, line height, v_offset, h_anchor) for the caller to draw a frame / +// background rectangle, run a low-detail LOD path, or compute snap bounds. - let space_w = font.word_spacing * scale; - let mut lines = Vec::new(); - let mut current = String::new(); - let mut current_w = 0.0_f32; - - for word in text.split(' ') { - let word_w = measure_mtext_chars(word, scale, font); - let gap = if current.is_empty() { 0.0 } else { space_w }; - if !current.is_empty() && current_w + gap + word_w > max_w { - lines.push(std::mem::take(&mut current)); - current_w = 0.0; +#[derive(Clone)] +pub enum AtomKind { + Word(String), + Space, + Tab, +} + +#[derive(Clone)] +pub struct LayoutAtom { + pub kind: AtomKind, + pub state: RunState, +} + +pub fn run_scale(state: &RunState, entity_h: f32, base_wf: f32) -> f32 { + (state.height_mul * entity_h / 9.0) * (state.width_mul * base_wf.abs()) +} + +pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> &'a str { + state.font.as_deref().unwrap_or(base) +} + +pub fn measure_word( + text: &str, + state: &RunState, + entity_h: f32, + base_wf: f32, + base_font: &str, +) -> f32 { + let scale = run_scale(state, entity_h, base_wf); + let font_name = resolve_font(state, base_font); + let font = cxf::get_font(font_name); + let mut w = 0.0_f32; + for ch in text.chars() { + w += match font.glyph(ch) { + Some(g) => (g.advance + font.letter_spacing * state.tracking) * scale, + None => (6.0 + font.letter_spacing * state.tracking) * scale, + }; + } + w +} + +pub fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 { + let scale = run_scale(state, entity_h, base_wf); + let font_name = resolve_font(state, base_font); + cxf::get_font(font_name).word_spacing * scale +} + +pub fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 { + match &atom.kind { + AtomKind::Word(t) => measure_word(t, &atom.state, entity_h, base_wf, base_font), + AtomKind::Space => measure_space(&atom.state, entity_h, base_wf, base_font), + AtomKind::Tab => 0.0, + } +} + +/// Cursor position after a `\t` atom: advance to the next user-defined tab +/// stop that lies past `cur_x`, falling back to a 4-em default grid when no +/// stop is reached. +pub fn next_tab_position( + cur_x: f32, + tab_stops: &[TabStop], + indent_left: f32, + entity_h: f32, +) -> f32 { + let local = cur_x - indent_left; + for ts in tab_stops { + if ts.position > local + 1e-4 { + return indent_left + ts.position; + } + } + let default_interval = entity_h * 4.0; + let n = (local / default_interval).floor() + 1.0; + indent_left + n * default_interval +} + +/// Break a flat MText paragraph atom stream into wrap-fit sub-lines. +pub fn wrap_paragraph( + atoms: Vec, + rect_w: f32, + indent_first: f32, + indent_left: f32, + indent_right: f32, + tab_stops: &[TabStop], + entity_h: f32, + base_wf: f32, + base_font: &str, +) -> Vec> { + if rect_w <= 0.0 { + return vec![atoms]; + } + let mut sublines: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut cur_w = 0.0_f32; + let mut subline_idx: usize = 0; + let line_start_x = |idx: usize| if idx == 0 { indent_first } else { indent_left }; + let line_max_w = |idx: usize| (rect_w - indent_right - line_start_x(idx)).max(0.0); + + for atom in atoms { + match &atom.kind { + AtomKind::Word(_) => { + let w = atom_width(&atom, entity_h, base_wf, base_font); + let max_w = line_max_w(subline_idx); + if !cur.is_empty() && cur_w + w > max_w { + while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) { + cur.pop(); + } + sublines.push(std::mem::take(&mut cur)); + cur_w = 0.0; + subline_idx += 1; + } + cur.push(atom); + cur_w += w; + } + AtomKind::Space => { + if cur.is_empty() { + continue; + } + cur_w += atom_width(&atom, entity_h, base_wf, base_font); + cur.push(atom); + } + AtomKind::Tab => { + let start_x = line_start_x(subline_idx); + let new_w = next_tab_position(cur_w + start_x, tab_stops, indent_left, entity_h) + - start_x; + let max_w = line_max_w(subline_idx); + if new_w > max_w && !cur.is_empty() { + sublines.push(std::mem::take(&mut cur)); + cur_w = 0.0; + subline_idx += 1; + } else { + cur.push(atom); + cur_w = new_w.min(max_w); + } + } } - if !current.is_empty() { - current.push(' '); - current_w += space_w; + } + if !cur.is_empty() { + sublines.push(cur); + } + if sublines.is_empty() { + sublines.push(Vec::new()); + } + sublines +} + +pub fn line_total_width( + atoms: &[LayoutAtom], + entity_h: f32, + base_wf: f32, + base_font: &str, + line_start_x: f32, + indent_left: f32, + tab_stops: &[TabStop], +) -> f32 { + let mut x = line_start_x; + for atom in atoms { + match atom.kind { + AtomKind::Tab => { + x = next_tab_position(x, tab_stops, indent_left, entity_h); + } + _ => x += atom_width(atom, entity_h, base_wf, base_font), } - current.push_str(word); - current_w += word_w; + } + x - line_start_x +} + +pub fn resolve_inline_color(c: &InlineColor) -> Option<[f32; 3]> { + match c { + InlineColor::Aci(idx) => aci_to_rgb(*idx).map(|(r, g, b)| { + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0] + }), + InlineColor::True(rgb) => Some(*rgb), + } +} + +/// Wrap a run's glyph text with MTEXT decoration markers so cxf's +/// `tessellate_text_run` emits the underline / overline / strikethrough +/// strokes for us — keeps decoration geometry in one place rather than +/// duplicating the y-position constants. +fn decorated(text: &str, state: &RunState) -> String { + if !(state.underline || state.overline || state.strike) { + return text.to_string(); + } + let mut s = String::with_capacity(text.len() + 6); + if state.underline { + s.push_str("\\L"); + } + if state.overline { + s.push_str("\\O"); + } + if state.strike { + s.push_str("\\K"); + } + s.push_str(text); + if state.underline { + s.push_str("\\l"); + } + if state.overline { + s.push_str("\\o"); + } + if state.strike { + s.push_str("\\k"); } - if !current.is_empty() { - lines.push(current); + s +} + +#[derive(Clone, Copy, Debug)] +pub enum MTextVAnchor { + /// Block top edge at insertion (first line's cap = insertion.y). + Top, + /// Block midpoint at insertion. + Middle, + /// Block bottom edge at insertion (last line's baseline = insertion.y). + Bottom, + /// MLEADER `MiddleOfTopLine` — first line's vertical centre at insertion. + MiddleOfTopLine, + /// MLEADER `MiddleOfBottomLine` — last line's vertical centre at insertion. + MiddleOfBottomLine, + /// MLEADER `BottomOfTopLineUnderline*` — first line's baseline at insertion. + BottomOfTopLine, +} + +/// Render inputs for [`layout_mtext`]. The caller resolves the text style +/// once and feeds the entity's geometry; the helper handles the entire +/// parse → wrap → render pipeline and returns both the rendered strokes and +/// the layout metrics (so callers can also draw frames / fills / LOD +/// substitutes from the same numbers). +pub struct MTextRenderOpts<'a> { + /// Raw MText-formatted value (the string the parser walks). + pub value: &'a str, + /// World-space insertion point — strokes are emitted with this as their + /// origin (after the per-sub-line rotation + cursor offset). + pub insertion: [f64; 3], + /// Entity text height in world units. + pub height: f32, + /// Box width for word-wrap (0 → no wrap; lines flow at the insertion). + pub rect_w: f32, + /// Final rotation in radians (already composed with `is_upside_down`). + pub rotation: f32, + /// Resolved style (font + width factor + oblique). Width factor sign + /// honours `is_backward` (negative → mirror). + pub style: &'a ResolvedTextStyle, + /// Horizontal anchor of the text block at the insertion point: + /// 0.0 = left, 0.5 = center, 1.0 = right. + pub attach_h_anchor: f32, + /// Vertical anchor of the text block at the insertion point. + pub v_anchor: MTextVAnchor, + /// DXF code 44 — multiplier on the default 5/3-em baseline gap. + pub line_spacing_factor: f32, + /// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2). + pub vertical_text: bool, +} + +/// Output of [`layout_mtext`]: stroke groups + the geometry the caller +/// needs for surrounding chrome (frame / fill / LOD baseline-or-rect). +pub struct MTextLayout { + /// One TextStroke per Word atom (Tab / Space contribute only to cursor + /// advance). The `color` field on each stroke carries the inline + /// `\C` / `\c` override when one was set, otherwise `None`. + pub strokes: Vec, + /// Per-sub-line width in entity-local (pre-rotation) units. Includes + /// any trailing whitespace that survived the trim — kept in sync with + /// the cursor advance so the alignment numbers and the visible glyphs + /// line up. + pub line_widths: Vec, + /// Sub-line count (≥ 1; an entity with an empty value still reports 1). + pub line_count: usize, + /// Baseline-to-baseline gap used when stepping between sub-lines. + pub line_height: f32, + /// Y of the first sub-line's baseline relative to the insertion point + /// (in the entity-local, pre-rotation frame). + pub v_offset: f32, +} + +/// Lay out and render an MText-formatted value, returning the stroke +/// groups plus the layout metrics needed by callers that draw chrome +/// (text frame, background fill, low-detail LOD substitutes) around the +/// text block. +pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { + let base_font_name = opts.style.font_name.clone(); + let base_font = cxf::get_font(&base_font_name); + let base_wf_abs = opts.style.width_factor.max(0.01); + let base_wf = if opts.style.is_backward { -base_wf_abs } else { base_wf_abs }; + let base_oblique = opts.style.oblique_angle; + let entity_h = opts.height; + let rect_w = opts.rect_w; + + // ── 1. Parse ───────────────────────────────────────────────────────── + let paragraphs = parse_mtext_paragraphs(opts.value, entity_h); + + // ── 2. Atomise + wrap each paragraph into sub-lines ────────────────── + struct SubLine { + atoms: Vec, + align: Option, + indent_first: f32, + indent_left: f32, + indent_right: f32, + tab_stops: Vec, + is_first_in_paragraph: bool, + } + + let mut sub_lines: Vec = Vec::new(); + 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(), + }); + } + } + } + + // Trim leading + trailing Space atoms so line_w / cursor_start agree + // on the paragraph's visible content. Without this a stray trailing + // space measures wider than it draws and centring / right-alignment + // is off by half a space-width. + 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, + ); + for (idx, atoms) in wrapped.into_iter().enumerate() { + sub_lines.push(SubLine { + atoms, + align: para.align, + indent_first: para.indent_first, + indent_left: para.indent_left, + indent_right: para.indent_right, + tab_stops: para.tab_stops.clone(), + is_first_in_paragraph: idx == 0, + }); + } + } + if sub_lines.is_empty() { + sub_lines.push(SubLine { + atoms: Vec::new(), + align: None, + indent_first: 0.0, + indent_left: 0.0, + indent_right: 0.0, + tab_stops: Vec::new(), + is_first_in_paragraph: true, + }); + } + + // ── 3. Block geometry (line spacing, attachment, rotation) ─────────── + let n_lines = sub_lines.len().max(1) as f32; + let ls_factor = if opts.line_spacing_factor > 0.0 { + opts.line_spacing_factor + } else { + 1.0 + }; + // DXF code 44 — multiplier on the default 5/3-em baseline-to-baseline gap. + let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing; + let h = entity_h; + let v_offset = match opts.v_anchor { + MTextVAnchor::Top => -h, + MTextVAnchor::Middle => ((n_lines - 1.0) * line_h - h) * 0.5, + MTextVAnchor::Bottom => (n_lines - 1.0) * line_h, + MTextVAnchor::MiddleOfTopLine => -h * 0.5, + MTextVAnchor::MiddleOfBottomLine => (n_lines - 1.0) * line_h - h * 0.5, + MTextVAnchor::BottomOfTopLine => 0.0, + }; + let attach_h_anchor = opts.attach_h_anchor; + let box_left = -attach_h_anchor * rect_w; + let rot = opts.rotation; + let (cos_r, sin_r) = (rot.cos(), rot.sin()); + let ins_x = opts.insertion[0]; + let ins_y = opts.insertion[1]; + + // ── 4. Render each sub-line ────────────────────────────────────────── + let mut all_strokes: Vec = Vec::new(); + let mut line_widths: Vec = Vec::with_capacity(sub_lines.len()); + for (i, sub) in sub_lines.iter().enumerate() { + let li = i as f32; + let (line_base_x, line_base_y) = if opts.vertical_text { + let col_offset = li * entity_h * 1.2; + ( + col_offset * cos_r + v_offset * (-sin_r), + col_offset * sin_r + v_offset * cos_r, + ) + } else { + let ly = -(li * line_h) + v_offset; + (ly * (-sin_r), ly * cos_r) + }; + + let content_left = if rect_w > 0.0 { + box_left + + if sub.is_first_in_paragraph { + sub.indent_first + } else { + sub.indent_left + } + } else { + 0.0 + }; + let content_right = if rect_w > 0.0 { + box_left + rect_w - sub.indent_right + } else { + 0.0 + }; + + let line_anchor: f32 = match sub.align { + Some(ParagraphAlign::Left) + | Some(ParagraphAlign::Justify) + | Some(ParagraphAlign::Distribute) => 0.0, + Some(ParagraphAlign::Center) => 0.5, + Some(ParagraphAlign::Right) => 1.0, + None => attach_h_anchor, + }; + + let line_w = line_total_width( + &sub.atoms, + entity_h, + base_wf, + &base_font_name, + 0.0, + sub.indent_left, + &sub.tab_stops, + ); + line_widths.push(line_w); + + let cursor_start = if rect_w > 0.0 { + let content_w = (content_right - content_left).max(0.0); + content_left + (content_w - line_w) * line_anchor + } else if line_anchor > 0.0 { + -line_w * line_anchor + } else { + 0.0 + }; + + let line_max_h = sub + .atoms + .iter() + .map(|a| a.state.height_mul * entity_h) + .fold(entity_h, f32::max); + + let mut cursor_x = cursor_start; + for atom in &sub.atoms { + match &atom.kind { + AtomKind::Word(text) => { + let run_h = atom.state.height_mul * entity_h; + let signed_wf = + base_wf.signum() * atom.state.width_mul * base_wf.abs(); + let oblique = base_oblique + atom.state.oblique_rad; + let font_name = resolve_font(&atom.state, &base_font_name); + let tracking = atom.state.tracking; + let valign_dy = match atom.state.valign { + 1 => (line_max_h - run_h) * 0.5, + 2 => line_max_h - run_h, + _ => 0.0, + }; + let color = atom.state.color.as_ref().and_then(resolve_inline_color); + let body = decorated(text, &atom.state); + + let lx = cursor_x; + let ly = valign_dy; + let world_dx = lx * cos_r - ly * sin_r; + let world_dy = lx * sin_r + ly * cos_r; + let origin: [f64; 2] = [ + ins_x + (line_base_x + world_dx) as f64, + ins_y + (line_base_y + world_dy) as f64, + ]; + let strokes = cxf::tessellate_text_run( + [0.0, 0.0], + run_h, + rot, + signed_wf, + oblique, + tracking, + font_name, + &body, + ); + all_strokes.push(TextStroke { + strokes, + origin, + color, + }); + cursor_x += + measure_word(text, &atom.state, entity_h, base_wf, &base_font_name); + } + AtomKind::Space => { + cursor_x += + measure_space(&atom.state, entity_h, base_wf, &base_font_name); + } + AtomKind::Tab => { + cursor_x = next_tab_position( + cursor_x, + &sub.tab_stops, + sub.indent_left, + entity_h, + ); + } + } + } + } + + MTextLayout { + strokes: all_strokes, + line_widths, + line_count: sub_lines.len(), + line_height: line_h, + v_offset, } - lines } diff --git a/src/scene/cxf.rs b/src/scene/cxf.rs index d3c64d9fd..0dbff401a 100644 --- a/src/scene/cxf.rs +++ b/src/scene/cxf.rs @@ -476,28 +476,10 @@ pub fn tessellate_text_run( out } -/// Measure the width of a rendered text string in font glyph units × scale. -/// Returns the total advance width in world units (height-independent: divide by height for -/// a normalised ratio, or use directly when `height` is already the desired em size). -pub fn measure_text(text: &str, height: f32, width_factor: f32, font_name: &str) -> f32 { - if text.is_empty() || height <= 0.0 { - return 0.0; - } - let font = get_font(font_name); - let scale = height / 9.0; - let wf = width_factor.clamp(0.01, 100.0); - let mut cursor_x: f32 = 0.0; - for ch in text.chars() { - if ch == ' ' { - cursor_x += font.word_spacing; - } else if let Some(glyph) = font.glyph(ch) { - cursor_x += glyph.advance + font.letter_spacing; - } else { - cursor_x += 6.0 + font.letter_spacing; - } - } - cursor_x * scale * wf -} +// `measure_text` was removed alongside the legacy MText helpers in +// `text_support`; the run-aware `measure_word` / `measure_space` in +// `text_support` carry per-run state (tracking / width factor / font +// override) and are what every caller now uses. // ── Parser ──────────────────────────────────────────────────────────────── diff --git a/src/scene/tessellate.rs b/src/scene/tessellate.rs index 17a1635dc..ee18cf1a2 100644 --- a/src/scene/tessellate.rs +++ b/src/scene/tessellate.rs @@ -13,9 +13,9 @@ // Entities not handled by acad_to_truck (Viewport, Hatch, …) are tessellated // by the legacy geometry() path so nothing regresses. -use crate::entities::multileader::{catmull_rom_pts, v_offset_for_attachment}; +use crate::entities::multileader::catmull_rom_pts; use acadrust::entities::{ - Dimension, Leader, LeaderContentType, MultiLeader, MultiLeaderPathType, Text, + Dimension, Leader, LeaderContentType, MText, MultiLeader, MultiLeaderPathType, Text, TextAttachmentPointType, }; use acadrust::tables::DimStyle; @@ -864,15 +864,14 @@ pub fn tessellate_dimension( } } - if let Some(text) = dimension_text_entity(dim, dim_txt, style, document) { + if let Some(synth_text_entity) = dimension_text_entity(dim, dim_txt, style, document) { // Tolerance Text rendered separately so DIMTFAC scales its height // and DIMTOLJ aligns it vertically against the primary text. - let tol_entity = dimension_tolerance_entity(dim, style, &text, dim_txt); + let tol_entity = dimension_tolerance_entity(dim, style, &synth_text_entity, dim_txt); // Route synthesised dim text through tessellate_entity so the // baseline/greek/full LOD ladder applies (zoom-out behaviour - // matches top-level Text). The text already has dim_scale baked - // into its height, so anno_scale stays 1.0. - let synth_text_entity = EntityType::Text(text); + // matches top-level Text / MText). The text already has dim_scale + // baked into its height, so anno_scale stays 1.0. let text_wires = crate::scene::tessellate_entity_dim_text( document, selected_set, @@ -890,8 +889,7 @@ pub fn tessellate_dimension( wires.push(w); } - if let Some(tol_text) = tol_entity { - let tol_entity_e = EntityType::Text(tol_text); + if let Some(tol_entity_e) = tol_entity { let tol_wires = crate::scene::tessellate_entity_dim_text( document, selected_set, @@ -1703,49 +1701,11 @@ pub fn tessellate_multileader( }) .unwrap_or_else(|| "STANDARD".to_string()); let style = crate::entities::text_support::resolve_text_style(&style_name, document); - let font_name = style.font_name; - let font = crate::scene::cxf::get_font(&font_name); - // MultiLeader text takes its width factor / oblique from the style - // (no entity-level override exists for MText-content multileaders). - // is_backward flips the sign on width_factor; is_upside_down adds π - // to the text rotation — same convention as Text / MText. - let base_wf = style.width_factor.max(0.01); - let width_factor = if style.is_backward { -base_wf } else { base_wf }; - let oblique = style.oblique_angle; if style.is_upside_down { rot += std::f32::consts::PI; } let (cos_r, sin_r) = (rot.cos(), rot.sin()); - // Strip MText format codes (e.g. `{\fArial Black|b0|i0|c162|p34;...}`), - // then split on \P / \n / \N and optionally word-wrap to text_width. - let plain = crate::entities::text_support::strip_mtext_codes(&ctx.text_string); - let explicit_lines = crate::entities::text_support::split_mtext_lines(&plain); - let lines: Vec = if ctx.text_width > 0.0 { - // Measurement uses positive width magnitude; the sign on - // width_factor is a render-time mirror flag. - let scale = height / 9.0 * width_factor.abs(); - // ctx.text_width lives in the same WCS frame as ctx.text_height; - // it does not need a second multiplication by effective_scale. - let max_w = ctx.text_width as f32; - explicit_lines - .iter() - .flat_map(|line| { - crate::entities::text_support::word_wrap(line, max_w, scale, font) - }) - .collect() - } else { - explicit_lines - }; - - let ls_factor = if ctx.line_spacing_factor > 0.0 { - ctx.line_spacing_factor as f32 - } else { - 1.0 - }; - let line_h = height * ls_factor * (5.0 / 3.0) * font.line_spacing; - let n_lines = lines.len().max(1) as f32; - // ml.text_alignment overrides the per-context attachment_point when // not Left (which is the default zero). Otherwise we honour the // context's stored attachment_point (Left/Center/Right). @@ -1780,16 +1740,32 @@ pub fn tessellate_multileader( } TextAttachmentDirectionType::Horizontal => ctx.text_left_attachment, }; - let v_offset = v_offset_for_attachment(vertical_attach, n_lines, height, line_h); + let v_anchor = + crate::entities::multileader::mleader_v_anchor(vertical_attach); - // line_widths use positive magnitude for measurement; tessellate_text_ex - // gets the signed width_factor below to honour the backward flag. - let scale = height / 9.0 * width_factor.abs(); - let line_widths: Vec = lines - .iter() - .map(|line| crate::entities::text_support::measure_mtext_chars(line, scale, font)) - .collect(); + // Shared MText pipeline — every inline format code (`\f`, `\C`/`\c`, + // `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked + // fractions, …) reaches the stroke output. Stroke origins are + // already in offset-relative space because we pass local_ins_x/y. + let layout = crate::entities::text_support::layout_mtext( + &crate::entities::text_support::MTextRenderOpts { + value: &ctx.text_string, + insertion: [local_ins_x as f64, local_ins_y as f64, z as f64], + height, + rect_w: ctx.text_width as f32, + rotation: rot, + style: &style, + attach_h_anchor: h_anchor, + v_anchor, + line_spacing_factor: ctx.line_spacing_factor as f32, + vertical_text: false, + }, + ); + let line_widths = &layout.line_widths; let max_line_w = line_widths.iter().cloned().fold(0.0_f32, f32::max); + let line_h = layout.line_height; + let v_offset = layout.v_offset; + let n_lines = layout.line_count.max(1) as f32; // Resolve text color (falls back to entity color for ByLayer / ByBlock). let text_color = if selected { @@ -1855,12 +1831,11 @@ pub fn tessellate_multileader( // 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(lines.len() * 6); - for (i, _) in lines.iter().enumerate() { + 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; - let line_w = line_widths[i]; if line_w <= 0.0 { continue; } @@ -1895,34 +1870,20 @@ pub fn tessellate_multileader( }); } } else { - // Build per-line stroke points in WCS. + // 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(); - for (i, line) in lines.iter().enumerate() { - let li = i as f32; - let line_y_local = -li * line_h + v_offset; - let line_w = line_widths[i]; - let h_shift_local = -line_w * h_anchor; - let wcs_dx = h_shift_local * cos_r - line_y_local * sin_r; - let wcs_dy = h_shift_local * sin_r + line_y_local * cos_r; - // Origin already in offset-relative space — tessellator will rotate - // glyph offsets around it and produce points directly in render space. - let origin = [local_ins_x + wcs_dx, local_ins_y + wcs_dy]; - let strokes = crate::scene::cxf::tessellate_text_ex( - origin, - height, - rot, - width_factor, - oblique, - &font_name, - line, - ); - for stroke in &strokes { + 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, y, z]); + text_points.push([x + ox, y + oy, z]); } } } @@ -2805,16 +2766,68 @@ fn dimension_snap_pts(dim: &Dimension, world_offset: [f64; 3]) -> Vec<(Vec3, Sna } } +/// Cheap heuristic: does this string contain anything the MText parser would +/// interpret? Used by `dimension_text_entity` to pick between a synthetic +/// `Text` (plain DXF special chars only) and a synthetic `MText` (full inline +/// format-code pipeline) for the dim text override. +fn value_has_mtext_codes(s: &str) -> bool { + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '{' || c == '}' { + return true; + } + if c == '\\' { + if let Some(&next) = chars.peek() { + // Any backslash followed by a known MText escape letter. + if matches!( + next, + 'H' | 'W' + | 'Q' + | 'T' + | 'A' + | 'C' + | 'c' + | 'f' + | 'F' + | 'p' + | 'L' + | 'l' + | 'O' + | 'o' + | 'K' + | 'k' + | 'S' + | 's' + | 'P' + | 'n' + | 'N' + | 't' + | 'U' + | 'u' + | 'M' + | 'X' + | '~' + | '{' + | '}' + ) { + return true; + } + } + } + } + false +} + fn dimension_text_entity( dim: &Dimension, text_height: f64, style: Option<&DimStyle>, document: &CadDocument, -) -> Option { +) -> Option { let value = dimension_text_value(dim, style)?; // Use f64 position directly to avoid f32 round-trip precision loss at large // coordinates (e.g. Turkish UTM ~4,000,000 m). tessellate() will apply - // world_offset when rendering this synthetic Text entity. + // world_offset when rendering this synthetic entity. let pos_f64 = dimension_text_pos_f64(dim, style, text_height); let base = dim.base(); @@ -2835,15 +2848,11 @@ fn dimension_text_entity( dimension_text_natural_rotation(dim) }; - let mut text = Text::with_value(value, pos_f64) - .with_height(text_height) - .with_rotation(rotation); - // Text style resolution priority: // 1. DIMTXSTY by handle (most reliable; survives rename) // 2. DIMTXSTY by name // 3. dim's own style_name (rare fallback) - text.style = style + let style_name = style .and_then(|s| { if !s.dimtxsty_handle.is_null() { document @@ -2862,6 +2871,41 @@ fn dimension_text_entity( }) .unwrap_or_else(|| base.style_name.clone()); + // Route through MText whenever the value carries inline format codes + // (`\f`, `\C`, `\H`, `\S`, brace scopes, …). Otherwise stay on the Text + // path — single-line dim text doesn't need the full MText pipeline. + if value_has_mtext_codes(&value) { + use acadrust::entities::dimension::AttachmentPointType as DA; + use acadrust::entities::AttachmentPoint as MA; + let attachment_point = match base.attachment_point { + DA::TopLeft => MA::TopLeft, + DA::TopCenter => MA::TopCenter, + DA::TopRight => MA::TopRight, + DA::MiddleLeft => MA::MiddleLeft, + DA::MiddleCenter => MA::MiddleCenter, + DA::MiddleRight => MA::MiddleRight, + DA::BottomLeft => MA::BottomLeft, + DA::BottomCenter => MA::BottomCenter, + DA::BottomRight => MA::BottomRight, + }; + let mut mtext = MText::with_value(value, pos_f64); + mtext.height = text_height; + mtext.rotation = rotation; + mtext.style = style_name; + mtext.attachment_point = attachment_point; + if base.line_spacing_factor.abs() > 1e-9 { + mtext.line_spacing_factor = base.line_spacing_factor; + } + mtext.normal = base.normal; + mtext.common = base.common.clone(); + return Some(EntityType::MText(mtext)); + } + + let mut text = Text::with_value(value, pos_f64) + .with_height(text_height) + .with_rotation(rotation); + text.style = style_name; + // Map AttachmentPointType (1..9 grid) to Text horizontal + vertical // alignments. 1=TopLeft … 9=BottomRight (column-major). let (ha, va) = attachment_to_text_align(base.attachment_point); @@ -2876,7 +2920,7 @@ fn dimension_text_entity( let _ = base.normal; text.common = base.common.clone(); - Some(text) + Some(EntityType::Text(text)) } fn attachment_to_text_align( @@ -3073,9 +3117,9 @@ fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option, - primary: &Text, + primary: &EntityType, primary_height: f64, -) -> Option { +) -> Option { let s = style?; let is_angular = matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)); let tol = build_tolerance_suffix(dim.measurement(), style, is_angular)?; @@ -3086,8 +3130,30 @@ fn dimension_tolerance_entity( }; let tol_height = primary_height * dimtfac; + // Pull the geometry we need from the synthetic primary entity (Text or + // MText — `dimension_text_entity` routes to MText when the dim value + // carries inline format codes). + let (primary_value_len, primary_insertion, primary_rotation, primary_style, primary_common) = + match primary { + EntityType::Text(t) => ( + t.value.chars().count(), + t.insertion_point, + t.rotation, + t.style.clone(), + t.common.clone(), + ), + EntityType::MText(m) => ( + m.value.chars().count(), + m.insertion_point, + m.rotation, + m.style.clone(), + m.common.clone(), + ), + _ => return None, + }; + // Approximate widths from glyph counts (~0.6 × cell size per char). - let primary_w = primary.value.chars().count() as f64 * primary_height * 0.6; + let primary_w = primary_value_len as f64 * primary_height * 0.6; let tol_w = tol.chars().count() as f64 * tol_height * 0.6; let gap = primary_height * 0.2; let dx_local = primary_w * 0.5 + tol_w * 0.5 + gap; @@ -3096,21 +3162,21 @@ fn dimension_tolerance_entity( 2 => primary_height * 0.5 - tol_height * 0.5, // top-aligned with primary top _ => 0.0, // centred (default for ±) }; - let rot = primary.rotation; + let rot = primary_rotation; let (sr, cr) = rot.sin_cos(); let pos = Vector3::new( - primary.insertion_point.x + dx_local * cr - dy_local * sr, - primary.insertion_point.y + dx_local * sr + dy_local * cr, - primary.insertion_point.z, + primary_insertion.x + dx_local * cr - dy_local * sr, + primary_insertion.y + dx_local * sr + dy_local * cr, + primary_insertion.z, ); let mut t = Text::with_value(tol, pos) .with_height(tol_height) .with_rotation(rot); - t.style = primary.style.clone(); - t.common = primary.common.clone(); + t.style = primary_style; + t.common = primary_common; t.horizontal_alignment = acadrust::entities::text::TextHorizontalAlignment::Center; t.vertical_alignment = acadrust::entities::text::TextVerticalAlignment::Middle; - Some(t) + Some(EntityType::Text(t)) } /// Wrap a measured value with the style's DIMPOST prefix/suffix template.