feat(text): route every text-bearing entity through the run-aware MText pipeline

Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.

Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
  `layout_mtext`; `\C7;Red`-style codes no longer render as literal
  glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
  and the LOD-aware path in [scene/tessellate.rs]) — drops the
  `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
  baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
  bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
  the value goes through the MText pipeline (SingleLine stays on the
  Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
  MText (instead of Text) whenever the value contains MText escapes;
  the LOD ladder via `tessellate_entity_dim_text` handles both kinds.

Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
  `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
  callers that draw chrome (frames, fills, baselines, greek rects)
  share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
  and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
  `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
  `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
  deleted — all callers migrated.

Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-22 05:53:01 +03:00
1 parent 5c0c144275
commit 4e5d2c8d04
8 files changed
+1027 -1070

No files matched your search

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