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 <noreply@anthropic.com>
This commit is contained in:
parent
ff31a07213
commit
f12e864aa6
10 changed files with 1223 additions and 148 deletions
|
|
@ -218,7 +218,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
|
||||||
&resolved.font_name,
|
&resolved.font_name,
|
||||||
line,
|
line,
|
||||||
);
|
);
|
||||||
strokes_all.push(TextStroke { strokes, origin });
|
strokes_all.push(TextStroke { strokes, origin, color: None });
|
||||||
}
|
}
|
||||||
let _ = input.line_count; // round-trip only — recomputed above
|
let _ = input.line_count; // round-trip only — recomputed above
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
use acadrust::entities::{AttachmentPoint, DrawingDirection, MText};
|
use acadrust::entities::{AttachmentPoint, DrawingDirection, MText};
|
||||||
|
use acadrust::types::aci_table::aci_to_rgb;
|
||||||
use glam::Vec3;
|
use glam::Vec3;
|
||||||
|
|
||||||
use crate::command::EntityTransform;
|
use crate::command::EntityTransform;
|
||||||
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip};
|
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip};
|
||||||
use crate::entities::text_support::{
|
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::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
|
||||||
use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject};
|
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 {
|
fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
|
||||||
let resolved_style = resolve_text_style(&t.style, document);
|
let resolved_style = resolve_text_style(&t.style, document);
|
||||||
let font_name = resolved_style.font_name;
|
let base_font_name = resolved_style.font_name.clone();
|
||||||
let font = cxf::get_font(&font_name);
|
let base_font = cxf::get_font(&base_font_name);
|
||||||
// MText has no entity-level width/oblique override (those are MText format
|
let base_wf_abs = resolved_style.width_factor.max(0.01);
|
||||||
// codes \W and \Q inside the value string, handled by `strip_mtext_codes`).
|
let base_wf = if resolved_style.is_backward { -base_wf_abs } else { base_wf_abs };
|
||||||
// Default rendering inherits both from the style. `is_backward` flips the
|
let base_oblique = resolved_style.oblique_angle;
|
||||||
// width-factor sign so text reads right-to-left.
|
let entity_h = t.height as f32;
|
||||||
let base_wf = resolved_style.width_factor.max(0.01);
|
let rect_w = t.rectangle_width as f32;
|
||||||
let style_width_factor = if resolved_style.is_backward { -base_wf } else { base_wf };
|
|
||||||
let style_oblique = resolved_style.oblique_angle;
|
// ── 1. Parse ────────────────────────────────────────────────────────────
|
||||||
let plain = strip_mtext_codes(&t.value);
|
let paragraphs = parse_mtext_paragraphs(&t.value, entity_h);
|
||||||
let explicit_lines = split_mtext_lines(&plain);
|
|
||||||
let lines: Vec<String> = if t.rectangle_width > 0.0 {
|
// ── 2. Atomise + wrap each paragraph into sub-lines ─────────────────────
|
||||||
// Measurement uses the magnitude only; the sign on style_width_factor
|
struct SubLine {
|
||||||
// is a render-time mirror flag, not a width scalar.
|
atoms: Vec<LayoutAtom>,
|
||||||
let scale = t.height as f32 / 9.0 * style_width_factor.abs();
|
align: Option<ParagraphAlign>,
|
||||||
let max_w = t.rectangle_width as f32;
|
indent_first: f32,
|
||||||
explicit_lines
|
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 ¶graphs {
|
||||||
|
// Flatten runs into atoms.
|
||||||
|
let mut atoms: Vec<LayoutAtom> = 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()
|
.iter()
|
||||||
.flat_map(|line| word_wrap(line, max_w, scale, font))
|
.position(|a| !matches!(a.kind, AtomKind::Space))
|
||||||
.collect()
|
.unwrap_or(atoms.len());
|
||||||
} else {
|
atoms.drain(..first_word);
|
||||||
explicit_lines
|
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
|
||||||
};
|
atoms.pop();
|
||||||
let n_lines = lines.len().max(1) as f32;
|
}
|
||||||
|
|
||||||
|
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 {
|
let ls_factor = if t.line_spacing_factor > 0.0 {
|
||||||
t.line_spacing_factor as f32
|
t.line_spacing_factor as f32
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
// AutoCAD DXF code 44 is a multiplier on the *default* baseline-to-baseline
|
// DXF code 44 — multiplier on the *default* baseline-to-baseline gap,
|
||||||
// distance, which is 5/3 × text_height (≈ 1.667). factor = 1.0 → single
|
// which AutoCAD defines as 5/3 × text height (≈ 1.667). 1.0 → single
|
||||||
// spacing, factor = 2.0 → double spacing, etc.
|
// spacing, 2.0 → double, etc.
|
||||||
let line_h = t.height as f32 * ls_factor * (5.0 / 3.0) * font.line_spacing;
|
let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing;
|
||||||
let h = t.height as f32;
|
let h = entity_h;
|
||||||
// CXF glyphs sit on the baseline (y=0) and extend UP by `h` (cap height).
|
// CXF glyphs sit on the baseline (y=0) and extend UP by `h`. v_offset
|
||||||
// Block top = line-0 baseline + h
|
// is the Y of the first sub-line's baseline relative to the insertion
|
||||||
// Block bottom = last-line baseline = line-0 baseline − (n_lines−1)·line_h
|
// point; pick it from the attachment-point's vertical anchor:
|
||||||
//
|
// Top → block top at insertion → v_offset = −h
|
||||||
// v_offset is the Y of line-0 baseline relative to the insertion point, so:
|
// Bottom → block bottom at insertion → v_offset = (n−1) · line_h
|
||||||
// Top attachment → block top at insertion → v_offset = −h
|
// Middle → midpoint of the two above
|
||||||
// Bottom attachment → block bottom at insertion → v_offset = (n_lines−1)·line_h
|
|
||||||
// Middle attachment → block center at insertion → midpoint of the two above
|
|
||||||
let v_offset = match t.attachment_point {
|
let v_offset = match t.attachment_point {
|
||||||
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => -h,
|
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => -h,
|
||||||
AttachmentPoint::MiddleLeft
|
AttachmentPoint::MiddleLeft
|
||||||
|
|
@ -128,7 +429,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
|
||||||
| AttachmentPoint::BottomCenter
|
| AttachmentPoint::BottomCenter
|
||||||
| AttachmentPoint::BottomRight => (n_lines - 1.0) * line_h,
|
| 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::TopCenter
|
||||||
| AttachmentPoint::MiddleCenter
|
| AttachmentPoint::MiddleCenter
|
||||||
| AttachmentPoint::BottomCenter => 0.5,
|
| AttachmentPoint::BottomCenter => 0.5,
|
||||||
|
|
@ -137,8 +438,8 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
|
||||||
}
|
}
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
};
|
};
|
||||||
|
let box_left = -attach_h_anchor * rect_w;
|
||||||
let vertical_text = matches!(t.drawing_direction, DrawingDirection::TopToBottom);
|
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 {
|
let rot = if resolved_style.is_upside_down {
|
||||||
t.rotation as f32 + std::f32::consts::PI
|
t.rotation as f32 + std::f32::consts::PI
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -149,41 +450,138 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
|
||||||
let ins_y = t.insertion_point.y;
|
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 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();
|
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;
|
let li = i as f32;
|
||||||
// Compute line offset as f32 (small relative values), apply to f64 insertion point.
|
let (line_base_x, line_base_y) = if vertical_text {
|
||||||
let (small_x, small_y) = if vertical_text {
|
let col_offset = li * entity_h * 1.2;
|
||||||
let col_offset = li * t.height as f32 * 1.2;
|
|
||||||
(
|
(
|
||||||
col_offset * cos_r + v_offset * (-sin_r),
|
col_offset * cos_r + v_offset * (-sin_r),
|
||||||
col_offset * sin_r + v_offset * cos_r,
|
col_offset * sin_r + v_offset * cos_r,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let line_y = -(li * line_h) + v_offset;
|
let ly = -(li * line_h) + v_offset;
|
||||||
(line_y * (-sin_r), line_y * cos_r)
|
(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();
|
// Paragraph-content area: [content_left, content_right] relative to
|
||||||
measure_mtext_chars(line, scale, font)
|
// 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 {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let h_shift = -line_w * h_anchor;
|
let content_right = if rect_w > 0.0 {
|
||||||
let origin: [f64; 2] = [
|
box_left + rect_w - sub.indent_right
|
||||||
ins_x + (small_x + h_shift * cos_r) as f64,
|
} else {
|
||||||
ins_y + (small_y + h_shift * sin_r) as f64,
|
0.0
|
||||||
];
|
};
|
||||||
let strokes = cxf::tessellate_text_ex(
|
|
||||||
[0.0, 0.0],
|
// Effective horizontal anchor for this sub-line: paragraph alignment
|
||||||
t.height as f32,
|
// (when explicitly set inline) wins, otherwise inherit from the
|
||||||
rot,
|
// entity attachment.
|
||||||
style_width_factor,
|
let line_anchor: f32 = match sub.align {
|
||||||
style_oblique,
|
Some(ParagraphAlign::Left)
|
||||||
&font_name,
|
| Some(ParagraphAlign::Justify)
|
||||||
line,
|
| 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 {
|
TruckEntity {
|
||||||
object: TruckObject::Text(all_strokes),
|
object: TruckObject::Text(all_strokes),
|
||||||
snap_pts: vec![(insertion, SnapHint::Insertion)],
|
snap_pts: vec![(insertion, SnapHint::Insertion)],
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
|
||||||
&t.value,
|
&t.value,
|
||||||
);
|
);
|
||||||
TruckEntity {
|
TruckEntity {
|
||||||
object: TruckObject::Text(vec![TextStroke { strokes, origin }]),
|
object: TruckObject::Text(vec![TextStroke { strokes, origin, color: None }]),
|
||||||
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
|
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
|
||||||
tangent_geoms: vec![],
|
tangent_geoms: vec![],
|
||||||
key_vertices: vec![],
|
key_vertices: vec![],
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,542 @@ pub fn resolve_dxf_special_chars(s: &str) -> String {
|
||||||
out
|
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 {
|
pub fn strip_mtext_codes(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
let mut chars = s.chars().peekable();
|
let mut chars = s.chars().peekable();
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ impl TruckConvertible for Tolerance {
|
||||||
let origin = [self.insertion_point.x, self.insertion_point.y];
|
let origin = [self.insertion_point.x, self.insertion_point.y];
|
||||||
|
|
||||||
Some(TruckEntity {
|
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)],
|
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
|
||||||
tangent_geoms: vec![],
|
tangent_geoms: vec![],
|
||||||
key_vertices: vec![],
|
key_vertices: vec![],
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,15 @@ use crate::scene::wire_model::{SnapHint, TangentGeom};
|
||||||
/// One group of glyph strokes with its world-space origin stored in f64.
|
/// 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
|
/// Strokes are in glyph-local space (origin = [0,0]) so that the large
|
||||||
/// world offset can be subtracted with f64 precision in tessellate.rs.
|
/// 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 struct TextStroke {
|
||||||
pub strokes: Vec<Vec<[f32; 2]>>,
|
pub strokes: Vec<Vec<[f32; 2]>>,
|
||||||
pub origin: [f64; 2],
|
pub origin: [f64; 2],
|
||||||
|
pub color: Option<[f32; 3]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|
|
||||||
|
|
@ -401,9 +401,32 @@ fn tessellate_sub_local(
|
||||||
// Pass `local_offset` as the f64 world-offset so tessellate subtracts it
|
// Pass `local_offset` as the f64 world-offset so tessellate subtracts it
|
||||||
// before casting to f32 — same precision-preservation trick used for
|
// before casting to f32 — same precision-preservation trick used for
|
||||||
// top-level entities, applied per-defn.
|
// 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,
|
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 {
|
if wire.points.len() > 100_000 {
|
||||||
return None;
|
return None;
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,33 @@ pub fn tessellate_text_ex(
|
||||||
oblique_angle: f32,
|
oblique_angle: f32,
|
||||||
font_name: &str,
|
font_name: &str,
|
||||||
text: &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]>> {
|
) -> Vec<Vec<[f32; 2]>> {
|
||||||
if text.is_empty() || height <= 0.0 {
|
if text.is_empty() || height <= 0.0 {
|
||||||
return vec![];
|
return vec![];
|
||||||
|
|
@ -388,7 +415,7 @@ pub fn tessellate_text_ex(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Partial digit sequence — advance as unknown glyph and move on
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -417,11 +444,11 @@ pub fn tessellate_text_ex(
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
cursor_x += (glyph.advance + font.letter_spacing) * wf;
|
cursor_x += (glyph.advance + font.letter_spacing * tracking) * wf;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
warn_missing_glyph(font_name, render_ch);
|
warn_missing_glyph(font_name, render_ch);
|
||||||
cursor_x += (6.0 + font.letter_spacing) * wf;
|
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4777,7 +4777,7 @@ fn tessellate_entity(
|
||||||
} else {
|
} else {
|
||||||
(0.0_f32, [0.0f32; 8])
|
(0.0_f32, [0.0f32; 8])
|
||||||
};
|
};
|
||||||
let mut wire = tessellate::tessellate(
|
let mut wires = tessellate::tessellate(
|
||||||
document,
|
document,
|
||||||
h,
|
h,
|
||||||
e,
|
e,
|
||||||
|
|
@ -4789,8 +4789,11 @@ fn tessellate_entity(
|
||||||
world_offset,
|
world_offset,
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
wire.aabb = entity_aabb(e, world_offset);
|
let ab = entity_aabb(e, world_offset);
|
||||||
return vec![wire];
|
for w in &mut wires {
|
||||||
|
w.aabb = ab;
|
||||||
|
}
|
||||||
|
return wires;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
|
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_aabb = entity_aabb(&sub, world_offset);
|
||||||
let sub_pattern_length = sub_pattern_length * pslt_factor;
|
let sub_pattern_length = sub_pattern_length * pslt_factor;
|
||||||
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
|
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
|
||||||
let mut wire = tessellate::tessellate(
|
let mut wires = tessellate::tessellate(
|
||||||
document,
|
document,
|
||||||
h,
|
h,
|
||||||
&sub,
|
&sub,
|
||||||
|
|
@ -5078,10 +5081,12 @@ fn tessellate_entity(
|
||||||
world_offset,
|
world_offset,
|
||||||
anno_scale,
|
anno_scale,
|
||||||
);
|
);
|
||||||
wire.name = h.value().to_string();
|
for w in &mut wires {
|
||||||
wire.aci = sub_aci;
|
w.name = h.value().to_string();
|
||||||
wire.aabb = sub_aabb;
|
w.aci = sub_aci;
|
||||||
vec![wire]
|
w.aabb = sub_aabb;
|
||||||
|
}
|
||||||
|
wires
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
append_insert_attribute_wires(
|
append_insert_attribute_wires(
|
||||||
|
|
@ -5219,7 +5224,7 @@ fn tessellate_entity(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut base = tessellate::tessellate(
|
let mut bases = tessellate::tessellate(
|
||||||
document,
|
document,
|
||||||
h,
|
h,
|
||||||
e,
|
e,
|
||||||
|
|
@ -5231,28 +5236,37 @@ fn tessellate_entity(
|
||||||
world_offset,
|
world_offset,
|
||||||
anno_scale,
|
anno_scale,
|
||||||
);
|
);
|
||||||
base.aci = aci;
|
for b in &mut bases {
|
||||||
base.aabb = aabb;
|
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) {
|
if let Some(clt) = crate::linetypes::complex_lt(lt_name) {
|
||||||
let mut wires = complex_lt::apply_along(
|
if let Some(base) = bases.first() {
|
||||||
&base.name,
|
let mut wires = complex_lt::apply_along(
|
||||||
&base.points,
|
&base.name,
|
||||||
clt,
|
&base.points,
|
||||||
(lt_scale * pslt_factor).max(1e-4),
|
clt,
|
||||||
entity_color,
|
(lt_scale * pslt_factor).max(1e-4),
|
||||||
sel,
|
entity_color,
|
||||||
base.line_weight_px,
|
sel,
|
||||||
);
|
base.line_weight_px,
|
||||||
if !wires.is_empty() {
|
);
|
||||||
for w in &mut wires {
|
if !wires.is_empty() {
|
||||||
w.aabb = aabb;
|
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
|
/// 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
|
sub_color
|
||||||
};
|
};
|
||||||
let sub_aabb = entity_aabb(&attr_entity, world_offset);
|
let sub_aabb = entity_aabb(&attr_entity, world_offset);
|
||||||
let mut wire = tessellate::tessellate(
|
let mut attr_wires = tessellate::tessellate(
|
||||||
document,
|
document,
|
||||||
insert_handle,
|
insert_handle,
|
||||||
&attr_entity,
|
&attr_entity,
|
||||||
|
|
@ -5579,10 +5593,12 @@ fn append_insert_attribute_wires(
|
||||||
);
|
);
|
||||||
// Use the INSERT's handle so selection / picking groups attribute
|
// Use the INSERT's handle so selection / picking groups attribute
|
||||||
// text with the parent insert instead of treating it as a stray text.
|
// text with the parent insert instead of treating it as a stray text.
|
||||||
wire.name = insert_handle.value().to_string();
|
for w in &mut attr_wires {
|
||||||
wire.aci = sub_aci;
|
w.name = insert_handle.value().to_string();
|
||||||
wire.aabb = sub_aabb;
|
w.aci = sub_aci;
|
||||||
wires.push(wire);
|
w.aabb = sub_aabb;
|
||||||
|
}
|
||||||
|
wires.extend(attr_wires);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ pub fn tessellate(
|
||||||
line_weight_px: f32,
|
line_weight_px: f32,
|
||||||
world_offset: [f64; 3],
|
world_offset: [f64; 3],
|
||||||
anno_scale: f32,
|
anno_scale: f32,
|
||||||
) -> WireModel {
|
) -> Vec<WireModel> {
|
||||||
let color = if selected {
|
let color = if selected {
|
||||||
WireModel::SELECTED
|
WireModel::SELECTED
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -145,7 +145,7 @@ pub fn tessellate(
|
||||||
// MultiLeader is handled by scene/mod.rs since it emits multiple WireModels
|
// MultiLeader is handled by scene/mod.rs since it emits multiple WireModels
|
||||||
// (leader, text, frame, fill) with distinct colors.
|
// (leader, text, frame, fill) with distinct colors.
|
||||||
if let EntityType::Leader(leader) = entity {
|
if let EntityType::Leader(leader) = entity {
|
||||||
return tessellate_leader_single(
|
return vec![tessellate_leader_single(
|
||||||
document,
|
document,
|
||||||
handle,
|
handle,
|
||||||
leader,
|
leader,
|
||||||
|
|
@ -154,74 +154,141 @@ pub fn tessellate(
|
||||||
line_weight_px,
|
line_weight_px,
|
||||||
world_offset,
|
world_offset,
|
||||||
anno_scale,
|
anno_scale,
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Try the truck path first ───────────────────────────────────────────
|
// ── Try the truck path first ───────────────────────────────────────────
|
||||||
if let Some(te) = convert(entity, document) {
|
if let Some(te) = convert(entity, document) {
|
||||||
match te.object {
|
match te.object {
|
||||||
// ── Text / MText: pre-tessellated glyph strokes ───────────────
|
// ── 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) => {
|
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 [ox, oy, oz] = world_offset;
|
||||||
let elev = entity_z(entity) - oz as f32;
|
let elev = entity_z(entity) - oz as f32;
|
||||||
|
|
||||||
let mut points: Vec<[f32; 3]> = Vec::new();
|
// anno_scale anchors at the first group's origin so multi-line
|
||||||
let mut first = true;
|
// MText lines spread apart correctly as they grow.
|
||||||
// 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.
|
|
||||||
let ref_origin = stroke_groups
|
let ref_origin = stroke_groups
|
||||||
.first()
|
.first()
|
||||||
.map(|g| g.origin)
|
.map(|g| g.origin)
|
||||||
.unwrap_or([0.0, 0.0]);
|
.unwrap_or([0.0, 0.0]);
|
||||||
let ref_lx = (ref_origin[0] - ox) as f32;
|
let ref_lx = (ref_origin[0] - ox) as f32;
|
||||||
let ref_ly = (ref_origin[1] - oy) 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 {
|
for group in &stroke_groups {
|
||||||
let lx = (group.origin[0] - ox) as f32;
|
let lx = (group.origin[0] - ox) as f32;
|
||||||
let ly = (group.origin[1] - oy) as f32;
|
let ly = (group.origin[1] - oy) as f32;
|
||||||
let slx = (lx - ref_lx) * anno_scale + ref_lx;
|
let slx = (lx - ref_lx) * anno_scale + ref_lx;
|
||||||
let sly = (ly - ref_ly) * anno_scale + ref_ly;
|
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 {
|
for stroke in &group.strokes {
|
||||||
if stroke.len() < 2 {
|
if stroke.len() < 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !first && !points.is_empty() {
|
if !bin_first[bi] && !pts.is_empty() {
|
||||||
points.push([f32::NAN, f32::NAN, f32::NAN]);
|
pts.push([f32::NAN, f32::NAN, f32::NAN]);
|
||||||
}
|
}
|
||||||
first = false;
|
bin_first[bi] = false;
|
||||||
for &[x, y] in stroke {
|
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 snap_pts = offset_snap_pts(te.snap_pts, world_offset);
|
||||||
let [ox, oy, oz] = world_offset;
|
|
||||||
let key_vertices: Vec<[f32; 3]> = te
|
let key_vertices: Vec<[f32; 3]> = te
|
||||||
.key_vertices
|
.key_vertices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||||
.collect();
|
.collect();
|
||||||
return WireModel {
|
|
||||||
name,
|
// Empty input (no glyphs) → emit a single empty wire so the
|
||||||
points,
|
// entity still has a hit-test target via snap_pts.
|
||||||
color,
|
if bins.is_empty() {
|
||||||
selected,
|
return vec![WireModel {
|
||||||
pattern_length: 0.0,
|
name,
|
||||||
pattern: [0.0; 8],
|
points: Vec::new(),
|
||||||
line_weight_px,
|
color,
|
||||||
snap_pts,
|
selected,
|
||||||
tangent_geoms: te.tangent_geoms,
|
pattern_length: 0.0,
|
||||||
aci: 0,
|
pattern: [0.0; 8],
|
||||||
key_vertices,
|
line_weight_px,
|
||||||
aabb: WireModel::UNBOUNDED_AABB,
|
snap_pts,
|
||||||
plinegen: true,
|
tangent_geoms: te.tangent_geoms,
|
||||||
vp_scissor: None,
|
aci: 0,
|
||||||
fill_tris: vec![],
|
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 ─────────────────────────────────
|
// ── Standard topology objects ─────────────────────────────────
|
||||||
|
|
@ -239,7 +306,7 @@ pub fn tessellate(
|
||||||
[(kx - ox) as f32, (ky - oy) as f32, (kz - oz) as f32]
|
[(kx - ox) as f32, (ky - oy) as f32, (kz - oz) as f32]
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
return WireModel {
|
return vec![WireModel {
|
||||||
name,
|
name,
|
||||||
points: vec![
|
points: vec![
|
||||||
[x - s, y, z],
|
[x - s, y, z],
|
||||||
|
|
@ -260,7 +327,7 @@ pub fn tessellate(
|
||||||
plinegen: true,
|
plinegen: true,
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
fill_tris: vec![],
|
fill_tris: vec![],
|
||||||
};
|
}];
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -275,7 +342,7 @@ pub fn tessellate(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||||
.collect();
|
.collect();
|
||||||
return WireModel {
|
return vec![WireModel {
|
||||||
name,
|
name,
|
||||||
points,
|
points,
|
||||||
color,
|
color,
|
||||||
|
|
@ -291,7 +358,7 @@ pub fn tessellate(
|
||||||
plinegen: true,
|
plinegen: true,
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
fill_tris: vec![],
|
fill_tris: vec![],
|
||||||
};
|
}];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,7 +371,7 @@ pub fn tessellate(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||||
.collect();
|
.collect();
|
||||||
return WireModel {
|
return vec![WireModel {
|
||||||
name,
|
name,
|
||||||
points,
|
points,
|
||||||
color,
|
color,
|
||||||
|
|
@ -320,7 +387,7 @@ pub fn tessellate(
|
||||||
plinegen: true,
|
plinegen: true,
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
fill_tris: vec![],
|
fill_tris: vec![],
|
||||||
};
|
}];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,7 +418,7 @@ pub fn tessellate(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||||
.collect();
|
.collect();
|
||||||
return WireModel {
|
return vec![WireModel {
|
||||||
name,
|
name,
|
||||||
points: local_pts,
|
points: local_pts,
|
||||||
color,
|
color,
|
||||||
|
|
@ -367,7 +434,7 @@ pub fn tessellate(
|
||||||
plinegen: true,
|
plinegen: true,
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
fill_tris,
|
fill_tris,
|
||||||
};
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
TruckObject::SegmentedLines(points) => {
|
TruckObject::SegmentedLines(points) => {
|
||||||
|
|
@ -388,7 +455,7 @@ pub fn tessellate(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||||
.collect();
|
.collect();
|
||||||
return WireModel {
|
return vec![WireModel {
|
||||||
name,
|
name,
|
||||||
points: local_pts,
|
points: local_pts,
|
||||||
color,
|
color,
|
||||||
|
|
@ -404,7 +471,7 @@ pub fn tessellate(
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
aabb: WireModel::UNBOUNDED_AABB,
|
aabb: WireModel::UNBOUNDED_AABB,
|
||||||
fill_tris: vec![],
|
fill_tris: vec![],
|
||||||
};
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
TruckObject::Volume(_) => {
|
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);
|
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));
|
wm.snap_pts.push((sp, SnapHint::Insertion));
|
||||||
}
|
}
|
||||||
return wm;
|
return vec![wm];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Legacy fallback for Viewport and other unhandled types ────────────
|
// ── Legacy fallback for Viewport and other unhandled types ────────────
|
||||||
let (points, snap_pts, tangent_geoms, key_vertices) = legacy_geometry(entity, world_offset);
|
let (points, snap_pts, tangent_geoms, key_vertices) = legacy_geometry(entity, world_offset);
|
||||||
WireModel {
|
vec![WireModel {
|
||||||
name,
|
name,
|
||||||
points,
|
points,
|
||||||
color,
|
color,
|
||||||
|
|
@ -450,7 +517,7 @@ pub fn tessellate(
|
||||||
plinegen: true,
|
plinegen: true,
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
fill_tris: vec![],
|
fill_tris: vec![],
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tessellate_dimension(
|
pub fn tessellate_dimension(
|
||||||
|
|
@ -1539,7 +1606,7 @@ pub fn tessellate_multileader(
|
||||||
for sub in synth_ins.explode_from_document(document) {
|
for sub in synth_ins.explode_from_document(document) {
|
||||||
let normalized =
|
let normalized =
|
||||||
crate::modules::home::modify::explode::normalize_insert_entity(sub);
|
crate::modules::home::modify::explode::normalize_insert_entity(sub);
|
||||||
let mut wire = tessellate(
|
let mut sub_wires = tessellate(
|
||||||
document,
|
document,
|
||||||
handle,
|
handle,
|
||||||
&normalized,
|
&normalized,
|
||||||
|
|
@ -1551,8 +1618,10 @@ pub fn tessellate_multileader(
|
||||||
world_offset,
|
world_offset,
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
wire.name = name.clone();
|
for w in &mut sub_wires {
|
||||||
wires.push(wire);
|
w.name = name.clone();
|
||||||
|
}
|
||||||
|
wires.extend(sub_wires);
|
||||||
}
|
}
|
||||||
// Block attributes attached to the multileader — render each as
|
// Block attributes attached to the multileader — render each as
|
||||||
// its own attribute entity at WCS location like INSERT does.
|
// its own attribute entity at WCS location like INSERT does.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue