fix(multileader): render text properly — strip MText codes, honor full layout

Multileader text was emitted as a single literal stroke pass with no MText
code handling, no rotation, no attachment, no line breaks, no per-piece
color and bad f32 precision under world_offset. Two separate render paths
both had the same bug, and one was masking the fix in the other.

This rewrite, applied to both paths:

- Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the
  rendered text is the actual content, not the markup.
- Honors text_rotation (preferring text_direction so transforms survive),
  text_attachment_point (Left/Center/Right horizontal anchor), and
  text_left_attachment for vertical alignment via a shared
  v_offset_for_attachment helper.
- Splits on \P/\n/\N and word-wraps to text_width.
- Applies line_spacing_factor with the same 5/3 × height convention as
  MText.
- Resolves the text style via text_style_handle (fallback STANDARD).
- Applies ml.scale_factor everywhere (arrow size, dogleg length, text
  height, text width). anno_scale is now only applied when
  enable_annotation_scale is set.
- Splits the scene-level output into separate WireModels so leader,
  text, frame and background fill each carry their own colour
  (ctx.text_color, ml.text_frame, ctx.background_fill_color).
- Mirrors text_direction, base_direction and root.direction in
  apply_transform so a mirrored multileader's text follows the geometry
  instead of staying at its original rotation.
- Computes the text origin and frame corners in offset-relative f32
  space — drawings sit at large absolute coordinates and casting to f32
  before subtracting world_offset was wrecking the rotated sub-glyph
  precision.
This commit is contained in:
Hakan Seven committed 2026-05-12 23:27:53 +03:00
1 parent 2600ae1815
commit 8bfa5088c4
3 files changed
+495 -80

No files matched your search

