forked from 1b-agent/opencadstudio-upstream
feat(mtext): integrate every inline format code in MTEXT renderer
Replace the line-based MTEXT pipeline with a run-aware parser + renderer.
Every DXF MTEXT inline code now reaches the stroke output: \H \W \Q \T \A
\C \c \f \F \M \X, the \p block (q/i/l/r/t/s), brace-scoped state, and
the \L\l \O\o \K\k decoration toggles.
Parser ([text_support]): emits Vec<MTextLine{ runs, align, indent_*,
tab_stops }> with per-run RunState; braces scope character state, paragraph
props persist across them.
Renderer ([mtext]): atomise → wrap → render. Per-run height/width/oblique/
tracking/font/colour/decorations/valign; paragraph indents and tab stops
honoured; default 4-em tab fallback.
Plumbing: tessellate() now returns Vec<WireModel>; TextStroke gains an
optional inline-colour override so per-run \C/\c emits one wire per colour.
cxf::tessellate_text_run adds a tracking multiplier. block_cache folds
multi-wire results into a single LocalWire (colour split preserved at
top-level, flattened inside cached defns).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
ff31a07213
commit
f12e864aa6
10 files changed
+1223
-148
No files matched your search
@@ -218,7 +218,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
|
||||
&resolved.font_name,
|
||||
line,
|
||||
);
|
||||
strokes_all.push(TextStroke { strokes, origin });
|
||||
strokes_all.push(TextStroke { strokes, origin, color: None });
|
||||
}
|
||||
let _ = input.line_count; // round-trip only — recomputed above
|
||||
|
||||
|
||||
+460
-62
@@ -1,10 +1,12 @@
|
||||
use acadrust::entities::{AttachmentPoint, DrawingDirection, MText};
|
||||
use acadrust::types::aci_table::aci_to_rgb;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::EntityTransform;
|
||||
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip};
|
||||
use crate::entities::text_support::{
|
||||
measure_mtext_chars, resolve_text_style, split_mtext_lines, strip_mtext_codes, word_wrap,
|
||||
parse_mtext_paragraphs, resolve_text_style, InlineColor, MTextRunKind, ParagraphAlign,
|
||||
RunState, TabStop,
|
||||
};
|
||||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
|
||||
use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject};
|
||||
@@ -75,50 +77,349 @@ fn drawing_dir_str(d: &DrawingDirection) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run-based MTEXT layout ──────────────────────────────────────────────────
|
||||
//
|
||||
// Layout proceeds in three passes:
|
||||
// 1. Atomise — turn each MTextLine.runs into a flat sequence of atoms
|
||||
// (Word / Space / Tab) so the wrapper can operate at break boundaries
|
||||
// while keeping per-character formatting state.
|
||||
// 2. Wrap — accumulate atoms into sub-lines using paragraph indents
|
||||
// (`indent_first` for the first sub-line, `indent_left` for
|
||||
// continuations, `indent_right` shrinks the right edge). Each Tab
|
||||
// jumps the cursor to the next tab stop (or the next 4-em default
|
||||
// stop when none is defined).
|
||||
// 3. Render — for every sub-line: pick paragraph alignment + indent,
|
||||
// walk atoms left → right, emit one TextStroke per Word using the
|
||||
// atom's RunState (height / width / oblique / tracking / font /
|
||||
// colour / decorations / valign).
|
||||
|
||||
#[derive(Clone)]
|
||||
enum AtomKind {
|
||||
Word(String),
|
||||
Space,
|
||||
Tab,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LayoutAtom {
|
||||
kind: AtomKind,
|
||||
state: RunState,
|
||||
}
|
||||
|
||||
fn run_scale(state: &RunState, entity_h: f32, base_wf: f32) -> f32 {
|
||||
(state.height_mul * entity_h / 9.0) * (state.width_mul * base_wf.abs())
|
||||
}
|
||||
|
||||
fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> &'a str {
|
||||
state.font.as_deref().unwrap_or(base)
|
||||
}
|
||||
|
||||
fn measure_word(text: &str, state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
|
||||
let scale = run_scale(state, entity_h, base_wf);
|
||||
let font_name = resolve_font(state, base_font);
|
||||
let font = cxf::get_font(font_name);
|
||||
let mut w = 0.0_f32;
|
||||
for ch in text.chars() {
|
||||
w += match font.glyph(ch) {
|
||||
Some(g) => (g.advance + font.letter_spacing * state.tracking) * scale,
|
||||
None => (6.0 + font.letter_spacing * state.tracking) * scale,
|
||||
};
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
|
||||
let scale = run_scale(state, entity_h, base_wf);
|
||||
let font_name = resolve_font(state, base_font);
|
||||
cxf::get_font(font_name).word_spacing * scale
|
||||
}
|
||||
|
||||
fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
|
||||
match &atom.kind {
|
||||
AtomKind::Word(t) => measure_word(t, &atom.state, entity_h, base_wf, base_font),
|
||||
AtomKind::Space => measure_space(&atom.state, entity_h, base_wf, base_font),
|
||||
AtomKind::Tab => 0.0, // tabs jump the cursor explicitly during wrap / render
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor position after a `\t` atom: advance to the next user-defined tab
|
||||
/// stop that lies past `cur_x`, falling back to a 4-em default grid when no
|
||||
/// stop is reached.
|
||||
fn next_tab_position(cur_x: f32, tab_stops: &[TabStop], indent_left: f32, entity_h: f32) -> f32 {
|
||||
let local = cur_x - indent_left; // tabs are measured from the content area's left edge
|
||||
for ts in tab_stops {
|
||||
if ts.position > local + 1e-4 {
|
||||
return indent_left + ts.position;
|
||||
}
|
||||
}
|
||||
let default_interval = entity_h * 4.0; // 4 em fallback, matches AutoCAD's "no tab stops"
|
||||
let n = (local / default_interval).floor() + 1.0;
|
||||
indent_left + n * default_interval
|
||||
}
|
||||
|
||||
/// Break a flat MText paragraph atom stream into wrap-fit sub-lines.
|
||||
fn wrap_paragraph(
|
||||
atoms: Vec<LayoutAtom>,
|
||||
rect_w: f32,
|
||||
indent_first: f32,
|
||||
indent_left: f32,
|
||||
indent_right: f32,
|
||||
tab_stops: &[TabStop],
|
||||
entity_h: f32,
|
||||
base_wf: f32,
|
||||
base_font: &str,
|
||||
) -> Vec<Vec<LayoutAtom>> {
|
||||
if rect_w <= 0.0 {
|
||||
return vec![atoms];
|
||||
}
|
||||
let mut sublines: Vec<Vec<LayoutAtom>> = Vec::new();
|
||||
let mut cur: Vec<LayoutAtom> = Vec::new();
|
||||
let mut cur_w = 0.0_f32;
|
||||
let mut subline_idx: usize = 0;
|
||||
let line_start_x = |idx: usize| if idx == 0 { indent_first } else { indent_left };
|
||||
let line_max_w = |idx: usize| (rect_w - indent_right - line_start_x(idx)).max(0.0);
|
||||
|
||||
for atom in atoms {
|
||||
match &atom.kind {
|
||||
AtomKind::Word(_) => {
|
||||
let w = atom_width(&atom, entity_h, base_wf, base_font);
|
||||
let max_w = line_max_w(subline_idx);
|
||||
if !cur.is_empty() && cur_w + w > max_w {
|
||||
while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) {
|
||||
cur.pop();
|
||||
}
|
||||
sublines.push(std::mem::take(&mut cur));
|
||||
cur_w = 0.0;
|
||||
subline_idx += 1;
|
||||
}
|
||||
cur.push(atom);
|
||||
cur_w += w;
|
||||
}
|
||||
AtomKind::Space => {
|
||||
if cur.is_empty() {
|
||||
continue;
|
||||
}
|
||||
cur_w += atom_width(&atom, entity_h, base_wf, base_font);
|
||||
cur.push(atom);
|
||||
}
|
||||
AtomKind::Tab => {
|
||||
let start_x = line_start_x(subline_idx);
|
||||
let new_w = next_tab_position(cur_w + start_x, tab_stops, indent_left, entity_h)
|
||||
- start_x;
|
||||
let max_w = line_max_w(subline_idx);
|
||||
if new_w > max_w && !cur.is_empty() {
|
||||
sublines.push(std::mem::take(&mut cur));
|
||||
cur_w = 0.0;
|
||||
subline_idx += 1;
|
||||
} else {
|
||||
cur.push(atom);
|
||||
cur_w = new_w.min(max_w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
sublines.push(cur);
|
||||
}
|
||||
if sublines.is_empty() {
|
||||
sublines.push(Vec::new());
|
||||
}
|
||||
sublines
|
||||
}
|
||||
|
||||
fn line_total_width(
|
||||
atoms: &[LayoutAtom],
|
||||
entity_h: f32,
|
||||
base_wf: f32,
|
||||
base_font: &str,
|
||||
line_start_x: f32,
|
||||
indent_left: f32,
|
||||
tab_stops: &[TabStop],
|
||||
) -> f32 {
|
||||
let mut x = line_start_x;
|
||||
for atom in atoms {
|
||||
match atom.kind {
|
||||
AtomKind::Tab => {
|
||||
x = next_tab_position(x, tab_stops, indent_left, entity_h);
|
||||
}
|
||||
_ => x += atom_width(atom, entity_h, base_wf, base_font),
|
||||
}
|
||||
}
|
||||
x - line_start_x
|
||||
}
|
||||
|
||||
fn resolve_inline_color(c: &InlineColor) -> Option<[f32; 3]> {
|
||||
match c {
|
||||
InlineColor::Aci(idx) => {
|
||||
aci_to_rgb(*idx).map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0])
|
||||
}
|
||||
InlineColor::True(rgb) => Some(*rgb),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a run's glyph text with MTEXT decoration markers so cxf's
|
||||
/// `tessellate_text_run` emits the underline / overline / strikethrough
|
||||
/// strokes for us — keeps decoration geometry in one place rather than
|
||||
/// duplicating the y-position constants.
|
||||
fn decorated(text: &str, state: &RunState) -> String {
|
||||
if !(state.underline || state.overline || state.strike) {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut s = String::with_capacity(text.len() + 6);
|
||||
if state.underline {
|
||||
s.push_str("\\L");
|
||||
}
|
||||
if state.overline {
|
||||
s.push_str("\\O");
|
||||
}
|
||||
if state.strike {
|
||||
s.push_str("\\K");
|
||||
}
|
||||
s.push_str(text);
|
||||
if state.underline {
|
||||
s.push_str("\\l");
|
||||
}
|
||||
if state.overline {
|
||||
s.push_str("\\o");
|
||||
}
|
||||
if state.strike {
|
||||
s.push_str("\\k");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
|
||||
let resolved_style = resolve_text_style(&t.style, document);
|
||||
let font_name = resolved_style.font_name;
|
||||
let font = cxf::get_font(&font_name);
|
||||
// MText has no entity-level width/oblique override (those are MText format
|
||||
// codes \W and \Q inside the value string, handled by `strip_mtext_codes`).
|
||||
// Default rendering inherits both from the style. `is_backward` flips the
|
||||
// width-factor sign so text reads right-to-left.
|
||||
let base_wf = resolved_style.width_factor.max(0.01);
|
||||
let style_width_factor = if resolved_style.is_backward { -base_wf } else { base_wf };
|
||||
let style_oblique = resolved_style.oblique_angle;
|
||||
let plain = strip_mtext_codes(&t.value);
|
||||
let explicit_lines = split_mtext_lines(&plain);
|
||||
let lines: Vec<String> = if t.rectangle_width > 0.0 {
|
||||
// Measurement uses the magnitude only; the sign on style_width_factor
|
||||
// is a render-time mirror flag, not a width scalar.
|
||||
let scale = t.height as f32 / 9.0 * style_width_factor.abs();
|
||||
let max_w = t.rectangle_width as f32;
|
||||
explicit_lines
|
||||
let base_font_name = resolved_style.font_name.clone();
|
||||
let base_font = cxf::get_font(&base_font_name);
|
||||
let base_wf_abs = resolved_style.width_factor.max(0.01);
|
||||
let base_wf = if resolved_style.is_backward { -base_wf_abs } else { base_wf_abs };
|
||||
let base_oblique = resolved_style.oblique_angle;
|
||||
let entity_h = t.height as f32;
|
||||
let rect_w = t.rectangle_width as f32;
|
||||
|
||||
// ── 1. Parse ────────────────────────────────────────────────────────────
|
||||
let paragraphs = parse_mtext_paragraphs(&t.value, entity_h);
|
||||
|
||||
// ── 2. Atomise + wrap each paragraph into sub-lines ─────────────────────
|
||||
struct SubLine {
|
||||
atoms: Vec<LayoutAtom>,
|
||||
align: Option<ParagraphAlign>,
|
||||
indent_first: f32,
|
||||
indent_left: f32,
|
||||
indent_right: f32,
|
||||
tab_stops: Vec<TabStop>,
|
||||
is_first_in_paragraph: bool,
|
||||
}
|
||||
|
||||
let mut sub_lines: Vec<SubLine> = Vec::new();
|
||||
for para in ¶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()
|
||||
.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<TextStroke> = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
|
||||
// ── 4. Render each sub-line ─────────────────────────────────────────────
|
||||
for (i, sub) in sub_lines.iter().enumerate() {
|
||||
let li = i as f32;
|
||||
// Compute line offset as f32 (small relative values), apply to f64 insertion point.
|
||||
let (small_x, small_y) = if vertical_text {
|
||||
let col_offset = li * t.height as f32 * 1.2;
|
||||
let (line_base_x, line_base_y) = if vertical_text {
|
||||
let col_offset = li * entity_h * 1.2;
|
||||
(
|
||||
col_offset * cos_r + v_offset * (-sin_r),
|
||||
col_offset * sin_r + v_offset * cos_r,
|
||||
)
|
||||
} else {
|
||||
let line_y = -(li * line_h) + v_offset;
|
||||
(line_y * (-sin_r), line_y * cos_r)
|
||||
let ly = -(li * line_h) + v_offset;
|
||||
(ly * (-sin_r), ly * cos_r)
|
||||
};
|
||||
let line_w = if h_anchor > 0.0 {
|
||||
let scale = t.height as f32 / 9.0 * style_width_factor.abs();
|
||||
measure_mtext_chars(line, scale, font)
|
||||
|
||||
// Paragraph-content area: [content_left, content_right] relative to
|
||||
// insertion point. With rect_w == 0 we have no box, so fall back to
|
||||
// anchoring at the insertion point (legacy behaviour).
|
||||
let content_left = if rect_w > 0.0 {
|
||||
box_left + if sub.is_first_in_paragraph { sub.indent_first } else { sub.indent_left }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let h_shift = -line_w * h_anchor;
|
||||
let origin: [f64; 2] = [
|
||||
ins_x + (small_x + h_shift * cos_r) as f64,
|
||||
ins_y + (small_y + h_shift * sin_r) as f64,
|
||||
];
|
||||
let strokes = cxf::tessellate_text_ex(
|
||||
[0.0, 0.0],
|
||||
t.height as f32,
|
||||
rot,
|
||||
style_width_factor,
|
||||
style_oblique,
|
||||
&font_name,
|
||||
line,
|
||||
let content_right = if rect_w > 0.0 {
|
||||
box_left + rect_w - sub.indent_right
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Effective horizontal anchor for this sub-line: paragraph alignment
|
||||
// (when explicitly set inline) wins, otherwise inherit from the
|
||||
// entity attachment.
|
||||
let line_anchor: f32 = match sub.align {
|
||||
Some(ParagraphAlign::Left)
|
||||
| Some(ParagraphAlign::Justify)
|
||||
| Some(ParagraphAlign::Distribute) => 0.0,
|
||||
Some(ParagraphAlign::Center) => 0.5,
|
||||
Some(ParagraphAlign::Right) => 1.0,
|
||||
None => attach_h_anchor,
|
||||
};
|
||||
|
||||
let line_w = line_total_width(
|
||||
&sub.atoms,
|
||||
entity_h,
|
||||
base_wf,
|
||||
&base_font_name,
|
||||
0.0,
|
||||
sub.indent_left,
|
||||
&sub.tab_stops,
|
||||
);
|
||||
all_strokes.push(TextStroke { strokes, origin });
|
||||
|
||||
// Cursor X (relative to insertion point, pre-rotation) where the
|
||||
// first atom starts. With a box: lay the line inside the paragraph
|
||||
// content area at `line_anchor`. Without a box: anchor the line at
|
||||
// the insertion point using the entity's attachment.
|
||||
let cursor_start = if rect_w > 0.0 {
|
||||
let content_w = (content_right - content_left).max(0.0);
|
||||
content_left + (content_w - line_w) * line_anchor
|
||||
} else if line_anchor > 0.0 {
|
||||
-line_w * line_anchor
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Line's tallest run height (for valign offsets).
|
||||
let line_max_h = sub
|
||||
.atoms
|
||||
.iter()
|
||||
.map(|a| a.state.height_mul * entity_h)
|
||||
.fold(entity_h, f32::max);
|
||||
|
||||
// Walk atoms left → right, emitting one TextStroke per Word atom.
|
||||
let mut cursor_x = cursor_start;
|
||||
for atom in &sub.atoms {
|
||||
match &atom.kind {
|
||||
AtomKind::Word(text) => {
|
||||
// Per-atom render parameters (composed with style baseline).
|
||||
let run_h = atom.state.height_mul * entity_h;
|
||||
let signed_wf = base_wf.signum() * atom.state.width_mul * base_wf.abs();
|
||||
let oblique = base_oblique + atom.state.oblique_rad;
|
||||
let font_name = resolve_font(&atom.state, &base_font_name);
|
||||
let tracking = atom.state.tracking;
|
||||
let valign_dy = match atom.state.valign {
|
||||
1 => (line_max_h - run_h) * 0.5,
|
||||
2 => line_max_h - run_h,
|
||||
_ => 0.0,
|
||||
};
|
||||
let color = atom.state.color.as_ref().and_then(resolve_inline_color);
|
||||
let body = decorated(text, &atom.state);
|
||||
|
||||
// Translate (cursor_x, valign_dy) into world space at this
|
||||
// sub-line's baseline.
|
||||
let lx = cursor_x;
|
||||
let ly = valign_dy;
|
||||
let world_dx = lx * cos_r - ly * sin_r;
|
||||
let world_dy = lx * sin_r + ly * cos_r;
|
||||
let origin: [f64; 2] = [
|
||||
ins_x + (line_base_x + world_dx) as f64,
|
||||
ins_y + (line_base_y + world_dy) as f64,
|
||||
];
|
||||
let strokes = cxf::tessellate_text_run(
|
||||
[0.0, 0.0],
|
||||
run_h,
|
||||
rot,
|
||||
signed_wf,
|
||||
oblique,
|
||||
tracking,
|
||||
font_name,
|
||||
&body,
|
||||
);
|
||||
all_strokes.push(TextStroke {
|
||||
strokes,
|
||||
origin,
|
||||
color,
|
||||
});
|
||||
cursor_x += measure_word(text, &atom.state, entity_h, base_wf, &base_font_name);
|
||||
}
|
||||
AtomKind::Space => {
|
||||
cursor_x += measure_space(&atom.state, entity_h, base_wf, &base_font_name);
|
||||
}
|
||||
AtomKind::Tab => {
|
||||
cursor_x = next_tab_position(
|
||||
cursor_x,
|
||||
&sub.tab_stops,
|
||||
sub.indent_left,
|
||||
entity_h,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TruckEntity {
|
||||
object: TruckObject::Text(all_strokes),
|
||||
snap_pts: vec![(insertion, SnapHint::Insertion)],
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -176,6 +176,542 @@ pub fn resolve_dxf_special_chars(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Rich MTEXT parser — full inline format-code coverage
|
||||
//
|
||||
// Recognised codes (DXF MTEXT inline):
|
||||
// Escapes: \\ \{ \} \~ \t \P \n \N \U+XXXX \u+XXXX
|
||||
// Toggles: \L\l \O\o \K\k (underline / overline / strike)
|
||||
// State: \H<v>[x]; \W<v>[x]; \Q<v>; \T<v>[x]; \A<n>;
|
||||
// \C<aci>; \c<rgb>;
|
||||
// \f<name>|b<0/1>|i<0/1>|c<n>|p<n>; \F<file>;
|
||||
// \M+<n>; \X \S<u><sep><l>;
|
||||
// Paragraph: \p[xi<v>,l<v>,r<v>,q[lcrjd],t<positions>,s<v>...];
|
||||
// Scope: { ... } push/pop full state
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Paragraph alignment encoded inline via `\p...q[lcrjd]...;`.
|
||||
/// `Justify` / `Distribute` render as `Left` (full inter-word redistribution
|
||||
/// is not implemented in the stroke renderer).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ParagraphAlign {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
Distribute,
|
||||
}
|
||||
|
||||
/// Inline colour override (`\C` ACI or `\c` 24-bit true colour). Resolved to
|
||||
/// linear RGB at render time via the document's ACI table.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum InlineColor {
|
||||
Aci(u8),
|
||||
True([f32; 3]),
|
||||
}
|
||||
|
||||
/// Tab-stop alignment kind (from `\pt<L|C|R><pos>` entries).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TabKind {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TabStop {
|
||||
pub position: f32,
|
||||
pub kind: TabKind,
|
||||
}
|
||||
|
||||
/// Per-run formatting state. All fields are multipliers / overrides relative
|
||||
/// to the entity-level defaults; the renderer composes them with the resolved
|
||||
/// text style at draw time.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RunState {
|
||||
/// Multiplier on entity text height (`\H<v>x;` → ×v; `\H<v>;` → v / entity_h)
|
||||
pub height_mul: f32,
|
||||
/// Multiplier on the (signed) style width-factor (`\W<v>;` → set, `\Wx;` → ×)
|
||||
pub width_mul: f32,
|
||||
/// Absolute oblique angle override in radians (`\Q<deg>;`)
|
||||
pub oblique_rad: f32,
|
||||
/// Tracking multiplier on `font.letter_spacing` (`\T<v>;`)
|
||||
pub tracking: f32,
|
||||
/// Vertical alignment of the run within its line box (0=baseline / 1=center
|
||||
/// / 2=top). Mainly used for fractions and superscript-like layout (`\A`).
|
||||
pub valign: u8,
|
||||
/// Font-name override, `None` ⇒ inherit the resolved style font.
|
||||
pub font: Option<String>,
|
||||
/// Colour override, `None` ⇒ inherit entity colour.
|
||||
pub color: Option<InlineColor>,
|
||||
pub underline: bool,
|
||||
pub overline: bool,
|
||||
pub strike: bool,
|
||||
}
|
||||
|
||||
impl Default for RunState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
height_mul: 1.0,
|
||||
width_mul: 1.0,
|
||||
oblique_rad: 0.0,
|
||||
tracking: 1.0,
|
||||
valign: 0,
|
||||
font: None,
|
||||
color: None,
|
||||
underline: false,
|
||||
overline: false,
|
||||
strike: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MTextRunKind {
|
||||
/// Renderable glyph text (DXF specials resolved, decoration markers stripped).
|
||||
Glyphs(String),
|
||||
/// `\t` — jump the cursor to the next tab stop (or default tab interval).
|
||||
Tab,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MTextRun {
|
||||
pub kind: MTextRunKind,
|
||||
pub state: RunState,
|
||||
}
|
||||
|
||||
/// One paragraph of MTEXT after parsing. Each line is a sequence of runs that
|
||||
/// share text content + a snapshot of formatting state, plus paragraph-level
|
||||
/// layout (alignment, indents, tab stops). `\P` / `\n` / `\N` start a new
|
||||
/// line; paragraph properties carry forward until the next `\p...;` block.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MTextLine {
|
||||
pub runs: Vec<MTextRun>,
|
||||
pub align: Option<ParagraphAlign>,
|
||||
pub indent_first: f32,
|
||||
pub indent_left: f32,
|
||||
pub indent_right: f32,
|
||||
pub tab_stops: Vec<TabStop>,
|
||||
}
|
||||
|
||||
impl MTextLine {
|
||||
/// Concatenated glyph text across all runs (decorations / tabs ignored).
|
||||
/// Useful for callers that only need an unstyled string (search, hit-test
|
||||
/// previews, fallback paths).
|
||||
pub fn plain_text(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for r in &self.runs {
|
||||
if let MTextRunKind::Glyphs(t) = &r.kind {
|
||||
s.push_str(t);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn is_blank(&self) -> bool {
|
||||
self.runs.iter().all(|r| match &r.kind {
|
||||
MTextRunKind::Glyphs(t) => t.trim().is_empty(),
|
||||
MTextRunKind::Tab => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct ParagraphProps {
|
||||
align: Option<ParagraphAlign>,
|
||||
indent_first: f32,
|
||||
indent_left: f32,
|
||||
indent_right: f32,
|
||||
tab_stops: Vec<TabStop>,
|
||||
}
|
||||
|
||||
/// Parse a `\p...;` body. Comma-separated tokens, each with a single-letter
|
||||
/// kind prefix. Unknown tokens are skipped — anything we don't understand is
|
||||
/// silently dropped rather than poisoning the rest of the paragraph.
|
||||
fn parse_paragraph_block(body: &str, props: &mut ParagraphProps) {
|
||||
// The legacy AutoCAD writer prefixes the block with a redundant `x`; skip
|
||||
// it so it doesn't confuse the kind matcher.
|
||||
let body = body.strip_prefix('x').unwrap_or(body);
|
||||
for token in body.split(',') {
|
||||
let token = token.trim();
|
||||
let mut chars = token.chars();
|
||||
let Some(kind) = chars.next() else { continue };
|
||||
let rest: String = chars.collect();
|
||||
match kind {
|
||||
'q' | 'Q' => {
|
||||
let sel = rest.chars().next().map(|c| c.to_ascii_lowercase());
|
||||
props.align = match sel {
|
||||
Some('l') => Some(ParagraphAlign::Left),
|
||||
Some('c') => Some(ParagraphAlign::Center),
|
||||
Some('r') => Some(ParagraphAlign::Right),
|
||||
Some('j') => Some(ParagraphAlign::Justify),
|
||||
Some('d') => Some(ParagraphAlign::Distribute),
|
||||
_ => props.align,
|
||||
};
|
||||
}
|
||||
'i' => {
|
||||
if let Ok(v) = rest.parse::<f32>() {
|
||||
props.indent_first = v;
|
||||
}
|
||||
}
|
||||
'l' => {
|
||||
if let Ok(v) = rest.parse::<f32>() {
|
||||
props.indent_left = v;
|
||||
}
|
||||
}
|
||||
'r' => {
|
||||
if let Ok(v) = rest.parse::<f32>() {
|
||||
props.indent_right = v;
|
||||
}
|
||||
}
|
||||
't' => {
|
||||
// Tab list. Each entry may be prefixed with L / C / R to
|
||||
// pick the tab kind; default is Left. The remainder is the
|
||||
// position in drawing units.
|
||||
props.tab_stops.clear();
|
||||
for entry in rest.split(',') {
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (kind, num_str) = match entry.chars().next() {
|
||||
Some(c @ ('L' | 'l')) => (TabKind::Left, &entry[c.len_utf8()..]),
|
||||
Some(c @ ('C' | 'c')) => (TabKind::Center, &entry[c.len_utf8()..]),
|
||||
Some(c @ ('R' | 'r')) => (TabKind::Right, &entry[c.len_utf8()..]),
|
||||
_ => (TabKind::Left, entry),
|
||||
};
|
||||
if let Ok(p) = num_str.parse::<f32>() {
|
||||
props.tab_stops.push(TabStop { position: p, kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
's' => {} // space-before — affects line spacing; ignored for now
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an unsigned 24-bit true-colour value into linear-ish [r,g,b] floats.
|
||||
/// AutoCAD packs `\c` as a 24-bit decimal: high byte = R, mid = G, low = B.
|
||||
fn parse_true_color(s: &str) -> Option<InlineColor> {
|
||||
let n: u32 = s.trim().parse().ok()?;
|
||||
let r = ((n >> 16) & 0xFF) as f32 / 255.0;
|
||||
let g = ((n >> 8) & 0xFF) as f32 / 255.0;
|
||||
let b = (n & 0xFF) as f32 / 255.0;
|
||||
Some(InlineColor::True([r, g, b]))
|
||||
}
|
||||
|
||||
/// Parse `\H` / `\W` / `\T` value. Returns `(value, is_relative)`; a trailing
|
||||
/// `x` marks a multiplier on the current state, otherwise the value is the
|
||||
/// absolute target (interpreted by the caller against entity defaults).
|
||||
fn parse_scalar_with_x_suffix(body: &str) -> Option<(f32, bool)> {
|
||||
let body = body.trim();
|
||||
let (num, is_rel) = if let Some(stripped) = body.strip_suffix('x').or_else(|| body.strip_suffix('X')) {
|
||||
(stripped, true)
|
||||
} else {
|
||||
(body, false)
|
||||
};
|
||||
Some((num.trim().parse::<f32>().ok()?, is_rel))
|
||||
}
|
||||
|
||||
/// Flush the glyph buffer as a `Glyphs` run on `line` using a snapshot of
|
||||
/// `state`. Resolves DXF `%%x` specials (degree / diameter / `%%nnn` literal
|
||||
/// unicode) so the renderer sees fully decoded text.
|
||||
fn flush_glyph_buf(line: &mut MTextLine, buf: &mut String, state: &RunState) {
|
||||
if buf.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text = resolve_dxf_special_chars(&std::mem::take(buf));
|
||||
line.runs.push(MTextRun {
|
||||
kind: MTextRunKind::Glyphs(text),
|
||||
state: state.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Walk the MTEXT value string and produce one [`MTextLine`] per visible
|
||||
/// paragraph (after stripping leading / trailing blank lines, as the legacy
|
||||
/// `split_mtext_lines` does). Every inline format code listed in the module
|
||||
/// header is recognised; unknown semicolon-terminated codes are stripped
|
||||
/// silently so future / vendor-specific extensions don't pollute the text.
|
||||
///
|
||||
/// `entity_height` is needed to translate absolute `\H<v>;` declarations into
|
||||
/// the height-multiplier representation carried in [`RunState`]; pass the
|
||||
/// MTEXT entity's `height` field.
|
||||
pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec<MTextLine> {
|
||||
let mut lines: Vec<MTextLine> = Vec::new();
|
||||
let mut current = MTextLine::default();
|
||||
let mut buf = String::new();
|
||||
let mut state = RunState::default();
|
||||
let mut state_stack: Vec<RunState> = Vec::new();
|
||||
let mut props = ParagraphProps::default();
|
||||
// No props_stack — paragraph props persist across braces by design.
|
||||
let entity_height = entity_height.max(1e-6);
|
||||
|
||||
let mut chars = s.chars().peekable();
|
||||
|
||||
let read_until_semi = |chars: &mut std::iter::Peekable<std::str::Chars>| -> String {
|
||||
let mut out = String::new();
|
||||
for c in chars.by_ref() {
|
||||
if c == ';' {
|
||||
break;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\\' => match chars.peek().copied() {
|
||||
// ── Line / paragraph break ────────────────────────────────
|
||||
Some('P') | Some('n') | Some('N') => {
|
||||
chars.next();
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
current.align = props.align;
|
||||
current.indent_first = props.indent_first;
|
||||
current.indent_left = props.indent_left;
|
||||
current.indent_right = props.indent_right;
|
||||
current.tab_stops = props.tab_stops.clone();
|
||||
lines.push(std::mem::take(&mut current));
|
||||
}
|
||||
// ── Whitespace literals ───────────────────────────────────
|
||||
Some('~') => {
|
||||
chars.next();
|
||||
buf.push('\u{00A0}'); // nbsp — treated as a regular space by the wrap pass
|
||||
}
|
||||
Some('t') => {
|
||||
chars.next();
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
current.runs.push(MTextRun {
|
||||
kind: MTextRunKind::Tab,
|
||||
state: state.clone(),
|
||||
});
|
||||
}
|
||||
// ── Unicode by hex code point ────────────────────────────
|
||||
Some('U') | Some('u') => {
|
||||
chars.next();
|
||||
if chars.peek() == Some(&'+') {
|
||||
chars.next();
|
||||
let mut hex = String::with_capacity(6);
|
||||
for _ in 0..6 {
|
||||
match chars.peek() {
|
||||
Some(&c) if c.is_ascii_hexdigit() => {
|
||||
hex.push(chars.next().unwrap());
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if chars.peek() == Some(&';') {
|
||||
chars.next();
|
||||
}
|
||||
if let Ok(n) = u32::from_str_radix(&hex, 16) {
|
||||
if let Some(c) = char::from_u32(n) {
|
||||
buf.push(c);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Bare `\U` / `\u` — strip to next `;`
|
||||
let _ = read_until_semi(&mut chars);
|
||||
}
|
||||
}
|
||||
// ── Stacked text \S<u><sep><l>; ──────────────────────────
|
||||
Some('S') | Some('s') => {
|
||||
chars.next();
|
||||
let mut upper = String::new();
|
||||
let mut lower = String::new();
|
||||
let mut sep = '/';
|
||||
let mut in_lower = false;
|
||||
for c in chars.by_ref() {
|
||||
if c == ';' {
|
||||
break;
|
||||
}
|
||||
if !in_lower && (c == '/' || c == '^' || c == '#') {
|
||||
sep = c;
|
||||
in_lower = true;
|
||||
} else if in_lower {
|
||||
lower.push(c);
|
||||
} else {
|
||||
upper.push(c);
|
||||
}
|
||||
}
|
||||
buf.push_str(&upper);
|
||||
if !lower.is_empty() {
|
||||
buf.push(if sep == '#' { '/' } else { sep });
|
||||
buf.push_str(&lower);
|
||||
}
|
||||
}
|
||||
// ── Decoration toggles (state, not markers) ──────────────
|
||||
Some('L') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = true; }
|
||||
Some('l') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = false; }
|
||||
Some('O') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = true; }
|
||||
Some('o') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = false; }
|
||||
Some('K') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = true; }
|
||||
Some('k') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = false; }
|
||||
// ── Literal backslash / braces ───────────────────────────
|
||||
Some('\\') => { chars.next(); buf.push('\\'); }
|
||||
Some('{') | Some('}') => { buf.push(chars.next().unwrap()); }
|
||||
// ── Paragraph props ──────────────────────────────────────
|
||||
Some('p') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
parse_paragraph_block(&body, &mut props);
|
||||
}
|
||||
// ── Height ───────────────────────────────────────────────
|
||||
Some('H') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
if is_rel {
|
||||
state.height_mul *= v;
|
||||
} else {
|
||||
state.height_mul = v / entity_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Width factor ─────────────────────────────────────────
|
||||
Some('W') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
if is_rel {
|
||||
state.width_mul *= v;
|
||||
} else {
|
||||
state.width_mul = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Oblique angle (degrees → radians) ────────────────────
|
||||
Some('Q') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Ok(deg) = body.trim().parse::<f32>() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state.oblique_rad = deg.to_radians();
|
||||
}
|
||||
}
|
||||
// ── Tracking ─────────────────────────────────────────────
|
||||
Some('T') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
if is_rel {
|
||||
state.tracking *= v;
|
||||
} else {
|
||||
state.tracking = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Vertical alignment ───────────────────────────────────
|
||||
Some('A') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Ok(n) = body.trim().parse::<u8>() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state.valign = n.min(2);
|
||||
}
|
||||
}
|
||||
// ── ACI colour ───────────────────────────────────────────
|
||||
Some('C') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Ok(n) = body.trim().parse::<u32>() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state.color = Some(InlineColor::Aci(n.min(255) as u8));
|
||||
}
|
||||
}
|
||||
// ── True colour ──────────────────────────────────────────
|
||||
Some('c') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Some(col) = parse_true_color(&body) {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state.color = Some(col);
|
||||
}
|
||||
}
|
||||
// ── Font (name + b/i/c/p flags) ──────────────────────────
|
||||
Some('f') | Some('F') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
// First `|`-separated field is the font name / file stem.
|
||||
let name = body.split('|').next().unwrap_or("").trim();
|
||||
if !name.is_empty() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
// Strip extension if `\F` passed a file path.
|
||||
let stem = name
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(name)
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(name);
|
||||
state.font = Some(stem.to_string());
|
||||
}
|
||||
}
|
||||
// ── Multibyte / codepage marker — strip silently ─────────
|
||||
Some('M') => {
|
||||
chars.next();
|
||||
let _ = read_until_semi(&mut chars);
|
||||
}
|
||||
// ── Dimension MTEXT paragraph-end marker — strip silently
|
||||
Some('X') => {
|
||||
chars.next();
|
||||
}
|
||||
// ── Unknown single-letter escape ─────────────────────────
|
||||
Some(_) => {
|
||||
chars.next();
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
// Scope push / pop — braces scope *character* state (font,
|
||||
// colour, height, decorations, …). Paragraph properties
|
||||
// (`\p...;`) are intentionally NOT scoped: AutoCAD treats them
|
||||
// as paragraph-level layout that persists across braces, and
|
||||
// real-world files routinely wrap inline `\pxqc;` inside a
|
||||
// `{\fArial;…}` block while expecting the alignment to apply
|
||||
// to the whole paragraph.
|
||||
'{' => {
|
||||
state_stack.push(state.clone());
|
||||
}
|
||||
'}' => {
|
||||
if let Some(prev) = state_stack.pop() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state = prev;
|
||||
}
|
||||
}
|
||||
'\r' => {}
|
||||
'\n' => {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
current.align = props.align;
|
||||
current.indent_first = props.indent_first;
|
||||
current.indent_left = props.indent_left;
|
||||
current.indent_right = props.indent_right;
|
||||
current.tab_stops = props.tab_stops.clone();
|
||||
lines.push(std::mem::take(&mut current));
|
||||
}
|
||||
other => buf.push(other),
|
||||
}
|
||||
}
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
current.align = props.align;
|
||||
current.indent_first = props.indent_first;
|
||||
current.indent_left = props.indent_left;
|
||||
current.indent_right = props.indent_right;
|
||||
current.tab_stops = props.tab_stops.clone();
|
||||
lines.push(current);
|
||||
|
||||
let start = lines.iter().position(|l| !l.is_blank()).unwrap_or(0);
|
||||
let end = lines
|
||||
.iter()
|
||||
.rposition(|l| !l.is_blank())
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
lines[start..end].to_vec()
|
||||
}
|
||||
|
||||
pub fn strip_mtext_codes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -10,9 +10,15 @@ use crate::scene::wire_model::{SnapHint, TangentGeom};
|
||||
/// One group of glyph strokes with its world-space origin stored in f64.
|
||||
/// Strokes are in glyph-local space (origin = [0,0]) so that the large
|
||||
/// world offset can be subtracted with f64 precision in tessellate.rs.
|
||||
///
|
||||
/// `color`, when set, overrides the entity colour for just this group — used
|
||||
/// by MTEXT inline `\C` / `\c` per-run colour. Strokes sharing the same
|
||||
/// (color, None) override are merged into one WireModel downstream; runs with
|
||||
/// distinct colours emit their own WireModel.
|
||||
pub struct TextStroke {
|
||||
pub strokes: Vec<Vec<[f32; 2]>>,
|
||||
pub origin: [f64; 2],
|
||||
pub color: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -401,9 +401,32 @@ fn tessellate_sub_local(
|
||||
// Pass `local_offset` as the f64 world-offset so tessellate subtracts it
|
||||
// before casting to f32 — same precision-preservation trick used for
|
||||
// top-level entities, applied per-defn.
|
||||
let wire = tessellate::tessellate(
|
||||
//
|
||||
// tessellate() may emit multiple WireModels for a single sub-entity (e.g.
|
||||
// MTEXT with inline `\C` / `\c` colour switches splits one entity into
|
||||
// one wire per colour group). The block-defn cache stores a single
|
||||
// LocalWire per sub-entity, so we fold the wires into one — points get
|
||||
// concatenated with NaN separators, the primary colour comes from the
|
||||
// first wire. Per-segment colour is lost inside cached block defns; for
|
||||
// top-level MTEXT outside blocks the colour split is preserved via the
|
||||
// hot path in `tessellate_entity`.
|
||||
let mut wires_out = tessellate::tessellate(
|
||||
doc, h, sub, false, sub_color, pat_len, pat, lw_px, local_offset, anno_scale,
|
||||
);
|
||||
if wires_out.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut wire = wires_out.remove(0);
|
||||
for extra in wires_out {
|
||||
if !wire.points.is_empty() && !extra.points.is_empty() {
|
||||
wire.points.push([f32::NAN, f32::NAN, f32::NAN]);
|
||||
}
|
||||
wire.points.extend(extra.points);
|
||||
wire.key_vertices.extend(extra.key_vertices);
|
||||
wire.snap_pts.extend(extra.snap_pts);
|
||||
wire.tangent_geoms.extend(extra.tangent_geoms);
|
||||
wire.fill_tris.extend(extra.fill_tris);
|
||||
}
|
||||
|
||||
if wire.points.len() > 100_000 {
|
||||
return None;
|
||||
|
||||
+30
-3
@@ -226,6 +226,33 @@ pub fn tessellate_text_ex(
|
||||
oblique_angle: f32,
|
||||
font_name: &str,
|
||||
text: &str,
|
||||
) -> Vec<Vec<[f32; 2]>> {
|
||||
tessellate_text_run(
|
||||
origin,
|
||||
height,
|
||||
rotation,
|
||||
width_factor,
|
||||
oblique_angle,
|
||||
1.0,
|
||||
font_name,
|
||||
text,
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`tessellate_text_ex`] but takes a `tracking` multiplier applied to
|
||||
/// the per-glyph `letter_spacing` advance. `tracking = 1.0` keeps the legacy
|
||||
/// behaviour; MTEXT's `\T<v>;` inline code routes here so per-run character
|
||||
/// spacing scales independently of the width factor.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tessellate_text_run(
|
||||
origin: [f32; 2],
|
||||
height: f32,
|
||||
rotation: f32,
|
||||
width_factor: f32,
|
||||
oblique_angle: f32,
|
||||
tracking: f32,
|
||||
font_name: &str,
|
||||
text: &str,
|
||||
) -> Vec<Vec<[f32; 2]>> {
|
||||
if text.is_empty() || height <= 0.0 {
|
||||
return vec![];
|
||||
@@ -388,7 +415,7 @@ pub fn tessellate_text_ex(
|
||||
}
|
||||
} else {
|
||||
// Partial digit sequence — advance as unknown glyph and move on
|
||||
cursor_x += (6.0 + font.letter_spacing) * wf;
|
||||
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -417,11 +444,11 @@ pub fn tessellate_text_ex(
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
cursor_x += (glyph.advance + font.letter_spacing) * wf;
|
||||
cursor_x += (glyph.advance + font.letter_spacing * tracking) * wf;
|
||||
}
|
||||
None => {
|
||||
warn_missing_glyph(font_name, render_ch);
|
||||
cursor_x += (6.0 + font.letter_spacing) * wf;
|
||||
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-30
@@ -4777,7 +4777,7 @@ fn tessellate_entity(
|
||||
} else {
|
||||
(0.0_f32, [0.0f32; 8])
|
||||
};
|
||||
let mut wire = tessellate::tessellate(
|
||||
let mut wires = tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
e,
|
||||
@@ -4789,8 +4789,11 @@ fn tessellate_entity(
|
||||
world_offset,
|
||||
1.0,
|
||||
);
|
||||
wire.aabb = entity_aabb(e, world_offset);
|
||||
return vec![wire];
|
||||
let ab = entity_aabb(e, world_offset);
|
||||
for w in &mut wires {
|
||||
w.aabb = ab;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
|
||||
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
|
||||
@@ -5066,7 +5069,7 @@ fn tessellate_entity(
|
||||
let sub_aabb = entity_aabb(&sub, world_offset);
|
||||
let sub_pattern_length = sub_pattern_length * pslt_factor;
|
||||
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
|
||||
let mut wire = tessellate::tessellate(
|
||||
let mut wires = tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
&sub,
|
||||
@@ -5078,10 +5081,12 @@ fn tessellate_entity(
|
||||
world_offset,
|
||||
anno_scale,
|
||||
);
|
||||
wire.name = h.value().to_string();
|
||||
wire.aci = sub_aci;
|
||||
wire.aabb = sub_aabb;
|
||||
vec![wire]
|
||||
for w in &mut wires {
|
||||
w.name = h.value().to_string();
|
||||
w.aci = sub_aci;
|
||||
w.aabb = sub_aabb;
|
||||
}
|
||||
wires
|
||||
})
|
||||
.collect();
|
||||
append_insert_attribute_wires(
|
||||
@@ -5219,7 +5224,7 @@ fn tessellate_entity(
|
||||
}
|
||||
}
|
||||
|
||||
let mut base = tessellate::tessellate(
|
||||
let mut bases = tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
e,
|
||||
@@ -5231,28 +5236,37 @@ fn tessellate_entity(
|
||||
world_offset,
|
||||
anno_scale,
|
||||
);
|
||||
base.aci = aci;
|
||||
base.aabb = aabb;
|
||||
for b in &mut bases {
|
||||
b.aci = aci;
|
||||
b.aabb = aabb;
|
||||
}
|
||||
|
||||
// Complex linetypes (with embedded shapes / text) expand the *base*
|
||||
// polyline along its tangent. Text-type entities never have a complex
|
||||
// linetype assigned, so we only consult the first wire here — multi-wire
|
||||
// returns come exclusively from MTEXT colour splits which can't trigger
|
||||
// this path.
|
||||
if let Some(clt) = crate::linetypes::complex_lt(lt_name) {
|
||||
let mut wires = complex_lt::apply_along(
|
||||
&base.name,
|
||||
&base.points,
|
||||
clt,
|
||||
(lt_scale * pslt_factor).max(1e-4),
|
||||
entity_color,
|
||||
sel,
|
||||
base.line_weight_px,
|
||||
);
|
||||
if !wires.is_empty() {
|
||||
for w in &mut wires {
|
||||
w.aabb = aabb;
|
||||
if let Some(base) = bases.first() {
|
||||
let mut wires = complex_lt::apply_along(
|
||||
&base.name,
|
||||
&base.points,
|
||||
clt,
|
||||
(lt_scale * pslt_factor).max(1e-4),
|
||||
entity_color,
|
||||
sel,
|
||||
base.line_weight_px,
|
||||
);
|
||||
if !wires.is_empty() {
|
||||
for w in &mut wires {
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
|
||||
vec![base]
|
||||
bases
|
||||
}
|
||||
|
||||
/// Build the 4 OBB corners (CCW: bl, br, tr, tl) of a Text / MText entity
|
||||
@@ -5565,7 +5579,7 @@ fn append_insert_attribute_wires(
|
||||
sub_color
|
||||
};
|
||||
let sub_aabb = entity_aabb(&attr_entity, world_offset);
|
||||
let mut wire = tessellate::tessellate(
|
||||
let mut attr_wires = tessellate::tessellate(
|
||||
document,
|
||||
insert_handle,
|
||||
&attr_entity,
|
||||
@@ -5579,10 +5593,12 @@ fn append_insert_attribute_wires(
|
||||
);
|
||||
// Use the INSERT's handle so selection / picking groups attribute
|
||||
// text with the parent insert instead of treating it as a stray text.
|
||||
wire.name = insert_handle.value().to_string();
|
||||
wire.aci = sub_aci;
|
||||
wire.aabb = sub_aabb;
|
||||
wires.push(wire);
|
||||
for w in &mut attr_wires {
|
||||
w.name = insert_handle.value().to_string();
|
||||
w.aci = sub_aci;
|
||||
w.aabb = sub_aabb;
|
||||
}
|
||||
wires.extend(attr_wires);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-49
@@ -112,7 +112,7 @@ pub fn tessellate(
|
||||
line_weight_px: f32,
|
||||
world_offset: [f64; 3],
|
||||
anno_scale: f32,
|
||||
) -> WireModel {
|
||||
) -> Vec<WireModel> {
|
||||
let color = if selected {
|
||||
WireModel::SELECTED
|
||||
} else {
|
||||
@@ -145,7 +145,7 @@ pub fn tessellate(
|
||||
// MultiLeader is handled by scene/mod.rs since it emits multiple WireModels
|
||||
// (leader, text, frame, fill) with distinct colors.
|
||||
if let EntityType::Leader(leader) = entity {
|
||||
return tessellate_leader_single(
|
||||
return vec![tessellate_leader_single(
|
||||
document,
|
||||
handle,
|
||||
leader,
|
||||
@@ -154,74 +154,141 @@ pub fn tessellate(
|
||||
line_weight_px,
|
||||
world_offset,
|
||||
anno_scale,
|
||||
);
|
||||
)];
|
||||
}
|
||||
|
||||
// ── Try the truck path first ───────────────────────────────────────────
|
||||
if let Some(te) = convert(entity, document) {
|
||||
match te.object {
|
||||
// ── Text / MText: pre-tessellated glyph strokes ───────────────
|
||||
//
|
||||
// Strokes are pre-grouped by world origin (one TextStroke per
|
||||
// line / per run / per fragment), each carrying an optional
|
||||
// colour override produced by MTEXT inline `\C` / `\c`. We bin
|
||||
// groups by override colour and emit one WireModel per bin so a
|
||||
// single MTEXT can hand back N colour-distinct wires when the
|
||||
// value mixes inline colours.
|
||||
TruckObject::Text(stroke_groups) => {
|
||||
// Each TextStroke keeps its strokes in glyph-local space and
|
||||
// its world origin as f64. Subtract world_offset in f64 before
|
||||
// casting to f32 so large UTM coordinates don't crush precision.
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let elev = entity_z(entity) - oz as f32;
|
||||
|
||||
let mut points: Vec<[f32; 3]> = Vec::new();
|
||||
let mut first = true;
|
||||
// Annotation scale: scale glyph strokes relative to the text
|
||||
// insertion point (first group's origin) so multi-line MText
|
||||
// lines spread apart correctly as well as growing in size.
|
||||
// anno_scale anchors at the first group's origin so multi-line
|
||||
// MText lines spread apart correctly as they grow.
|
||||
let ref_origin = stroke_groups
|
||||
.first()
|
||||
.map(|g| g.origin)
|
||||
.unwrap_or([0.0, 0.0]);
|
||||
let ref_lx = (ref_origin[0] - ox) as f32;
|
||||
let ref_ly = (ref_origin[1] - oy) as f32;
|
||||
|
||||
// Selection forces a single uniform colour — never split.
|
||||
let split_by_color = !selected;
|
||||
|
||||
// Bins: key = Some(rgb) or None (= inherit entity colour).
|
||||
// Preserve insertion order so wire-emit ordering tracks the
|
||||
// original glyph stream (useful for stable hit-test indexing).
|
||||
let mut bins: Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)> = Vec::new();
|
||||
let mut bin_first: Vec<bool> = Vec::new();
|
||||
let find_or_make =
|
||||
|key: Option<[f32; 3]>, bins: &mut Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)>, firsts: &mut Vec<bool>| -> usize {
|
||||
if let Some(i) = bins.iter().position(|(k, _)| *k == key) {
|
||||
i
|
||||
} else {
|
||||
bins.push((key, Vec::new()));
|
||||
firsts.push(true);
|
||||
bins.len() - 1
|
||||
}
|
||||
};
|
||||
|
||||
for group in &stroke_groups {
|
||||
let lx = (group.origin[0] - ox) as f32;
|
||||
let ly = (group.origin[1] - oy) as f32;
|
||||
let slx = (lx - ref_lx) * anno_scale + ref_lx;
|
||||
let sly = (ly - ref_ly) * anno_scale + ref_ly;
|
||||
let bin_key = if split_by_color { group.color } else { None };
|
||||
let bi = find_or_make(bin_key, &mut bins, &mut bin_first);
|
||||
let pts = &mut bins[bi].1;
|
||||
for stroke in &group.strokes {
|
||||
if stroke.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if !first && !points.is_empty() {
|
||||
points.push([f32::NAN, f32::NAN, f32::NAN]);
|
||||
if !bin_first[bi] && !pts.is_empty() {
|
||||
pts.push([f32::NAN, f32::NAN, f32::NAN]);
|
||||
}
|
||||
first = false;
|
||||
bin_first[bi] = false;
|
||||
for &[x, y] in stroke {
|
||||
points.push([x * anno_scale + slx, y * anno_scale + sly, elev]);
|
||||
pts.push([x * anno_scale + slx, y * anno_scale + sly, elev]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let key_vertices: Vec<[f32; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return WireModel {
|
||||
name,
|
||||
points,
|
||||
color,
|
||||
selected,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px,
|
||||
snap_pts,
|
||||
tangent_geoms: te.tangent_geoms,
|
||||
aci: 0,
|
||||
key_vertices,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
};
|
||||
|
||||
// Empty input (no glyphs) → emit a single empty wire so the
|
||||
// entity still has a hit-test target via snap_pts.
|
||||
if bins.is_empty() {
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points: Vec::new(),
|
||||
color,
|
||||
selected,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px,
|
||||
snap_pts,
|
||||
tangent_geoms: te.tangent_geoms,
|
||||
aci: 0,
|
||||
key_vertices,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}];
|
||||
}
|
||||
|
||||
let bin_count = bins.len();
|
||||
let mut out: Vec<WireModel> = Vec::with_capacity(bin_count);
|
||||
for (idx, (override_rgb, pts)) in bins.into_iter().enumerate() {
|
||||
let wire_color = match override_rgb {
|
||||
Some([r, g, b]) => [r, g, b, color[3]],
|
||||
None => color,
|
||||
};
|
||||
// Snap points and key vertices belong to the entity as a
|
||||
// whole — attach them only to the first emitted wire so
|
||||
// pickers / hover don't double-count.
|
||||
let (snap, keys, tangents) = if idx == 0 {
|
||||
(
|
||||
snap_pts.clone(),
|
||||
key_vertices.clone(),
|
||||
te.tangent_geoms.clone(),
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new(), Vec::new())
|
||||
};
|
||||
out.push(WireModel {
|
||||
name: name.clone(),
|
||||
points: pts,
|
||||
color: wire_color,
|
||||
selected,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px,
|
||||
snap_pts: snap,
|
||||
tangent_geoms: tangents,
|
||||
aci: 0,
|
||||
key_vertices: keys,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Standard topology objects ─────────────────────────────────
|
||||
@@ -239,7 +306,7 @@ pub fn tessellate(
|
||||
[(kx - ox) as f32, (ky - oy) as f32, (kz - oz) as f32]
|
||||
})
|
||||
.collect();
|
||||
return WireModel {
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points: vec![
|
||||
[x - s, y, z],
|
||||
@@ -260,7 +327,7 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
};
|
||||
}];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -275,7 +342,7 @@ pub fn tessellate(
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return WireModel {
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points,
|
||||
color,
|
||||
@@ -291,7 +358,7 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
};
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +371,7 @@ pub fn tessellate(
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return WireModel {
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points,
|
||||
color,
|
||||
@@ -320,7 +387,7 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
};
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +418,7 @@ pub fn tessellate(
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return WireModel {
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points: local_pts,
|
||||
color,
|
||||
@@ -367,7 +434,7 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris,
|
||||
};
|
||||
}];
|
||||
}
|
||||
|
||||
TruckObject::SegmentedLines(points) => {
|
||||
@@ -388,7 +455,7 @@ pub fn tessellate(
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return WireModel {
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points: local_pts,
|
||||
color,
|
||||
@@ -404,7 +471,7 @@ pub fn tessellate(
|
||||
vp_scissor: None,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
fill_tris: vec![],
|
||||
};
|
||||
}];
|
||||
}
|
||||
|
||||
TruckObject::Volume(_) => {
|
||||
@@ -427,14 +494,14 @@ pub fn tessellate(
|
||||
let sp = Vec3::new((p.x - ox) as f32, (p.y - oy) as f32, (p.z - oz) as f32);
|
||||
wm.snap_pts.push((sp, SnapHint::Insertion));
|
||||
}
|
||||
return wm;
|
||||
return vec![wm];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy fallback for Viewport and other unhandled types ────────────
|
||||
let (points, snap_pts, tangent_geoms, key_vertices) = legacy_geometry(entity, world_offset);
|
||||
WireModel {
|
||||
vec![WireModel {
|
||||
name,
|
||||
points,
|
||||
color,
|
||||
@@ -450,7 +517,7 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
pub fn tessellate_dimension(
|
||||
@@ -1539,7 +1606,7 @@ pub fn tessellate_multileader(
|
||||
for sub in synth_ins.explode_from_document(document) {
|
||||
let normalized =
|
||||
crate::modules::home::modify::explode::normalize_insert_entity(sub);
|
||||
let mut wire = tessellate(
|
||||
let mut sub_wires = tessellate(
|
||||
document,
|
||||
handle,
|
||||
&normalized,
|
||||
@@ -1551,8 +1618,10 @@ pub fn tessellate_multileader(
|
||||
world_offset,
|
||||
1.0,
|
||||
);
|
||||
wire.name = name.clone();
|
||||
wires.push(wire);
|
||||
for w in &mut sub_wires {
|
||||
w.name = name.clone();
|
||||
}
|
||||
wires.extend(sub_wires);
|
||||
}
|
||||
// Block attributes attached to the multileader — render each as
|
||||
// its own attribute entity at WCS location like INSERT does.
|
||||
|
||||
Reference in new issue
Block a user