Files
OneCAD-Draft/src/entities/text_support.rs
T
Hakan SevenandClaude Opus 4.8 d528a3e2a5 feat(text): parse TEXT via acadrust + extend SDF text to MTEXT
Two related steps for the text pipeline:

- TEXT parsing now goes through acadrust's parse_plain_text (like MTEXT
  uses parse_mtext). text::acad_text_encode parses the value's %% codes —
  including %%u/%%o underline/overline and %%nnn — and re-encodes to the
  stroke tessellator's \L/\O + resolved-Unicode grammar, so OCS no longer
  tokenizes TEXT %% codes itself. (ATTRIB/TOLERANCE/DIMENSION still use the
  local tokenizer for now.) Bumps the acadrust pin to the commit that
  completes the TEXT/MTEXT control-code set.

- SDF text (OCS_TEXT_SDF) now covers MTEXT and every text-producing
  entity, not just top-level TEXT. TextStroke carries a GlyphRun (layout
  params); the collector walks convert()'s TruckObject::Text runs and
  applies annotation scale exactly as tessellate does for strokes.
  layout_glyph_quads shares lff::tokenize_run so %% specials resolve and
  \L/\O decorations are skipped (decoration LINES in SDF are still TODO).

Verified: stroke rendering unchanged with the flag off; SDF glyphs render
correctly for TEXT and MTEXT with OCS_TEXT_SDF=1. 154 lib tests green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 00:53:19 +03:00

1737 lines
64 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::{GlyphRun, 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<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,
})
}
}
/// Font-name stem: drop any path and extension, so a `\F` font path like
/// `C:\\fonts\\arial.ttf` and a plain `\f` name `arial` resolve to the same font.
fn font_stem(name: &str) -> String {
name.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.split('.')
.next()
.unwrap_or(name)
.to_string()
}
/// Parse an MTEXT string into the layout's `Vec<MTextLine>`, using acadrust's
/// structured `mtext_format::parse_mtext` — OCS keeps only the layout engine
/// (`layout_mtext` and callers read `MTextLine`/`RunState`), not a second MTEXT
/// inline parser.
///
/// Representation notes:
/// - DXF `%%d`/`%%p`/`%%c` arrive already resolved to Unicode from acadrust;
/// the stroke tokenizer treats those as ordinary glyphs.
/// - Stacking (`\S`) is flattened inline to `num<sep>den` (`^` for limit, else
/// `/`) since the stroke path has no fraction layout.
/// - `\H`: a relative factor (`\Hx`) applies directly; an absolute height is
/// divided by the entity height. See `acadrust::…::MTextScalar`.
pub fn adapt_mtext_paragraphs(
s: &str,
entity_height: f32,
trim_blank_edges: bool,
) -> Vec<MTextLine> {
use acadrust::entities::mtext_format::{
parse_mtext, MTextColor, MTextLineAlignment, MTextParagraphAlignment, MTextScalar,
SpanProperties, StackingType,
};
let entity_height = entity_height.max(1e-6);
let doc = parse_mtext(s, true);
fn map_align(a: MTextParagraphAlignment) -> Option<ParagraphAlign> {
match a {
MTextParagraphAlignment::Left => Some(ParagraphAlign::Left),
MTextParagraphAlignment::Right => Some(ParagraphAlign::Right),
MTextParagraphAlignment::Center => Some(ParagraphAlign::Center),
MTextParagraphAlignment::Justified => Some(ParagraphAlign::Justify),
MTextParagraphAlignment::Distributed => Some(ParagraphAlign::Distribute),
MTextParagraphAlignment::Default => None,
}
}
fn color_of(p: &SpanProperties) -> Option<InlineColor> {
if let Some((r, g, b)) = p.color_rgb {
return Some(InlineColor::True([
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
]));
}
match p.color {
Some(MTextColor::Index(n)) => Some(InlineColor::Aci(n.min(255) as u8)),
_ => None,
}
}
let mut lines: Vec<MTextLine> = Vec::new();
for para in &doc.paragraphs {
let props = &para.properties;
let mut line = MTextLine {
align: props.alignment.and_then(map_align),
indent_first: props.first_line_indent.unwrap_or(0.0) as f32,
indent_left: props.left_margin.unwrap_or(0.0) as f32,
indent_right: props.right_margin.unwrap_or(0.0) as f32,
tab_stops: props
.tab_stops
.iter()
.map(|&p| TabStop {
position: p as f32,
kind: TabKind::Left,
})
.collect(),
runs: Vec::new(),
};
for span in &para.spans {
let p = &span.properties;
let state = RunState {
// Relative `\H…x;` is a factor applied directly; absolute `\H…;`
// is a drawing-unit height resolved against the entity height.
height_mul: match p.height {
Some(MTextScalar::Factor(f)) => f as f32,
Some(MTextScalar::Absolute(a)) => a as f32 / entity_height,
None => 1.0,
},
width_mul: p.width_factor.map(|w| w as f32).unwrap_or(1.0),
oblique_rad: p
.oblique_angle
.map(|q| (q as f32).to_radians())
.unwrap_or(0.0),
tracking: p.tracking.map(|t| t as f32).unwrap_or(1.0),
valign: match p.line_align {
Some(MTextLineAlignment::Middle) => 1,
Some(MTextLineAlignment::Top) => 2,
_ => 0,
},
font: p.font.as_ref().map(|f| font_stem(&f.name)),
color: color_of(p),
underline: p.stroke.underline(),
overline: p.stroke.overline(),
strike: p.stroke.strikethrough(),
};
let text = match &span.stacking {
Some(st) => {
let sep = match st.stacking_type {
StackingType::Limit => '^',
_ => '/',
};
let mut t = st.numerator.clone();
if !st.denominator.is_empty() {
t.push(sep);
t.push_str(&st.denominator);
}
t
}
None => span.text.clone(),
};
if text.is_empty() {
continue;
}
line.runs.push(MTextRun {
kind: MTextRunKind::Glyphs(text),
state,
});
}
lines.push(line);
}
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
// per-run width measurement and word-wrap; MTEXT inline parsing now comes from
// acadrust via `adapt_mtext_paragraphs`. The supported surface for callers is
// `adapt_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`,
// `text_local_bounds`, and `resolve_dxf_special_chars`.
/// 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 = adapt_mtext_paragraphs(&m.value, entity_h, true);
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 `adapt_mtext_paragraphs` (acadrust `parse_mtext`).
// 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<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 = adapt_mtext_paragraphs(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,
// `text` is the plain word (specials already resolved,
// no decoration markers); `run_h` is raw like the strokes.
run: Some(GlyphRun {
text: text.clone(),
font: font_name.to_string(),
height: run_h,
rotation: rot,
width_factor: signed_wf,
oblique,
tracking,
}),
});
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
}
#[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::<usize>() + 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"
);
}
}
#[cfg(test)]
mod adapter_tests {
use super::*;
/// Glyph text + state of the first renderable run.
fn first_run(lines: &[MTextLine]) -> (String, RunState) {
for l in lines {
for r in &l.runs {
if let MTextRunKind::Glyphs(t) = &r.kind {
return (t.clone(), r.state.clone());
}
}
}
panic!("no glyph run produced");
}
#[test]
fn plain_text_is_default_state() {
let lines = adapt_mtext_paragraphs("Hello", 2.5, true);
assert_eq!(lines.len(), 1);
let (t, st) = first_run(&lines);
assert_eq!(t, "Hello");
assert_eq!(st, RunState::default());
}
#[test]
fn relative_height_is_a_factor() {
// `\H2x;` multiplies the current height → height_mul 2.0, independent of
// the entity height. (This is the case that needed acadrust's MTextScalar.)
let (_, st) = first_run(&adapt_mtext_paragraphs("\\H2x;big", 2.5, true));
assert!((st.height_mul - 2.0).abs() < 1e-4, "got {}", st.height_mul);
}
#[test]
fn absolute_height_resolves_against_entity_height() {
// `\H5;` is an absolute height → divided by the entity height (2.5) → 2.0.
let (_, st) = first_run(&adapt_mtext_paragraphs("\\H5;abs", 2.5, true));
assert!((st.height_mul - 2.0).abs() < 1e-4, "got {}", st.height_mul);
}
#[test]
fn color_font_oblique_valign_decoration() {
let (_, c) = first_run(&adapt_mtext_paragraphs("\\C1;red", 2.5, true));
assert_eq!(c.color, Some(InlineColor::Aci(1)));
let (_, f) = first_run(&adapt_mtext_paragraphs("\\fArial;x", 2.5, true));
assert_eq!(f.font.as_deref(), Some("Arial"));
let (_, q) = first_run(&adapt_mtext_paragraphs("\\Q15;x", 2.5, true));
assert!((q.oblique_rad - 15f32.to_radians()).abs() < 1e-4);
let (_, w) = first_run(&adapt_mtext_paragraphs("\\W1.5;x", 2.5, true));
assert!((w.width_mul - 1.5).abs() < 1e-4);
let (_, a) = first_run(&adapt_mtext_paragraphs("\\A1;x", 2.5, true));
assert_eq!(a.valign, 1);
let (_, d) = first_run(&adapt_mtext_paragraphs("\\Lunder\\l", 2.5, true));
assert!(d.underline);
}
#[test]
fn stacking_flattens_like_the_legacy_parser() {
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1/2;", 2.5, true));
assert_eq!(t, "1/2");
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1^2;", 2.5, true));
assert_eq!(t, "1^2");
// Diagonal `#` renders with a `/` separator.
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1#2;", 2.5, true));
assert_eq!(t, "1/2");
}
#[test]
fn paragraph_breaks_split_lines() {
let lines = adapt_mtext_paragraphs("a\\Pb\\Pc", 2.5, true);
assert_eq!(lines.len(), 3);
}
}