From 33314a58893404aec2f33e1b952f260025de13a9 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 22 Jun 2026 18:18:35 +0300 Subject: [PATCH] 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 --- src/app/document.rs | 11 +++++ src/app/mtext_editor.rs | 4 +- src/app/text_inline.rs | 2 + src/entities/leader.rs | 20 +++++++- src/entities/multileader.rs | 25 ++++++++-- src/modules/annotate/dim_baseline.rs | 8 ++-- src/modules/annotate/dim_continue.rs | 9 ++-- src/modules/annotate/leader_cmd.rs | 44 +++++++++++++---- src/modules/annotate/linear_dim.rs | 70 ++++++++++++++++------------ src/modules/annotate/mleader_cmd.rs | 40 ++++++++++------ 10 files changed, 160 insertions(+), 73 deletions(-) diff --git a/src/app/document.rs b/src/app/document.rs index 28c21d892..0caae3ea3 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -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 /// the grid overlay. Identity / origin-at-zero outside model space. pub(super) fn ucs_grid_basis(&self) -> (glam::Vec3, glam::Mat4) { diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index aff2713d3..f6dfed869 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -632,7 +632,7 @@ impl super::OpenCADStudio { let i = self.active_tab; let Some(ed) = self.mtext_editor.take() else { return false }; let body_empty = ed.content.text().trim().is_empty(); - let mt = ed.build_mtext(); + let mut mt = ed.build_mtext(); if body_empty { // Empty content: drop a new entity; leave an edited one untouched. self.refresh_properties(); @@ -661,6 +661,8 @@ impl super::OpenCADStudio { self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; } 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.commit_entity(EntityType::MText(mt)); self.tabs[i].dirty = true; diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index e66470631..6d1fc10aa 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -225,6 +225,8 @@ impl super::OpenCADStudio { if !cur_style.is_empty() { 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.commit_entity(EntityType::Text(t)); self.tabs[i].dirty = true; diff --git a/src/entities/leader.rs b/src/entities/leader.rs index dbe88f2a9..47ef8e972 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -65,7 +65,19 @@ fn to_truck(leader: &Leader) -> TruckEntity { if leader.hookline_enabled && verts.len() >= 2 { let last = verts.last().unwrap(); 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 } else { -1.0_f64 @@ -74,7 +86,11 @@ fn to_truck(leader: &Leader) -> TruckEntity { let last_f = p3(last); points.push(nan); 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 { diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 04a839397..d174edcc6 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -102,11 +102,22 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option= leader_ref.x { - 1.0 - } else { - -1.0 + // Reading direction of the text / landing — the stored text_direction + // (UCS X for UCS-placed leaders, world X otherwise), so the whole text-side + // layout follows the UCS instead of world horizontal. + 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 // 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 Self { - let axis = if rotation.abs() < 0.1 { - Vec3::X - } else { - Vec3::Y - }; + // Measurement axis = the base dim's rotation angle (any angle, incl. a + // UCS-aligned one), not a world horizontal/vertical. + let axis = Vec3::new(rotation.cos() as f32, rotation.sin() as f32, 0.0); let perp = Vec3::new(-axis.y, axis.x, 0.0); let base_offset = (definition_point - p1).dot(perp); let dimdli = if dimdli.abs() < 1e-6 { diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index 02be66423..9cf52e817 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -58,12 +58,9 @@ impl DimContinueCommand { /// `definition_point` — where the dim line was placed (defines perpendicular offset). /// `rotation` — 0.0 = horizontal dim, PI/2 = vertical dim. pub fn from_base(p1: Vec3, p2: Vec3, definition_point: Vec3, rotation: f64) -> Self { - // Axis unit vector along the measurement direction. - let axis = if rotation.abs() < 0.1 { - Vec3::X - } else { - Vec3::Y - }; + // Axis unit vector along the measurement direction — the base dim's + // rotation angle (any angle, incl. a UCS-aligned one), not a world H/V. + let axis = Vec3::new(rotation.cos() as f32, rotation.sin() as f32, 0.0); // Perpendicular unit vector toward the dim line. let perp = Vec3::new(-axis.y, axis.x, 0.0); let dim_offset = (definition_point - p1).dot(perp); diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index 1f5338f99..b618f2f19 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -13,7 +13,7 @@ use acadrust::entities::mtext::AttachmentPoint; use acadrust::entities::{Leader, LeaderCreationType, MText}; use acadrust::types::Vector3; use acadrust::EntityType; -use glam::Vec3; +use glam::{Mat4, Vec3}; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -32,11 +32,12 @@ pub fn tool() -> ToolDef { pub struct LeaderCommand { verts: Vec, + ucs: Mat4, } impl LeaderCommand { pub fn new() -> Self { - Self { verts: Vec::new() } + Self { verts: Vec::new(), ucs: Mat4::IDENTITY } } } @@ -45,6 +46,10 @@ impl CadCommand for LeaderCommand { "LEADER" } + fn set_ucs(&mut self, ucs: Mat4) { + self.ucs = ucs; + } + fn prompt(&self) -> String { if self.verts.is_empty() { "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 // the in-place MText editor so the user types the annotation text. - let leader = build_leader(&self.verts); - let (anchor, attach) = annotation_anchor(&self.verts, leader.text_height); - let mtext = build_mtext("", anchor, leader.text_height, attach); + let leader = build_leader(&self.verts, self.ucs); + let (anchor, attach) = annotation_anchor(&self.verts, leader.text_height, self.ucs); + let mtext = build_mtext("", anchor, leader.text_height, attach, self.ucs); CmdResult::CommitManyAndEditText { entities: vec![EntityType::Leader(leader), EntityType::MText(mtext)], 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) } -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()); l.creation_type = LeaderCreationType::WithText; 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 } /// 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 /// 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 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 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 { AttachmentPoint::MiddleLeft } else { @@ -120,12 +135,21 @@ fn annotation_anchor(verts: &[Vec3], text_height: f64) -> (Vec3, AttachmentPoint (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(); m.value = text.to_string(); m.insertion_point = v3(pos); m.height = height; 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 } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 2c47b0ed3..cc2ccfed2 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -5,7 +5,21 @@ use acadrust::EntityType; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; 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")); @@ -26,12 +40,14 @@ enum Step { pub struct LinearDimensionCommand { step: Step, + ucs: Mat4, } impl LinearDimensionCommand { pub fn new() -> Self { Self { step: Step::FirstPoint, + ucs: Mat4::IDENTITY, } } } @@ -41,6 +57,10 @@ impl CadCommand for LinearDimensionCommand { "DIMLINEAR" } + fn set_ucs(&mut self, ucs: Mat4) { + self.ucs = ucs; + } + fn prompt(&self) -> String { match self.step { Step::FirstPoint => "DIMLINEAR Specify first extension line origin:".into(), @@ -61,14 +81,23 @@ impl CadCommand for LinearDimensionCommand { } Step::DimensionLine { first, second } => { let mut dim = DimensionLinear::new(v3(first), v3(second)); - dim.rotation = if (second.y - first.y).abs() > (second.x - first.x).abs() { - std::f64::consts::FRAC_PI_2 - } else { - 0.0 - }; + // Measure along the UCS axis the span is closest to; the DXF + // rotation is that axis's angle in world space. + let axis = measure_axis(first, second, self.ucs); + 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.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.actual_measurement = dim.measurement(); CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Linear(dim))) @@ -89,7 +118,8 @@ impl CadCommand for LinearDimensionCommand { Step::FirstPoint => None, Step::SecondPoint(first) => Some(preview_wire(vec![first, pt])), 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) -> WireModel { } } -fn linear_dimension_preview(first: Vec3, second: Vec3, def: Vec3) -> Vec { - 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 - } - }; +fn linear_dimension_preview(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec { let perp = Vec3::new(-axis.y, axis.x, 0.0); let offset = (def - first).dot(perp); let d1 = first + perp * offset; @@ -146,17 +166,7 @@ fn linear_dimension_preview(first: Vec3, second: Vec3, def: Vec3) -> Vec { ] } -fn linear_text_pos(first: Vec3, second: Vec3, def: 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 - } - }; +fn linear_text_pos(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec3 { let perp = Vec3::new(-axis.y, axis.x, 0.0); let offset = (def - first).dot(perp); let d1 = first + perp * offset; diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index f7fad9985..8762de559 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -8,7 +8,7 @@ use acadrust::entities::MultiLeader; use acadrust::types::Vector3; use acadrust::EntityType; -use glam::Vec3; +use glam::{Mat4, Vec3}; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -27,11 +27,12 @@ pub fn tool() -> ToolDef { pub struct MLeaderCommand { verts: Vec, + ucs: Mat4, } impl MLeaderCommand { pub fn new() -> Self { - Self { verts: Vec::new() } + Self { verts: Vec::new(), ucs: Mat4::IDENTITY } } } @@ -40,6 +41,10 @@ impl CadCommand for MLeaderCommand { "MLEADER" } + fn set_ucs(&mut self, ucs: Mat4) { + self.ucs = ucs; + } + fn prompt(&self) -> String { if self.verts.is_empty() { "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 // 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)) } @@ -86,7 +91,7 @@ fn v3(p: Vec3) -> Vector3 { 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 let (leader_pts, content_pt) = verts.split_at(verts.len() - 1); let content_pt = content_pt[0]; @@ -103,26 +108,33 @@ fn build_mleader(text: &str, verts: &[Vec3]) -> MultiLeader { ml.dogleg_length = 2.5; 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. + // "Horizontal" landing + text run along the active UCS X axis (identity = + // 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 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 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() { - // Leader ends at the clicked point; a horizontal landing runs from - // there toward the text. - root.direction = Vector3::new(sign, 0.0, 0.0); + // Leader ends at the clicked point; the landing runs from there toward + // the text along the UCS X axis. + root.direction = Vector3::new(landing.x as f64, landing.y as f64, 0.0); root.connection_point = content_v3; 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. + // user dragged toward, offset along the UCS X axis. + let off = landing * DOGLEG as f32; 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 }