fix(mleader): honour the baked context — anchors, rotation, dogleg, frames

The annotation context is AutoCAD's baked display state; the renderer
now trusts it instead of re-deriving:

- text_attachment_point (L/C/R) is the stored horizontal anchor — the
  leader-side guess shifted centre/right-attached texts by their width.
- ctx.text_direction/text_rotation apply as stored; forcing the
  Horizontal angle type to 0 un-rotated angled leaders. The conn.y
  baseline snap now applies only to unrotated text.
- The dogleg runs along the leader root's stored direction (angled
  leaders drew it world-horizontal), and vertically-attached leaders
  draw no dogleg at all.
- Underline attachments (BottomLine / BottomOfTopLineUnderline…) draw
  the leader-coloured rule under the attached line(s), full text width.
- Vertical text flow (ctx.text_flow_direction) feeds the shared
  vertical layout; frame/background boxes wrap the laid-out glyph
  bounds padded by ctx.landing_gap, and run-less decoration strokes +
  inline \C colours reach the output instead of being dropped.
- The wrap-width grip rides the shared flow_grip_axis() (rotation, flow
  and attachment side), and its drag projects on the same axis.

acadrust bump: every MULTILEADER decodes (has_ds peek), MTEXT
top-to-bottom direction reads correctly.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-23 02:58:17 +03:00
1 parent 6e225e068c
commit ac0a6ac150
2 files changed
+312 -116

No files matched your search

