fix: resolve DXF %%x codes and render MText decoration strokes
Text/MText values now correctly handle DXF special-character sequences: - %%d → °, %%p → ±, %%c → ⌀, %%nnn → Unicode scalar (resolved in tessellation) - %%u / %%o toggle underline and overline strokes for TEXT entities MText decoration codes (\L/\l, \O/\o, \K/\k) were previously consumed as argument-bearing codes, silently eating the decorated text. Fixed strip_mtext_codes to keep them as zero-width markers; tessellate_text_ex now emits actual line strokes (underline, overline, strikethrough). measure_mtext_chars and word_wrap skip markers so line widths are correct. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
089612584d
commit
4504916f97
4 changed files with 222 additions and 37 deletions
|
|
@ -4,7 +4,7 @@ use glam::Vec3;
|
||||||
use crate::command::EntityTransform;
|
use crate::command::EntityTransform;
|
||||||
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip};
|
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip};
|
||||||
use crate::entities::text_support::{
|
use crate::entities::text_support::{
|
||||||
resolve_text_style, split_mtext_lines, strip_mtext_codes, word_wrap,
|
measure_mtext_chars, resolve_text_style, split_mtext_lines, strip_mtext_codes, word_wrap,
|
||||||
};
|
};
|
||||||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
|
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
|
||||||
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
|
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
|
||||||
|
|
@ -145,16 +145,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
|
||||||
};
|
};
|
||||||
let line_w = if h_anchor > 0.0 {
|
let line_w = if h_anchor > 0.0 {
|
||||||
let scale = t.height as f32 / 9.0 * style_width_factor;
|
let scale = t.height as f32 / 9.0 * style_width_factor;
|
||||||
line.chars()
|
measure_mtext_chars(line, scale, font)
|
||||||
.map(|c| {
|
|
||||||
if c == ' ' {
|
|
||||||
return font.word_spacing * scale;
|
|
||||||
}
|
|
||||||
font.glyph(c)
|
|
||||||
.map(|g| (g.advance + font.letter_spacing) * scale)
|
|
||||||
.unwrap_or(t.height as f32 * 0.6)
|
|
||||||
})
|
|
||||||
.sum()
|
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use glam::Vec3;
|
||||||
|
|
||||||
use crate::command::EntityTransform;
|
use crate::command::EntityTransform;
|
||||||
use crate::entities::common::{edit_prop as edit, parse_f64, square_grip};
|
use crate::entities::common::{edit_prop as edit, parse_f64, square_grip};
|
||||||
use crate::entities::text_support::{resolve_text_style, text_local_bounds};
|
use crate::entities::text_support::{resolve_dxf_special_chars, resolve_text_style, text_local_bounds};
|
||||||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
|
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
|
||||||
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
|
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
|
||||||
use crate::scene::cxf;
|
use crate::scene::cxf;
|
||||||
|
|
@ -71,9 +71,11 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
|
||||||
(_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x as f32, a.y as f32],
|
(_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x as f32, a.y as f32],
|
||||||
_ => [t.insertion_point.x as f32, t.insertion_point.y as f32],
|
_ => [t.insertion_point.x as f32, t.insertion_point.y as f32],
|
||||||
};
|
};
|
||||||
|
// Strip %%u/%%o for bounds (they add no width); resolve %%d/%%c/%%p for correct advance.
|
||||||
|
let value_for_bounds = resolve_dxf_special_chars(&t.value);
|
||||||
let bounds = text_local_bounds(
|
let bounds = text_local_bounds(
|
||||||
&font_name,
|
&font_name,
|
||||||
&t.value,
|
&value_for_bounds,
|
||||||
t.height as f32,
|
t.height as f32,
|
||||||
width_factor,
|
width_factor,
|
||||||
oblique_angle,
|
oblique_angle,
|
||||||
|
|
@ -99,6 +101,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
|
||||||
anchor[0] - (anchor_local_x * cos_r - anchor_local_y * sin_r),
|
anchor[0] - (anchor_local_x * cos_r - anchor_local_y * sin_r),
|
||||||
anchor[1] - (anchor_local_x * sin_r + anchor_local_y * cos_r),
|
anchor[1] - (anchor_local_x * sin_r + anchor_local_y * cos_r),
|
||||||
];
|
];
|
||||||
|
// Pass raw value — tessellate_text_ex resolves %%x codes and emits decoration strokes.
|
||||||
let strokes_2d = cxf::tessellate_text_ex(
|
let strokes_2d = cxf::tessellate_text_ex(
|
||||||
origin,
|
origin,
|
||||||
t.height as f32,
|
t.height as f32,
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,58 @@ pub fn text_local_bounds(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values:
|
||||||
|
/// - `%%d` / `%%D` → `°`
|
||||||
|
/// - `%%p` / `%%P` → `±`
|
||||||
|
/// - `%%c` / `%%C` → `⌀`
|
||||||
|
/// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts)
|
||||||
|
/// - `%%o` / `%%O` → overline toggle (stripped)
|
||||||
|
/// - `%%%%` → `%`
|
||||||
|
/// - `%%nnn` (3 decimal digits) → Unicode scalar `nnn`
|
||||||
|
/// Any unrecognised `%%x` is passed through unchanged.
|
||||||
|
pub fn resolve_dxf_special_chars(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c != '%' || chars.peek() != Some(&'%') {
|
||||||
|
out.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chars.next(); // consume second '%'
|
||||||
|
match chars.peek().map(|c| c.to_ascii_lowercase()) {
|
||||||
|
Some('d') => { chars.next(); out.push('°'); }
|
||||||
|
Some('p') => { chars.next(); out.push('±'); }
|
||||||
|
Some('c') => { chars.next(); out.push('⌀'); }
|
||||||
|
Some('u') | Some('o') => { chars.next(); } // toggle codes — strip silently
|
||||||
|
Some('%') => { chars.next(); out.push('%'); }
|
||||||
|
Some(d) if d.is_ascii_digit() => {
|
||||||
|
let mut digits = String::with_capacity(3);
|
||||||
|
for _ in 0..3 {
|
||||||
|
match chars.peek() {
|
||||||
|
Some(&ch) if ch.is_ascii_digit() => { digits.push(chars.next().unwrap()); }
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digits.len() == 3 {
|
||||||
|
if let Ok(n) = digits.parse::<u32>() {
|
||||||
|
if let Some(ch) = char::from_u32(n) {
|
||||||
|
out.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('%');
|
||||||
|
out.push('%');
|
||||||
|
out.push_str(&digits);
|
||||||
|
}
|
||||||
|
_ => { out.push('%'); out.push('%'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn strip_mtext_codes(s: &str) -> String {
|
pub fn strip_mtext_codes(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
let mut chars = s.chars().peekable();
|
let mut chars = s.chars().peekable();
|
||||||
|
|
@ -116,7 +168,14 @@ pub fn strip_mtext_codes(s: &str) -> String {
|
||||||
chars.next();
|
chars.next();
|
||||||
out.push(' ');
|
out.push(' ');
|
||||||
}
|
}
|
||||||
Some(c) if "pHWQTACcLOKlfFUu".contains(c) => {
|
// Decoration toggles — standalone (no args, no ';'). Keep as \X markers
|
||||||
|
// so tessellate_text_ex 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());
|
||||||
|
}
|
||||||
|
// Codes with semicolon-terminated arguments — strip entirely.
|
||||||
|
Some(c) if "pHWQTACcfFUu".contains(c) => {
|
||||||
chars.next();
|
chars.next();
|
||||||
for c in chars.by_ref() {
|
for c in chars.by_ref() {
|
||||||
if c == ';' {
|
if c == ';' {
|
||||||
|
|
@ -139,7 +198,7 @@ pub fn strip_mtext_codes(s: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
resolve_dxf_special_chars(&out)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn split_mtext_lines(s: &str) -> Vec<String> {
|
pub fn split_mtext_lines(s: &str) -> Vec<String> {
|
||||||
|
|
@ -149,32 +208,42 @@ pub fn split_mtext_lines(s: &str) -> Vec<String> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
pub fn word_wrap(text: &str, max_w: f32, scale: f32, font: &'static cxf::CxfFile) -> Vec<String> {
|
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() {
|
if max_w <= 0.0 || text.is_empty() {
|
||||||
return vec![text.to_string()];
|
return vec![text.to_string()];
|
||||||
}
|
}
|
||||||
|
|
||||||
let glyph_w = |c: char| -> f32 {
|
let space_w = font.word_spacing * scale;
|
||||||
if c == ' ' {
|
|
||||||
return font.word_spacing * scale;
|
|
||||||
}
|
|
||||||
font.glyph(c)
|
|
||||||
.map(|g| (g.advance + font.letter_spacing) * scale)
|
|
||||||
.unwrap_or(scale * 6.0)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
let mut current = String::new();
|
let mut current = String::new();
|
||||||
let mut current_w = 0.0_f32;
|
let mut current_w = 0.0_f32;
|
||||||
|
|
||||||
for word in text.split(' ') {
|
for word in text.split(' ') {
|
||||||
let word_w: f32 = word.chars().map(glyph_w).sum();
|
let word_w = measure_mtext_chars(word, scale, font);
|
||||||
let space_w = if current.is_empty() {
|
let gap = if current.is_empty() { 0.0 } else { space_w };
|
||||||
0.0
|
if !current.is_empty() && current_w + gap + word_w > max_w {
|
||||||
} else {
|
|
||||||
glyph_w(' ')
|
|
||||||
};
|
|
||||||
if !current.is_empty() && current_w + space_w + word_w > max_w {
|
|
||||||
lines.push(std::mem::take(&mut current));
|
lines.push(std::mem::take(&mut current));
|
||||||
current_w = 0.0;
|
current_w = 0.0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
src/scene/cxf.rs
134
src/scene/cxf.rs
|
|
@ -216,11 +216,12 @@ pub fn tessellate_text_ex(
|
||||||
let font = get_font(font_name);
|
let font = get_font(font_name);
|
||||||
let scale = height / 9.0;
|
let scale = height / 9.0;
|
||||||
let wf = width_factor.clamp(0.01, 100.0);
|
let wf = width_factor.clamp(0.01, 100.0);
|
||||||
let ob = oblique_angle.tan(); // shear multiplier
|
let ob = oblique_angle.tan();
|
||||||
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
|
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
|
||||||
|
|
||||||
let xform = |gx: f32, gy: f32, cursor_x: f32| -> [f32; 2] {
|
// Transform from glyph-space (cursor_x + gx, gy) to world space.
|
||||||
let sx = (cursor_x + gx) * scale * wf + gy * scale * ob;
|
let xform = |gx: f32, gy: f32, cx: f32| -> [f32; 2] {
|
||||||
|
let sx = (cx + gx) * scale * wf + gy * scale * ob;
|
||||||
let sy = gy * scale;
|
let sy = gy * scale;
|
||||||
[
|
[
|
||||||
origin[0] + sx * cos_r - sy * sin_r,
|
origin[0] + sx * cos_r - sy * sin_r,
|
||||||
|
|
@ -230,13 +231,122 @@ pub fn tessellate_text_ex(
|
||||||
|
|
||||||
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
|
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
|
||||||
let mut cursor_x: f32 = 0.0;
|
let mut cursor_x: f32 = 0.0;
|
||||||
|
// Decoration toggle state: Some(start_cursor_x) when active.
|
||||||
|
// Y positions in 9-unit em space (baseline=0, cap=9).
|
||||||
|
let mut underline: Option<f32> = None;
|
||||||
|
let mut overline: Option<f32> = None;
|
||||||
|
let mut strikethrough: Option<f32> = None;
|
||||||
|
const UNDER_Y: f32 = -1.5;
|
||||||
|
const OVER_Y: f32 = 10.5;
|
||||||
|
const STRIKE_Y: f32 = 4.5;
|
||||||
|
|
||||||
for ch in text.chars() {
|
let mut chars = text.chars().peekable();
|
||||||
if ch == ' ' {
|
while let Some(ch) = chars.next() {
|
||||||
|
// MText decoration toggles: \L on, \l off, \O on, \o off, \K on, \k off.
|
||||||
|
if ch == '\\' {
|
||||||
|
match chars.peek().copied() {
|
||||||
|
Some('L') => {
|
||||||
|
chars.next();
|
||||||
|
if underline.is_none() { underline = Some(cursor_x); }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some('l') => {
|
||||||
|
chars.next();
|
||||||
|
if let Some(s) = underline.take() {
|
||||||
|
out.push(vec![xform(s, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some('O') => {
|
||||||
|
chars.next();
|
||||||
|
if overline.is_none() { overline = Some(cursor_x); }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some('o') => {
|
||||||
|
chars.next();
|
||||||
|
if let Some(s) = overline.take() {
|
||||||
|
out.push(vec![xform(s, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some('K') => {
|
||||||
|
chars.next();
|
||||||
|
if strikethrough.is_none() { strikethrough = Some(cursor_x); }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some('k') => {
|
||||||
|
chars.next();
|
||||||
|
if let Some(s) = strikethrough.take() {
|
||||||
|
out.push(vec![xform(s, STRIKE_Y, 0.0), xform(cursor_x, STRIKE_Y, 0.0)]);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {} // not a decoration code — fall through and render as backslash glyph
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve DXF %%x special-character sequences inline.
|
||||||
|
let render_ch: char = if ch == '%' && chars.peek() == Some(&'%') {
|
||||||
|
chars.next(); // consume second '%'
|
||||||
|
match chars.peek().map(|c| c.to_ascii_lowercase()) {
|
||||||
|
Some('d') => { chars.next(); '°' }
|
||||||
|
Some('p') => { chars.next(); '±' }
|
||||||
|
Some('c') => { chars.next(); '⌀' }
|
||||||
|
Some('%') => { chars.next(); '%' }
|
||||||
|
Some('u') => {
|
||||||
|
chars.next();
|
||||||
|
underline = match underline.take() {
|
||||||
|
Some(start) => {
|
||||||
|
out.push(vec![xform(start, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => Some(cursor_x),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some('o') => {
|
||||||
|
chars.next();
|
||||||
|
overline = match overline.take() {
|
||||||
|
Some(start) => {
|
||||||
|
out.push(vec![xform(start, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => Some(cursor_x),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some(d) if d.is_ascii_digit() => {
|
||||||
|
// %%nnn — 3-digit decimal Unicode scalar
|
||||||
|
let mut digits = String::with_capacity(3);
|
||||||
|
for _ in 0..3 {
|
||||||
|
match chars.peek() {
|
||||||
|
Some(&c) if c.is_ascii_digit() => { digits.push(chars.next().unwrap()); }
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digits.len() == 3 {
|
||||||
|
if let Ok(n) = digits.parse::<u32>() {
|
||||||
|
if let Some(c) = char::from_u32(n) {
|
||||||
|
c
|
||||||
|
} else { continue; }
|
||||||
|
} else { continue; }
|
||||||
|
} else {
|
||||||
|
// Partial digit sequence — advance as unknown glyph and move on
|
||||||
|
cursor_x += (6.0 + font.letter_spacing) * wf;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => { continue; } // unknown %%x — skip silently
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ch
|
||||||
|
};
|
||||||
|
|
||||||
|
if render_ch == ' ' {
|
||||||
cursor_x += font.word_spacing;
|
cursor_x += font.word_spacing;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match font.glyph(ch) {
|
match font.glyph(render_ch) {
|
||||||
Some(glyph) => {
|
Some(glyph) => {
|
||||||
for stroke in &glyph.strokes {
|
for stroke in &glyph.strokes {
|
||||||
if stroke.len() < 2 {
|
if stroke.len() < 2 {
|
||||||
|
|
@ -256,6 +366,18 @@ pub fn tessellate_text_ex(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close any decoration spans that weren't explicitly closed.
|
||||||
|
if let Some(start) = underline {
|
||||||
|
out.push(vec![xform(start, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]);
|
||||||
|
}
|
||||||
|
if let Some(start) = overline {
|
||||||
|
out.push(vec![xform(start, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
|
||||||
|
}
|
||||||
|
if let Some(start) = strikethrough {
|
||||||
|
out.push(vec![xform(start, STRIKE_Y, 0.0), xform(cursor_x, STRIKE_Y, 0.0)]);
|
||||||
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue