feat(mtext): integrate every inline format code in MTEXT renderer

Replace the line-based MTEXT pipeline with a run-aware parser + renderer.
Every DXF MTEXT inline code now reaches the stroke output: \H \W \Q \T \A
\C \c \f \F \M \X, the \p block (q/i/l/r/t/s), brace-scoped state, and
the \L\l \O\o \K\k decoration toggles.

Parser ([text_support]): emits Vec<MTextLine{ runs, align, indent_*,
tab_stops }> with per-run RunState; braces scope character state, paragraph
props persist across them.

Renderer ([mtext]): atomise → wrap → render. Per-run height/width/oblique/
tracking/font/colour/decorations/valign; paragraph indents and tab stops
honoured; default 4-em tab fallback.

Plumbing: tessellate() now returns Vec<WireModel>; TextStroke gains an
optional inline-colour override so per-run \C/\c emits one wire per colour.
cxf::tessellate_text_run adds a tracking multiplier. block_cache folds
multi-wire results into a single LocalWire (colour split preserved at
top-level, flattened inside cached defns).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-21 23:42:22 +03:00
1 parent ff31a07213
commit f12e864aa6
10 files changed
+1223 -148

No files matched your search

+1 -1
View File
@@ -218,7 +218,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
&resolved.font_name,
line,
);
strokes_all.push(TextStroke { strokes, origin });
strokes_all.push(TextStroke { strokes, origin, color: None });
}
let _ = input.line_count; // round-trip only — recomputed above
+460 -62
View File
@@ -1,10 +1,12 @@
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::{
measure_mtext_chars, resolve_text_style, split_mtext_lines, strip_mtext_codes, word_wrap,
parse_mtext_paragraphs, resolve_text_style, InlineColor, MTextRunKind, ParagraphAlign,
RunState, TabStop,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject};
@@ -75,50 +77,349 @@ 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 font_name = resolved_style.font_name;
let font = cxf::get_font(&font_name);
// MText has no entity-level width/oblique override (those are MText format
// codes \W and \Q inside the value string, handled by `strip_mtext_codes`).
// Default rendering inherits both from the style. `is_backward` flips the
// width-factor sign so text reads right-to-left.
let base_wf = resolved_style.width_factor.max(0.01);
let style_width_factor = if resolved_style.is_backward { -base_wf } else { base_wf };
let style_oblique = resolved_style.oblique_angle;
let plain = strip_mtext_codes(&t.value);
let explicit_lines = split_mtext_lines(&plain);
let lines: Vec<String> = if t.rectangle_width > 0.0 {
// Measurement uses the magnitude only; the sign on style_width_factor
// is a render-time mirror flag, not a width scalar.
let scale = t.height as f32 / 9.0 * style_width_factor.abs();
let max_w = t.rectangle_width as f32;
explicit_lines
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()
.flat_map(|line| word_wrap(line, max_w, scale, font))
.collect()
} else {
explicit_lines
};
let n_lines = lines.len().max(1) as f32;
.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
};
// AutoCAD DXF code 44 is a multiplier on the *default* baseline-to-baseline
// distance, which is 5/3 × text_height (≈ 1.667). factor = 1.0 → single
// spacing, factor = 2.0 → double spacing, etc.
let line_h = t.height as f32 * ls_factor * (5.0 / 3.0) * font.line_spacing;
let h = t.height as f32;
// CXF glyphs sit on the baseline (y=0) and extend UP by `h` (cap height).
// Block top = line-0 baseline + h
// Block bottom = last-line baseline = line-0 baseline − (n_lines−1)·line_h
//
// v_offset is the Y of line-0 baseline relative to the insertion point, so:
// Top attachment → block top at insertion → v_offset = −h
// Bottom attachment → block bottom at insertion → v_offset = (n_lines−1)·line_h
// Middle attachment → block center at insertion → midpoint of the two above
// 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
@@ -128,7 +429,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
| AttachmentPoint::BottomCenter
| AttachmentPoint::BottomRight => (n_lines - 1.0) * line_h,
};
let h_anchor = match t.attachment_point {
let attach_h_anchor: f32 = match t.attachment_point {
AttachmentPoint::TopCenter
| AttachmentPoint::MiddleCenter
| AttachmentPoint::BottomCenter => 0.5,
@@ -137,8 +438,8 @@ 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);
// is_upside_down flips the text 180° around the insertion point.
let rot = if resolved_style.is_upside_down {
t.rotation as f32 + std::f32::consts::PI
} else {
@@ -149,41 +450,138 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
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();
for (i, line) in lines.iter().enumerate() {
// ── 4. Render each sub-line ─────────────────────────────────────────────
for (i, sub) in sub_lines.iter().enumerate() {
let li = i as f32;
// Compute line offset as f32 (small relative values), apply to f64 insertion point.
let (small_x, small_y) = if vertical_text {
let col_offset = li * t.height as f32 * 1.2;
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 line_y = -(li * line_h) + v_offset;
(line_y * (-sin_r), line_y * cos_r)
let ly = -(li * line_h) + v_offset;
(ly * (-sin_r), ly * cos_r)
};
let line_w = if h_anchor > 0.0 {
let scale = t.height as f32 / 9.0 * style_width_factor.abs();
measure_mtext_chars(line, scale, font)
// 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 h_shift = -line_w * h_anchor;
let origin: [f64; 2] = [
ins_x + (small_x + h_shift * cos_r) as f64,
ins_y + (small_y + h_shift * sin_r) as f64,
];
let strokes = cxf::tessellate_text_ex(
[0.0, 0.0],
t.height as f32,
rot,
style_width_factor,
style_oblique,
&font_name,
line,
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,
);
all_strokes.push(TextStroke { strokes, origin });
// 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,
);
}
}
}
}
TruckEntity {
object: TruckObject::Text(all_strokes),
snap_pts: vec![(insertion, SnapHint::Insertion)],
+1 -1
View File
@@ -141,7 +141,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
&t.value,
);
TruckEntity {
object: TruckObject::Text(vec![TextStroke { strokes, origin }]),
object: TruckObject::Text(vec![TextStroke { strokes, origin, color: None }]),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
+536
View File
@@ -176,6 +176,542 @@ pub fn resolve_dxf_special_chars(s: &str) -> String {
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 {
/// 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(),
MTextRunKind::Tab => false,
})
}
}
#[derive(Clone, Debug, Default)]
struct ParagraphProps {
align: Option<ParagraphAlign>,
indent_first: f32,
indent_left: f32,
indent_right: f32,
tab_stops: Vec<TabStop>,
}
/// Parse a `\p...;` body. Comma-separated tokens, each with a single-letter
/// kind prefix. Unknown tokens are skipped — anything we don't understand is
/// silently dropped rather than poisoning the rest of the paragraph.
fn parse_paragraph_block(body: &str, props: &mut ParagraphProps) {
// The legacy AutoCAD writer prefixes the block with a redundant `x`; skip
// it so it doesn't confuse the kind matcher.
let body = body.strip_prefix('x').unwrap_or(body);
for token in body.split(',') {
let token = token.trim();
let mut chars = token.chars();
let Some(kind) = chars.next() else { continue };
let rest: String = chars.collect();
match kind {
'q' | 'Q' => {
let sel = rest.chars().next().map(|c| c.to_ascii_lowercase());
props.align = match sel {
Some('l') => Some(ParagraphAlign::Left),
Some('c') => Some(ParagraphAlign::Center),
Some('r') => Some(ParagraphAlign::Right),
Some('j') => Some(ParagraphAlign::Justify),
Some('d') => Some(ParagraphAlign::Distribute),
_ => props.align,
};
}
'i' => {
if let Ok(v) = rest.parse::<f32>() {
props.indent_first = v;
}
}
'l' => {
if let Ok(v) = rest.parse::<f32>() {
props.indent_left = v;
}
}
'r' => {
if let Ok(v) = rest.parse::<f32>() {
props.indent_right = v;
}
}
't' => {
// Tab list. Each entry may be prefixed with L / C / R to
// pick the tab kind; default is Left. The remainder is the
// position in drawing units.
props.tab_stops.clear();
for entry in rest.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (kind, num_str) = match entry.chars().next() {
Some(c @ ('L' | 'l')) => (TabKind::Left, &entry[c.len_utf8()..]),
Some(c @ ('C' | 'c')) => (TabKind::Center, &entry[c.len_utf8()..]),
Some(c @ ('R' | 'r')) => (TabKind::Right, &entry[c.len_utf8()..]),
_ => (TabKind::Left, entry),
};
if let Ok(p) = num_str.parse::<f32>() {
props.tab_stops.push(TabStop { position: p, kind });
}
}
}
's' => {} // space-before — affects line spacing; ignored for now
_ => {}
}
}
}
/// Parse an unsigned 24-bit true-colour value into linear-ish [r,g,b] floats.
/// AutoCAD packs `\c` as a 24-bit decimal: high byte = R, mid = G, low = B.
fn parse_true_color(s: &str) -> Option<InlineColor> {
let n: u32 = s.trim().parse().ok()?;
let r = ((n >> 16) & 0xFF) as f32 / 255.0;
let g = ((n >> 8) & 0xFF) as f32 / 255.0;
let b = (n & 0xFF) as f32 / 255.0;
Some(InlineColor::True([r, g, b]))
}
/// Parse `\H` / `\W` / `\T` value. Returns `(value, is_relative)`; a trailing
/// `x` marks a multiplier on the current state, otherwise the value is the
/// absolute target (interpreted by the caller against entity defaults).
fn parse_scalar_with_x_suffix(body: &str) -> Option<(f32, bool)> {
let body = body.trim();
let (num, is_rel) = if let Some(stripped) = body.strip_suffix('x').or_else(|| body.strip_suffix('X')) {
(stripped, true)
} else {
(body, false)
};
Some((num.trim().parse::<f32>().ok()?, is_rel))
}
/// Flush the glyph buffer as a `Glyphs` run on `line` using a snapshot of
/// `state`. Resolves DXF `%%x` specials (degree / diameter / `%%nnn` literal
/// unicode) so the renderer sees fully decoded text.
fn flush_glyph_buf(line: &mut MTextLine, buf: &mut String, state: &RunState) {
if buf.is_empty() {
return;
}
let text = resolve_dxf_special_chars(&std::mem::take(buf));
line.runs.push(MTextRun {
kind: MTextRunKind::Glyphs(text),
state: state.clone(),
});
}
/// Walk the MTEXT value string and produce one [`MTextLine`] per visible
/// paragraph (after stripping leading / trailing blank lines, as the legacy
/// `split_mtext_lines` does). Every inline format code listed in the module
/// header is recognised; unknown semicolon-terminated codes are stripped
/// silently so future / vendor-specific extensions don't pollute the text.
///
/// `entity_height` is needed to translate absolute `\H<v>;` declarations into
/// the height-multiplier representation carried in [`RunState`]; pass the
/// MTEXT entity's `height` field.
pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec<MTextLine> {
let mut lines: Vec<MTextLine> = Vec::new();
let mut current = MTextLine::default();
let mut buf = String::new();
let mut state = RunState::default();
let mut state_stack: Vec<RunState> = Vec::new();
let mut props = ParagraphProps::default();
// No props_stack — paragraph props persist across braces by design.
let entity_height = entity_height.max(1e-6);
let mut chars = s.chars().peekable();
let read_until_semi = |chars: &mut std::iter::Peekable<std::str::Chars>| -> String {
let mut out = String::new();
for c in chars.by_ref() {
if c == ';' {
break;
}
out.push(c);
}
out
};
while let Some(ch) = chars.next() {
match ch {
'\\' => match chars.peek().copied() {
// ── Line / paragraph break ────────────────────────────────
Some('P') | Some('n') | Some('N') => {
chars.next();
flush_glyph_buf(&mut current, &mut buf, &state);
current.align = props.align;
current.indent_first = props.indent_first;
current.indent_left = props.indent_left;
current.indent_right = props.indent_right;
current.tab_stops = props.tab_stops.clone();
lines.push(std::mem::take(&mut current));
}
// ── Whitespace literals ───────────────────────────────────
Some('~') => {
chars.next();
buf.push('\u{00A0}'); // nbsp — treated as a regular space by the wrap pass
}
Some('t') => {
chars.next();
flush_glyph_buf(&mut current, &mut buf, &state);
current.runs.push(MTextRun {
kind: MTextRunKind::Tab,
state: state.clone(),
});
}
// ── Unicode by hex code point ────────────────────────────
Some('U') | Some('u') => {
chars.next();
if chars.peek() == Some(&'+') {
chars.next();
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();
}
if let Ok(n) = u32::from_str_radix(&hex, 16) {
if let Some(c) = char::from_u32(n) {
buf.push(c);
continue;
}
}
} else {
// Bare `\U` / `\u` — strip to next `;`
let _ = read_until_semi(&mut chars);
}
}
// ── Stacked text \S<u><sep><l>; ──────────────────────────
Some('S') | Some('s') => {
chars.next();
let mut upper = String::new();
let mut lower = String::new();
let mut sep = '/';
let mut in_lower = false;
for c in chars.by_ref() {
if c == ';' {
break;
}
if !in_lower && (c == '/' || c == '^' || c == '#') {
sep = c;
in_lower = true;
} else if in_lower {
lower.push(c);
} else {
upper.push(c);
}
}
buf.push_str(&upper);
if !lower.is_empty() {
buf.push(if sep == '#' { '/' } else { sep });
buf.push_str(&lower);
}
}
// ── Decoration toggles (state, not markers) ──────────────
Some('L') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = true; }
Some('l') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = false; }
Some('O') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = true; }
Some('o') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = false; }
Some('K') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = true; }
Some('k') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = false; }
// ── Literal backslash / braces ───────────────────────────
Some('\\') => { chars.next(); buf.push('\\'); }
Some('{') | Some('}') => { buf.push(chars.next().unwrap()); }
// ── Paragraph props ──────────────────────────────────────
Some('p') => {
chars.next();
let body = read_until_semi(&mut chars);
parse_paragraph_block(&body, &mut props);
}
// ── Height ───────────────────────────────────────────────
Some('H') => {
chars.next();
let body = read_until_semi(&mut chars);
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
flush_glyph_buf(&mut current, &mut buf, &state);
if is_rel {
state.height_mul *= v;
} else {
state.height_mul = v / entity_height;
}
}
}
// ── Width factor ─────────────────────────────────────────
Some('W') => {
chars.next();
let body = read_until_semi(&mut chars);
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
flush_glyph_buf(&mut current, &mut buf, &state);
if is_rel {
state.width_mul *= v;
} else {
state.width_mul = v;
}
}
}
// ── Oblique angle (degrees → radians) ────────────────────
Some('Q') => {
chars.next();
let body = read_until_semi(&mut chars);
if let Ok(deg) = body.trim().parse::<f32>() {
flush_glyph_buf(&mut current, &mut buf, &state);
state.oblique_rad = deg.to_radians();
}
}
// ── Tracking ─────────────────────────────────────────────
Some('T') => {
chars.next();
let body = read_until_semi(&mut chars);
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
flush_glyph_buf(&mut current, &mut buf, &state);
if is_rel {
state.tracking *= v;
} else {
state.tracking = v;
}
}
}
// ── Vertical alignment ───────────────────────────────────
Some('A') => {
chars.next();
let body = read_until_semi(&mut chars);
if let Ok(n) = body.trim().parse::<u8>() {
flush_glyph_buf(&mut current, &mut buf, &state);
state.valign = n.min(2);
}
}
// ── ACI colour ───────────────────────────────────────────
Some('C') => {
chars.next();
let body = read_until_semi(&mut chars);
if let Ok(n) = body.trim().parse::<u32>() {
flush_glyph_buf(&mut current, &mut buf, &state);
state.color = Some(InlineColor::Aci(n.min(255) as u8));
}
}
// ── True colour ──────────────────────────────────────────
Some('c') => {
chars.next();
let body = read_until_semi(&mut chars);
if let Some(col) = parse_true_color(&body) {
flush_glyph_buf(&mut current, &mut buf, &state);
state.color = Some(col);
}
}
// ── Font (name + b/i/c/p flags) ──────────────────────────
Some('f') | Some('F') => {
chars.next();
let body = read_until_semi(&mut chars);
// First `|`-separated field is the font name / file stem.
let name = body.split('|').next().unwrap_or("").trim();
if !name.is_empty() {
flush_glyph_buf(&mut current, &mut buf, &state);
// Strip extension if `\F` passed a file path.
let stem = name
.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.split('.')
.next()
.unwrap_or(name);
state.font = Some(stem.to_string());
}
}
// ── Multibyte / codepage marker — strip silently ─────────
Some('M') => {
chars.next();
let _ = read_until_semi(&mut chars);
}
// ── Dimension MTEXT paragraph-end marker — strip silently
Some('X') => {
chars.next();
}
// ── Unknown single-letter escape ─────────────────────────
Some(_) => {
chars.next();
}
None => {}
},
// Scope push / pop — braces scope *character* state (font,
// colour, height, decorations, …). Paragraph properties
// (`\p...;`) are intentionally NOT scoped: AutoCAD treats them
// as paragraph-level layout that persists across braces, and
// real-world files routinely wrap inline `\pxqc;` inside a
// `{\fArial;…}` block while expecting the alignment to apply
// to the whole paragraph.
'{' => {
state_stack.push(state.clone());
}
'}' => {
if let Some(prev) = state_stack.pop() {
flush_glyph_buf(&mut current, &mut buf, &state);
state = prev;
}
}
'\r' => {}
'\n' => {
flush_glyph_buf(&mut current, &mut buf, &state);
current.align = props.align;
current.indent_first = props.indent_first;
current.indent_left = props.indent_left;
current.indent_right = props.indent_right;
current.tab_stops = props.tab_stops.clone();
lines.push(std::mem::take(&mut current));
}
other => buf.push(other),
}
}
flush_glyph_buf(&mut current, &mut buf, &state);
current.align = props.align;
current.indent_first = props.indent_first;
current.indent_left = props.indent_left;
current.indent_right = props.indent_right;
current.tab_stops = props.tab_stops.clone();
lines.push(current);
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()
}
pub fn strip_mtext_codes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
+1 -1
View File
@@ -244,7 +244,7 @@ impl TruckConvertible for Tolerance {
let origin = [self.insertion_point.x, self.insertion_point.y];
Some(TruckEntity {
object: TruckObject::Text(vec![TextStroke { strokes, origin }]),
object: TruckObject::Text(vec![TextStroke { strokes, origin, color: None }]),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
+6
View File
@@ -10,9 +10,15 @@ use crate::scene::wire_model::{SnapHint, TangentGeom};
/// One group of glyph strokes with its world-space origin stored in f64.
/// Strokes are in glyph-local space (origin = [0,0]) so that the large
/// world offset can be subtracted with f64 precision in tessellate.rs.
///
/// `color`, when set, overrides the entity colour for just this group — used
/// by MTEXT inline `\C` / `\c` per-run colour. Strokes sharing the same
/// (color, None) override are merged into one WireModel downstream; runs with
/// distinct colours emit their own WireModel.
pub struct TextStroke {
pub strokes: Vec<Vec<[f32; 2]>>,
pub origin: [f64; 2],
pub color: Option<[f32; 3]>,
}
#[allow(dead_code)]
+24 -1
View File
@@ -401,9 +401,32 @@ fn tessellate_sub_local(
// Pass `local_offset` as the f64 world-offset so tessellate subtracts it
// before casting to f32 — same precision-preservation trick used for
// top-level entities, applied per-defn.
let wire = tessellate::tessellate(
//
// tessellate() may emit multiple WireModels for a single sub-entity (e.g.
// MTEXT with inline `\C` / `\c` colour switches splits one entity into
// one wire per colour group). The block-defn cache stores a single
// LocalWire per sub-entity, so we fold the wires into one — points get
// concatenated with NaN separators, the primary colour comes from the
// first wire. Per-segment colour is lost inside cached block defns; for
// top-level MTEXT outside blocks the colour split is preserved via the
// hot path in `tessellate_entity`.
let mut wires_out = tessellate::tessellate(
doc, h, sub, false, sub_color, pat_len, pat, lw_px, local_offset, anno_scale,
);
if wires_out.is_empty() {
return None;
}
let mut wire = wires_out.remove(0);
for extra in wires_out {
if !wire.points.is_empty() && !extra.points.is_empty() {
wire.points.push([f32::NAN, f32::NAN, f32::NAN]);
}
wire.points.extend(extra.points);
wire.key_vertices.extend(extra.key_vertices);
wire.snap_pts.extend(extra.snap_pts);
wire.tangent_geoms.extend(extra.tangent_geoms);
wire.fill_tris.extend(extra.fill_tris);
}
if wire.points.len() > 100_000 {
return None;
+30 -3
View File
@@ -226,6 +226,33 @@ pub fn tessellate_text_ex(
oblique_angle: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
tessellate_text_run(
origin,
height,
rotation,
width_factor,
oblique_angle,
1.0,
font_name,
text,
)
}
/// Like [`tessellate_text_ex`] but takes a `tracking` multiplier applied to
/// the per-glyph `letter_spacing` advance. `tracking = 1.0` keeps the legacy
/// behaviour; MTEXT's `\T<v>;` inline code routes here so per-run character
/// spacing scales independently of the width factor.
#[allow(clippy::too_many_arguments)]
pub fn tessellate_text_run(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
tracking: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
if text.is_empty() || height <= 0.0 {
return vec![];
@@ -388,7 +415,7 @@ pub fn tessellate_text_ex(
}
} else {
// Partial digit sequence — advance as unknown glyph and move on
cursor_x += (6.0 + font.letter_spacing) * wf;
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
continue;
}
}
@@ -417,11 +444,11 @@ pub fn tessellate_text_ex(
.collect(),
);
}
cursor_x += (glyph.advance + font.letter_spacing) * wf;
cursor_x += (glyph.advance + font.letter_spacing * tracking) * wf;
}
None => {
warn_missing_glyph(font_name, render_ch);
cursor_x += (6.0 + font.letter_spacing) * wf;
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
}
}
}
+46 -30
View File
@@ -4777,7 +4777,7 @@ fn tessellate_entity(
} else {
(0.0_f32, [0.0f32; 8])
};
let mut wire = tessellate::tessellate(
let mut wires = tessellate::tessellate(
document,
h,
e,
@@ -4789,8 +4789,11 @@ fn tessellate_entity(
world_offset,
1.0,
);
wire.aabb = entity_aabb(e, world_offset);
return vec![wire];
let ab = entity_aabb(e, world_offset);
for w in &mut wires {
w.aabb = ab;
}
return wires;
}
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
@@ -5066,7 +5069,7 @@ fn tessellate_entity(
let sub_aabb = entity_aabb(&sub, world_offset);
let sub_pattern_length = sub_pattern_length * pslt_factor;
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
let mut wire = tessellate::tessellate(
let mut wires = tessellate::tessellate(
document,
h,
&sub,
@@ -5078,10 +5081,12 @@ fn tessellate_entity(
world_offset,
anno_scale,
);
wire.name = h.value().to_string();
wire.aci = sub_aci;
wire.aabb = sub_aabb;
vec![wire]
for w in &mut wires {
w.name = h.value().to_string();
w.aci = sub_aci;
w.aabb = sub_aabb;
}
wires
})
.collect();
append_insert_attribute_wires(
@@ -5219,7 +5224,7 @@ fn tessellate_entity(
}
}
let mut base = tessellate::tessellate(
let mut bases = tessellate::tessellate(
document,
h,
e,
@@ -5231,28 +5236,37 @@ fn tessellate_entity(
world_offset,
anno_scale,
);
base.aci = aci;
base.aabb = aabb;
for b in &mut bases {
b.aci = aci;
b.aabb = aabb;
}
// Complex linetypes (with embedded shapes / text) expand the *base*
// polyline along its tangent. Text-type entities never have a complex
// linetype assigned, so we only consult the first wire here — multi-wire
// returns come exclusively from MTEXT colour splits which can't trigger
// this path.
if let Some(clt) = crate::linetypes::complex_lt(lt_name) {
let mut wires = complex_lt::apply_along(
&base.name,
&base.points,
clt,
(lt_scale * pslt_factor).max(1e-4),
entity_color,
sel,
base.line_weight_px,
);
if !wires.is_empty() {
for w in &mut wires {
w.aabb = aabb;
if let Some(base) = bases.first() {
let mut wires = complex_lt::apply_along(
&base.name,
&base.points,
clt,
(lt_scale * pslt_factor).max(1e-4),
entity_color,
sel,
base.line_weight_px,
);
if !wires.is_empty() {
for w in &mut wires {
w.aabb = aabb;
}
return wires;
}
return wires;
}
}
vec![base]
bases
}
/// Build the 4 OBB corners (CCW: bl, br, tr, tl) of a Text / MText entity
@@ -5565,7 +5579,7 @@ fn append_insert_attribute_wires(
sub_color
};
let sub_aabb = entity_aabb(&attr_entity, world_offset);
let mut wire = tessellate::tessellate(
let mut attr_wires = tessellate::tessellate(
document,
insert_handle,
&attr_entity,
@@ -5579,10 +5593,12 @@ fn append_insert_attribute_wires(
);
// Use the INSERT's handle so selection / picking groups attribute
// text with the parent insert instead of treating it as a stray text.
wire.name = insert_handle.value().to_string();
wire.aci = sub_aci;
wire.aabb = sub_aabb;
wires.push(wire);
for w in &mut attr_wires {
w.name = insert_handle.value().to_string();
w.aci = sub_aci;
w.aabb = sub_aabb;
}
wires.extend(attr_wires);
}
}
+118 -49
View File
@@ -112,7 +112,7 @@ pub fn tessellate(
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
) -> WireModel {
) -> Vec<WireModel> {
let color = if selected {
WireModel::SELECTED
} else {
@@ -145,7 +145,7 @@ pub fn tessellate(
// MultiLeader is handled by scene/mod.rs since it emits multiple WireModels
// (leader, text, frame, fill) with distinct colors.
if let EntityType::Leader(leader) = entity {
return tessellate_leader_single(
return vec![tessellate_leader_single(
document,
handle,
leader,
@@ -154,74 +154,141 @@ pub fn tessellate(
line_weight_px,
world_offset,
anno_scale,
);
)];
}
// ── Try the truck path first ───────────────────────────────────────────
if let Some(te) = convert(entity, document) {
match te.object {
// ── Text / MText: pre-tessellated glyph strokes ───────────────
//
// Strokes are pre-grouped by world origin (one TextStroke per
// line / per run / per fragment), each carrying an optional
// colour override produced by MTEXT inline `\C` / `\c`. We bin
// groups by override colour and emit one WireModel per bin so a
// single MTEXT can hand back N colour-distinct wires when the
// value mixes inline colours.
TruckObject::Text(stroke_groups) => {
// Each TextStroke keeps its strokes in glyph-local space and
// its world origin as f64. Subtract world_offset in f64 before
// casting to f32 so large UTM coordinates don't crush precision.
let [ox, oy, oz] = world_offset;
let elev = entity_z(entity) - oz as f32;
let mut points: Vec<[f32; 3]> = Vec::new();
let mut first = true;
// Annotation scale: scale glyph strokes relative to the text
// insertion point (first group's origin) so multi-line MText
// lines spread apart correctly as well as growing in size.
// anno_scale anchors at the first group's origin so multi-line
// MText lines spread apart correctly as they grow.
let ref_origin = stroke_groups
.first()
.map(|g| g.origin)
.unwrap_or([0.0, 0.0]);
let ref_lx = (ref_origin[0] - ox) as f32;
let ref_ly = (ref_origin[1] - oy) as f32;
// Selection forces a single uniform colour — never split.
let split_by_color = !selected;
// Bins: key = Some(rgb) or None (= inherit entity colour).
// Preserve insertion order so wire-emit ordering tracks the
// original glyph stream (useful for stable hit-test indexing).
let mut bins: Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)> = Vec::new();
let mut bin_first: Vec<bool> = Vec::new();
let find_or_make =
|key: Option<[f32; 3]>, bins: &mut Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)>, firsts: &mut Vec<bool>| -> usize {
if let Some(i) = bins.iter().position(|(k, _)| *k == key) {
i
} else {
bins.push((key, Vec::new()));
firsts.push(true);
bins.len() - 1
}
};
for group in &stroke_groups {
let lx = (group.origin[0] - ox) as f32;
let ly = (group.origin[1] - oy) as f32;
let slx = (lx - ref_lx) * anno_scale + ref_lx;
let sly = (ly - ref_ly) * anno_scale + ref_ly;
let bin_key = if split_by_color { group.color } else { None };
let bi = find_or_make(bin_key, &mut bins, &mut bin_first);
let pts = &mut bins[bi].1;
for stroke in &group.strokes {
if stroke.len() < 2 {
continue;
}
if !first && !points.is_empty() {
points.push([f32::NAN, f32::NAN, f32::NAN]);
if !bin_first[bi] && !pts.is_empty() {
pts.push([f32::NAN, f32::NAN, f32::NAN]);
}
first = false;
bin_first[bi] = false;
for &[x, y] in stroke {
points.push([x * anno_scale + slx, y * anno_scale + sly, elev]);
pts.push([x * anno_scale + slx, y * anno_scale + sly, elev]);
}
}
}
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
let [ox, oy, oz] = world_offset;
let key_vertices: Vec<[f32; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
name,
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
// Empty input (no glyphs) → emit a single empty wire so the
// entity still has a hit-test target via snap_pts.
if bins.is_empty() {
return vec![WireModel {
name,
points: Vec::new(),
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
}];
}
let bin_count = bins.len();
let mut out: Vec<WireModel> = Vec::with_capacity(bin_count);
for (idx, (override_rgb, pts)) in bins.into_iter().enumerate() {
let wire_color = match override_rgb {
Some([r, g, b]) => [r, g, b, color[3]],
None => color,
};
// Snap points and key vertices belong to the entity as a
// whole — attach them only to the first emitted wire so
// pickers / hover don't double-count.
let (snap, keys, tangents) = if idx == 0 {
(
snap_pts.clone(),
key_vertices.clone(),
te.tangent_geoms.clone(),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
name: name.clone(),
points: pts,
color: wire_color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: tangents,
aci: 0,
key_vertices: keys,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
return out;
}
// ── Standard topology objects ─────────────────────────────────
@@ -239,7 +306,7 @@ pub fn tessellate(
[(kx - ox) as f32, (ky - oy) as f32, (kz - oz) as f32]
})
.collect();
return WireModel {
return vec![WireModel {
name,
points: vec![
[x - s, y, z],
@@ -260,7 +327,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
}];
}
_ => {}
}
@@ -275,7 +342,7 @@ pub fn tessellate(
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
return vec![WireModel {
name,
points,
color,
@@ -291,7 +358,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
}];
}
}
@@ -304,7 +371,7 @@ pub fn tessellate(
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
return vec![WireModel {
name,
points,
color,
@@ -320,7 +387,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
}];
}
}
@@ -351,7 +418,7 @@ pub fn tessellate(
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
return vec![WireModel {
name,
points: local_pts,
color,
@@ -367,7 +434,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris,
};
}];
}
TruckObject::SegmentedLines(points) => {
@@ -388,7 +455,7 @@ pub fn tessellate(
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
return vec![WireModel {
name,
points: local_pts,
color,
@@ -404,7 +471,7 @@ pub fn tessellate(
vp_scissor: None,
aabb: WireModel::UNBOUNDED_AABB,
fill_tris: vec![],
};
}];
}
TruckObject::Volume(_) => {
@@ -427,14 +494,14 @@ pub fn tessellate(
let sp = Vec3::new((p.x - ox) as f32, (p.y - oy) as f32, (p.z - oz) as f32);
wm.snap_pts.push((sp, SnapHint::Insertion));
}
return wm;
return vec![wm];
}
}
}
// ── Legacy fallback for Viewport and other unhandled types ────────────
let (points, snap_pts, tangent_geoms, key_vertices) = legacy_geometry(entity, world_offset);
WireModel {
vec![WireModel {
name,
points,
color,
@@ -450,7 +517,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
}
}]
}
pub fn tessellate_dimension(
@@ -1539,7 +1606,7 @@ pub fn tessellate_multileader(
for sub in synth_ins.explode_from_document(document) {
let normalized =
crate::modules::home::modify::explode::normalize_insert_entity(sub);
let mut wire = tessellate(
let mut sub_wires = tessellate(
document,
handle,
&normalized,
@@ -1551,8 +1618,10 @@ pub fn tessellate_multileader(
world_offset,
1.0,
);
wire.name = name.clone();
wires.push(wire);
for w in &mut sub_wires {
w.name = name.clone();
}
wires.extend(sub_wires);
}
// Block attributes attached to the multileader — render each as
// its own attribute entity at WCS location like INSERT does.