cad-editor/src/entities/text_support.rs
Karim Jerbi 627761b629 fix(text): render TTF glyphs as solid fills, fix font fallback, correct Z-order
Text elements previously rendered as hollow wireframe outlines and sometimes
fell back to the wrong system font due to case-sensitive exact matching. Also,
the viewport background grid drew on top of solid entities.

Case-insensitive and partial-name fallback matching (e.g., mapping "arialn"
to "Arial Narrow") is now added to `sysfont::canonical_family_name`, ensuring
text resolves to the correct installed system font.

TrueType font contours are now triangulated into solid fills (`fill_tris`)
using `lyon_tessellation` in `ttf_glyph.rs`. To prevent the GPU from misclassifying
this 2D text as a 3D mesh (which broke flat shading), text tessellation splits
the output into separate outline and fill `WireModel`s.

The background grid is extracted from `SelectionCanvas` into `GridCanvas` and
moved beneath the 3D viewport to ensure proper layering. Finally, `DepthBiasState`
is enabled on all face/fill pipelines to apply a polygon offset, perfectly
resolving Z-fighting between coplanar wireframe lines and solid faces.
2026-06-25 18:17:01 +01:00

1841 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: {
eprintln!("[resolve_text_style] style={:?} font_file={:?} true_type_font={:?} → font_name={:?}",
style.map(|s| &s.name), style.map(|s| &s.font_file), style.map(|s| &s.true_type_font), &font_name);
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<TextLocalBounds> {
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::<u32>() {
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<v>[x]; \W<v>[x]; \Q<v>; \T<v>[x]; \A<n>;
// \C<aci>; \c<rgb>;
// \f<name>|b<0/1>|i<0/1>|c<n>|p<n>; \F<file>;
// \M+<n>; \X \S<u><sep><l>;
// Paragraph: \p[xi<v>,l<v>,r<v>,q[lcrjd],t<positions>,s<v>...];
// 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<L|C|R><pos>` 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 (`\H<v>x;` → ×v; `\H<v>;` → v / entity_h)
pub height_mul: f32,
/// Multiplier on the (signed) style width-factor (`\W<v>;` → set, `\Wx;` → ×)
pub width_mul: f32,
/// Absolute oblique angle override in radians (`\Q<deg>;`)
pub oblique_rad: f32,
/// Tracking multiplier on `font.letter_spacing` (`\T<v>;`)
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<String>,
/// Colour override, `None` ⇒ inherit entity colour.
pub color: Option<InlineColor>,
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<MTextRun>,
pub align: Option<ParagraphAlign>,
pub indent_first: f32,
pub indent_left: f32,
pub indent_right: f32,
pub tab_stops: Vec<TabStop>,
}
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<ParagraphAlign>,
indent_first: f32,
indent_left: f32,
indent_right: f32,
tab_stops: Vec<TabStop>,
}
/// 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::<f32>() {
props.indent_first = v;
}
}
'l' => {
if let Ok(v) = rest.parse::<f32>() {
props.indent_left = v;
}
}
'r' => {
if let Ok(v) = rest.parse::<f32>() {
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::<f32>() {
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<InlineColor> {
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::<f32>().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<v>;` 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<MTextLine> {
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<MTextLine> {
let mut lines: Vec<MTextLine> = Vec::new();
let mut current = MTextLine::default();
let mut buf = String::new();
let mut state = RunState::default();
let mut state_stack: Vec<RunState> = 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<std::str::Chars>| -> 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<u><sep><l>; ──────────────────────────
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::<f32>() {
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::<u8>() {
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::<u32>() {
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 &paragraphs {
let mut atoms: Vec<LayoutAtom> = Vec::new();
for run in &para.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,
&para.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) -> &'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 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<LayoutAtom>,
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<Vec<LayoutAtom>> {
if rect_w <= 0.0 {
return vec![atoms];
}
let mut sublines: Vec<Vec<LayoutAtom>> = Vec::new();
let mut cur: Vec<LayoutAtom> = 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<TextStroke>,
/// 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<f32>,
/// 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<GlyphBox>,
}
/// 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<LayoutAtom>,
align: Option<ParagraphAlign>,
indent_first: f32,
indent_left: f32,
indent_right: f32,
tab_stops: Vec<TabStop>,
is_first_in_paragraph: bool,
}
let mut sub_lines: Vec<SubLine> = Vec::new();
for para in &paragraphs {
let mut atoms: Vec<LayoutAtom> = Vec::new();
for run in &para.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,
&para.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<TextStroke> = Vec::new();
let mut line_widths: Vec<f32> = Vec::with_capacity(sub_lines.len());
let mut glyph_boxes: Vec<GlyphBox> = 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<usize>,
) -> 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
}