diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 53cd41a00..5c3e43bd7 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -371,6 +371,58 @@ impl H7CAD { self.restore_pre_cmd_tangent(); self.command_line.push_output(&msg); } + CmdResult::AlignSelected { handles, src1, dst1, angle_rad, scale } => { + if handles.is_empty() { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } else { + let label = self.history_label_from_active_cmd(i, "ALIGN"); + self.push_undo_snapshot(i, label); + // Step 1: translate so src1 is at origin + self.tabs[i].scene.transform_entities( + &handles, + &crate::command::EntityTransform::Translate(-src1), + ); + // Step 2: uniform scale (only when != 1) + if (scale - 1.0).abs() > 1e-4 { + self.tabs[i].scene.transform_entities( + &handles, + &crate::command::EntityTransform::Scale { + center: glam::Vec3::ZERO, + factor: scale, + }, + ); + } + // Step 3: rotate in XZ plane by angle_rad + if angle_rad.abs() > 1e-4 { + self.tabs[i].scene.transform_entities( + &handles, + &crate::command::EntityTransform::Rotate { + center: glam::Vec3::ZERO, + angle_rad, + }, + ); + } + // Step 4: translate to dst1 + self.tabs[i].scene.transform_entities( + &handles, + &crate::command::EntityTransform::Translate(dst1), + ); + self.tabs[i].dirty = true; + self.tabs[i].scene.deselect_all(); + for h in &handles { + self.tabs[i].scene.select_entity(*h, false); + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line.push_output("ALIGN: applied."); + self.refresh_properties(); + } + } CmdResult::LengthenEntity { handle, pick_pt, mode } => { use crate::modules::home::modify::lengthen::lengthen_entity; let result = self.tabs[i].scene.document diff --git a/src/app/commands.rs b/src/app/commands.rs index 2e2a2c3ce..5a3211540 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -985,6 +985,13 @@ impl H7CAD { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + "ALIGN"|"AL" => { + use crate::modules::home::modify::align::AlignCommand; + let cmd = AlignCommand::new(); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + "LENGTHEN"|"LEN" => { use crate::modules::home::modify::lengthen::LengthenCommand; let cmd = LengthenCommand::new(); @@ -1031,7 +1038,7 @@ impl H7CAD { "HELP"|"?" => { self.command_line.push_output( "Draw: LINE CIRCLE ARC PLINE RECT POLY POINT ELLIPSE SPLINE RAY XLINE HATCH | \ - Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM BREAK JOIN LENGTHEN | \ + Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM BREAK JOIN LENGTHEN ALIGN | \ Array: ARRAY ARRAYRECT ARRAYPOLAR ARRAYPATH | \ Text: TEXT MTEXT LEADER MLEADER | \ Dimension: DIMLINEAR DIMANGULAR DIMRADIUS | \ diff --git a/src/command/mod.rs b/src/command/mod.rs index d8dfd4d4f..f8046015a 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -109,6 +109,14 @@ pub enum CmdResult { pick_pt: Vec3, mode: crate::modules::home::modify::lengthen::LenMode, }, + /// Align selected entities: translate to dst1, rotate by angle_rad, optional scale. + AlignSelected { + handles: Vec, + src1: Vec3, + dst1: Vec3, + angle_rad: f32, + scale: f32, + }, } // ── Trait ───────────────────────────────────────────────────────────────── diff --git a/src/modules/home/modify/align.rs b/src/modules/home/modify/align.rs new file mode 100644 index 000000000..d1d5c3097 --- /dev/null +++ b/src/modules/home/modify/align.rs @@ -0,0 +1,180 @@ +// ALIGN command — align selected objects using 1 or 2 point pairs. +// +// Workflow: +// 1. Select objects (Enter to finish selection) +// 2. First source point → first destination point +// 3. Second source point → second destination point (Enter to skip = translate only) +// 4. Enter = apply (scale = optional: Y/N prompt after 2nd pair) +// +// With 1 pair: pure translation (src1 → dst1) +// With 2 pairs: translate + rotate (+ optional uniform scale to fit) + +use glam::{Mat4, Vec3}; +use acadrust::Handle; + +use crate::command::{CadCommand, CmdResult, EntityTransform}; + +pub struct AlignCommand { + state: AlignState, + handles: Vec, + src1: Option, + dst1: Option, + src2: Option, + dst2: Option, +} + +#[derive(PartialEq)] +enum AlignState { + Gathering, + Src1, + Dst1, + Src2, + Dst2, + AskScale, +} + +impl AlignCommand { + pub fn new() -> Self { + Self { + state: AlignState::Gathering, + handles: vec![], + src1: None, dst1: None, + src2: None, dst2: None, + } + } +} + +impl CadCommand for AlignCommand { + fn name(&self) -> &'static str { "ALIGN" } + + fn prompt(&self) -> String { + match self.state { + AlignState::Gathering => format!("ALIGN Select objects ({} selected, Enter when done):", self.handles.len()), + AlignState::Src1 => "ALIGN Specify 1st source point:".into(), + AlignState::Dst1 => "ALIGN Specify 1st destination point:".into(), + AlignState::Src2 => "ALIGN Specify 2nd source point (Enter = translate only):".into(), + AlignState::Dst2 => "ALIGN Specify 2nd destination point:".into(), + AlignState::AskScale => "ALIGN Scale objects based on alignment points? [Y/N]:".into(), + } + } + + fn is_selection_gathering(&self) -> bool { + self.state == AlignState::Gathering + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.handles = handles; + CmdResult::NeedPoint + } + + fn on_point(&mut self, pt: Vec3) -> CmdResult { + match self.state { + AlignState::Gathering => CmdResult::NeedPoint, + AlignState::Src1 => { + self.src1 = Some(pt); + self.state = AlignState::Dst1; + CmdResult::NeedPoint + } + AlignState::Dst1 => { + self.dst1 = Some(pt); + self.state = AlignState::Src2; + CmdResult::NeedPoint + } + AlignState::Src2 => { + self.src2 = Some(pt); + self.state = AlignState::Dst2; + CmdResult::NeedPoint + } + AlignState::Dst2 => { + self.dst2 = Some(pt); + self.state = AlignState::AskScale; + CmdResult::NeedPoint + } + AlignState::AskScale => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.state { + AlignState::Gathering => { + if self.handles.is_empty() { return CmdResult::Cancel; } + self.state = AlignState::Src1; + CmdResult::NeedPoint + } + AlignState::Src2 => { + // Only 1 pair — pure translation + match (self.src1, self.dst1) { + (Some(s), Some(d)) => { + let delta = d - s; + CmdResult::TransformSelected( + self.handles.clone(), + EntityTransform::Translate(delta), + ) + } + _ => CmdResult::Cancel, + } + } + AlignState::AskScale => { + // No scale (default N) + self.compute_align(false) + } + _ => CmdResult::Cancel, + } + } + + fn wants_text_input(&self) -> bool { + self.state == AlignState::AskScale + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.state != AlignState::AskScale { return None; } + let scale = text.trim().to_uppercase().starts_with('Y'); + Some(self.compute_align(scale)) + } +} + +impl AlignCommand { + fn compute_align(&self, with_scale: bool) -> CmdResult { + let (s1, d1, s2, d2) = match (self.src1, self.dst1, self.src2, self.dst2) { + (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d), + _ => return CmdResult::Cancel, + }; + + // Build transform: move s1→d1, rotate so s2-s1 aligns with d2-d1 (in XZ plane) + let src_vec = s2 - s1; + let dst_vec = d2 - d1; + + let src_len = src_vec.length(); + let dst_len = dst_vec.length(); + + if src_len < 1e-6 || dst_len < 1e-6 { + // Degenerate: just translate + let delta = d1 - s1; + return CmdResult::TransformSelected( + self.handles.clone(), + EntityTransform::Translate(delta), + ); + } + + // Angle from src_vec to dst_vec in XZ plane + let src_angle = src_vec.z.atan2(src_vec.x); + let dst_angle = dst_vec.z.atan2(dst_vec.x); + let angle = dst_angle - src_angle; + + let scale_factor = if with_scale { dst_len / src_len } else { 1.0 }; + + // Apply: translate to origin (s1), scale, rotate, translate to d1 + // We use the EntityTransform enum — it doesn't support composed transforms directly. + // Return a special align result that carries the full matrix. + let _ = (angle, scale_factor, with_scale); + + // Compose via AlignTransform CmdResult + CmdResult::AlignSelected { + handles: self.handles.clone(), + src1: s1, + dst1: d1, + angle_rad: angle, + scale: scale_factor, + } + } +} diff --git a/src/modules/home/modify/mod.rs b/src/modules/home/modify/mod.rs index a4beda290..42f0cc663 100644 --- a/src/modules/home/modify/mod.rs +++ b/src/modules/home/modify/mod.rs @@ -1,3 +1,4 @@ +pub mod align; pub mod array; pub mod break_cmd; pub mod copy;