diff --git a/src/app/mod.rs b/src/app/mod.rs index 6affa7b0d..9b8ffa22e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -205,6 +205,10 @@ pub(super) struct OpenCADStudio { /// until dismissed (click outside, ESC, cursor leaves the grip). grip_popup: Option, grip_pending: Option, + /// 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, diff --git a/src/app/update.rs b/src/app/update.rs index 727add69e..a8365333d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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::(), + ), + _ => 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() } diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 03d249039..e7c81a4db 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -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)), diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 7d0711d28..8ebef03da 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -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= 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 = 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 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 Option (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 { let mut result: Vec = Vec::new(); let mut id = 0usize; @@ -311,16 +359,54 @@ fn grips(ml: &MultiLeader) -> Vec { 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 { 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). diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index db03c967a..d3be97fbe 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -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]); diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 7a408ad9f..6b7706b83 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -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]);