diff --git a/COMMANDS.md b/COMMANDS.md index ff54c13c3..1812dc2af 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -141,6 +141,7 @@ Status of every standard CAD command in Open CAD Studio: | `FIELD` | — | Auto-updating text field | ❌ | | `SPELL` | SP | Spell check | ❌ | | `ARCTEXT` | — | Text along an arc | ❌ | +| `TORIENT` | — | Orient text for readability | ✅ | --- diff --git a/src/app/commands.rs b/src/app/commands.rs index 459b14cf2..4f347f201 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1122,6 +1122,33 @@ impl OpenCADStudio { } } + "TORIENT" => { + let handles: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let cmd = SelectObjectsCommand::new("TORIENT"); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } else { + use crate::modules::draw::modify::torient::TorientCommand; + let entities: Vec<_> = handles + .iter() + .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned().map(|e| (h, e))) + .collect(); + let cam_rot = self.tabs[i].scene.camera.borrow().rotation; + let right = cam_rot * glam::Vec3::X; + let view_twist = right.y.atan2(right.x) as f64; + let new_cmd = TorientCommand::new(handles, entities, view_twist); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + } + "POINT" | "PO" => { use crate::modules::draw::draw::point::PointCommand; let new_cmd = PointCommand::new(); diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index 5e65c51f7..cdc26d404 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -20,3 +20,4 @@ pub mod splinedit; pub mod stretch; pub mod translate; pub mod trim; +pub mod torient; diff --git a/src/modules/draw/modify/torient.rs b/src/modules/draw/modify/torient.rs new file mode 100644 index 000000000..d0359940a --- /dev/null +++ b/src/modules/draw/modify/torient.rs @@ -0,0 +1,190 @@ +// TORIENT tool — interactive command. +// +// Command: TORIENT +// Requires at least one entity selected before starting. +// Step 1: Wait for an angle (numerical input), Enter (Most Readable), or pick first point. +// Step 2: If first point picked, pick second point to define angle vector. + +use acadrust::Handle; +use glam::Vec3; + +use crate::command::{CadCommand, CmdResult, DynField}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use acadrust::EntityType; + +// ── Command implementation ───────────────────────────────────────────────── + +enum Step { + AngleOrFirstPoint, + SecondPoint { first_point: Vec3 }, +} + +pub struct TorientCommand { + handles: Vec, + entities: Vec<(Handle, EntityType)>, + view_twist: f64, + step: Step, +} + +impl TorientCommand { + pub fn new(handles: Vec, entities: Vec<(Handle, EntityType)>, view_twist: f64) -> Self { + Self { + handles, + entities, + view_twist, + step: Step::AngleOrFirstPoint, + } + } + + fn commit_angle(&self, new_angle_rad: Option) -> CmdResult { + let mut replacements = Vec::new(); + + for (handle, entity) in &self.entities { + let mut new_entity = entity.clone(); + let mut changed = false; + + match &mut new_entity { + EntityType::Text(text) => { + let angle = new_angle_rad.unwrap_or_else(|| most_readable_angle(text.rotation, self.view_twist)); + text.rotation = angle; + changed = true; + } + EntityType::MText(mtext) => { + let angle = new_angle_rad.unwrap_or_else(|| most_readable_angle(mtext.rotation as f64, self.view_twist)); + mtext.rotation = angle as f64; + changed = true; + } + EntityType::AttributeDefinition(attdef) => { + let angle = new_angle_rad.unwrap_or_else(|| most_readable_angle(attdef.rotation, self.view_twist)); + attdef.rotation = angle; + changed = true; + } + EntityType::Insert(insert) => { + let mut block_changed = false; + for attr in &mut insert.attributes { + let angle = new_angle_rad.unwrap_or_else(|| most_readable_angle(attr.rotation, self.view_twist)); + attr.rotation = angle; + block_changed = true; + } + if block_changed { + changed = true; + } + } + _ => {} + } + + if changed { + replacements.push((*handle, vec![new_entity])); + } + } + + if replacements.is_empty() { + CmdResult::Cancel + } else { + CmdResult::ReplaceMany(replacements, vec![]) + } + } +} + +fn most_readable_angle(wcs_angle_rad: f64, view_twist: f64) -> f64 { + let two_pi = 2.0 * std::f64::consts::PI; + + // Calculate the angle as it appears on screen + let mut screen_angle = (wcs_angle_rad - view_twist) % two_pi; + if screen_angle < 0.0 { + screen_angle += two_pi; + } + + // "Most Readable" logic: if upside down on screen (angle > 90 and <= 270 deg) + let half_pi = std::f64::consts::PI / 2.0; + let three_half_pi = 3.0 * std::f64::consts::PI / 2.0; + + let mut final_wcs_angle = wcs_angle_rad; + if screen_angle > half_pi + 1e-6 && screen_angle <= three_half_pi + 1e-6 { + final_wcs_angle += std::f64::consts::PI; + } + + // Normalize final WCS angle + final_wcs_angle %= two_pi; + if final_wcs_angle < 0.0 { + final_wcs_angle += two_pi; + } + final_wcs_angle +} + +impl CadCommand for TorientCommand { + fn name(&self) -> &'static str { + "TORIENT" + } + + fn prompt(&self) -> String { + match &self.step { + Step::AngleOrFirstPoint => "TORIENT New absolute rotation :".into(), + Step::SecondPoint { .. } => "TORIENT Specify second point:".into(), + } + } + + fn on_point(&mut self, pt: Vec3) -> CmdResult { + match self.step { + Step::AngleOrFirstPoint => { + self.step = Step::SecondPoint { first_point: pt }; + CmdResult::NeedPoint + } + Step::SecondPoint { first_point } => { + let dx = pt.x - first_point.x; + let dy = pt.y - first_point.y; + let angle = dy.atan2(dx) as f64; + self.commit_angle(Some(angle)) + } + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::AngleOrFirstPoint => self.commit_angle(None), + Step::SecondPoint { .. } => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_text_input(&mut self, text: &str) -> Option { + if let Step::AngleOrFirstPoint = self.step { + let deg: f64 = text.trim().replace(',', ".").parse().ok()?; + Some(self.commit_angle(Some(deg.to_radians()))) + } else { + None + } + } + + fn on_preview_wires(&mut self, pt: Vec3) -> Vec { + if let Step::SecondPoint { first_point } = self.step { + vec![WireModel::solid( + "rubber_band".into(), + vec![[first_point.x, first_point.y, first_point.z], [pt.x, pt.y, pt.z]], + WireModel::CYAN, + false, + )] + } else { + vec![] + } + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::AngleOrFirstPoint => DynField::Angle, + Step::SecondPoint { .. } => DynField::Point, + } + } + + fn dyn_spec(&self) -> Option { + None + } + + fn dyn_commit_as_text(&self) -> bool { + matches!(self.step, Step::AngleOrFirstPoint) + } +}