Add bounding-box object alignment (ALIGNLEFT/TOP/etc.), ported from Mac2CAM

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 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-15 01:08:41 +02:00
1 parent 59ae38ae1a
commit 0f739b459d
10 files changed
+329

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M3 20h18" stroke="#ffffff" stroke-width="1.8"/>
<path d="M7 6v11M12 10v7M17 4v13" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M12 3v18" stroke="#ffffff" stroke-width="1.8" stroke-dasharray="2 2"/>
<path d="M6 7h12M8 12h8M4 17h16" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M4 3v18" stroke="#ffffff" stroke-width="1.8"/>
<path d="M7 7h11M7 12h7M7 17h13" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M20 3v18" stroke="#ffffff" stroke-width="1.8"/>
<path d="M6 7h11M10 12h7M4 17h13" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M3 4h18" stroke="#ffffff" stroke-width="1.8"/>
<path d="M7 7v11M12 7v7M17 7v13" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round">
<path d="M3 12h18" stroke="#ffffff" stroke-width="1.8" stroke-dasharray="2 2"/>
<path d="M7 6v12M12 8v8M17 4v16" stroke="#2496ed" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

+193
View File
@@ -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<Task<Message>> {
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");
}
}
+8
View File
@@ -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 {
+63
View File
@@ -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<Vec<Handle>> {
let selected: HashSet<Handle> = 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::<HashSet<_>>());
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]));
}
}
+41
View File
@@ -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",