diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 020785a0a..33f0720c5 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -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 diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index cf18f2806..aeddd2be3 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -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, + rect_w: f32, + indent_first: f32, + indent_left: f32, + indent_right: f32, + tab_stops: &[TabStop], + entity_h: f32, + base_wf: f32, + base_font: &str, +) -> Vec> { + if rect_w <= 0.0 { + return vec![atoms]; + } + let mut sublines: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut cur_w = 0.0_f32; + let mut subline_idx: usize = 0; + let line_start_x = |idx: usize| if idx == 0 { indent_first } else { indent_left }; + let line_max_w = |idx: usize| (rect_w - indent_right - line_start_x(idx)).max(0.0); + + for atom in atoms { + match &atom.kind { + AtomKind::Word(_) => { + let w = atom_width(&atom, entity_h, base_wf, base_font); + let max_w = line_max_w(subline_idx); + if !cur.is_empty() && cur_w + w > max_w { + while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) { + cur.pop(); + } + sublines.push(std::mem::take(&mut cur)); + cur_w = 0.0; + subline_idx += 1; + } + cur.push(atom); + cur_w += w; + } + AtomKind::Space => { + if cur.is_empty() { + continue; + } + cur_w += atom_width(&atom, entity_h, base_wf, base_font); + cur.push(atom); + } + AtomKind::Tab => { + let start_x = line_start_x(subline_idx); + let new_w = next_tab_position(cur_w + start_x, tab_stops, indent_left, entity_h) + - start_x; + let max_w = line_max_w(subline_idx); + if new_w > max_w && !cur.is_empty() { + sublines.push(std::mem::take(&mut cur)); + cur_w = 0.0; + subline_idx += 1; + } else { + cur.push(atom); + cur_w = new_w.min(max_w); + } + } + } + } + if !cur.is_empty() { + sublines.push(cur); + } + if sublines.is_empty() { + sublines.push(Vec::new()); + } + sublines +} + +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 = 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, + align: Option, + indent_first: f32, + indent_left: f32, + indent_right: f32, + tab_stops: Vec, + is_first_in_paragraph: bool, + } + + let mut sub_lines: Vec = Vec::new(); + for para in ¶graphs { + // Flatten runs into atoms. + let mut atoms: Vec = Vec::new(); + for run in ¶.runs { + match &run.kind { + MTextRunKind::Glyphs(text) => { + let mut word = String::new(); + for ch in text.chars() { + if ch == ' ' || ch == '\u{00A0}' { + if !word.is_empty() { + atoms.push(LayoutAtom { + kind: AtomKind::Word(std::mem::take(&mut word)), + state: run.state.clone(), + }); + } + atoms.push(LayoutAtom { + kind: AtomKind::Space, + state: run.state.clone(), + }); + } else { + word.push(ch); + } + } + if !word.is_empty() { + atoms.push(LayoutAtom { + kind: AtomKind::Word(word), + state: run.state.clone(), + }); + } + } + MTextRunKind::Tab => { + atoms.push(LayoutAtom { + kind: AtomKind::Tab, + state: run.state.clone(), + }); + } + } + } + + // Trim leading + trailing Space atoms — 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, + ¶.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 = 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)], diff --git a/src/entities/text.rs b/src/entities/text.rs index 69c833ff7..13d0bad96 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -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![], diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index c620a3d17..d858fc50e 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -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[x]; \W[x]; \Q; \T[x]; \A; +// \C; \c; +// \f|b<0/1>|i<0/1>|c|p; \F; +// \M+; \X \S; +// Paragraph: \p[xi,l,r,q[lcrjd],t,s...]; +// Scope: { ... } push/pop full state +// ────────────────────────────────────────────────────────────────────────── + +/// Paragraph alignment encoded inline via `\p...q[lcrjd]...;`. +/// `Justify` / `Distribute` render as `Left` (full inter-word redistribution +/// is not implemented in the stroke renderer). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParagraphAlign { + Left, + Center, + Right, + Justify, + Distribute, +} + +/// Inline colour override (`\C` ACI or `\c` 24-bit true colour). Resolved to +/// linear RGB at render time via the document's ACI table. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum InlineColor { + Aci(u8), + True([f32; 3]), +} + +/// Tab-stop alignment kind (from `\pt` entries). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TabKind { + Left, + Center, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TabStop { + pub position: f32, + pub kind: TabKind, +} + +/// Per-run formatting state. All fields are multipliers / overrides relative +/// to the entity-level defaults; the renderer composes them with the resolved +/// text style at draw time. +#[derive(Clone, Debug, PartialEq)] +pub struct RunState { + /// Multiplier on entity text height (`\Hx;` → ×v; `\H;` → v / entity_h) + pub height_mul: f32, + /// Multiplier on the (signed) style width-factor (`\W;` → set, `\Wx;` → ×) + pub width_mul: f32, + /// Absolute oblique angle override in radians (`\Q;`) + pub oblique_rad: f32, + /// Tracking multiplier on `font.letter_spacing` (`\T;`) + pub tracking: f32, + /// Vertical alignment of the run within its line box (0=baseline / 1=center + /// / 2=top). Mainly used for fractions and superscript-like layout (`\A`). + pub valign: u8, + /// Font-name override, `None` ⇒ inherit the resolved style font. + pub font: Option, + /// Colour override, `None` ⇒ inherit entity colour. + pub color: Option, + pub underline: bool, + pub overline: bool, + pub strike: bool, +} + +impl Default for RunState { + fn default() -> Self { + Self { + height_mul: 1.0, + width_mul: 1.0, + oblique_rad: 0.0, + tracking: 1.0, + valign: 0, + font: None, + color: None, + underline: false, + overline: false, + strike: false, + } + } +} + +#[derive(Clone, Debug)] +pub enum MTextRunKind { + /// Renderable glyph text (DXF specials resolved, decoration markers stripped). + Glyphs(String), + /// `\t` — jump the cursor to the next tab stop (or default tab interval). + Tab, +} + +#[derive(Clone, Debug)] +pub struct MTextRun { + pub kind: MTextRunKind, + pub state: RunState, +} + +/// One paragraph of MTEXT after parsing. Each line is a sequence of runs that +/// share text content + a snapshot of formatting state, plus paragraph-level +/// layout (alignment, indents, tab stops). `\P` / `\n` / `\N` start a new +/// line; paragraph properties carry forward until the next `\p...;` block. +#[derive(Clone, Debug, Default)] +pub struct MTextLine { + pub runs: Vec, + pub align: Option, + pub indent_first: f32, + pub indent_left: f32, + pub indent_right: f32, + pub tab_stops: Vec, +} + +impl MTextLine { + /// 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, + indent_first: f32, + indent_left: f32, + indent_right: f32, + tab_stops: Vec, +} + +/// Parse a `\p...;` body. Comma-separated tokens, each with a single-letter +/// kind prefix. Unknown tokens are skipped — anything we don't understand is +/// silently dropped rather than poisoning the rest of the paragraph. +fn parse_paragraph_block(body: &str, props: &mut ParagraphProps) { + // The legacy AutoCAD writer prefixes the block with a redundant `x`; skip + // it so it doesn't confuse the kind matcher. + let body = body.strip_prefix('x').unwrap_or(body); + for token in body.split(',') { + let token = token.trim(); + let mut chars = token.chars(); + let Some(kind) = chars.next() else { continue }; + let rest: String = chars.collect(); + match kind { + 'q' | 'Q' => { + let sel = rest.chars().next().map(|c| c.to_ascii_lowercase()); + props.align = match sel { + Some('l') => Some(ParagraphAlign::Left), + Some('c') => Some(ParagraphAlign::Center), + Some('r') => Some(ParagraphAlign::Right), + Some('j') => Some(ParagraphAlign::Justify), + Some('d') => Some(ParagraphAlign::Distribute), + _ => props.align, + }; + } + 'i' => { + if let Ok(v) = rest.parse::() { + props.indent_first = v; + } + } + 'l' => { + if let Ok(v) = rest.parse::() { + props.indent_left = v; + } + } + 'r' => { + if let Ok(v) = rest.parse::() { + props.indent_right = v; + } + } + 't' => { + // Tab list. Each entry may be prefixed with L / C / R to + // pick the tab kind; default is Left. The remainder is the + // position in drawing units. + props.tab_stops.clear(); + for entry in rest.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let (kind, num_str) = match entry.chars().next() { + Some(c @ ('L' | 'l')) => (TabKind::Left, &entry[c.len_utf8()..]), + Some(c @ ('C' | 'c')) => (TabKind::Center, &entry[c.len_utf8()..]), + Some(c @ ('R' | 'r')) => (TabKind::Right, &entry[c.len_utf8()..]), + _ => (TabKind::Left, entry), + }; + if let Ok(p) = num_str.parse::() { + props.tab_stops.push(TabStop { position: p, kind }); + } + } + } + 's' => {} // space-before — affects line spacing; ignored for now + _ => {} + } + } +} + +/// Parse an unsigned 24-bit true-colour value into linear-ish [r,g,b] floats. +/// AutoCAD packs `\c` as a 24-bit decimal: high byte = R, mid = G, low = B. +fn parse_true_color(s: &str) -> Option { + let n: u32 = s.trim().parse().ok()?; + let r = ((n >> 16) & 0xFF) as f32 / 255.0; + let g = ((n >> 8) & 0xFF) as f32 / 255.0; + let b = (n & 0xFF) as f32 / 255.0; + Some(InlineColor::True([r, g, b])) +} + +/// Parse `\H` / `\W` / `\T` value. Returns `(value, is_relative)`; a trailing +/// `x` marks a multiplier on the current state, otherwise the value is the +/// absolute target (interpreted by the caller against entity defaults). +fn parse_scalar_with_x_suffix(body: &str) -> Option<(f32, bool)> { + let body = body.trim(); + let (num, is_rel) = if let Some(stripped) = body.strip_suffix('x').or_else(|| body.strip_suffix('X')) { + (stripped, true) + } else { + (body, false) + }; + Some((num.trim().parse::().ok()?, is_rel)) +} + +/// Flush the glyph buffer as a `Glyphs` run on `line` using a snapshot of +/// `state`. Resolves DXF `%%x` specials (degree / diameter / `%%nnn` literal +/// unicode) so the renderer sees fully decoded text. +fn flush_glyph_buf(line: &mut MTextLine, buf: &mut String, state: &RunState) { + if buf.is_empty() { + return; + } + let text = resolve_dxf_special_chars(&std::mem::take(buf)); + line.runs.push(MTextRun { + kind: MTextRunKind::Glyphs(text), + state: state.clone(), + }); +} + +/// Walk the MTEXT value string and produce one [`MTextLine`] per visible +/// paragraph (after stripping leading / trailing blank lines, as the legacy +/// `split_mtext_lines` does). Every inline format code listed in the module +/// header is recognised; unknown semicolon-terminated codes are stripped +/// silently so future / vendor-specific extensions don't pollute the text. +/// +/// `entity_height` is needed to translate absolute `\H;` declarations into +/// the height-multiplier representation carried in [`RunState`]; pass the +/// MTEXT entity's `height` field. +pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec { + let mut lines: Vec = Vec::new(); + let mut current = MTextLine::default(); + let mut buf = String::new(); + let mut state = RunState::default(); + let mut state_stack: Vec = Vec::new(); + let mut props = ParagraphProps::default(); + // No props_stack — paragraph props persist across braces by design. + let entity_height = entity_height.max(1e-6); + + let mut chars = s.chars().peekable(); + + let read_until_semi = |chars: &mut std::iter::Peekable| -> String { + let mut out = String::new(); + for c in chars.by_ref() { + if c == ';' { + break; + } + out.push(c); + } + out + }; + + while let Some(ch) = chars.next() { + match ch { + '\\' => match chars.peek().copied() { + // ── Line / paragraph break ──────────────────────────────── + Some('P') | Some('n') | Some('N') => { + chars.next(); + flush_glyph_buf(&mut current, &mut buf, &state); + current.align = props.align; + current.indent_first = props.indent_first; + current.indent_left = props.indent_left; + current.indent_right = props.indent_right; + current.tab_stops = props.tab_stops.clone(); + lines.push(std::mem::take(&mut current)); + } + // ── Whitespace literals ─────────────────────────────────── + Some('~') => { + chars.next(); + buf.push('\u{00A0}'); // nbsp — treated as a regular space by the wrap pass + } + Some('t') => { + chars.next(); + flush_glyph_buf(&mut current, &mut buf, &state); + current.runs.push(MTextRun { + kind: MTextRunKind::Tab, + state: state.clone(), + }); + } + // ── Unicode by hex code point ──────────────────────────── + Some('U') | Some('u') => { + chars.next(); + if chars.peek() == Some(&'+') { + chars.next(); + 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; ────────────────────────── + Some('S') | Some('s') => { + chars.next(); + let mut upper = String::new(); + let mut lower = String::new(); + let mut sep = '/'; + let mut in_lower = false; + for c in chars.by_ref() { + if c == ';' { + break; + } + if !in_lower && (c == '/' || c == '^' || c == '#') { + sep = c; + in_lower = true; + } else if in_lower { + lower.push(c); + } else { + upper.push(c); + } + } + buf.push_str(&upper); + if !lower.is_empty() { + buf.push(if sep == '#' { '/' } else { sep }); + buf.push_str(&lower); + } + } + // ── Decoration toggles (state, not markers) ────────────── + Some('L') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = true; } + Some('l') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = false; } + Some('O') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = true; } + Some('o') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = false; } + Some('K') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = true; } + Some('k') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = false; } + // ── Literal backslash / braces ─────────────────────────── + Some('\\') => { chars.next(); buf.push('\\'); } + Some('{') | Some('}') => { buf.push(chars.next().unwrap()); } + // ── Paragraph props ────────────────────────────────────── + Some('p') => { + chars.next(); + let body = read_until_semi(&mut chars); + parse_paragraph_block(&body, &mut props); + } + // ── Height ─────────────────────────────────────────────── + Some('H') => { + chars.next(); + let body = read_until_semi(&mut chars); + if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) { + flush_glyph_buf(&mut current, &mut buf, &state); + if is_rel { + state.height_mul *= v; + } else { + state.height_mul = v / entity_height; + } + } + } + // ── Width factor ───────────────────────────────────────── + Some('W') => { + chars.next(); + let body = read_until_semi(&mut chars); + if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) { + flush_glyph_buf(&mut current, &mut buf, &state); + if is_rel { + state.width_mul *= v; + } else { + state.width_mul = v; + } + } + } + // ── Oblique angle (degrees → radians) ──────────────────── + Some('Q') => { + chars.next(); + let body = read_until_semi(&mut chars); + if let Ok(deg) = body.trim().parse::() { + flush_glyph_buf(&mut current, &mut buf, &state); + state.oblique_rad = deg.to_radians(); + } + } + // ── Tracking ───────────────────────────────────────────── + Some('T') => { + chars.next(); + let body = read_until_semi(&mut chars); + if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) { + flush_glyph_buf(&mut current, &mut buf, &state); + if is_rel { + state.tracking *= v; + } else { + state.tracking = v; + } + } + } + // ── Vertical alignment ─────────────────────────────────── + Some('A') => { + chars.next(); + let body = read_until_semi(&mut chars); + if let Ok(n) = body.trim().parse::() { + flush_glyph_buf(&mut current, &mut buf, &state); + state.valign = n.min(2); + } + } + // ── ACI colour ─────────────────────────────────────────── + Some('C') => { + chars.next(); + let body = read_until_semi(&mut chars); + if let Ok(n) = body.trim().parse::() { + flush_glyph_buf(&mut current, &mut buf, &state); + state.color = Some(InlineColor::Aci(n.min(255) as u8)); + } + } + // ── True colour ────────────────────────────────────────── + Some('c') => { + chars.next(); + let body = read_until_semi(&mut chars); + if let Some(col) = parse_true_color(&body) { + flush_glyph_buf(&mut current, &mut buf, &state); + state.color = Some(col); + } + } + // ── Font (name + b/i/c/p flags) ────────────────────────── + Some('f') | Some('F') => { + chars.next(); + let body = read_until_semi(&mut chars); + // First `|`-separated field is the font name / file stem. + let name = body.split('|').next().unwrap_or("").trim(); + if !name.is_empty() { + flush_glyph_buf(&mut current, &mut buf, &state); + // Strip extension if `\F` passed a file path. + let stem = name + .rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .split('.') + .next() + .unwrap_or(name); + state.font = Some(stem.to_string()); + } + } + // ── Multibyte / codepage marker — strip silently ───────── + Some('M') => { + chars.next(); + let _ = read_until_semi(&mut chars); + } + // ── Dimension MTEXT paragraph-end marker — strip silently + Some('X') => { + chars.next(); + } + // ── Unknown single-letter escape ───────────────────────── + Some(_) => { + chars.next(); + } + None => {} + }, + // Scope push / pop — braces scope *character* state (font, + // colour, height, decorations, …). Paragraph properties + // (`\p...;`) are intentionally NOT scoped: AutoCAD treats them + // as paragraph-level layout that persists across braces, and + // real-world files routinely wrap inline `\pxqc;` inside a + // `{\fArial;…}` block while expecting the alignment to apply + // to the whole paragraph. + '{' => { + state_stack.push(state.clone()); + } + '}' => { + if let Some(prev) = state_stack.pop() { + flush_glyph_buf(&mut current, &mut buf, &state); + state = prev; + } + } + '\r' => {} + '\n' => { + flush_glyph_buf(&mut current, &mut buf, &state); + current.align = props.align; + current.indent_first = props.indent_first; + current.indent_left = props.indent_left; + current.indent_right = props.indent_right; + current.tab_stops = props.tab_stops.clone(); + lines.push(std::mem::take(&mut current)); + } + other => buf.push(other), + } + } + flush_glyph_buf(&mut current, &mut buf, &state); + current.align = props.align; + current.indent_first = props.indent_first; + current.indent_left = props.indent_left; + current.indent_right = props.indent_right; + current.tab_stops = props.tab_stops.clone(); + lines.push(current); + + 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(); diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index 5a96df070..d7204b525 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -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![], diff --git a/src/scene/acad_to_truck.rs b/src/scene/acad_to_truck.rs index 977b4d733..9c03ff510 100644 --- a/src/scene/acad_to_truck.rs +++ b/src/scene/acad_to_truck.rs @@ -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>, pub origin: [f64; 2], + pub color: Option<[f32; 3]>, } #[allow(dead_code)] diff --git a/src/scene/block_cache.rs b/src/scene/block_cache.rs index f333c6af1..f0fd5ebd0 100644 --- a/src/scene/block_cache.rs +++ b/src/scene/block_cache.rs @@ -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; diff --git a/src/scene/cxf.rs b/src/scene/cxf.rs index 3ab66eda4..d3c64d9fd 100644 --- a/src/scene/cxf.rs +++ b/src/scene/cxf.rs @@ -226,6 +226,33 @@ pub fn tessellate_text_ex( oblique_angle: f32, font_name: &str, text: &str, +) -> Vec> { + 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;` 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> { 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; } } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 59afa10ae..146e75c66 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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); } } diff --git a/src/scene/tessellate.rs b/src/scene/tessellate.rs index 6686c07ea..17a1635dc 100644 --- a/src/scene/tessellate.rs +++ b/src/scene/tessellate.rs @@ -112,7 +112,7 @@ pub fn tessellate( line_weight_px: f32, world_offset: [f64; 3], anno_scale: f32, -) -> WireModel { +) -> Vec { 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 = Vec::new(); + let find_or_make = + |key: Option<[f32; 3]>, bins: &mut Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)>, firsts: &mut Vec| -> 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 = 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.