+194 -24
View File
@@ -1,4 +1,32 @@
use acadrust::entities::{LeaderContentType, MultiLeader, MultiLeaderPathType, TextAttachmentType};
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 {
match attach {
TextAttachmentType::TopOfTopLine => -h,
TextAttachmentType::MiddleOfTopLine => -h * 0.5,
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::BottomOfBottomLine | TextAttachmentType::BottomLine => {
(n_lines - 1.0) * line_h
}
TextAttachmentType::BottomOfTopLineUnderlineBottomLine
| TextAttachmentType::BottomOfTopLineUnderlineTopLine
| TextAttachmentType::BottomOfTopLineUnderlineAll => 0.0,
}
}
use glam::Vec3;
use crate::command::EntityTransform;
@@ -146,33 +174,117 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
}
}
// Text strokes (MText content rendered inline)
// Text strokes (MText content rendered inline). Mirrors the MText pipeline:
// strip inline format codes, split / word-wrap into lines, then place each
// line with the multileader's rotation, horizontal & vertical attachment,
// line spacing and scale_factor applied. Annotation scale is NOT applied
// here — this path is consumed by snap / truck export which work in WCS.
if ml.content_type == LeaderContentType::MText && !ml.context.text_string.is_empty() {
let height = if ml.context.text_height > 0.0 {
ml.context.text_height
let ctx = &ml.context;
let raw_height = if ctx.text_height > 0.0 {
ctx.text_height
} else {
ml.text_height
};
let ins = &ml.context.text_location;
} as f32;
let scale_factor = ml.scale_factor as f32;
let height = raw_height * scale_factor;
let ins = &ctx.text_location;
let ins_x = ins.x;
let ins_y = ins.y;
let z = ins.z as f32;
snap_pts.push(node([ins.x as f32, ins.y as f32, z]));
let style = crate::entities::text_support::resolve_text_style("STANDARD", document);
let strokes = crate::scene::cxf::tessellate_text_ex(
[ins.x as f32, ins.y as f32],
height as f32,
0.0,
style.width_factor.max(0.01),
style.oblique_angle,
&style.font_name,
&ml.context.text_string,
);
for stroke in &strokes {
if stroke.len() < 2 {
continue;
}
points.push(nan);
for &[x, y] in stroke {
points.push([x, y, z]);
// Prefer text_direction (carries through rotations/mirrors); fall back
// to text_rotation when no direction has been set.
let td = ctx.text_direction;
let rot = if td.x.abs() > 1e-9 || td.y.abs() > 1e-9 {
(td.y as f32).atan2(td.x as f32)
} else {
ctx.text_rotation as f32
};
let (cos_r, sin_r) = (rot.cos(), rot.sin());
snap_pts.push(node([ins_x as f32, ins_y as f32, z]));
// Resolve text style via handle when available, falling back to STANDARD.
let style_name = ctx
.text_style_handle
.as_ref()
.and_then(|h| {
document
.text_styles
.iter()
.find(|s| s.handle == *h)
.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);
let width_factor = style.width_factor.max(0.01);
let oblique = style.oblique_angle;
// 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 {
let scale = height / 9.0 * width_factor;
let max_w = ctx.text_width as f32 * scale_factor;
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;
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);
let scale = height / 9.0 * width_factor;
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;
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 {
if stroke.len() < 2 {
continue;
}
points.push(nan);
for &[x, y] in stroke {
points.push([x, y, z]);
}
}
}
}
@@ -532,11 +644,29 @@ fn parse_attachment(s: &str) -> TextAttachmentType {
fn apply_transform(ml: &mut MultiLeader, t: &EntityTransform) {
crate::scene::transform::apply_standard_entity_transform(ml, t, |entity, p1, p2| {
// Reflect every point on the leader (line points, connection points,
// break-point endpoints) AND every direction vector that drives the
// text orientation. Without the direction reflection text would keep
// its original rotation while the leader appears mirrored.
for root in &mut entity.context.leader_roots {
for line in &mut root.lines {
for p in &mut line.points {
crate::scene::transform::reflect_xy_point(&mut p.x, &mut p.y, p1, p2);
}
for bp in &mut line.break_points {
crate::scene::transform::reflect_xy_point(
&mut bp.start_point.x,
&mut bp.start_point.y,
p1,
p2,
);
crate::scene::transform::reflect_xy_point(
&mut bp.end_point.x,
&mut bp.end_point.y,
p1,
p2,
);
}
}
crate::scene::transform::reflect_xy_point(
&mut root.connection_point.x,
@@ -544,6 +674,21 @@ fn apply_transform(ml: &mut MultiLeader, t: &EntityTransform) {
p1,
p2,
);
reflect_xy_direction(&mut root.direction.x, &mut root.direction.y, p1, p2);
for bp in &mut root.break_points {
crate::scene::transform::reflect_xy_point(
&mut bp.start_point.x,
&mut bp.start_point.y,
p1,
p2,
);
crate::scene::transform::reflect_xy_point(
&mut bp.end_point.x,
&mut bp.end_point.y,
p1,
p2,
);
}
}
crate::scene::transform::reflect_xy_point(
&mut entity.context.text_location.x,
@@ -551,9 +696,34 @@ fn apply_transform(ml: &mut MultiLeader, t: &EntityTransform) {
p1,
p2,
);
reflect_xy_direction(
&mut entity.context.text_direction.x,
&mut entity.context.text_direction.y,
p1,
p2,
);
reflect_xy_direction(
&mut entity.context.base_direction.x,
&mut entity.context.base_direction.y,
p1,
p2,
);
});
}
/// Reflect a direction (not position) vector across the mirror line p1→p2.
/// Reflecting a direction is the same as reflecting `p1 + dir` then subtracting
/// the reflection of `p1`, which simplifies to reflecting around the origin.
fn reflect_xy_direction(dx: &mut f64, dy: &mut f64, p1: Vec3, p2: Vec3) {
let zero = Vec3::ZERO;
let p2_rel = Vec3::new(p2.x - p1.x, p2.y - p1.y, 0.0);
let mut tip_x = *dx;
let mut tip_y = *dy;
crate::scene::transform::reflect_xy_point(&mut tip_x, &mut tip_y, zero, p2_rel);
*dx = tip_x;
*dy = tip_y;
}
// ── Trait impls ────────────────────────────────────────────────────────────
impl TruckConvertible for MultiLeader {