feat(ucs): orient text-bearing annotations to the active UCS

New annotations now place their text and reading geometry along the
active UCS X axis (identity = world), so they read square to the user's
coordinate system instead of world-horizontal:

- TEXT / MTEXT: rotation defaults to the UCS X angle
  (DocumentTab::ucs_rotation_angle, model space only).
- Linear dimensions: measure along the UCS axis the span is nearest, and
  bake the UCS X angle as the text rotation so the value reads square
  even under a style that forces horizontal text. DIMCONTINUE / DIMBASELINE
  derive their axis from the base dim's rotation (any angle), not a
  world H/V test.
- LEADER: the MText annotation rotates to the UCS, and the hookline runs
  along the leader's horizontal_direction (set to UCS X), used by the
  renderer instead of world horizontal.
- MLEADER: context.text_direction = UCS X drives both the text rotation
  and, in the renderer, the text side and dogleg/landing direction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-22 18:18:35 +03:00
1 parent 0c2a97e17e
commit 33314a5889
10 files changed
+160 -73

No files matched your search

+11
View File
@@ -217,6 +217,17 @@ impl DocumentTab {
) )
} }
/// World-space rotation angle (radians) of the active UCS X axis — the
/// default rotation for new text-bearing objects so their text aligns to
/// the user's coordinate system. Zero outside model space / with no UCS.
pub(super) fn ucs_rotation_angle(&self) -> f64 {
if self.scene.current_layout != "Model" {
return 0.0;
}
let (_, x, ..) = self.ucs_xform().axes();
(x.y as f64).atan2(x.x as f64)
}
/// Grid origin (render/wire space) and UCS→world rotation for grid snap and /// Grid origin (render/wire space) and UCS→world rotation for grid snap and
/// the grid overlay. Identity / origin-at-zero outside model space. /// the grid overlay. Identity / origin-at-zero outside model space.
pub(super) fn ucs_grid_basis(&self) -> (glam::Vec3, glam::Mat4) { pub(super) fn ucs_grid_basis(&self) -> (glam::Vec3, glam::Mat4) {
+3 -1
View File
@@ -632,7 +632,7 @@ impl super::OpenCADStudio {
let i = self.active_tab; let i = self.active_tab;
let Some(ed) = self.mtext_editor.take() else { return false }; let Some(ed) = self.mtext_editor.take() else { return false };
let body_empty = ed.content.text().trim().is_empty(); let body_empty = ed.content.text().trim().is_empty();
let mt = ed.build_mtext(); let mut mt = ed.build_mtext();
if body_empty { if body_empty {
// Empty content: drop a new entity; leave an edited one untouched. // Empty content: drop a new entity; leave an edited one untouched.
self.refresh_properties(); self.refresh_properties();
@@ -661,6 +661,8 @@ impl super::OpenCADStudio {
self.tabs[i].scene.bump_geometry(); self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
} else { } else {
// Align new MText to the active UCS (text runs along the UCS X axis).
mt.rotation = self.tabs[i].ucs_rotation_angle();
self.push_undo_snapshot(i, "MTEXT"); self.push_undo_snapshot(i, "MTEXT");
self.commit_entity(EntityType::MText(mt)); self.commit_entity(EntityType::MText(mt));
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
+2
View File
@@ -225,6 +225,8 @@ impl super::OpenCADStudio {
if !cur_style.is_empty() { if !cur_style.is_empty() {
t.style = cur_style; t.style = cur_style;
} }
// Align new text to the active UCS (baseline along the UCS X axis).
t.rotation = self.tabs[i].ucs_rotation_angle();
self.push_undo_snapshot(i, "TEXT"); self.push_undo_snapshot(i, "TEXT");
self.commit_entity(EntityType::Text(t)); self.commit_entity(EntityType::Text(t));
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
+18 -2
View File
@@ -65,7 +65,19 @@ fn to_truck(leader: &Leader) -> TruckEntity {
if leader.hookline_enabled && verts.len() >= 2 { if leader.hookline_enabled && verts.len() >= 2 {
let last = verts.last().unwrap(); let last = verts.last().unwrap();
let prev = &verts[verts.len() - 2]; let prev = &verts[verts.len() - 2];
let sign = if (last.x - prev.x) >= 0.0 { // Landing runs along the leader's horizontal direction (UCS X for
// UCS-placed leaders, world X otherwise), on the side the leader
// approaches from.
let (hx, hy) = {
let h = leader.horizontal_direction;
let l = (h.x * h.x + h.y * h.y).sqrt();
if l > 1e-9 {
(h.x / l, h.y / l)
} else {
(1.0, 0.0)
}
};
let sign = if (last.x - prev.x) * hx + (last.y - prev.y) * hy >= 0.0 {
1.0_f64 1.0_f64
} else { } else {
-1.0_f64 -1.0_f64
@@ -74,7 +86,11 @@ fn to_truck(leader: &Leader) -> TruckEntity {
let last_f = p3(last); let last_f = p3(last);
points.push(nan); points.push(nan);
points.push(last_f); points.push(last_f);
points.push([last_f[0] + sign * len, last_f[1], last_f[2]]); points.push([
last_f[0] + sign * len * hx,
last_f[1] + sign * len * hy,
last_f[2],
]);
} }
TruckEntity { TruckEntity {
+20 -5
View File
@@ -102,11 +102,22 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
.copied() .copied()
.or_else(|| ml.context.leader_roots.first().map(|r| r.connection_point)) .or_else(|| ml.context.leader_roots.first().map(|r| r.connection_point))
.unwrap_or(text_loc); .unwrap_or(text_loc);
let text_sign: f64 = if text_loc.x >= leader_ref.x { // Reading direction of the text / landing — the stored text_direction
1.0 // (UCS X for UCS-placed leaders, world X otherwise), so the whole text-side
} else { // layout follows the UCS instead of world horizontal.
-1.0 let (tdx, tdy) = {
let td = ml.context.text_direction;
let l = (td.x * td.x + td.y * td.y).sqrt();
if l > 1e-9 {
(td.x / l, td.y / l)
} else {
let a = ml.context.text_rotation;
(a.cos(), a.sin())
}
}; };
// Which side of the leader the text sits on, measured along that direction.
let rel_dot = (text_loc.x - leader_ref.x) * tdx + (text_loc.y - leader_ref.y) * tdy;
let text_sign: f64 = if rel_dot >= 0.0 { 1.0 } else { -1.0 };
// Lay the text out once, up front: its width fixes where the landing meets // 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. // the text. The same layout is reused below to emit the glyph strokes.
@@ -164,7 +175,11 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
}; };
// text_location is the leader-facing (near) edge; the landing runs one // text_location is the leader-facing (near) edge; the landing runs one
// dogleg from there back toward the leader. // dogleg from there back toward the leader.
let landing_pt = [text_loc.x - text_sign * dogleg, text_loc.y, text_loc.z]; let landing_pt = [
text_loc.x - text_sign * dogleg * tdx,
text_loc.y - text_sign * dogleg * tdy,
text_loc.z,
];
for root in &ml.context.leader_roots { for root in &ml.context.leader_roots {
let cp = &root.connection_point; let cp = &root.connection_point;
+3 -5
View File
@@ -71,11 +71,9 @@ impl DimBaselineCommand {
rotation: f64, rotation: f64,
dimdli: f32, dimdli: f32,
) -> Self { ) -> Self {
let axis = if rotation.abs() < 0.1 { // Measurement axis = the base dim's rotation angle (any angle, incl. a
Vec3::X // UCS-aligned one), not a world horizontal/vertical.
} else { let axis = Vec3::new(rotation.cos() as f32, rotation.sin() as f32, 0.0);
Vec3::Y
};
let perp = Vec3::new(-axis.y, axis.x, 0.0); let perp = Vec3::new(-axis.y, axis.x, 0.0);
let base_offset = (definition_point - p1).dot(perp); let base_offset = (definition_point - p1).dot(perp);
let dimdli = if dimdli.abs() < 1e-6 { let dimdli = if dimdli.abs() < 1e-6 {
+3 -6
View File
@@ -58,12 +58,9 @@ impl DimContinueCommand {
/// `definition_point` — where the dim line was placed (defines perpendicular offset). /// `definition_point` — where the dim line was placed (defines perpendicular offset).
/// `rotation` — 0.0 = horizontal dim, PI/2 = vertical dim. /// `rotation` — 0.0 = horizontal dim, PI/2 = vertical dim.
pub fn from_base(p1: Vec3, p2: Vec3, definition_point: Vec3, rotation: f64) -> Self { pub fn from_base(p1: Vec3, p2: Vec3, definition_point: Vec3, rotation: f64) -> Self {
// Axis unit vector along the measurement direction. // Axis unit vector along the measurement direction — the base dim's
let axis = if rotation.abs() < 0.1 { // rotation angle (any angle, incl. a UCS-aligned one), not a world H/V.
Vec3::X let axis = Vec3::new(rotation.cos() as f32, rotation.sin() as f32, 0.0);
} else {
Vec3::Y
};
// Perpendicular unit vector toward the dim line. // Perpendicular unit vector toward the dim line.
let perp = Vec3::new(-axis.y, axis.x, 0.0); let perp = Vec3::new(-axis.y, axis.x, 0.0);
let dim_offset = (definition_point - p1).dot(perp); let dim_offset = (definition_point - p1).dot(perp);
+34 -10
View File
@@ -13,7 +13,7 @@ use acadrust::entities::mtext::AttachmentPoint;
use acadrust::entities::{Leader, LeaderCreationType, MText}; use acadrust::entities::{Leader, LeaderCreationType, MText};
use acadrust::types::Vector3; use acadrust::types::Vector3;
use acadrust::EntityType; use acadrust::EntityType;
use glam::Vec3; use glam::{Mat4, Vec3};
use crate::command::{CadCommand, CmdResult}; use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::modules::{IconKind, ModuleEvent, ToolDef};
@@ -32,11 +32,12 @@ pub fn tool() -> ToolDef {
pub struct LeaderCommand { pub struct LeaderCommand {
verts: Vec<Vec3>, verts: Vec<Vec3>,
ucs: Mat4,
} }
impl LeaderCommand { impl LeaderCommand {
pub fn new() -> Self { pub fn new() -> Self {
Self { verts: Vec::new() } Self { verts: Vec::new(), ucs: Mat4::IDENTITY }
} }
} }
@@ -45,6 +46,10 @@ impl CadCommand for LeaderCommand {
"LEADER" "LEADER"
} }
fn set_ucs(&mut self, ucs: Mat4) {
self.ucs = ucs;
}
fn prompt(&self) -> String { fn prompt(&self) -> String {
if self.verts.is_empty() { if self.verts.is_empty() {
"LEADER Specify arrowhead point:".into() "LEADER Specify arrowhead point:".into()
@@ -67,9 +72,9 @@ impl CadCommand for LeaderCommand {
} }
// Place the leader plus an empty MText annotation, link them, then open // Place the leader plus an empty MText annotation, link them, then open
// the in-place MText editor so the user types the annotation text. // the in-place MText editor so the user types the annotation text.
let leader = build_leader(&self.verts); let leader = build_leader(&self.verts, self.ucs);
let (anchor, attach) = annotation_anchor(&self.verts, leader.text_height); let (anchor, attach) = annotation_anchor(&self.verts, leader.text_height, self.ucs);
let mtext = build_mtext("", anchor, leader.text_height, attach); let mtext = build_mtext("", anchor, leader.text_height, attach, self.ucs);
CmdResult::CommitManyAndEditText { CmdResult::CommitManyAndEditText {
entities: vec![EntityType::Leader(leader), EntityType::MText(mtext)], entities: vec![EntityType::Leader(leader), EntityType::MText(mtext)],
edit_index: 1, edit_index: 1,
@@ -96,22 +101,32 @@ fn v3(p: Vec3) -> Vector3 {
Vector3::new(p.x as f64, p.y as f64, p.z as f64) Vector3::new(p.x as f64, p.y as f64, p.z as f64)
} }
fn build_leader(verts: &[Vec3]) -> Leader { fn build_leader(verts: &[Vec3], ucs: Mat4) -> Leader {
let mut l = Leader::from_vertices(verts.iter().map(|p| v3(*p)).collect()); let mut l = Leader::from_vertices(verts.iter().map(|p| v3(*p)).collect());
l.creation_type = LeaderCreationType::WithText; l.creation_type = LeaderCreationType::WithText;
l.hookline_enabled = true; l.hookline_enabled = true;
// The hookline / text read along the UCS X axis (DXF "horizontal direction
// for text"); the renderer uses it instead of world horizontal.
let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X);
l.horizontal_direction = Vector3::new(ux.x as f64, ux.y as f64, 0.0);
l l
} }
/// Text anchor at the end of the landing line, and the attachment point that /// Text anchor at the end of the landing line, and the attachment point that
/// keeps the text reading away from the leader (text to the right of a /// keeps the text reading away from the leader (text to the right of a
/// left-pointing landing, to the left of a right-pointing one). /// left-pointing landing, to the left of a right-pointing one).
fn annotation_anchor(verts: &[Vec3], text_height: f64) -> (Vec3, AttachmentPoint) { fn annotation_anchor(
verts: &[Vec3],
text_height: f64,
ucs: Mat4,
) -> (Vec3, AttachmentPoint) {
let last = *verts.last().unwrap(); let last = *verts.last().unwrap();
let prev = verts[verts.len() - 2]; let prev = verts[verts.len() - 2];
let to_right = last.x >= prev.x; // Side + landing run along the UCS X axis (identity = world).
let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X);
let to_right = (last - prev).dot(ux) >= 0.0;
let sign = if to_right { 1.0_f32 } else { -1.0_f32 }; let sign = if to_right { 1.0_f32 } else { -1.0_f32 };
let anchor = Vec3::new(last.x + sign * text_height as f32 * 1.5, last.y, last.z); let anchor = last + ux * (sign * text_height as f32 * 1.5);
let attach = if to_right { let attach = if to_right {
AttachmentPoint::MiddleLeft AttachmentPoint::MiddleLeft
} else { } else {
@@ -120,12 +135,21 @@ fn annotation_anchor(verts: &[Vec3], text_height: f64) -> (Vec3, AttachmentPoint
(anchor, attach) (anchor, attach)
} }
fn build_mtext(text: &str, pos: Vec3, height: f64, attach: AttachmentPoint) -> MText { fn build_mtext(
text: &str,
pos: Vec3,
height: f64,
attach: AttachmentPoint,
ucs: Mat4,
) -> MText {
let mut m = MText::new(); let mut m = MText::new();
m.value = text.to_string(); m.value = text.to_string();
m.insertion_point = v3(pos); m.insertion_point = v3(pos);
m.height = height; m.height = height;
m.attachment_point = attach; m.attachment_point = attach;
// Text reads along the UCS X axis.
let ux = ucs.transform_vector3(Vec3::X);
m.rotation = (ux.y as f64).atan2(ux.x as f64);
m m
} }
+40 -30
View File
@@ -5,7 +5,21 @@ use acadrust::EntityType;
use crate::command::{CadCommand, CmdResult}; use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel; use crate::scene::model::wire_model::WireModel;
use glam::Vec3; use glam::{Mat4, Vec3};
/// World-space measurement axis for a linear dimension between `first` and
/// `second`, chosen as the UCS X or Y axis (whichever the span is closer to).
/// `ucs` is the UCS→wire affine; identity gives the world X/Y behaviour.
fn measure_axis(first: Vec3, second: Vec3, ucs: Mat4) -> Vec3 {
let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X);
let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y);
let du = ucs.inverse().transform_vector3(second - first);
if du.x.abs() >= du.y.abs() {
ux
} else {
uy
}
}
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_linear.svg")); pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_linear.svg"));
@@ -26,12 +40,14 @@ enum Step {
pub struct LinearDimensionCommand { pub struct LinearDimensionCommand {
step: Step, step: Step,
ucs: Mat4,
} }
impl LinearDimensionCommand { impl LinearDimensionCommand {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
step: Step::FirstPoint, step: Step::FirstPoint,
ucs: Mat4::IDENTITY,
} }
} }
} }
@@ -41,6 +57,10 @@ impl CadCommand for LinearDimensionCommand {
"DIMLINEAR" "DIMLINEAR"
} }
fn set_ucs(&mut self, ucs: Mat4) {
self.ucs = ucs;
}
fn prompt(&self) -> String { fn prompt(&self) -> String {
match self.step { match self.step {
Step::FirstPoint => "DIMLINEAR Specify first extension line origin:".into(), Step::FirstPoint => "DIMLINEAR Specify first extension line origin:".into(),
@@ -61,14 +81,23 @@ impl CadCommand for LinearDimensionCommand {
} }
Step::DimensionLine { first, second } => { Step::DimensionLine { first, second } => {
let mut dim = DimensionLinear::new(v3(first), v3(second)); let mut dim = DimensionLinear::new(v3(first), v3(second));
dim.rotation = if (second.y - first.y).abs() > (second.x - first.x).abs() { // Measure along the UCS axis the span is closest to; the DXF
std::f64::consts::FRAC_PI_2 // rotation is that axis's angle in world space.
} else { let axis = measure_axis(first, second, self.ucs);
0.0 dim.rotation = (axis.y as f64).atan2(axis.x as f64);
}; // Bake the UCS X-axis angle as the text rotation so the
// measurement text reads horizontally in the UCS (i.e. square on
// screen) regardless of the style's force-horizontal flags,
// which would otherwise pin it to world-horizontal and tilt it
// under a rotated UCS. Left at 0 (natural) when there's no UCS.
let ux = self.ucs.transform_vector3(Vec3::X);
let ucs_ang = (ux.y as f64).atan2(ux.x as f64);
if ucs_ang.abs() > 1e-9 {
dim.base.text_rotation = ucs_ang;
}
dim.definition_point = v3(pt); dim.definition_point = v3(pt);
dim.base.definition_point = v3(pt); dim.base.definition_point = v3(pt);
dim.base.text_middle_point = v3(linear_text_pos(first, second, pt)); dim.base.text_middle_point = v3(linear_text_pos(first, second, pt, axis));
dim.base.insertion_point = dim.base.text_middle_point; dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement(); dim.base.actual_measurement = dim.measurement();
CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Linear(dim))) CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Linear(dim)))
@@ -89,7 +118,8 @@ impl CadCommand for LinearDimensionCommand {
Step::FirstPoint => None, Step::FirstPoint => None,
Step::SecondPoint(first) => Some(preview_wire(vec![first, pt])), Step::SecondPoint(first) => Some(preview_wire(vec![first, pt])),
Step::DimensionLine { first, second } => { Step::DimensionLine { first, second } => {
Some(preview_wire(linear_dimension_preview(first, second, pt))) let axis = measure_axis(first, second, self.ucs);
Some(preview_wire(linear_dimension_preview(first, second, pt, axis)))
} }
} }
} }
@@ -119,17 +149,7 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
} }
} }
fn linear_dimension_preview(first: Vec3, second: Vec3, def: Vec3) -> Vec<Vec3> { fn linear_dimension_preview(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec<Vec3> {
let axis = {
let d = second - first;
if d.length_squared() <= 1e-12 {
Vec3::X
} else if d.x.abs() >= d.y.abs() {
Vec3::X
} else {
Vec3::Y
}
};
let perp = Vec3::new(-axis.y, axis.x, 0.0); let perp = Vec3::new(-axis.y, axis.x, 0.0);
let offset = (def - first).dot(perp); let offset = (def - first).dot(perp);
let d1 = first + perp * offset; let d1 = first + perp * offset;
@@ -146,17 +166,7 @@ fn linear_dimension_preview(first: Vec3, second: Vec3, def: Vec3) -> Vec<Vec3> {
] ]
} }
fn linear_text_pos(first: Vec3, second: Vec3, def: Vec3) -> Vec3 { fn linear_text_pos(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec3 {
let axis = {
let d = second - first;
if d.length_squared() <= 1e-12 {
Vec3::X
} else if d.x.abs() >= d.y.abs() {
Vec3::X
} else {
Vec3::Y
}
};
let perp = Vec3::new(-axis.y, axis.x, 0.0); let perp = Vec3::new(-axis.y, axis.x, 0.0);
let offset = (def - first).dot(perp); let offset = (def - first).dot(perp);
let d1 = first + perp * offset; let d1 = first + perp * offset;
+26 -14
View File
@@ -8,7 +8,7 @@
use acadrust::entities::MultiLeader; use acadrust::entities::MultiLeader;
use acadrust::types::Vector3; use acadrust::types::Vector3;
use acadrust::EntityType; use acadrust::EntityType;
use glam::Vec3; use glam::{Mat4, Vec3};
use crate::command::{CadCommand, CmdResult}; use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::modules::{IconKind, ModuleEvent, ToolDef};
@@ -27,11 +27,12 @@ pub fn tool() -> ToolDef {
pub struct MLeaderCommand { pub struct MLeaderCommand {
verts: Vec<Vec3>, verts: Vec<Vec3>,
ucs: Mat4,
} }
impl MLeaderCommand { impl MLeaderCommand {
pub fn new() -> Self { pub fn new() -> Self {
Self { verts: Vec::new() } Self { verts: Vec::new(), ucs: Mat4::IDENTITY }
} }
} }
@@ -40,6 +41,10 @@ impl CadCommand for MLeaderCommand {
"MLEADER" "MLEADER"
} }
fn set_ucs(&mut self, ucs: Mat4) {
self.ucs = ucs;
}
fn prompt(&self) -> String { fn prompt(&self) -> String {
if self.verts.is_empty() { if self.verts.is_empty() {
"MLEADER Specify arrowhead point:".into() "MLEADER Specify arrowhead point:".into()
@@ -62,7 +67,7 @@ impl CadCommand for MLeaderCommand {
} }
// Place the leader with empty text, then open the in-place MText editor // Place the leader with empty text, then open the in-place MText editor
// so the user types the annotation into the rich editor. // so the user types the annotation into the rich editor.
let ml = build_mleader("", &self.verts); let ml = build_mleader("", &self.verts, self.ucs);
CmdResult::CommitAndEditText(EntityType::MultiLeader(ml)) CmdResult::CommitAndEditText(EntityType::MultiLeader(ml))
} }
@@ -86,7 +91,7 @@ fn v3(p: Vec3) -> Vector3 {
Vector3::new(p.x as f64, p.y as f64, p.z as f64) Vector3::new(p.x as f64, p.y as f64, p.z as f64)
} }
fn build_mleader(text: &str, verts: &[Vec3]) -> MultiLeader { fn build_mleader(text: &str, verts: &[Vec3], ucs: Mat4) -> MultiLeader {
// Last vertex = content/text location; remaining = leader line points // Last vertex = content/text location; remaining = leader line points
let (leader_pts, content_pt) = verts.split_at(verts.len() - 1); let (leader_pts, content_pt) = verts.split_at(verts.len() - 1);
let content_pt = content_pt[0]; let content_pt = content_pt[0];
@@ -103,26 +108,33 @@ fn build_mleader(text: &str, verts: &[Vec3]) -> MultiLeader {
ml.dogleg_length = 2.5; ml.dogleg_length = 2.5;
const DOGLEG: f64 = 2.5; const DOGLEG: f64 = 2.5;
// Which side of the leader the text sits on, from the last leader // "Horizontal" landing + text run along the active UCS X axis (identity =
// segment's horizontal direction. The landing is always horizontal. // world), so the annotation reads square to the user's coordinate system.
let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X);
// Which side of the leader the text sits on, measured along the UCS X axis.
let last_leader = leader_pts.last().copied().unwrap_or(content_pt); let last_leader = leader_pts.last().copied().unwrap_or(content_pt);
let to_right = (content_pt.x - last_leader.x) >= 0.0; let to_right = (content_pt - last_leader).dot(ux) >= 0.0;
let sign = if to_right { 1.0 } else { -1.0 }; let sign = if to_right { 1.0 } else { -1.0 };
let landing = ux * (sign as f32);
// Text + landing read along the UCS X axis. text_direction is what the
// renderer consults first, so set both.
ml.context.text_rotation = (ux.y as f64).atan2(ux.x as f64);
ml.context.text_direction = Vector3::new(ux.x as f64, ux.y as f64, 0.0);
if let Some(root) = ml.context.leader_roots.first_mut() { if let Some(root) = ml.context.leader_roots.first_mut() {
// Leader ends at the clicked point; a horizontal landing runs from // Leader ends at the clicked point; the landing runs from there toward
// there toward the text. // the text along the UCS X axis.
root.direction = Vector3::new(sign, 0.0, 0.0); root.direction = Vector3::new(landing.x as f64, landing.y as f64, 0.0);
root.connection_point = content_v3; root.connection_point = content_v3;
root.landing_distance = DOGLEG; root.landing_distance = DOGLEG;
} }
// Seed the text one landing-length past the leader end, on the side the // 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 // user dragged toward, offset along the UCS X axis.
// recomputed every frame from the text grip's position relative to the let off = landing * DOGLEG as f32;
// leader, so moving the arrow or the text re-mirrors the layout.
ml.context.text_location = ml.context.text_location =
Vector3::new(content_v3.x + sign * DOGLEG, content_v3.y, content_v3.z); Vector3::new(content_v3.x + off.x as f64, content_v3.y + off.y as f64, content_v3.z);
ml ml
} }