Generated
+1 -1
View File
@@ -74,7 +74,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.4.0"
source = "git+https://github.com/OpenAEC-Foundation/acadifc?branch=main#4ef83a122446504a988ed8ed7904b1c1b5f1b51f"
source = "git+https://github.com/OpenAEC-Foundation/acadifc?branch=main#1497d7ab8be725e0bed84ce31e85fa20646ddc51"
dependencies = [
"ahash 0.8.12",
"anyhow",
+311 -115
View File
@@ -29,6 +29,29 @@ pub(crate) fn mleader_v_anchor(attach: TextAttachmentType) -> MTextVAnchor {
}
use glam::DVec3;
/// The attachment that governs the text's flow axis: the top/bottom pair for
/// vertically-attached leaders (picked by which side the leader comes from),
/// the left attachment otherwise. Shared by the render and the grips.
pub(crate) fn active_vertical_attachment(ml: &MultiLeader) -> TextAttachmentType {
use acadrust::entities::multileader::TextAttachmentDirectionType;
match ml.text_attachment_direction {
TextAttachmentDirectionType::Vertical => {
let from_top = ml
.context
.leader_roots
.first()
.map(|r| r.direction.y < 0.0)
.unwrap_or(true);
if from_top {
ml.text_top_attachment
} else {
ml.text_bottom_attachment
}
}
TextAttachmentDirectionType::Horizontal => ml.context.text_left_attachment,
}
}
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_angle_prop as edit_angle, edit_prop as edit, num_prop as num_row, ro_prop as ro, square_grip, triangle_grip,
@@ -318,52 +341,87 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
/// Side the text reads toward (+1 right / -1 left) and the text box's far
/// edge (the wrap-width grip position). The box width is the explicit wrap
/// width when set, else the natural laid-out width.
fn text_box_geom(ml: &MultiLeader) -> (f64, [f64; 3]) {
let tl = ml.context.text_location;
let leader_ref = ml
.context
.leader_roots
.first()
.and_then(|r| r.lines.first())
.and_then(|l| l.points.last())
.copied()
.or_else(|| ml.context.leader_roots.first().map(|r| r.connection_point))
.unwrap_or(tl);
let sign = if tl.x >= leader_ref.x { 1.0 } else { -1.0 };
let height = if ml.context.text_height > 0.0 {
ml.context.text_height as f32
fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
// Flow axis + far-edge grip point of the MText box: along the rotated
// baseline for horizontal flow, DOWN the column for vertical — matches
// the render, so the wrap-width grip rides (and drags) the same limit
// the layout wraps at.
let ctx = &ml.context;
let tl = ctx.text_location;
let td = ctx.text_direction;
let mut rot = if td.x.abs() > 1e-9 || td.y.abs() > 1e-9 {
td.y.atan2(td.x)
} else {
ml.text_height as f32 * ml.scale_factor as f32
ctx.text_rotation
};
let style = ResolvedTextStyle {
font_name: "STANDARD".to_string(),
width_factor: 1.0,
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
if ml.text_direction_negative {
rot += std::f64::consts::PI;
}
let vertical = matches!(
ctx.text_flow_direction,
acadrust::entities::multileader::FlowDirectionType::Vertical
);
let h_anchor: f32 = match ml.text_attachment_point {
acadrust::entities::multileader::TextAttachmentPointType::Left => 0.0,
acadrust::entities::multileader::TextAttachmentPointType::Center => 0.5,
acadrust::entities::multileader::TextAttachmentPointType::Right => 1.0,
};
let layout = layout_mtext(&MTextRenderOpts {
// Not an MTEXT: text in a fixed box, never columnar.
columns: Default::default(),
value: &ml.context.text_string,
insertion: [0.0, 0.0, 0.0],
height,
rect_w: ml.context.text_width as f32,
rotation: 0.0,
style: &style,
attach_h_anchor: if sign >= 0.0 { 0.0 } else { 1.0 },
v_anchor: MTextVAnchor::Middle,
line_spacing_factor: ml.context.line_spacing_factor as f32,
vertical_text: false,
want_glyph_boxes: false,
});
let nat_w = layout.line_widths.iter().cloned().fold(0.0_f32, f32::max) as f64;
let box_w = if ml.context.text_width > 1e-6 {
ml.context.text_width
let (dir_v, k) = crate::entities::text_support::flow_grip_axis(
rot,
vertical,
h_anchor,
mleader_v_anchor(active_vertical_attachment(ml)),
);
let dir = [dir_v.x, dir_v.y];
let height = if ctx.text_height > 0.0 {
ctx.text_height
} else {
nat_w.max(height as f64)
ml.text_height * ml.scale_factor
};
(sign, [tl.x + sign * box_w, tl.y, tl.z])
let extent = if ctx.text_width > 1e-6 {
ctx.text_width
} else {
let style = ResolvedTextStyle {
font_name: "STANDARD".to_string(),
width_factor: 1.0,
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
};
let layout = layout_mtext(&MTextRenderOpts {
columns: Default::default(),
value: &ctx.text_string,
insertion: [0.0, 0.0, 0.0],
height: height as f32,
rect_w: 0.0,
rotation: 0.0,
style: &style,
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: ctx.line_spacing_factor as f32,
vertical_text: vertical,
want_glyph_boxes: false,
});
let lb = layout.local_bounds;
let nat = if lb[0] <= lb[2] {
if vertical {
(lb[3] - lb[1]) as f64
} else {
(lb[2] - lb[0]) as f64
}
} else {
height
};
nat.max(height)
};
(
[dir[0] * k, dir[1] * k],
[
tl.x + dir[0] * k * extent,
tl.y + dir[1] * k * extent,
tl.z,
],
)
}
fn grips(ml: &MultiLeader) -> Vec<GripDef> {
@@ -468,14 +526,21 @@ fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) {
}
idx += 1;
if idx == grip_id {
// Dragging the box edge sets the MText wrap width.
let (sign, far) = text_box_geom(ml);
let new_far_x = match apply {
GripApply::Absolute(a) => a.x as f64,
GripApply::Translate(d) => far[0] + d.x as f64,
// Dragging the box edge sets the MText wrap limit, projected on
// the box's flow axis (rotated baseline, or down the column for
// vertical flow).
let (dir, far) = text_box_geom(ml);
let (nx, ny) = match apply {
GripApply::Absolute(a) => (a.x as f64, a.y as f64),
GripApply::Translate(d) => (far[0] + d.x as f64, far[1] + d.y as f64),
};
let tl = &ml.context.text_location;
// `dir` carries the attachment-side factor (may be ±half), so
// normalise by its squared length: width = proj(flow)/k.
let d2 = (dir[0] * dir[0] + dir[1] * dir[1]).max(1e-12);
let proj = ((nx - tl.x) * dir[0] + (ny - tl.y) * dir[1]) / d2;
let min_w = ml.text_height.max(1.0) * 0.5;
ml.context.text_width = (sign * (new_far_x - ml.context.text_location.x)).max(min_w);
ml.context.text_width = proj.max(min_w);
}
}
}
@@ -1380,7 +1445,15 @@ impl MultiLeaderTess for MultiLeader {
}
}
if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 {
// Vertically-attached leaders (text above/below) have no dogleg:
// the leader meets the text edge directly, so the short horizontal
// dash would be a stray mark AutoCAD never draws.
let vertical_attach = matches!(
ml.text_attachment_direction,
acadrust::entities::multileader::TextAttachmentDirectionType::Vertical
);
if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 && !vertical_attach
{
// Horizontal landing (dogleg) from the leader elbow (connection
// point) toward the text side. The stored geometry places the
// text so its near-bottom edge sits one landing_gap past this
@@ -1388,9 +1461,24 @@ impl MultiLeaderTess for MultiLeader {
// text_location (the block's top-left insertion) would streak a
// stray line up the side of the text.
let d = ml.dogleg_length * effective_scale as f64;
// The dogleg runs along the leader root's stored direction —
// for a rotated leader that is the angled baseline, not world
// X. Roots without a usable direction keep the legacy
// horizontal toward the text side.
let (ux, uy) = ml
.context
.leader_roots
.first()
.map(|r| (r.direction.x, r.direction.y))
.filter(|(x, y)| (x * x + y * y).sqrt() > 1e-9)
.map(|(x, y)| {
let l = (x * x + y * y).sqrt();
(x / l, y / l)
})
.unwrap_or((text_sign_w, 0.0));
let landing_end = [
(cp.x + text_sign_w * d) as f32,
(cp.y) as f32,
(cp.x + ux * d) as f32,
(cp.y + uy * d) as f32,
cp_f[2],
];
points.push(nan);
@@ -1524,52 +1612,42 @@ impl MultiLeaderTess for MultiLeader {
// sit at large absolute coordinates and casting first then subtracting
// throws away the precision needed for the rotated sub-glyph offsets.
let local_ins_x = (ins.x) as f32;
// Horizontal leaders attach at the connection point: the dogleg runs
// horizontally at connection_point.y, and the text's attachment line
// (bottom/middle/top per text_left_attachment) sits there. Anchor the
// insertion Y to that connection instead of the stored text_location.y
// so the text meets the landing exactly — the two can disagree by a
// few percent because our MText line height need not match the writer's.
let local_ins_y = match ml.text_attachment_direction {
TextAttachmentDirectionType::Horizontal => ml
.context
.leader_roots
.first()
.map(|r| r.connection_point.y as f32)
.unwrap_or(ins.y as f32),
TextAttachmentDirectionType::Vertical => ins.y as f32,
};
let z = (ins.z) as f32;
// Rotation: prefer text_direction (transforms survive rotations /
// mirrors when acadrust updates it) and fall back to text_rotation.
// ml.text_angle_type then constrains the final angle:
// - Horizontal: always 0
// - ParallelToLastLeaderLine: keep stored direction
// - Optimized: clamp to (-π/2, π/2] (flip upside-down)
// text_direction_negative finally adds π so reading goes the other way.
// Rotation: the annotation context is AutoCAD's BAKED display
// state — text_direction / text_rotation already reflect whatever
// the style's angle setting produced at edit time. Re-deriving
// from ml.text_angle_type here (e.g. forcing Horizontal to 0)
// un-rotated leaders whose stored context is angled; the angle
// type only guides recomputation during edits, not display.
let td = ctx.text_direction;
let mut 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
};
match ml.text_angle_type {
acadrust::entities::multileader::TextAngleType::Horizontal => rot = 0.0,
acadrust::entities::multileader::TextAngleType::Optimized => {
let pi = std::f32::consts::PI;
if rot > pi / 2.0 {
rot -= pi;
} else if rot <= -pi / 2.0 {
rot += pi;
}
}
acadrust::entities::multileader::TextAngleType::ParallelToLastLeaderLine => {}
}
if ml.text_direction_negative {
rot += std::f32::consts::PI;
}
// Horizontal leaders attach at the connection point: the dogleg runs
// horizontally at connection_point.y, and the text's attachment line
// (bottom/middle/top per text_left_attachment) sits there. Anchor the
// insertion Y to that connection instead of the stored text_location.y
// so the text meets the landing exactly — the two can disagree by a
// few percent because our MText line height need not match the writer's.
// Only for UNROTATED text: an angled context stores its true anchor in
// text_location, and snapping Y alone would shear it off its baseline.
let local_ins_y = match ml.text_attachment_direction {
TextAttachmentDirectionType::Horizontal if rot.abs() < 1e-3 => ml
.context
.leader_roots
.first()
.map(|r| r.connection_point.y as f32)
.unwrap_or(ins.y as f32),
_ => ins.y as f32,
};
// Resolve text style via handle when available, falling back to STANDARD.
let style_name = ctx
.text_style_handle
@@ -1589,10 +1667,17 @@ impl MultiLeaderTess for MultiLeader {
}
let (cos_r, sin_r) = (rot.cos(), rot.sin());
// Side-anchored on the leader-facing edge so the text reads
// outward; flips live with the leader/text side.
use acadrust::entities::multileader::TextAttachmentDirectionType;
let h_anchor: f32 = if text_sign_w >= 0.0 { 0.0 } else { 1.0 };
// The stored text attachment point is the insertion's horizontal
// anchor within the text block (Left/Center/Right) — honour it
// instead of guessing from the leader side.
use acadrust::entities::multileader::{
TextAttachmentDirectionType, TextAttachmentPointType,
};
let h_anchor: f32 = match ml.text_attachment_point {
TextAttachmentPointType::Left => 0.0,
TextAttachmentPointType::Center => 0.5,
TextAttachmentPointType::Right => 1.0,
};
// Pick the vertical-anchor attachment based on text_attachment_direction:
// Horizontal — leader attaches left/right; use ml.text_left_attachment
// (matches the file's stored ctx.text_left_attachment).
@@ -1600,22 +1685,7 @@ impl MultiLeaderTess for MultiLeader {
// or ml.text_bottom_attachment depending on which side
// the leader is coming from (chosen via the first
// root.direction.y sign).
let leader_from_top = ml
.context
.leader_roots
.first()
.map(|r| r.direction.y < 0.0)
.unwrap_or(true);
let vertical_attach = match ml.text_attachment_direction {
TextAttachmentDirectionType::Vertical => {
if leader_from_top {
ml.text_top_attachment
} else {
ml.text_bottom_attachment
}
}
TextAttachmentDirectionType::Horizontal => ctx.text_left_attachment,
};
let vertical_attach = active_vertical_attachment(ml);
let v_anchor = mleader_v_anchor(vertical_attach);
// Shared MText pipeline — every inline format code (`\f`, `\C`/`\c`,
@@ -1634,7 +1704,11 @@ impl MultiLeaderTess for MultiLeader {
attach_h_anchor: h_anchor,
v_anchor,
line_spacing_factor: ctx.line_spacing_factor as f32,
vertical_text: false,
// Vertical flow (top-to-bottom) is stored per-context.
vertical_text: matches!(
ctx.text_flow_direction,
acadrust::entities::multileader::FlowDirectionType::Vertical
),
want_glyph_boxes: false,
});
let line_widths = &layout.line_widths;
@@ -1659,9 +1733,38 @@ impl MultiLeaderTess for MultiLeader {
// done by the text-highlight overlay.
let neutral = color_or_inherit(&ctx.text_color, entity_color);
let mut sdf_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
// Run-less stroke groups are decoration geometry (underline /
// overline / strike-through, stacked-fraction bars) — they have
// no glyphs to replace them, so they must be drawn as lines.
let mut deco_pts: Vec<[f32; 3]> = Vec::new();
let mut deco_fill: Vec<[f32; 3]> = Vec::new();
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
for ts in &layout.strokes {
let Some(run) = &ts.run else { continue };
let Some(run) = &ts.run else {
for stroke in &ts.strokes {
if stroke.len() < 2 {
continue;
}
if !deco_pts.is_empty() {
deco_pts.push([f32::NAN; 3]);
}
for &[x, y] in stroke {
deco_pts.push([
ts.origin[0] as f32 + x,
ts.origin[1] as f32 + y,
z,
]);
}
}
for &[x, y] in &ts.fill_tris {
deco_fill.push([
ts.origin[0] as f32 + x,
ts.origin[1] as f32 + y,
z,
]);
}
continue;
};
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
&mut atlas,
run.height,
@@ -1673,17 +1776,23 @@ impl MultiLeaderTess for MultiLeader {
run.bold,
&run.text,
);
// Inline `\C` / `\c` colour wins over the entity text
// colour, matching the top-level Text arm.
let gcolor = ts
.color
.map(|c| [c[0], c[1], c[2], neutral[3]])
.unwrap_or(neutral);
crate::scene::pipeline::text_gpu::push_glyph_vertices(
&mut sdf_verts,
&quads,
[ts.origin[0], ts.origin[1], z as f64],
1.0,
neutral,
gcolor,
0.0,
);
}
}
if !sdf_verts.is_empty() {
if !sdf_verts.is_empty() || !deco_pts.is_empty() {
// Pick box from the glyph quads (f64 accumulate → f32).
let (mut nx, mut ny, mut xx, mut xy) =
(f64::MAX, f64::MAX, f64::MIN, f64::MIN);
@@ -1695,6 +1804,12 @@ impl MultiLeaderTess for MultiLeader {
ny = ny.min(y);
xy = xy.max(y);
}
for p in deco_pts.iter().filter(|p| p[0].is_finite()) {
nx = nx.min(p[0] as f64);
xx = xx.max(p[0] as f64);
ny = ny.min(p[1] as f64);
xy = xy.max(p[1] as f64);
}
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
@@ -1706,7 +1821,7 @@ impl MultiLeaderTess for MultiLeader {
dash_align_end: None,
text_verts: sdf_verts,
name: name.clone(),
points: vec![],
points: deco_pts,
points_low: Vec::new(),
color: neutral,
selected,
@@ -1722,6 +1837,71 @@ impl MultiLeaderTess for MultiLeader {
key_vertices: vec![],
aabb: [nx as f32, ny as f32, xx as f32, xy as f32],
plinegen: true,
fill_tris: deco_fill,
fill_tris_low: Vec::new(),
});
}
}
// Underline attachments (BottomLine / BottomOfTopLineUnderline…):
// the leader visually continues under the text, so draw a rule the
// full text width at the attached line, in the leader's colour.
{
use acadrust::entities::multileader::TextAttachmentType as TA;
let ul_lines: Option<Vec<usize>> = match vertical_attach {
TA::BottomOfTopLineUnderlineTopLine => Some(vec![0]),
TA::BottomOfTopLineUnderlineAll => {
Some((0..layout.line_count).collect())
}
TA::BottomLine | TA::BottomOfTopLineUnderlineBottomLine => {
Some(vec![layout.line_count.saturating_sub(1)])
}
_ => None,
};
if let Some(lines) = ul_lines {
let mut pts: Vec<[f32; 3]> = Vec::new();
let (x0, x1) =
(-max_line_w * h_anchor, max_line_w * (1.0 - h_anchor));
for li in lines {
let y = v_offset - li as f32 * line_h - height * 0.15;
let (ax, ay) = (
local_ins_x + x0 * cos_r - y * sin_r,
local_ins_y + x0 * sin_r + y * cos_r,
);
let (bx, by) = (
local_ins_x + x1 * cos_r - y * sin_r,
local_ins_y + x1 * sin_r + y * cos_r,
);
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
pts.push([ax, ay, z]);
pts.push([bx, by, z]);
}
wires.push(WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
depth_override: None,
fill_is_3d: false,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: pts,
points_low: Vec::new(),
color: line_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
@@ -1730,12 +1910,28 @@ impl MultiLeaderTess for MultiLeader {
// Text frame / background-fill rectangle in local frame, then rotated to WCS.
if ml.text_frame || ctx.background_fill_enabled {
// Visual gap so the frame/fill doesn't touch glyph caps.
let pad = height * 0.25;
let block_top = v_offset + height + pad;
let block_bottom = v_offset - (n_lines - 1.0) * line_h - pad;
let block_left = -max_line_w * h_anchor - pad;
let block_right = max_line_w * (1.0 - h_anchor) + pad;
// Frame/fill offset from the glyphs: the context's landing gap
// (AutoCAD spaces the box by exactly that), with the old
// quarter-height heuristic as the no-gap fallback.
let pad = if ctx.landing_gap > 0.0 {
ctx.landing_gap as f32
} else {
height * 0.25
};
// Box the glyphs that were actually laid out (valid for
// vertical flow too); the metric-derived box is only the
// no-glyph fallback.
let lb = layout.local_bounds;
let (block_left, block_bottom, block_right, block_top) = if lb[0] <= lb[2] {
(lb[0] - pad, lb[1] - pad, lb[2] + pad, lb[3] + pad)
} else {
(
-max_line_w * h_anchor - pad,
v_offset - (n_lines - 1.0) * line_h - pad,
max_line_w * (1.0 - h_anchor) + pad,
v_offset + height + pad,
)
};
let local_corners: [[f32; 2]; 4] = [
[block_left, block_bottom],
[block_right, block_bottom],