From 0f739b459d251c156558591060969173da8a38b3 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Tue, 15 Sep 2026 00:52:01 +0200 Subject: [PATCH] Add bounding-box object alignment (ALIGNLEFT/TOP/etc.), ported from Mac2CAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six one-shot alignment commands for the current selection — ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM — distinct from the existing point-picking ALIGN/ALIGN3D command: each nudges every selected object (or complete group, via new Scene::selected_object_units) so its bounding-box edge/center lines up with the extreme edge/center already present in the selection, closer to a word processor's "align objects" than AutoCAD's 3-point ALIGN. Wired as plain one-click Tool entries in the Modify extension panel, next to the existing ALIGN entry, matching that panel's flat-list convention (no point prompts, so no dropdown submenu needed). Co-Authored-By: Claude Sonnet 5 --- assets/icons/align_bottom.svg | 4 + assets/icons/align_hcenter.svg | 4 + assets/icons/align_left.svg | 4 + assets/icons/align_right.svg | 4 + assets/icons/align_top.svg | 4 + assets/icons/align_vcenter.svg | 4 + src/app/commands/inquiry.rs | 193 +++++++++++++++++++++++++++++++ src/modules/draw/modify/align.rs | 8 ++ src/scene/group_layer.rs | 63 ++++++++++ src/ui/ribbon/modify_panel.rs | 41 +++++++ 10 files changed, 329 insertions(+) create mode 100644 assets/icons/align_bottom.svg create mode 100644 assets/icons/align_hcenter.svg create mode 100644 assets/icons/align_left.svg create mode 100644 assets/icons/align_right.svg create mode 100644 assets/icons/align_top.svg create mode 100644 assets/icons/align_vcenter.svg diff --git a/assets/icons/align_bottom.svg b/assets/icons/align_bottom.svg new file mode 100644 index 000000000..ca7fa2bea --- /dev/null +++ b/assets/icons/align_bottom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_hcenter.svg b/assets/icons/align_hcenter.svg new file mode 100644 index 000000000..7ea838f62 --- /dev/null +++ b/assets/icons/align_hcenter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_left.svg b/assets/icons/align_left.svg new file mode 100644 index 000000000..aedbf0ec9 --- /dev/null +++ b/assets/icons/align_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_right.svg b/assets/icons/align_right.svg new file mode 100644 index 000000000..35f934aa0 --- /dev/null +++ b/assets/icons/align_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_top.svg b/assets/icons/align_top.svg new file mode 100644 index 000000000..99efe0452 --- /dev/null +++ b/assets/icons/align_top.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_vcenter.svg b/assets/icons/align_vcenter.svg new file mode 100644 index 000000000..4740c61fc --- /dev/null +++ b/assets/icons/align_vcenter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index aa9c088a7..f96c77268 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1,6 +1,100 @@ use super::*; impl OpenCADStudio { + /// ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: + /// nudges each selected object (or complete group — see + /// `Scene::selected_object_units`) so its bounding-box edge/center lines + /// up with the extreme edge/center of the whole selection. Distinct from + /// the point-picking `AlignCommand` (`ALIGN`/`ALIGN3D`): this is a + /// one-shot bounds alignment with no point prompts, closer to a + /// word processor's "align objects" toolbar than AutoCAD's 3-point ALIGN. + fn align_selected_bounds(&mut self, i: usize, command: &str) { + use crate::command::EntityTransform; + use glam::DVec3; + + let handles: Vec<_> = self.tabs[i] + .scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if handles.len() < 2 { + self.command_line + .push_info(crate::t!("Select at least two unlocked objects to align.").as_ref()); + return; + } + + let bounds: Vec<_> = self.tabs[i] + .scene + .selected_object_units(&handles) + .into_iter() + .filter_map(|unit| { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for handle in &unit { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + let bb = entity.as_entity().bounding_box(); + min_x = min_x.min(bb.min.x); + min_y = min_y.min(bb.min.y); + max_x = max_x.max(bb.max.x); + max_y = max_y.max(bb.max.y); + } + [min_x, min_y, max_x, max_y] + .iter() + .all(|value| value.is_finite()) + .then_some((unit, min_x, min_y, max_x, max_y)) + }) + .collect(); + if bounds.len() < 2 { + self.command_line + .push_info(crate::t!("The selected objects do not provide usable bounds.").as_ref()); + return; + } + + let min_x = bounds.iter().map(|(_, x, _, _, _)| *x).fold(f64::INFINITY, f64::min); + let min_y = bounds.iter().map(|(_, _, y, _, _)| *y).fold(f64::INFINITY, f64::min); + let max_x = bounds.iter().map(|(_, _, _, x, _)| *x).fold(f64::NEG_INFINITY, f64::max); + let max_y = bounds.iter().map(|(_, _, _, _, y)| *y).fold(f64::NEG_INFINITY, f64::max); + let center_x = (min_x + max_x) * 0.5; + let center_y = (min_y + max_y) * 0.5; + + let label = match command { + "ALIGNLEFT" => "Align Left", + "ALIGNHCENTER" => "Align Horizontal Centers", + "ALIGNRIGHT" => "Align Right", + "ALIGNTOP" => "Align Top", + "ALIGNVCENTER" => "Align Vertical Centers", + "ALIGNBOTTOM" => "Align Bottom", + _ => command, + }; + let pending = self.begin_undo(i, label, handles.len(), true); + for (unit, unit_min_x, unit_min_y, unit_max_x, unit_max_y) in &bounds { + let (dx, dy) = match command { + "ALIGNLEFT" => (min_x - unit_min_x, 0.0), + "ALIGNHCENTER" => (center_x - (unit_min_x + unit_max_x) * 0.5, 0.0), + "ALIGNRIGHT" => (max_x - unit_max_x, 0.0), + "ALIGNTOP" => (0.0, max_y - unit_max_y), + "ALIGNVCENTER" => (0.0, center_y - (unit_min_y + unit_max_y) * 0.5), + "ALIGNBOTTOM" => (0.0, min_y - unit_min_y), + _ => (0.0, 0.0), + }; + self.tabs[i].scene.transform_entities( + unit, + &EntityTransform::Translate(DVec3::new(dx, dy, 0.0)), + ); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.command_line.push_output( + crate::tf!("{command}: aligned {} object(s).", bounds.len()).as_ref(), + ); + } + pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option> { match cmd { "3DORBIT" => { @@ -980,6 +1074,11 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + "ALIGNLEFT" | "ALIGNHCENTER" | "ALIGNRIGHT" | "ALIGNTOP" | "ALIGNVCENTER" + | "ALIGNBOTTOM" => { + self.align_selected_bounds(i, cmd); + } + "LENGTHEN" => { use crate::modules::draw::modify::lengthen::LengthenCommand; let cmd = LengthenCommand::new(); @@ -2211,3 +2310,97 @@ mod flatten_tests { ); } } + +#[cfg(test)] +mod align_selected_bounds_tests { + use super::*; + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> acadrust::Handle { + app.tabs[app.active_tab] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(x1, y1, 0.0), + Vector3::new(x2, y2, 0.0), + ))) + } + + fn line_start_x(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 { + match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(EntityType::Line(l)) => l.start.x.min(l.end.x), + other => panic!("expected a Line, got {other:?}"), + } + } + + fn line_max_y(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 { + match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(EntityType::Line(l)) => l.start.y.max(l.end.y), + other => panic!("expected a Line, got {other:?}"), + } + } + + /// A single selected object has no "other object" to align to — refuse + /// with an informational message rather than silently moving nothing + /// (or panicking on the empty-bounds case). + #[test] + fn refuses_with_fewer_than_two_selected_objects() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 0.0, 0.0, 2.0, 1.0); + app.tabs[app.active_tab].scene.select_entities(&[a]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + assert_eq!(line_start_x(&app, a), 0.0, "the lone object must not move"); + } + + /// ALIGNLEFT moves every selected object's left edge to the leftmost + /// edge already present in the selection; ALIGNTOP does the same for + /// the topmost edge. Both are undoable as one step. + #[test] + fn aligns_left_and_top_edges_and_is_undoable() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let left = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); // leftmost & topmost already + let right = add_line(&mut app, 5.0, -3.0, 8.0, -2.0); // further right and lower + app.tabs[app.active_tab].scene.select_entities(&[left, right]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + assert_eq!(line_start_x(&app, left), 0.0, "the already-leftmost object must not move"); + assert_eq!(line_start_x(&app, right), 0.0, "the other object's left edge should meet it"); + + app.dispatch_inquiry("ALIGNTOP", i); + assert_eq!(line_max_y(&app, left), 1.0, "the already-topmost object must not move"); + assert_eq!(line_max_y(&app, right), 1.0, "the other object's top edge should meet it"); + + app.undo_steps(1); // undo ALIGNTOP + assert_eq!(line_max_y(&app, right), -2.0, "undo should restore the pre-ALIGNTOP position"); + app.undo_steps(1); // undo ALIGNLEFT + assert_eq!(line_start_x(&app, right), 5.0, "undo should restore the original geometry"); + } + + /// A complete, fully-selected group (`Scene::selected_object_units`) + /// moves as one rigid unit instead of each member line sliding to meet + /// the *other* members of its own group. + #[test] + fn a_complete_group_aligns_as_one_rigid_unit() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 5.0, 0.0, 6.0, 1.0); + let b = add_line(&mut app, 7.0, 0.0, 8.0, 1.0); + let other = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); + app.tabs[app.active_tab].scene.create_group("pair".to_string(), vec![a, b]); + app.tabs[app.active_tab].scene.select_entities(&[a, b, other]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + // The group's own left edge (a's, at x=5) moves to meet `other`'s + // left edge (x=0): a shifts by -5, and b — sharing the same + // transform — must shift by exactly the same amount, not collapse + // onto `a`. + assert_eq!(line_start_x(&app, a), 0.0); + assert_eq!(line_start_x(&app, b), 2.0, "b must keep its offset from a, not collapse onto it"); + } +} diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index 118dc8a87..c28454aa8 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -304,6 +304,14 @@ impl AlignCommand { } inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand +// ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: one-shot +// bounding-box alignment, dispatched directly (no CadCommand of their own) by +// `OpenCADStudio::align_selected_bounds` in `src/app/commands/inquiry.rs`. +inventory::submit!(crate::command::CommandRegistration { + names: &[ + "ALIGNLEFT", "ALIGNHCENTER", "ALIGNRIGHT", "ALIGNTOP", "ALIGNVCENTER", "ALIGNBOTTOM", + ], +}); #[cfg(test)] mod tests { diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index e636bde8f..7c88d7f65 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -203,6 +203,42 @@ impl Scene { self.bump_selection_set(); } } + + /// Partition selected entity handles into visual objects for operations + /// such as edge alignment. A selectable CAD group is one object and must + /// receive one shared transform; treating its member lines independently + /// can collapse a rectangle or triangle onto its own edge. + pub fn selected_object_units(&self, handles: &[Handle]) -> Vec> { + let selected: HashSet = handles.iter().copied().collect(); + let mut claimed = HashSet::default(); + let mut units = Vec::new(); + for group in self.groups().filter(|group| group.selectable) { + let members: Vec<_> = group + .entities + .iter() + .copied() + .filter(|handle| selected.contains(handle)) + .collect(); + if !members.is_empty() + && members.iter().all(|handle| !claimed.contains(handle)) + && group + .entities + .iter() + .all(|handle| selected.contains(handle)) + { + claimed.extend(members.iter().copied()); + units.push(members); + } + } + units.extend( + handles + .iter() + .copied() + .filter(|handle| !claimed.contains(handle)) + .map(|handle| vec![handle]), + ); + units + } } #[cfg(test)] @@ -247,4 +283,31 @@ mod group_expansion_tests { assert_eq!(both, [a, b, c, d, e].into_iter().collect::>()); assert!(!both.contains(&lonely)); } + + /// `selected_object_units` is `Scene::selected_object_units`'s + /// counterpart for operations like edge alignment (`ALIGNTOP`, etc.): + /// a complete, fully-selected group collapses to one unit sharing a + /// single transform, so aligning a triangle's edge to another object + /// moves the whole triangle rather than collapsing its own lines onto + /// each other. + #[test] + fn selected_object_units_keep_a_complete_group_together() { + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let mut line = |x: f64| { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + }; + let (a, b, c, standalone) = (line(0.0), line(1.0), line(2.0), line(10.0)); + scene.create_group("triangle".into(), vec![a, b, c]); + + let units = scene.selected_object_units(&[a, b, c, standalone]); + assert_eq!(units.len(), 2); + assert!(units.iter().any(|unit| unit == &vec![a, b, c])); + assert!(units.iter().any(|unit| unit == &vec![standalone])); + } } diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs index 835e03c27..e7256acce 100644 --- a/src/ui/ribbon/modify_panel.rs +++ b/src/ui/ribbon/modify_panel.rs @@ -37,6 +37,47 @@ pub(super) const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/modify_align.svg"), options: &[], }, + // ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: + // one-shot bounding-box alignment of the current selection — distinct + // from "ALIGN" above (point-picked 3D placement). No point prompts, so + // each is a plain one-click Tool rather than a dropdown submenu, matching + // this panel's existing flat-list convention. + Tool { + command: "ALIGNLEFT", + label: "Align Left", + icon: include_bytes!("../../../assets/icons/align_left.svg"), + options: &[], + }, + Tool { + command: "ALIGNHCENTER", + label: "Align Horizontal Centers", + icon: include_bytes!("../../../assets/icons/align_hcenter.svg"), + options: &[], + }, + Tool { + command: "ALIGNRIGHT", + label: "Align Right", + icon: include_bytes!("../../../assets/icons/align_right.svg"), + options: &[], + }, + Tool { + command: "ALIGNTOP", + label: "Align Top", + icon: include_bytes!("../../../assets/icons/align_top.svg"), + options: &[], + }, + Tool { + command: "ALIGNVCENTER", + label: "Align Vertical Centers", + icon: include_bytes!("../../../assets/icons/align_vcenter.svg"), + options: &[], + }, + Tool { + command: "ALIGNBOTTOM", + label: "Align Bottom", + icon: include_bytes!("../../../assets/icons/align_bottom.svg"), + options: &[], + }, Tool { command: "BREAK", label: "Break",