forked from 1b-agent/opencadstudio-upstream
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:
1 parent
59ae38ae1a
commit
0f739b459d
10 files changed
+329
No files matched your search
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user