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()
}