use acadrust::types::aci_table::aci_to_rgb; use acadrust::{CadDocument, EntityType}; use crate::scene::convert::acad_to_truck::TextStroke; use crate::scene::text::font_face::Face; use crate::scene::text::lff; pub struct ResolvedTextStyle { pub font_name: String, pub width_factor: f32, pub oblique_angle: f32, pub is_backward: bool, pub is_upside_down: bool, } pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle { let style = document.text_styles.iter().find(|entry| { entry.name.eq_ignore_ascii_case(style_name) || (style_name.trim().is_empty() && entry.name.eq_ignore_ascii_case("Standard")) }); let mut font_name = if let Some(style) = style { if !style.true_type_font.trim().is_empty() { style.true_type_font.trim().to_string() } else if !style.font_file.trim().is_empty() { let file = style.font_file.trim(); let basename = file.rsplit(['/', '\\']).next().unwrap_or(file); let stem = basename.split('.').next().unwrap_or(basename).trim(); if !stem.is_empty() { stem.to_string() } else if !style.name.trim().is_empty() { style.name.trim().to_string() } else { "Standard".to_string() } } else if !style.name.trim().is_empty() { style.name.trim().to_string() } else { "Standard".to_string() } } else if style_name.trim().is_empty() { "Standard".to_string() } else { style_name.trim().to_string() }; if !lff::is_builtin(&font_name) { if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) { font_name = canonical; } } 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), } } pub struct TextLocalBounds { /// Inked extent (glyph strokes only) — drives vertical alignment, where /// the cap / baseline geometry is what matters. pub ink_min: [f32; 2], pub ink_max: [f32; 2], /// Pen advance along the baseline, including leading / trailing spaces and /// inter-glyph spacing. Drives horizontal alignment so spaces in the string /// keep their width instead of collapsing to the first / last inked glyph. pub advance: f32, } pub fn text_local_bounds( font_name: &str, text: &str, height: f32, width_factor: f32, oblique_angle: f32, ) -> Option { if text.is_empty() || height <= 0.0 { return None; } let face = Face::resolve(font_name); let scale = height / 9.0; let wf = width_factor.abs().clamp(0.01, 100.0); let ob = oblique_angle.tan(); let mut cursor_x = 0.0_f32; let mut min_x = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; let mut min_y = f32::INFINITY; let mut max_y = f32::NEG_INFINITY; for ch in text.chars() { if ch == ' ' { cursor_x += face.word_spacing(); continue; } match face.glyph(ch) { Some(glyph) => { for stroke in &glyph.strokes { for &[gx, gy] in stroke { let sx = (cursor_x + gx) * scale * wf + gy * scale * ob; let sy = gy * scale; min_x = min_x.min(sx); max_x = max_x.max(sx); min_y = min_y.min(sy); max_y = max_y.max(sy); } } cursor_x += glyph.advance + face.letter_spacing(); } None => { cursor_x += 6.0 + face.letter_spacing(); } } } // Pen advance is measured at the baseline, so oblique shear (which skews x // only by gy) does not enter it. Valid even for an all-space string. let advance = cursor_x * scale * wf; if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() { Some(TextLocalBounds { ink_min: [min_x, min_y], ink_max: [max_x, max_y], advance, }) } else { None } } /// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values: /// - `%%d` / `%%D` → `°` /// - `%%p` / `%%P` → `±` /// - `%%c` / `%%C` → `⌀` /// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts) /// - `%%o` / `%%O` → overline toggle (stripped) /// - `%%%%` → `%` /// - `%%nnn` (3 decimal digits) → Unicode scalar `nnn` /// Any unrecognised `%%x` is passed through unchanged. pub fn resolve_dxf_special_chars(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { if c != '%' || chars.peek() != Some(&'%') { out.push(c); continue; } chars.next(); // consume second '%' match chars.peek().map(|c| c.to_ascii_lowercase()) { Some('d') => { chars.next(); out.push('°'); } Some('p') => { chars.next(); out.push('±'); } Some('c') => { chars.next(); out.push('⌀'); } Some('u') | Some('o') => { chars.next(); } // toggle codes — strip silently Some('%') => { chars.next(); out.push('%'); } Some(d) if d.is_ascii_digit() => { let mut digits = String::with_capacity(3); for _ in 0..3 { match chars.peek() { Some(&ch) if ch.is_ascii_digit() => { digits.push(chars.next().unwrap()); } _ => break, } } if digits.len() == 3 { if let Ok(n) = digits.parse::() { if let Some(ch) = char::from_u32(n) { out.push(ch); continue; } } } out.push('%'); out.push('%'); out.push_str(&digits); } _ => { out.push('%'); out.push('%'); } } } out } // ────────────────────────────────────────────────────────────────────────── // Rich MTEXT parser — full inline format-code coverage // // Recognised codes (DXF MTEXT inline): // Escapes: \\ \{ \} \~ \t \P \n \N \U+XXXX \u+XXXX // Toggles: \L\l \O\o \K\k (underline / overline / strike) // State: \H[x]; \W[x]; \Q; \T[x]; \A; // \C; \c; // \f|b<0/1>|i<0/1>|c|p; \F; // \M+; \X \S; // Paragraph: \p[xi,l,r,q[lcrjd],t,s...]; // Scope: { ... } push/pop full state // ────────────────────────────────────────────────────────────────────────── /// Paragraph alignment encoded inline via `\p...q[lcrjd]...;`. /// `Justify` / `Distribute` render as `Left` (full inter-word redistribution /// is not implemented in the stroke renderer). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ParagraphAlign { Left, Center, Right, Justify, Distribute, } /// Inline colour override (`\C` ACI or `\c` 24-bit true colour). Resolved to /// linear RGB at render time via the document's ACI table. #[derive(Clone, Copy, Debug, PartialEq)] pub enum InlineColor { Aci(u8), True([f32; 3]), } /// Tab-stop alignment kind (from `\pt` entries). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TabKind { Left, Center, Right, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct TabStop { pub position: f32, pub kind: TabKind, } /// Per-run formatting state. All fields are multipliers / overrides relative /// to the entity-level defaults; the renderer composes them with the resolved /// text style at draw time. #[derive(Clone, Debug, PartialEq)] pub struct RunState { /// Multiplier on entity text height (`\Hx;` → ×v; `\H;` → v / entity_h) pub height_mul: f32, /// Multiplier on the (signed) style width-factor (`\W;` → set, `\Wx;` → ×) pub width_mul: f32, /// Absolute oblique angle override in radians (`\Q;`) pub oblique_rad: f32, /// Tracking multiplier on `font.letter_spacing` (`\T;`) pub tracking: f32, /// Vertical alignment of the run within its line box (0=baseline / 1=center /// / 2=top). Mainly used for fractions and superscript-like layout (`\A`). pub valign: u8, /// Font-name override, `None` ⇒ inherit the resolved style font. pub font: Option, /// Colour override, `None` ⇒ inherit entity colour. pub color: Option, pub underline: bool, pub overline: bool, pub strike: bool, } impl Default for RunState { fn default() -> Self { Self { height_mul: 1.0, width_mul: 1.0, oblique_rad: 0.0, tracking: 1.0, valign: 0, font: None, color: None, underline: false, overline: false, strike: false, } } } #[derive(Clone, Debug)] pub enum MTextRunKind { /// Renderable glyph text (DXF specials resolved, decoration markers stripped). Glyphs(String), /// `\t` — jump the cursor to the next tab stop (or default tab interval). Tab, } #[derive(Clone, Debug)] pub struct MTextRun { pub kind: MTextRunKind, pub state: RunState, } /// One paragraph of MTEXT after parsing. Each line is a sequence of runs that /// share text content + a snapshot of formatting state, plus paragraph-level /// layout (alignment, indents, tab stops). `\P` / `\n` / `\N` start a new /// line; paragraph properties carry forward until the next `\p...;` block. #[derive(Clone, Debug, Default)] pub struct MTextLine { pub runs: Vec, pub align: Option, pub indent_first: f32, pub indent_left: f32, pub indent_right: f32, pub tab_stops: Vec, } impl MTextLine { pub fn is_blank(&self) -> bool { self.runs.iter().all(|r| match &r.kind { MTextRunKind::Glyphs(t) => t.trim().is_empty(), MTextRunKind::Tab => false, }) } } #[derive(Clone, Debug, Default)] struct ParagraphProps { align: Option, indent_first: f32, indent_left: f32, indent_right: f32, tab_stops: Vec, } /// Parse a `\p...;` body. Comma-separated tokens, each with a single-letter /// kind prefix. Unknown tokens are skipped — anything we don't understand is /// silently dropped rather than poisoning the rest of the paragraph. fn parse_paragraph_block(body: &str, props: &mut ParagraphProps) { // The legacy AutoCAD writer prefixes the block with a redundant `x`; skip // it so it doesn't confuse the kind matcher. let body = body.strip_prefix('x').unwrap_or(body); for token in body.split(',') { let token = token.trim(); let mut chars = token.chars(); let Some(kind) = chars.next() else { continue }; let rest: String = chars.collect(); match kind { 'q' | 'Q' => { let sel = rest.chars().next().map(|c| c.to_ascii_lowercase()); props.align = match sel { Some('l') => Some(ParagraphAlign::Left), Some('c') => Some(ParagraphAlign::Center), Some('r') => Some(ParagraphAlign::Right), Some('j') => Some(ParagraphAlign::Justify), Some('d') => Some(ParagraphAlign::Distribute), _ => props.align, }; } 'i' => { if let Ok(v) = rest.parse::() { props.indent_first = v; } } 'l' => { if let Ok(v) = rest.parse::() { props.indent_left = v; } } 'r' => { if let Ok(v) = rest.parse::() { props.indent_right = v; } } 't' => { // Tab list. Each entry may be prefixed with L / C / R to // pick the tab kind; default is Left. The remainder is the // position in drawing units. props.tab_stops.clear(); for entry in rest.split(',') { let entry = entry.trim(); if entry.is_empty() { continue; } let (kind, num_str) = match entry.chars().next() { Some(c @ ('L' | 'l')) => (TabKind::Left, &entry[c.len_utf8()..]), Some(c @ ('C' | 'c')) => (TabKind::Center, &entry[c.len_utf8()..]), Some(c @ ('R' | 'r')) => (TabKind::Right, &entry[c.len_utf8()..]), _ => (TabKind::Left, entry), }; if let Ok(p) = num_str.parse::() { props.tab_stops.push(TabStop { position: p, kind }); } } } 's' => {} // space-before — affects line spacing; ignored for now _ => {} } } } /// Parse an unsigned 24-bit true-colour value into linear-ish [r,g,b] floats. /// AutoCAD packs `\c` as a 24-bit decimal: high byte = R, mid = G, low = B. fn parse_true_color(s: &str) -> Option { let n: u32 = s.trim().parse().ok()?; let r = ((n >> 16) & 0xFF) as f32 / 255.0; let g = ((n >> 8) & 0xFF) as f32 / 255.0; let b = (n & 0xFF) as f32 / 255.0; Some(InlineColor::True([r, g, b])) } /// Parse `\H` / `\W` / `\T` value. Returns `(value, is_relative)`; a trailing /// `x` marks a multiplier on the current state, otherwise the value is the /// absolute target (interpreted by the caller against entity defaults). fn parse_scalar_with_x_suffix(body: &str) -> Option<(f32, bool)> { let body = body.trim(); let (num, is_rel) = if let Some(stripped) = body.strip_suffix('x').or_else(|| body.strip_suffix('X')) { (stripped, true) } else { (body, false) }; Some((num.trim().parse::().ok()?, is_rel)) } /// Flush the glyph buffer as a `Glyphs` run on `line` using a snapshot of /// `state`. Resolves DXF `%%x` specials (degree / diameter / `%%nnn` literal /// unicode) so the renderer sees fully decoded text. fn flush_glyph_buf(line: &mut MTextLine, buf: &mut String, state: &RunState) { if buf.is_empty() { return; } let text = resolve_dxf_special_chars(&std::mem::take(buf)); line.runs.push(MTextRun { kind: MTextRunKind::Glyphs(text), state: state.clone(), }); } /// Walk the MTEXT value string and produce one [`MTextLine`] per visible /// paragraph (after stripping leading / trailing blank lines, as the legacy /// `split_mtext_lines` does). Every inline format code listed in the module /// header is recognised; unknown semicolon-terminated codes are stripped /// silently so future / vendor-specific extensions don't pollute the text. /// /// `entity_height` is needed to translate absolute `\H;` declarations into /// the height-multiplier representation carried in [`RunState`]; pass the /// MTEXT entity's `height` field. pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec { parse_mtext_paragraphs_ex(s, entity_height, true) } /// Like [`parse_mtext_paragraphs`] but with control over blank-edge trimming. /// /// `trim_blank_edges` drops leading and trailing blank paragraphs (the /// rendering default, so a stray trailing `\P` adds no empty space). The MText /// editor passes `false` so a freshly inserted newline keeps its empty /// paragraph and the caret can sit on the new line. pub fn parse_mtext_paragraphs_ex( s: &str, entity_height: f32, trim_blank_edges: bool, ) -> Vec { let mut lines: Vec = Vec::new(); let mut current = MTextLine::default(); let mut buf = String::new(); let mut state = RunState::default(); let mut state_stack: Vec = Vec::new(); let mut props = ParagraphProps::default(); // No props_stack — paragraph props persist across braces by design. let entity_height = entity_height.max(1e-6); let mut chars = s.chars().peekable(); let read_until_semi = |chars: &mut std::iter::Peekable| -> String { let mut out = String::new(); for c in chars.by_ref() { if c == ';' { break; } out.push(c); } out }; while let Some(ch) = chars.next() { match ch { '\\' => match chars.peek().copied() { // ── Line / paragraph break ──────────────────────────────── Some('P') | Some('n') | Some('N') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); current.align = props.align; current.indent_first = props.indent_first; current.indent_left = props.indent_left; current.indent_right = props.indent_right; current.tab_stops = props.tab_stops.clone(); lines.push(std::mem::take(&mut current)); } // ── Whitespace literals ─────────────────────────────────── Some('~') => { chars.next(); buf.push('\u{00A0}'); // nbsp — treated as a regular space by the wrap pass } Some('t') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); current.runs.push(MTextRun { kind: MTextRunKind::Tab, state: state.clone(), }); } // ── Unicode by hex code point ──────────────────────────── Some('U') | Some('u') => { chars.next(); if chars.peek() == Some(&'+') { chars.next(); // A `\U+XXXX` escape is exactly four hex digits (a BMP // code point). Reading more greedily swallows the // following literal characters — e.g. the diameter // value `\U+220520` ("⌀20") was parsed as code point // 0x220520, which is out of range, so the whole text // vanished. (#139) let mut hex = String::with_capacity(4); for _ in 0..4 { match chars.peek() { Some(&c) if c.is_ascii_hexdigit() => { hex.push(chars.next().unwrap()); } _ => break, } } if chars.peek() == Some(&';') { chars.next(); } if let Ok(n) = u32::from_str_radix(&hex, 16) { if let Some(c) = char::from_u32(n) { buf.push(c); continue; } } } else { // Bare `\U` / `\u` — strip to next `;` let _ = read_until_semi(&mut chars); } } // ── Stacked text \S; ────────────────────────── 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); } } buf.push_str(&upper); if !lower.is_empty() { buf.push(if sep == '#' { '/' } else { sep }); buf.push_str(&lower); } } // ── Decoration toggles (state, not markers) ────────────── Some('L') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = true; } Some('l') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = false; } Some('O') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = true; } Some('o') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = false; } Some('K') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = true; } Some('k') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = false; } // ── Literal backslash / braces ─────────────────────────── Some('\\') => { chars.next(); buf.push('\\'); } Some('{') | Some('}') => { buf.push(chars.next().unwrap()); } // ── Paragraph props ────────────────────────────────────── Some('p') => { chars.next(); let body = read_until_semi(&mut chars); parse_paragraph_block(&body, &mut props); } // ── Height ─────────────────────────────────────────────── Some('H') => { chars.next(); let body = read_until_semi(&mut chars); if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) { flush_glyph_buf(&mut current, &mut buf, &state); if is_rel { state.height_mul *= v; } else { state.height_mul = v / entity_height; } } } // ── Width factor ───────────────────────────────────────── Some('W') => { chars.next(); let body = read_until_semi(&mut chars); if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) { flush_glyph_buf(&mut current, &mut buf, &state); if is_rel { state.width_mul *= v; } else { state.width_mul = v; } } } // ── Oblique angle (degrees → radians) ──────────────────── Some('Q') => { chars.next(); let body = read_until_semi(&mut chars); if let Ok(deg) = body.trim().parse::() { flush_glyph_buf(&mut current, &mut buf, &state); state.oblique_rad = deg.to_radians(); } } // ── Tracking ───────────────────────────────────────────── Some('T') => { chars.next(); let body = read_until_semi(&mut chars); if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) { flush_glyph_buf(&mut current, &mut buf, &state); if is_rel { state.tracking *= v; } else { state.tracking = v; } } } // ── Vertical alignment ─────────────────────────────────── Some('A') => { chars.next(); let body = read_until_semi(&mut chars); if let Ok(n) = body.trim().parse::() { flush_glyph_buf(&mut current, &mut buf, &state); state.valign = n.min(2); } } // ── ACI colour ─────────────────────────────────────────── Some('C') => { chars.next(); let body = read_until_semi(&mut chars); if let Ok(n) = body.trim().parse::() { flush_glyph_buf(&mut current, &mut buf, &state); state.color = Some(InlineColor::Aci(n.min(255) as u8)); } } // ── True colour ────────────────────────────────────────── Some('c') => { chars.next(); let body = read_until_semi(&mut chars); if let Some(col) = parse_true_color(&body) { flush_glyph_buf(&mut current, &mut buf, &state); state.color = Some(col); } } // ── Font (name + b/i/c/p flags) ────────────────────────── Some('f') | Some('F') => { chars.next(); let body = read_until_semi(&mut chars); // First `|`-separated field is the font name / file stem. let name = body.split('|').next().unwrap_or("").trim(); if !name.is_empty() { flush_glyph_buf(&mut current, &mut buf, &state); // Strip extension if `\F` passed a file path. let stem = name .rsplit(['/', '\\']) .next() .unwrap_or(name) .split('.') .next() .unwrap_or(name); state.font = Some(stem.to_string()); } } // ── Multibyte / codepage marker — strip silently ───────── Some('M') => { chars.next(); let _ = read_until_semi(&mut chars); } // ── Dimension MTEXT paragraph-end marker — strip silently Some('X') => { chars.next(); } // ── Unknown single-letter escape ───────────────────────── Some(_) => { chars.next(); } None => {} }, // Scope push / pop — braces scope *character* state (font, // colour, height, decorations, …). Paragraph properties // (`\p...;`) are intentionally NOT scoped: AutoCAD treats them // as paragraph-level layout that persists across braces, and // real-world files routinely wrap inline `\pxqc;` inside a // `{\fArial;…}` block while expecting the alignment to apply // to the whole paragraph. '{' => { state_stack.push(state.clone()); } '}' => { if let Some(prev) = state_stack.pop() { flush_glyph_buf(&mut current, &mut buf, &state); state = prev; } } '\r' => {} '\n' => { flush_glyph_buf(&mut current, &mut buf, &state); current.align = props.align; current.indent_first = props.indent_first; current.indent_left = props.indent_left; current.indent_right = props.indent_right; current.tab_stops = props.tab_stops.clone(); lines.push(std::mem::take(&mut current)); } other => buf.push(other), } } flush_glyph_buf(&mut current, &mut buf, &state); current.align = props.align; current.indent_first = props.indent_first; current.indent_left = props.indent_left; current.indent_right = props.indent_right; current.tab_stops = props.tab_stops.clone(); lines.push(current); if !trim_blank_edges { return lines; } let start = lines.iter().position(|l| !l.is_blank()).unwrap_or(0); let end = lines .iter() .rposition(|l| !l.is_blank()) .map(|i| i + 1) .unwrap_or(0); lines[start..end].to_vec() } // 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`. Drives LOD splits (greek-rect per row, baseline /// counts) without re-running the full stroke tessellation; routes through /// the same parse + atomise + wrap pipeline as `layout_mtext` so the LOD /// row count and the rendered row count never disagree. pub fn mtext_line_count( m: &acadrust::entities::MText, document: &CadDocument, anno_scale: f32, ) -> usize { let resolved = resolve_text_style(&m.style, document); let entity_h = (m.height as f32) * anno_scale; let base_wf_abs = resolved.width_factor.max(0.01); let base_wf = if resolved.is_backward { -base_wf_abs } else { base_wf_abs }; let base_font_name = resolved.font_name.clone(); let rect_w = (m.rectangle_width as f32) * anno_scale; let paragraphs = 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() .position(|a| !matches!(a.kind, AtomKind::Space)) .unwrap_or(atoms.len()); atoms.drain(..first_word); while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) { atoms.pop(); } let wrapped = wrap_paragraph( atoms, rect_w, para.indent_first, para.indent_left, para.indent_right, ¶.tab_stops, entity_h, base_wf, &base_font_name, ); total += wrapped.len().max(1); } total.max(1) } // ────────────────────────────────────────────────────────────────────────────── // Shared MText layout / render pipeline // ────────────────────────────────────────────────────────────────────────────── // // `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. #[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) -> std::borrow::Cow<'a, str> { let Some(font) = state.font.as_deref().map(str::trim).filter(|f| !f.is_empty()) else { return std::borrow::Cow::Borrowed(base); }; if lff::is_builtin(font) { return std::borrow::Cow::Borrowed(font); } if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(font) { std::borrow::Cow::Owned(canonical) } else { std::borrow::Cow::Borrowed(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 face = Face::resolve(&font_name); let mut w = 0.0_f32; for ch in text.chars() { w += match face.glyph(ch) { Some(g) => (g.advance + face.letter_spacing() * state.tracking) * scale, None => (6.0 + face.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); Face::resolve(&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 !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), } } 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 lff'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 } #[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, /// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with /// one world-space box per visible character (used by the MText editor's /// click-to-select preview). Off in the hot render path. pub want_glyph_boxes: bool, } /// One selectable character in the laid-out text: its world-space AABB plus /// the running index of visible characters (in reading order) so the editor /// can map a clicked box back to an offset in the value. #[derive(Clone, Copy, Debug)] pub struct GlyphBox { pub vis: usize, pub xmin: f32, pub xmax: f32, pub ymin: f32, pub ymax: f32, } /// 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, /// One world-space AABB per visible character — only populated when /// `MTextRenderOpts::want_glyph_boxes` is set. pub glyph_boxes: Vec, } /// 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 = Face::resolve(&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 ───────────────────────────────────────────────────────── // The editor (want_glyph_boxes) keeps blank edges so a freshly typed // trailing newline yields an empty paragraph the caret can sit on. let paragraphs = parse_mtext_paragraphs_ex(opts.value, entity_h, !opts.want_glyph_boxes); // ── 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. // // Skipped when emitting glyph boxes (the MText editor) so a space the // user just typed at the end keeps a selectable box and the caret can // sit after it. if !opts.want_glyph_boxes { 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()); let mut glyph_boxes: Vec = Vec::new(); let mut vis: usize = 0; // Transform an entity-local point to world space (mirrors the stroke // origin maths) so glyph boxes line up with the drawn glyphs. let to_world = |line_base_x: f32, line_base_y: f32, lx: f32, ly: f32| -> (f32, f32) { let wdx = lx * cos_r - ly * sin_r; let wdy = lx * sin_r + ly * cos_r; ( ins_x as f32 + line_base_x + wdx, ins_y as f32 + line_base_y + wdy, ) }; 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); // A paragraph break (explicit `\n` / `\P`) that started this line gets // a zero-width caret slot at the line start, so the MText editor can // place the caret on a fresh/empty line. if opts.want_glyph_boxes && i > 0 && sub.is_first_in_paragraph { let (ax, ay) = to_world(line_base_x, line_base_y, cursor_start, 0.0); let (_, by) = to_world(line_base_x, line_base_y, cursor_start, entity_h); glyph_boxes.push(GlyphBox { vis, xmin: ax, xmax: ax, ymin: ay.min(by), ymax: ay.max(by), }); vis += 1; } 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, fill_tris) = lff::tessellate_text_run( [0.0, 0.0], run_h, rot, signed_wf, oblique, tracking, &font_name, &body, ); all_strokes.push(TextStroke { strokes, origin, color, fill_tris, }); if opts.want_glyph_boxes { // Per-character boxes, advancing exactly as // `measure_word` does so they track the glyphs. let scale = run_scale(&atom.state, entity_h, base_wf); let face = Face::resolve(&font_name); let mut cx = cursor_x; for ch in text.chars() { let adv = match face.glyph(ch) { Some(g) => { (g.advance + face.letter_spacing() * tracking) * scale } None => (6.0 + face.letter_spacing() * tracking) * scale, }; let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly); let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h); glyph_boxes.push(GlyphBox { vis, xmin: ax.min(bx), xmax: ax.max(bx), ymin: ay.min(by), ymax: ay.max(by), }); vis += 1; cx += adv; } } cursor_x += measure_word(text, &atom.state, entity_h, base_wf, &base_font_name); } AtomKind::Space => { let adv = measure_space(&atom.state, entity_h, base_wf, &base_font_name); if opts.want_glyph_boxes { let run_h = atom.state.height_mul * entity_h; let (ax, ay) = to_world(line_base_x, line_base_y, cursor_x, 0.0); let (bx, by) = to_world(line_base_x, line_base_y, cursor_x + adv, run_h); glyph_boxes.push(GlyphBox { vis, xmin: ax.min(bx), xmax: ax.max(bx), ymin: ay.min(by), ymax: ay.max(by), }); vis += 1; } cursor_x += adv; } 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, glyph_boxes, } } pub(crate) fn text_obb_corners_native( e: &EntityType, anno_scale: f32, mtext_lines_override: Option, ) -> Option<[[f64; 3]; 4]> { use acadrust::entities::attribute_definition::{ HorizontalAlignment as AttrHA, VerticalAlignment as AttrVA, }; use acadrust::entities::{AttachmentPoint, TextHorizontalAlignment, TextVerticalAlignment}; let anno = anno_scale as f64; // Map Attribute alignment enums to the same h/v anchor fractions used by // Text. Kept local because the Attribute enum is distinct from Text's. let attr_h_anchor = |ha: AttrHA| -> f64 { match ha { AttrHA::Left => 0.0, AttrHA::Center | AttrHA::Middle | AttrHA::Aligned | AttrHA::Fit => 0.5, AttrHA::Right => 1.0, } }; let attr_v_anchor = |va: AttrVA| -> f64 { match va { AttrVA::Baseline | AttrVA::Bottom => 0.0, AttrVA::Middle => 0.5, AttrVA::Top => 1.0, } }; let attr_use_align_pt = |ha: AttrHA, va: AttrVA| -> bool { !matches!((ha, va), (AttrHA::Left, AttrVA::Baseline)) }; let (ix, iy, iz, w, h, rot, h_anchor, v_anchor) = match e { EntityType::Text(t) => { let h_world = t.height * anno; let w_factor = if t.width_factor > 0.0 { t.width_factor } else { 1.0 }; let n = t.value.chars().count().max(1) as f64; // Approximate glyph width: AutoCAD's stroke fonts average ~0.6 em. let w_world = n * h_world * w_factor * 0.6; let h_anchor = match t.horizontal_alignment { TextHorizontalAlignment::Left => 0.0, TextHorizontalAlignment::Center | TextHorizontalAlignment::Middle | TextHorizontalAlignment::Aligned | TextHorizontalAlignment::Fit => 0.5, TextHorizontalAlignment::Right => 1.0, }; let v_anchor = match t.vertical_alignment { TextVerticalAlignment::Baseline | TextVerticalAlignment::Bottom => 0.0, TextVerticalAlignment::Middle => 0.5, TextVerticalAlignment::Top => 1.0, }; // AutoCAD writes `alignment_point` (DXF 11) whenever the text // isn't simple Left+Baseline; in that case it — not the // insertion_point — is the anchor the alignment fractions map to. let use_alignment_pt = !matches!( (t.horizontal_alignment, t.vertical_alignment), ( TextHorizontalAlignment::Left, TextVerticalAlignment::Baseline, ) ); let anchor = match (use_alignment_pt, t.alignment_point) { (true, Some(p)) => p, _ => t.insertion_point, }; ( anchor.x, anchor.y, anchor.z, w_world, h_world, t.rotation, h_anchor, v_anchor, ) } EntityType::AttributeDefinition(a) => { let h_world = a.height * anno; let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 }; // Render the tag in preview when no default; matches `attribute.rs`. let display = if a.default_value.is_empty() { &a.tag } else { &a.default_value }; let n = display.chars().count().max(1) as f64; let w_world = n * h_world * w_factor * 0.6; let h_anchor = attr_h_anchor(a.horizontal_alignment); let v_anchor = attr_v_anchor(a.vertical_alignment); let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) { a.alignment_point } else { a.insertion_point }; ( anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor, ) } EntityType::AttributeEntity(a) => { let h_world = a.height * anno; let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 }; let n = a.value.chars().count().max(1) as f64; let w_world = n * h_world * w_factor * 0.6; let h_anchor = attr_h_anchor(a.horizontal_alignment); let v_anchor = attr_v_anchor(a.vertical_alignment); let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) { a.alignment_point } else { a.insertion_point }; ( anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor, ) } EntityType::Tolerance(t) => { // Approximate: a feature control frame is roughly 1 line tall; // width comes from char-count of the (already-stripped) text. // GD&T symbols all advance one cell, so plain char count is close. let raw_h = if t.text_height > 0.0 { t.text_height } else { 2.5 }; let h_world = raw_h * anno; // Each cell ≈ 1.4 × height (matches `tolerance.rs` min_cell_w). let n = t.text.chars().filter(|c| *c != '\n').count().max(1) as f64; let w_world = h_world * 1.4 * n * 0.5; // rough — fine for LOD greek // direction encodes rotation as a vector. let rot = (t.direction.y).atan2(t.direction.x); ( t.insertion_point.x, t.insertion_point.y, t.insertion_point.z, w_world, h_world * 1.5, // frame is taller than glyph cap by ~0.5 h rot, 0.0, // anchored at insertion point (bottom-left) 0.0, ) } EntityType::MText(m) => { let h_world = m.height * anno; let raw_lines = (m.value.matches('\n').count() + 1) as f64; let effective_lines = match mtext_lines_override { Some(n) => n.max(1) as f64, None => raw_lines, }; let w_world = if m.rectangle_width > 0.0 { m.rectangle_width } else { h_world * 8.0 * effective_lines.max(1.0) }; // Wrap-aware override beats `rectangle_height` — the stored // height can be stale on DWGs that were re-saved without // updating the bounds. let total_h = if mtext_lines_override.is_some() { h_world * effective_lines.max(1.0) * m.line_spacing_factor.max(0.5) } else { m.rectangle_height.unwrap_or( h_world * raw_lines.max(1.0) * m.line_spacing_factor.max(0.5), ) }; // MText `attachment_point` puts `insertion_point` at one of the // 9 corners/midpoints of the text bbox. h_anchor / v_anchor are // fractions from (left, bottom) of the bbox. let (h_anchor, v_anchor) = match m.attachment_point { AttachmentPoint::TopLeft => (0.0, 1.0), AttachmentPoint::TopCenter => (0.5, 1.0), AttachmentPoint::TopRight => (1.0, 1.0), AttachmentPoint::MiddleLeft => (0.0, 0.5), AttachmentPoint::MiddleCenter => (0.5, 0.5), AttachmentPoint::MiddleRight => (1.0, 0.5), AttachmentPoint::BottomLeft => (0.0, 0.0), AttachmentPoint::BottomCenter => (0.5, 0.0), AttachmentPoint::BottomRight => (1.0, 0.0), }; ( m.insertion_point.x, m.insertion_point.y, m.insertion_point.z, w_world, total_h, m.rotation, h_anchor, v_anchor, ) } _ => return None, }; let x0 = -h_anchor * w; let x1 = (1.0 - h_anchor) * w; let y0 = -v_anchor * h; let y1 = (1.0 - v_anchor) * h; let (s, c) = (rot.sin(), rot.cos()); let rot_pt = |lx: f64, ly: f64| -> [f64; 3] { let rx = lx * c - ly * s; let ry = lx * s + ly * c; [ix + rx, iy + ry, iz] }; Some([ rot_pt(x0, y0), rot_pt(x1, y0), rot_pt(x1, y1), rot_pt(x0, y1), ]) } pub(crate) fn text_baseline_points( e: &EntityType, anno_scale: f32, n_lines: usize, ) -> Vec<[f32; 3]> { let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else { return vec![]; }; let line_h = match e { EntityType::Text(t) => (t.height * anno_scale as f64) as f32, EntityType::MText(m) => (m.height * anno_scale as f64) as f32, EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32, EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32, EntityType::Tolerance(t) => { let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 }; (raw * anno_scale as f64) as f32 } _ => return vec![], }; if line_h <= 0.0 { return vec![]; } let n_lines = n_lines.max(1); let cast = |p: [f64; 3]| -> [f32; 3] { [(p[0]) as f32, (p[1]) as f32, (p[2]) as f32] }; let bl = cast(corners[0]); let br = cast(corners[1]); let full_tl = cast(corners[3]); let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]); let ulen = (ux * ux + uy * uy + uz * uz).sqrt(); if ulen < 1e-9 { return vec![bl, br]; } let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen); let mut pts = Vec::with_capacity(n_lines * 3); for i in 0..n_lines { // i = 0 is the topmost line — its bottom sits one line_h below // the OBB top (≈ `ulen`). For n_lines > ulen/line_h the deepest // baselines clamp to the OBB bottom. let bot_off = ((i + 1) as f32) * line_h; let along = (ulen - bot_off).max(0.0); let p0 = [bl[0] + nx * along, bl[1] + ny * along, bl[2] + nz * along]; let p1 = [br[0] + nx * along, br[1] + ny * along, br[2] + nz * along]; if !pts.is_empty() { pts.push([f32::NAN; 3]); } pts.extend_from_slice(&[p0, p1]); } pts } /// Filled tris for a greeked top-level Text / MText. One 2-triangle rect /// per visible line — `n_lines` is the actual rendered line count from /// `mtext_line_count` (1 for Text). Stacked top → bottom along the OBB's /// up direction. The face3d pipeline skips its 0.45 dim for wires with /// empty `points`, so these tris render at the literal text color. pub(crate) fn text_greek_obb_tris( e: &EntityType, anno_scale: f32, n_lines: usize, ) -> Vec<[f32; 3]> { let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else { return vec![]; }; let line_h = match e { EntityType::Text(t) => (t.height * anno_scale as f64) as f32, EntityType::MText(m) => (m.height * anno_scale as f64) as f32, EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32, EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32, EntityType::Tolerance(t) => { let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 }; (raw * anno_scale as f64) as f32 } _ => return vec![], }; if line_h <= 0.0 { return vec![]; } let n_lines = n_lines.max(1); let cast = |p: [f64; 3]| -> [f32; 3] { [(p[0]) as f32, (p[1]) as f32, (p[2]) as f32] }; let bl = cast(corners[0]); let br = cast(corners[1]); let full_tl = cast(corners[3]); let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]); let ulen = (ux * ux + uy * uy + uz * uz).sqrt(); if ulen < 1e-9 { return vec![]; } let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen); let mut tris = Vec::with_capacity(n_lines * 6); for i in 0..n_lines { let top_along = (ulen - (i as f32) * line_h).max(0.0); let bot_along = (ulen - ((i + 1) as f32) * line_h).max(0.0); let tl = [ bl[0] + nx * top_along, bl[1] + ny * top_along, bl[2] + nz * top_along, ]; let tr = [ br[0] + nx * top_along, br[1] + ny * top_along, br[2] + nz * top_along, ]; let lbl = [ bl[0] + nx * bot_along, bl[1] + ny * bot_along, bl[2] + nz * bot_along, ]; let lbr = [ br[0] + nx * bot_along, br[1] + ny * bot_along, br[2] + nz * bot_along, ]; tris.extend_from_slice(&[lbl, lbr, tr, lbl, tr, tl]); } tris } #[cfg(test)] mod tests { use super::*; fn style(font_name: &str) -> ResolvedTextStyle { ResolvedTextStyle { font_name: font_name.to_string(), width_factor: 1.0, oblique_angle: 0.0, is_backward: false, is_upside_down: false, } } fn stroke_point_count(layout: &MTextLayout) -> usize { layout .strokes .iter() .map(|s| s.strokes.iter().map(Vec::len).sum::() + s.fill_tris.len()) .sum() } #[test] fn unresolved_inline_font_falls_back_to_style_font() { let base = "txt"; let mut state = RunState::default(); state.font = Some("__definitely_not_an_installed_font__".to_string()); assert_eq!(resolve_font(&state, base), base); let layout = layout_mtext(&MTextRenderOpts { value: "{\\f__definitely_not_an_installed_font__|b0|i0|c0|p34;Storage Units}", insertion: [0.0, 0.0, 0.0], height: 2.5, rect_w: 0.0, rotation: 0.0, style: &style(base), attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, vertical_text: false, want_glyph_boxes: false, }); assert!( stroke_point_count(&layout) > 0, "unresolvable inline \\f should render through the style font" ); } #[test] fn block_style_font_name_from_ttf_file_renders_mtext() { let layout = layout_mtext(&MTextRenderOpts { value: "FERRAGAMO", insertion: [0.0, 0.0, 0.0], height: 20.0, rect_w: 0.0, rotation: 0.0, style: &style("arial"), attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, vertical_text: false, want_glyph_boxes: false, }); assert!( stroke_point_count(&layout) > 0, "style font derived from arial.ttf should produce drawable block text" ); } }