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