feat(leader): dynamic MLEADER text placement, grips, and consistent arrows

Reworks MLEADER text/leader geometry and grip editing, and aligns LEADER
to MLEADER's conventions (kept as separate entities).

Text & landing (render path):
- Text is side-anchored on the leader-facing edge and reads outward; the
  side, alignment and the horizontal landing are recomputed every frame
  from the text grip's position relative to the leader, so moving the
  arrow or the text re-mirrors the whole layout and the landing stays
  attached to the text's near edge.
- Fixes #49: text was centred on the leader endpoint regardless of side.

Grips:
- Text-location grip plus an MTEXT-style triangle wrap-width grip at the
  box's far edge; dragging it sets the MText wrap width.
- Multifunction grip menu restored and made functional: leader-line
  vertices offer Add Leader / Remove Leader (Add grabs the new arrow so it
  follows the cursor — click places, Esc removes it); the text grip offers
  Move Independent (text only) and Move with Leader (translates the whole
  multileader via a move-all sentinel grip).

Arrows:
- LEADER and MLEADER arrowheads now render at the same size (text height),
  matching previews.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-03 16:17:29 +03:00
1 parent 6f64d8d0d9
commit 8b3d083789
6 files changed
+395 -162

No files matched your search

+5
View File
@@ -205,6 +205,10 @@ pub(super) struct OpenCADStudio {
/// until dismissed (click outside, ESC, cursor leaves the grip).
grip_popup: Option<GripPopup>,
grip_pending: Option<GripPendingValue>,
/// A leader line just added via the "Add Leader" grip menu whose arrow is
/// being placed (follows the cursor). `(entity handle, new-arrow grip id)`.
/// Esc before the placement click removes it again.
grip_add_provisional: Option<(acadrust::Handle, usize)>,
/// Open Quick Select panel state. `None` = panel closed. Filters are
/// applied via `Message::QSelectApply`; the panel is dismissed on
/// Apply / Cancel / Esc / outside-click.
@@ -980,6 +984,7 @@ impl OpenCADStudio {
grip_hover: None,
grip_popup: None,
grip_pending: None,
grip_add_provisional: None,
qselect: None,
show_ucs_icon: true,
show_viewcube: true,
+71 -6
View File
@@ -1283,6 +1283,19 @@ impl OpenCADStudio {
// A hot grip (click-move-click placement in progress) ends on
// Escape, leaving the entity at its last previewed position.
if self.tabs[self.active_tab].active_grip.take().is_some() {
// An Add-Leader arrow being placed: Esc removes it again.
if let Some((h, gid)) = self.grip_add_provisional.take() {
let i = self.active_tab;
use crate::entities::traits::EntityTypeOps;
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) {
e.apply_grip_menu(
gid,
crate::scene::object::GripMenuAction::RemoveLeader,
);
}
self.tabs[i].scene.bump_geometry();
self.refresh_selected_grips();
}
self.tabs[self.active_tab].snap_result = None;
self.refresh_properties();
return Task::none();
@@ -2516,6 +2529,8 @@ impl OpenCADStudio {
return Task::none();
}
self.tabs[i].active_grip = None;
// Placement confirmed — keep the just-added leader.
self.grip_add_provisional = None;
self.tabs[i].snap_result = None;
self.refresh_properties();
return Task::none();
@@ -3310,20 +3325,33 @@ impl OpenCADStudio {
};
use crate::entities::traits::EntityTypeOps;
use crate::scene::object::GripMenuAction;
if matches!(item.action, GripMenuAction::Stretch) {
// Stretch = move this grip. Engage it so the next click
// places it (click-move-click) — same as picking the grip
// directly in the viewport. Without this the menu just
if matches!(
item.action,
GripMenuAction::Stretch
| GripMenuAction::MoveWithLeader
| GripMenuAction::MoveIndependent
) {
// Stretch / Move = grab this grip. Engage it so the next
// click places it (click-move-click) — same as picking the
// grip directly in the viewport. Without this the menu just
// closed and the grip never became hot (issue #48).
if let Some(g) = self.tabs[i]
.selected_grips
.iter()
.find(|g| g.id == popup.grip_id)
{
// "Move with Leader" drags the whole multileader; the
// others move just the picked grip.
let (grip_id, is_translate) =
if matches!(item.action, GripMenuAction::MoveWithLeader) {
(crate::entities::multileader::MOVE_ALL_GRIP, true)
} else {
(popup.grip_id, g.is_midpoint)
};
self.tabs[i].active_grip = Some(GripEdit {
handle: popup.handle,
grip_id: popup.grip_id,
is_translate: g.is_midpoint,
grip_id,
is_translate,
origin_world: g.world,
last_world: g.world,
});
@@ -3351,6 +3379,27 @@ impl OpenCADStudio {
}
// One-shot action — apply immediately.
self.push_undo_snapshot(i, item.label);
// For Add Leader, the new arrow becomes the last grip; remember
// its id so we can grab it for placement right after.
let add_leader_gid = if matches!(item.action, GripMenuAction::AddLeader) {
self.tabs[i]
.scene
.document
.get_entity(popup.handle)
.and_then(|e| match e {
acadrust::EntityType::MultiLeader(ml) => Some(
ml.context
.leader_roots
.iter()
.flat_map(|r| r.lines.iter())
.map(|l| l.points.len())
.sum::<usize>(),
),
_ => None,
})
} else {
None
};
if let Some(entity) = self
.tabs[i]
.scene
@@ -3363,6 +3412,22 @@ impl OpenCADStudio {
self.tabs[i].dirty = true;
self.refresh_selected_grips();
self.refresh_properties();
// Grab the new arrow so it follows the cursor (click places it,
// Esc removes it).
if let Some(new_gid) = add_leader_gid {
if let Some(g) =
self.tabs[i].selected_grips.iter().find(|g| g.id == new_gid)
{
self.tabs[i].active_grip = Some(GripEdit {
handle: popup.handle,
grip_id: new_gid,
is_translate: false,
origin_world: g.world,
last_world: g.world,
});
self.grip_add_provisional = Some((popup.handle, new_gid));
}
}
Task::none()
}
+3 -2
View File
@@ -44,7 +44,8 @@ fn to_truck(leader: &Leader) -> TruckEntity {
let dy = next.y - tip.y;
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let (dx, dy) = (dx / len, dy / len);
let sz = (leader.text_height).max(1.0) * 0.8;
// Arrowhead sized to the text height, matching the MLEADER arrowhead.
let sz = (leader.text_height).max(1.0);
let a = std::f64::consts::PI / 6.0;
let (s, c) = a.sin_cos();
let tip_f = p3(tip);
@@ -627,7 +628,7 @@ impl LeaderTess for Leader {
.unwrap_or(anno_scale as f64);
let arrow_size = match style {
Some(s) => (s.dimasz * dim_scale) as f32,
None => (self.text_height as f32).max(1.0) * 0.8 * anno_scale,
None => (self.text_height as f32).max(1.0) * anno_scale,
};
let arrow = match style {
Some(s) => arrow_from_block(document, s.dimldrblk, arrow_size.max(0.001)),
+294 -142
View File
@@ -1,9 +1,9 @@
use acadrust::entities::{
LeaderContentType, MultiLeader, MultiLeaderPathType, TextAttachmentPointType, TextAttachmentType,
LeaderContentType, MultiLeader, MultiLeaderPathType, TextAttachmentType,
};
use crate::entities::text_support::{
layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor,
layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle,
};
/// Map MLEADER's vertical attachment enum onto the shared `MTextVAnchor`
@@ -29,7 +29,9 @@ pub(crate) fn mleader_v_anchor(attach: TextAttachmentType) -> MTextVAnchor {
use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{center_grip, edit_prop as edit, ro_prop as ro, square_grip};
use crate::entities::common::{
center_grip, edit_prop as edit, ro_prop as ro, square_grip, triangle_grip,
};
use crate::entities::traits::{TruckConvertible};
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::object::{GripApply, GripDef, PropSection, PropValue, Property};
@@ -88,6 +90,81 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
)
};
// Text-side geometry, recomputed every frame so dragging the arrow or the
// text re-mirrors the whole layout. The text block is centred on its grip
// (text_location); the landing meets the text's near edge and the leader
// line ends one dogleg before that.
let text_loc = 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(text_loc);
let text_sign: f64 = if text_loc.x >= leader_ref.x { 1.0 } else { -1.0 };
// Lay the text out once, up front: its width fixes where the landing meets
// the text. The same layout is reused below to emit the glyph strokes.
let text_layout = if ml.content_type == LeaderContentType::MText
&& !ml.context.text_string.is_empty()
{
let ctx = &ml.context;
let height = if ctx.text_height > 0.0 {
ctx.text_height as f32
} else {
ml.text_height as f32 * ml.scale_factor as f32
};
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
};
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 resolved = resolve_text_style(&style_name, document);
if resolved.is_upside_down {
rot += std::f32::consts::PI;
}
Some(layout_mtext(&MTextRenderOpts {
value: &ctx.text_string,
insertion: [text_loc.x, text_loc.y, text_loc.z],
height,
rect_w: ctx.text_width as f32,
rotation: rot,
style: &resolved,
// Side-anchored on the leader-facing edge so the text reads
// outward; flips live with the leader/text side.
attach_h_anchor: if text_sign >= 0.0 { 0.0 } else { 1.0 },
v_anchor: mleader_v_anchor(ctx.text_left_attachment),
line_spacing_factor: ctx.line_spacing_factor as f32,
vertical_text: false,
want_glyph_boxes: false,
}))
} else {
None
};
let dogleg = if ml.enable_landing && ml.enable_dogleg {
ml.dogleg_length.max(0.0)
} else {
0.0
};
// text_location is the leader-facing (near) edge; the landing runs one
// dogleg from there back toward the leader.
let landing_pt = [text_loc.x - text_sign * dogleg, text_loc.y, text_loc.z];
for root in &ml.context.leader_roots {
let cp = &root.connection_point;
let cp_f = p3(cp);
@@ -104,12 +181,13 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
}
first = false;
// Build the full control-point list: line.points + connection_point
// Build the full control-point list: line.points + landing point
let mut ctrl: Vec<[f64; 3]> = line.points.iter().map(|p| p3(p)).collect();
let last_f = *ctrl.last().unwrap_or(&cp_f);
let dist = ((last_f[0] - cp_f[0]).powi(2) + (last_f[1] - cp_f[1]).powi(2)).sqrt();
let last_f = *ctrl.last().unwrap_or(&landing_pt);
let dist =
((last_f[0] - landing_pt[0]).powi(2) + (last_f[1] - landing_pt[1]).powi(2)).sqrt();
if dist > 1e-9 {
ctrl.push(cp_f);
ctrl.push(landing_pt);
}
for &c in &ctrl {
key_verts.push(c);
@@ -168,97 +246,18 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
}
}
// Landing shelf at connection_point
if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 {
let dir = &root.direction;
let dl = (dir.x * dir.x + dir.y * dir.y).sqrt().max(1e-9);
let d = ml.dogleg_length;
// Horizontal landing from the leader end to the text's near edge.
if dogleg > 0.0 {
points.push(nan);
points.push(cp_f);
points.push([cp.x + dir.x / dl * d, cp.y + dir.y / dl * d, cp.z]);
points.push(landing_pt);
points.push([text_loc.x, text_loc.y, text_loc.z]);
}
}
// 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 ctx = &ml.context;
// `ctx.text_height` (when > 0) is already in WCS — scale_factor is
// baked in. Only the fallback path needs to multiply.
let scale_factor = ml.scale_factor as f32;
let height = if ctx.text_height > 0.0 {
ctx.text_height as f32
} else {
ml.text_height as f32 * scale_factor
};
let ins = &ctx.text_location;
let ins_x = ins.x;
let ins_y = ins.y;
let z = ins.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 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
};
snap_pts.push(node([ins_x, ins_y, 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 resolved = resolve_text_style(&style_name, document);
if resolved.is_upside_down {
rot += std::f32::consts::PI;
}
// 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);
// 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,
want_glyph_boxes: false,
});
// Text strokes, drawn from the layout computed up front (centred on the
// text grip). The snap node is the grip itself.
if let Some(layout) = &text_layout {
snap_pts.push(node([text_loc.x, text_loc.y, text_loc.z]));
for ts in &layout.strokes {
let ox = ts.origin[0];
let oy = ts.origin[1];
@@ -268,7 +267,7 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
}
points.push(nan);
for &[x, y] in stroke {
points.push([ox + x as f64, oy + y as f64, z]);
points.push([ox + x as f64, oy + y as f64, text_loc.z]);
}
}
}
@@ -293,6 +292,55 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
// 0 .. total_line_pts - 1 : square grips on every LeaderLine vertex
// total_line_pts : diamond grip on context.text_location (if MText)
/// 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
} else {
ml.text_height as f32 * ml.scale_factor as f32
};
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 {
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
} else {
nat_w.max(height as f64)
};
(sign, [tl.x + sign * box_w, tl.y, tl.z])
}
fn grips(ml: &MultiLeader) -> Vec<GripDef> {
let mut result: Vec<GripDef> = Vec::new();
let mut id = 0usize;
@@ -311,16 +359,54 @@ fn grips(ml: &MultiLeader) -> Vec<GripDef> {
if ml.content_type == LeaderContentType::MText {
let tl = &ml.context.text_location;
// Text-location grip, then the wrap-width grip at the box's far edge.
result.push(center_grip(
id,
Vec3::new(tl.x as f32, tl.y as f32, tl.z as f32),
));
id += 1;
let (_, far) = text_box_geom(ml);
result.push(triangle_grip(
id,
Vec3::new(far[0] as f32, far[1] as f32, far[2] as f32),
));
}
result
}
/// Sentinel grip id meaning "translate the whole multileader" — used by the
/// text grip's "Move with Leader" action so the leader follows the text.
pub(crate) const MOVE_ALL_GRIP: usize = usize::MAX;
fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) {
if grip_id == MOVE_ALL_GRIP {
let (dx, dy, dz) = match apply {
GripApply::Translate(d) => (d.x as f64, d.y as f64, d.z as f64),
GripApply::Absolute(a) => (
a.x as f64 - ml.context.text_location.x,
a.y as f64 - ml.context.text_location.y,
a.z as f64 - ml.context.text_location.z,
),
};
for root in &mut ml.context.leader_roots {
for line in &mut root.lines {
for p in &mut line.points {
p.x += dx;
p.y += dy;
p.z += dz;
}
}
root.connection_point.x += dx;
root.connection_point.y += dy;
root.connection_point.z += dz;
}
ml.context.text_location.x += dx;
ml.context.text_location.y += dy;
ml.context.text_location.z += dz;
return;
}
let mut idx = 0usize;
for root in &mut ml.context.leader_roots {
@@ -346,20 +432,34 @@ fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) {
}
}
// Text location grip
if ml.content_type == LeaderContentType::MText && idx == grip_id {
let tl = &mut ml.context.text_location;
match apply {
GripApply::Absolute(a) => {
tl.x = a.x as f64;
tl.y = a.y as f64;
tl.z = a.z as f64;
}
GripApply::Translate(d) => {
tl.x += d.x as f64;
tl.y += d.y as f64;
tl.z += d.z as f64;
// Text-location grip (idx == n_vertices), then the wrap-width grip.
if ml.content_type == LeaderContentType::MText {
if idx == grip_id {
let tl = &mut ml.context.text_location;
match apply {
GripApply::Absolute(a) => {
tl.x = a.x as f64;
tl.y = a.y as f64;
tl.z = a.z as f64;
}
GripApply::Translate(d) => {
tl.x += d.x as f64;
tl.y += d.y as f64;
tl.z += d.z as f64;
}
}
return;
}
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,
};
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);
}
}
}
@@ -840,7 +940,6 @@ impl crate::entities::traits::Grippable for MultiLeader {
grip_id: usize,
) -> Vec<crate::scene::object::GripMenuItem> {
use crate::scene::object::{GripMenuAction, GripMenuItem};
// Compute vertex count to identify text-location grip (last id).
let n_vertices: usize = self
.context
.leader_roots
@@ -848,16 +947,26 @@ impl crate::entities::traits::Grippable for MultiLeader {
.flat_map(|r| r.lines.iter())
.map(|l| l.points.len())
.sum();
let is_text_loc = self.content_type == LeaderContentType::MText
&& grip_id == n_vertices;
if is_text_loc {
vec![
GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch },
GripMenuItem { label: "Move with Leader", action: GripMenuAction::MoveWithLeader },
GripMenuItem { label: "Move Independent", action: GripMenuAction::MoveIndependent },
]
if self.content_type == LeaderContentType::MText && grip_id >= n_vertices {
if grip_id == n_vertices {
// Text-location grip.
vec![
GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch },
GripMenuItem {
label: "Move with Leader",
action: GripMenuAction::MoveWithLeader,
},
GripMenuItem {
label: "Move Independent",
action: GripMenuAction::MoveIndependent,
},
]
} else {
// Wrap-width grip: drag only.
Vec::new()
}
} else {
// Leader-line vertex.
vec![
GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch },
GripMenuItem { label: "Add Leader", action: GripMenuAction::AddLeader },
@@ -867,12 +976,50 @@ impl crate::entities::traits::Grippable for MultiLeader {
}
fn apply_grip_menu(
&mut self,
_grip_id: usize,
_action: crate::scene::object::GripMenuAction,
grip_id: usize,
action: crate::scene::object::GripMenuAction,
) {
// Add/Remove Leader, Move with Leader, Move Independent — these
// require deeper structural edits across leader_roots/lines and
// are stubbed here; Stretch falls through to the regular drag.
use crate::scene::object::GripMenuAction as A;
// Locate the (root, line) and vertex position owning this grip id.
let mut idx = 0usize;
let mut loc: Option<(usize, usize, acadrust::types::Vector3)> = None;
'find: for (ri, root) in self.context.leader_roots.iter().enumerate() {
for (li, line) in root.lines.iter().enumerate() {
let n = line.points.len();
if grip_id < idx + n {
loc = Some((ri, li, line.points[grip_id - idx]));
break 'find;
}
idx += n;
}
}
let Some((ri, li, vpos)) = loc else { return };
match action {
A::RemoveLeader => {
let total: usize =
self.context.leader_roots.iter().map(|r| r.lines.len()).sum();
if total > 1 {
self.context.leader_roots[ri].lines.remove(li);
if self.context.leader_roots[ri].lines.is_empty()
&& self.context.leader_roots.len() > 1
{
self.context.leader_roots.remove(ri);
}
}
}
A::AddLeader => {
// Append to the last root so the new arrow is the last grip id
// (so the caller can immediately grab it for placement). Seed it
// below the picked vertex; the user drags it to the final spot.
let _ = ri;
let off = self.text_height.max(1.0) * 4.0;
let arrow = acadrust::types::Vector3::new(vpos.x, vpos.y - off, vpos.z);
if let Some(root) = self.context.leader_roots.last_mut() {
root.create_line(vec![arrow]);
}
}
_ => {}
}
}
}
@@ -1004,6 +1151,20 @@ impl MultiLeaderTess for MultiLeader {
let mut arrow_fill: Vec<[f32; 3]> = Vec::new();
let mut first = true;
// Which side the text grip sits on, recomputed every frame so the text
// alignment and the landing mirror live when the arrow or text moves.
let text_loc_w = ml.context.text_location;
let leader_ref_w = 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(text_loc_w);
let text_sign_w: f64 = if text_loc_w.x >= leader_ref_w.x { 1.0 } else { -1.0 };
for root in &ml.context.leader_roots {
let cp = &root.connection_point;
let cp_f = p3(cp);
@@ -1078,21 +1239,19 @@ impl MultiLeaderTess for MultiLeader {
}
if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 {
let dir = &root.direction;
let dl = (dir.x * dir.x + dir.y * dir.y).sqrt().max(1e-9);
// Horizontal landing toward the text's side (mirrors live).
let d = ml.dogleg_length * effective_scale as f64;
let landing_end = [
(cp.x + dir.x / dl * d - ox) as f32,
(cp.y + dir.y / dl * d - oy) as f32,
(cp.x + text_sign_w * d - ox) as f32,
(cp.y - oy) as f32,
cp_f[2],
];
points.push(nan);
points.push(cp_f);
points.push(landing_end);
// extend_leader_to_text: continue past the landing to the text
// insertion point along the same direction.
if ml.extend_leader_to_text
&& ml.content_type == LeaderContentType::MText
// Continue the landing to the text's near edge so it stays
// attached as the text moves.
if ml.content_type == LeaderContentType::MText
&& !ml.context.text_string.is_empty()
{
let tx = (ml.context.text_location.x - ox) as f32;
@@ -1262,17 +1421,10 @@ impl MultiLeaderTess for MultiLeader {
}
let (cos_r, sin_r) = (rot.cos(), rot.sin());
// 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).
use acadrust::entities::multileader::{TextAlignmentType, TextAttachmentDirectionType};
let h_anchor = match (ml.text_alignment, ctx.text_attachment_point) {
(TextAlignmentType::Center, _) => 0.5_f32,
(TextAlignmentType::Right, _) => 1.0,
(TextAlignmentType::Left, TextAttachmentPointType::Center) => 0.5,
(TextAlignmentType::Left, TextAttachmentPointType::Right) => 1.0,
_ => 0.0,
};
// 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 };
// 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).
+2 -2
View File
@@ -1,6 +1,6 @@
// LEADER command
//
// Flow (matching the classic AutoCAD LEADER):
// Flow:
// 1. Click the arrowhead point, then one or more bend points.
// 2. Enter (≥2 points) places the leader line plus a linked, empty MText
// annotation at the landing, and opens the in-place MText editor so the
@@ -132,7 +132,7 @@ fn build_mtext(text: &str, pos: Vec3, height: f64, attach: AttachmentPoint) -> M
fn preview_wire(pts: &[Vec3]) -> WireModel {
let mut points: Vec<[f32; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
if pts.len() >= 2 {
let [w1, w2] = arrowhead_wings(pts[0], pts[1], 2.0);
let [w1, w2] = arrowhead_wings(pts[0], pts[1], 2.5);
points.push([f32::NAN; 3]);
points.push([w1.x, w1.y, w1.z]);
points.push([pts[0].x, pts[0].y, pts[0].z]);
+20 -10
View File
@@ -102,25 +102,35 @@ fn build_mleader(text: &str, verts: &[Vec3]) -> MultiLeader {
ml.arrowhead_size = 2.5;
ml.dogleg_length = 2.5;
// Direction: from last leader pt toward content
if let (Some(last_leader), Some(root)) =
(leader_pts.last(), ml.context.leader_roots.first_mut())
{
let dx = (content_pt.x - last_leader.x) as f64;
let dy = (content_pt.y - last_leader.y) as f64;
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
root.direction = Vector3::new(dx / len, dy / len, 0.0);
const DOGLEG: f64 = 2.5;
// Which side of the leader the text sits on, from the last leader
// segment's horizontal direction. The landing is always horizontal.
let last_leader = leader_pts.last().copied().unwrap_or(content_pt);
let to_right = (content_pt.x - last_leader.x) >= 0.0;
let sign = if to_right { 1.0 } else { -1.0 };
if let Some(root) = ml.context.leader_roots.first_mut() {
// Leader ends at the clicked point; a horizontal landing runs from
// there toward the text.
root.direction = Vector3::new(sign, 0.0, 0.0);
root.connection_point = content_v3;
root.landing_distance = 2.5;
root.landing_distance = DOGLEG;
}
// Seed the text one landing-length past the leader end, on the side the
// user dragged toward. The actual horizontal alignment and the landing are
// recomputed every frame from the text grip's position relative to the
// leader, so moving the arrow or the text re-mirrors the layout.
ml.context.text_location =
Vector3::new(content_v3.x + sign * DOGLEG, content_v3.y, content_v3.z);
ml
}
fn preview_wire(pts: &[Vec3]) -> WireModel {
let mut points: Vec<[f32; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
if pts.len() >= 2 {
let [w1, w2] = arrowhead_wings(pts[0], pts[1], 2.0);
let [w1, w2] = arrowhead_wings(pts[0], pts[1], 2.5);
points.push([f32::NAN; 3]);
points.push([w1.x, w1.y, w1.z]);
points.push([pts[0].x, pts[0].y, pts[0].z]);