diff --git a/Cargo.lock b/Cargo.lock
index 7efbae01f..946a0eb3e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
-source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd"
+source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=ea7ace7#ea7ace7047c5c5ba170d8257080b5b881df6c063"
dependencies = [
"acadrust",
"cavalier_contours",
@@ -930,7 +930,7 @@ dependencies = [
[[package]]
name = "cadkernel-constraints"
version = "0.1.0"
-source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd"
+source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=ea7ace7#ea7ace7047c5c5ba170d8257080b5b881df6c063"
dependencies = [
"nalgebra",
]
diff --git a/Cargo.toml b/Cargo.toml
index 688a7c795..89a10af13 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,8 +33,8 @@ rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
-cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a", features = ["acis", "offset"] }
-cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a" }
+cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "ea7ace7", features = ["acis", "offset"] }
+cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "ea7ace7" }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
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/command_driver.rs b/src/app/command_driver.rs
index cdeea8ed3..4bf5ba085 100644
--- a/src/app/command_driver.rs
+++ b/src/app/command_driver.rs
@@ -989,6 +989,9 @@ impl OpenCADStudio {
.scene
.parametric_constraint_set_mut(scope)
.remove(id);
+ if self.tabs[i].scene.selected_constraint == Some(id) {
+ self.tabs[i].scene.selected_constraint = None;
+ }
let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched
.into_iter()
.map(|h| (h, crate::scene::ChangeKind::Modified))
@@ -7906,6 +7909,7 @@ mod parametric_constraint_undo_tests {
.unwrap()
.constraints[0]
.id;
+ app.tabs[app.active_tab].scene.selected_constraint = Some(id);
app.delete_parametric_constraint(id);
assert!(app.tabs[app.active_tab]
@@ -7914,6 +7918,7 @@ mod parametric_constraint_undo_tests {
.unwrap()
.constraints
.is_empty());
+ assert_eq!(app.tabs[app.active_tab].scene.selected_constraint, None);
app.undo_steps(1);
assert_eq!(
diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs
index aa9c088a7..df9671461 100644
--- a/src/app/commands/inquiry.rs
+++ b/src/app/commands/inquiry.rs
@@ -1,6 +1,84 @@
use super::*;
impl OpenCADStudio {
+ /// Align every selected object or complete group by one edge or center.
+ fn align_selected_bounds(&mut self, i: usize, command: &str) {
+ use crate::command::EntityTransform;
+ use cadkernel::space::BoundsAlignment;
+ use glam::DVec3;
+
+ let (alignment, label) = match command {
+ "ALIGNLEFT" => (BoundsAlignment::Left, "Align Left"),
+ "ALIGNHCENTER" => (BoundsAlignment::HorizontalCenter, "Align Horizontal Centers"),
+ "ALIGNRIGHT" => (BoundsAlignment::Right, "Align Right"),
+ "ALIGNTOP" => (BoundsAlignment::Top, "Align Top"),
+ "ALIGNVCENTER" => (BoundsAlignment::VerticalCenter, "Align Vertical Centers"),
+ "ALIGNBOTTOM" => (BoundsAlignment::Bottom, "Align Bottom"),
+ _ => return,
+ };
+
+ 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);
+ }
+ let bounds = [min_x, min_y, max_x, max_y];
+ bounds
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some((unit, bounds))
+ })
+ .collect();
+ if bounds.len() < 2 {
+ self.command_line
+ .push_info(crate::t!("The selected objects do not provide usable bounds.").as_ref());
+ return;
+ }
+
+ let extents: Vec<_> = bounds.iter().map(|(_, bounds)| *bounds).collect();
+ let offsets = cadkernel::space::align_aabbs_2d(&extents, alignment)
+ .expect("validated at least two finite bounds");
+ let pending = self.begin_undo(i, label, handles.len(), true);
+ for ((unit, _), [dx, dy]) in bounds.iter().zip(offsets) {
+ 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 +1058,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 +2294,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/app/mod.rs b/src/app/mod.rs
index 7f420ab42..8523c5101 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -2902,7 +2902,7 @@ pub enum Message {
/// A Constraints-section row was clicked: select every entity in the
/// list (replacing the current selection) in the viewport.
PropConstraintLinkClick(Vec),
- /// Remove one parametric constraint from the active standard graph scope.
+ /// Remove one parametric constraint selected from Properties or the viewport.
PropConstraintDelete(crate::scene::parametric_constraints::ConstraintId),
// ── About window ────────────────────────────────────────────────────
AboutOpen,
diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs
index 965ac0cce..2d7c6c690 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -4329,6 +4329,13 @@ impl OpenCADStudio {
}
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
+ // A selected constraint-glyph pill takes Delete before entity
+ // erase — the two selections are mutually exclusive (see
+ // `Scene::selected_constraint`).
+ if let Some(id) = self.tabs[i].scene.selected_constraint {
+ self.delete_parametric_constraint(id);
+ return Task::none();
+ }
let handles: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect();
if !handles.is_empty() {
// Erase is delta-safe unless a target is in a group (group
@@ -6263,7 +6270,6 @@ impl OpenCADStudio {
self.refresh_properties();
Task::none()
}
-
// ── Options / About windows ───────────────────────────────────
Message::OptionsOpen => {
self.active_modal = Some(super::ModalKind::Options);
diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs
index ae3372607..8d8c9ec98 100644
--- a/src/app/update/viewport.rs
+++ b/src/app/update/viewport.rs
@@ -4423,163 +4423,185 @@ properties={:.1}ms picked={}",
sel.box_current = None;
} else {
if box_anchor.is_none() {
- let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
- let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
- let t_arm = crate::perf::enabled().then(Instant::now);
- let prior_selection = self.tabs[i].scene.selected.len();
- let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
- all_wires,
- click_world,
- view_rot,
- eye,
- bounds,
- crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
- );
- let candidate_handles = self.tabs[i]
- .scene
- .interaction_candidate_handles(&click_candidates);
-
- // Selection cycling: where two or more objects
- // overlap, open a list box to pick which one; a
- // single object falls through to the normal click.
- // Gated behind the toggle, so default picking is
- // unchanged when off.
- let mut handled_by_cycling = false;
- if self.selection_cycling {
- let cands: Vec = scene::pick::hit_test::click_hits_all(
- p,
- &click_candidates,
- view_rot,
- eye,
- bounds,
- self.tabs[i].scene.document.header.lineweight_display,
- crate::ui::overlay::pick_box_aperture_px(self.pick_box),
- )
- .into_iter()
- .filter_map(|s| Scene::handle_from_wire_name(s))
- .filter(|&h| self.tabs[i].scene.passes_selection_filter(h))
- .collect();
- if cands.len() >= 2 {
- // Overlap: open the list box at the cursor.
- self.cycle_candidates = Some((p_full, cands));
- handled_by_cycling = true;
- }
+ // A parametric-constraint glyph draws above its geometry
+ // and takes click priority over the entity beneath it.
+ let glyph_hit = (self.tabs[i].scene.current_layout == "Model")
+ .then(|| {
+ let scope = self.tabs[i].current_parametric_scope();
+ self.tabs[i].scene.constraint_glyph_hit(
+ scope,
+ canvas_sz,
+ self.show_constraint_values,
+ p_full,
+ )
+ })
+ .flatten();
+ if let Some(id) = glyph_hit {
+ self.tabs[i].scene.deselect_all();
+ self.tabs[i].scene.selected_constraint = Some(id);
+ self.refresh_properties();
+ selection_just_completed = true;
}
-
- if !handled_by_cycling {
- let hit = scene::pick::hit_test::click_hit(
- p,
- &click_candidates,
+ if glyph_hit.is_none() {
+ let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
+ let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
+ let t_arm = crate::perf::enabled().then(Instant::now);
+ let prior_selection = self.tabs[i].scene.selected.len();
+ let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
+ all_wires,
+ click_world,
view_rot,
eye,
bounds,
- self.tabs[i].scene.document.header.lineweight_display,
- crate::ui::overlay::pick_box_aperture_px(self.pick_box),
- )
- .and_then(|s| Scene::handle_from_wire_name(s))
- .or_else(|| {
- scene::pick::hit_test::click_hit_hatch(
+ crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
+ );
+ let candidate_handles = self.tabs[i]
+ .scene
+ .interaction_candidate_handles(&click_candidates);
+
+ // Selection cycling: where two or more objects
+ // overlap, open a list box to pick which one; a
+ // single object falls through to the normal click.
+ // Gated behind the toggle, so default picking is
+ // unchanged when off.
+ let mut handled_by_cycling = false;
+ if self.selection_cycling {
+ let cands: Vec = scene::pick::hit_test::click_hits_all(
p,
- &self.tabs[i]
- .scene
- .visible_hatches_for_click(candidate_handles.as_ref()),
+ &click_candidates,
view_rot,
eye,
bounds,
- candidate_handles.as_ref(),
- )
- })
- .or_else(|| {
- // Block-internal hatch: resolve to the parent Insert.
- scene::pick::hit_test::click_hit_insert_hatch(
- p,
- self.tabs[i].scene.insert_hatches_for_click().as_ref(),
- view_rot,
- eye,
- bounds,
- candidate_handles.as_ref(),
- )
- })
- .or_else(|| {
- // Normal 3D selection follows the displayed B-rep
- // edges. Face-interior picking remains reserved for
- // modelling commands that explicitly request it.
- self.tabs[i].scene.solid_edge_click_hit(
- p,
- view_rot,
- eye,
- bounds,
- candidate_handles.as_ref(),
+ self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
)
- });
- // Selection filter: drop a pick whose type is excluded.
- let hit = hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h));
- if let Some(handle) = hit {
- // Individual picks accumulate (issue #47):
- // each plain click adds to the selection,
- // Shift+click removes the picked entity.
- // PICKADD 0 (#226): a plain click
- // REPLACES the selection instead and
- // Shift+click toggles membership.
- if self.shift_down || self.select_remove_mode {
- // Remove was asked for by name, so it only
- // ever takes away — the toggle below is
- // Shift's PICKADD-0 behaviour, not its.
- if !self.select_remove_mode
- && !selection_pick_add
- && !self.tabs[i].scene.selected.contains(&handle)
- {
- self.tabs[i].scene.select_entity(handle, false);
- self.tabs[i].scene.expand_selection_for_groups(&[handle]);
+ .into_iter()
+ .filter_map(|s| Scene::handle_from_wire_name(s))
+ .filter(|&h| self.tabs[i].scene.passes_selection_filter(h))
+ .collect();
+ if cands.len() >= 2 {
+ // Overlap: open the list box at the cursor.
+ self.cycle_candidates = Some((p_full, cands));
+ handled_by_cycling = true;
+ }
+ }
+
+ if !handled_by_cycling {
+ let hit = scene::pick::hit_test::click_hit(
+ p,
+ &click_candidates,
+ view_rot,
+ eye,
+ bounds,
+ self.tabs[i].scene.document.header.lineweight_display,
+ crate::ui::overlay::pick_box_aperture_px(self.pick_box),
+ )
+ .and_then(|s| Scene::handle_from_wire_name(s))
+ .or_else(|| {
+ scene::pick::hit_test::click_hit_hatch(
+ p,
+ &self.tabs[i]
+ .scene
+ .visible_hatches_for_click(candidate_handles.as_ref()),
+ view_rot,
+ eye,
+ bounds,
+ candidate_handles.as_ref(),
+ )
+ })
+ .or_else(|| {
+ // Block-internal hatch: resolve to the parent Insert.
+ scene::pick::hit_test::click_hit_insert_hatch(
+ p,
+ self.tabs[i].scene.insert_hatches_for_click().as_ref(),
+ view_rot,
+ eye,
+ bounds,
+ candidate_handles.as_ref(),
+ )
+ })
+ .or_else(|| {
+ // Normal 3D selection follows the displayed B-rep
+ // edges. Face-interior picking remains reserved for
+ // modelling commands that explicitly request it.
+ self.tabs[i].scene.solid_edge_click_hit(
+ p,
+ view_rot,
+ eye,
+ bounds,
+ candidate_handles.as_ref(),
+ crate::ui::overlay::pick_box_aperture_px(self.pick_box),
+ )
+ });
+ // Selection filter: drop a pick whose type is excluded.
+ let hit =
+ hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h));
+ if let Some(handle) = hit {
+ // Individual picks accumulate (issue #47):
+ // each plain click adds to the selection,
+ // Shift+click removes the picked entity.
+ // PICKADD 0 (#226): a plain click
+ // REPLACES the selection instead and
+ // Shift+click toggles membership.
+ if self.shift_down || self.select_remove_mode {
+ // Remove was asked for by name, so it only
+ // ever takes away — the toggle below is
+ // Shift's PICKADD-0 behaviour, not its.
+ if !self.select_remove_mode
+ && !selection_pick_add
+ && !self.tabs[i].scene.selected.contains(&handle)
+ {
+ self.tabs[i].scene.select_entity(handle, false);
+ self.tabs[i].scene.expand_selection_for_groups(&[handle]);
+ } else {
+ self.tabs[i].scene.deselect_entity(handle);
+ }
} else {
- self.tabs[i].scene.deselect_entity(handle);
+ self.tabs[i]
+ .scene
+ .select_entity(handle, !selection_pick_add);
+ self.tabs[i].scene.expand_selection_for_groups(&[handle]);
}
- } else {
- self.tabs[i]
- .scene
- .select_entity(handle, !selection_pick_add);
- self.tabs[i].scene.expand_selection_for_groups(&[handle]);
- }
- self.refresh_properties();
- selection_just_completed = true;
- } else {
- // Empty-space click only ARMS a box here; it
- // no longer clears the selection, so a box can
- // add to it (issue #83). The box completion
- // (or Esc) decides what happens to the
- // selection.
- // PICKADD 0 (#226): OS convention — the
- // empty click also drops the selection.
- if !selection_pick_add && !self.shift_down {
- self.tabs[i].scene.deselect_all();
self.refresh_properties();
- }
- // Pin the anchor to the world point under it
- // so a zoom/pan mid-drag re-projects it
- // instead of leaving it frozen in pixels
- // (#234). Computed before the selection
- // borrow so the &self projection can't clash.
- let anchor_world = self.cursor_model_point(i, &edit_cam, p, bounds);
- if let Some(t) = t_arm {
- let arm_ms = t.elapsed().as_secs_f64() * 1000.0;
- if arm_ms >= 5.0 {
- crate::perf_record!(
- "[perf] select-arm {arm_ms:>7.1}ms pick+clear, \
+ selection_just_completed = true;
+ } else {
+ // Empty-space click only ARMS a box here; it
+ // no longer clears the selection, so a box can
+ // add to it (issue #83). The box completion
+ // (or Esc) decides what happens to the
+ // selection.
+ // PICKADD 0 (#226): OS convention — the
+ // empty click also drops the selection.
+ if !selection_pick_add && !self.shift_down {
+ self.tabs[i].scene.deselect_all();
+ self.refresh_properties();
+ }
+ // Pin the anchor to the world point under it
+ // so a zoom/pan mid-drag re-projects it
+ // instead of leaving it frozen in pixels
+ // (#234). Computed before the selection
+ // borrow so the &self projection can't clash.
+ let anchor_world = self.cursor_model_point(i, &edit_cam, p, bounds);
+ if let Some(t) = t_arm {
+ let arm_ms = t.elapsed().as_secs_f64() * 1000.0;
+ if arm_ms >= 5.0 {
+ crate::perf_record!(
+ "[perf] select-arm {arm_ms:>7.1}ms pick+clear, \
was_selected={}",
- prior_selection,
- );
+ prior_selection,
+ );
+ }
+ }
+ let mut sel = self.tabs[i].scene.selection.borrow_mut();
+ // Full-canvas space: ViewportMove updates
+ // box_current in canvas coords and the overlay
+ // draws there; release maps back into the tile.
+ sel.box_anchor = Some(p_full);
+ sel.box_current = Some(p_full);
+ sel.box_anchor_world = Some(anchor_world);
+ if !sel.box_crossing_locked {
+ sel.box_crossing = false;
}
- }
- let mut sel = self.tabs[i].scene.selection.borrow_mut();
- // Full-canvas space: ViewportMove updates
- // box_current in canvas coords and the overlay
- // draws there; release maps back into the tile.
- sel.box_anchor = Some(p_full);
- sel.box_current = Some(p_full);
- sel.box_anchor_world = Some(anchor_world);
- if !sel.box_crossing_locked {
- sel.box_crossing = false;
}
}
}
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index bd48e6260..8666a23ee 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -813,71 +813,22 @@ bg={bg_ms:.1}ms n={view_count}"
.active_cmd
.as_ref()
.is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering());
- // Constraint glyphs are displayed in model space only.
- let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = if is_paper {
+ let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool, bool)> = if is_paper {
Vec::new()
} else {
- let (vw, vh) = sel_ref.vp_size;
let scope = tab.current_parametric_scope();
- match tab.scene.parametric_constraint_set(scope) {
- Some(set) if !set.constraints.is_empty() => {
- let edit_frame = tab.scene.viewport_edit_frame((vw, vh));
- let bounds = match &edit_frame {
- Some((_, full)) => *full,
- None => tab.scene.active_model_tile_bounds(vw, vh),
- };
- let (view_rot, eye) = if let Some((cam, _)) = &edit_frame {
- (cam.view_proj_rte(bounds), cam.eye())
- } else {
- let cam = tab.scene.camera.borrow();
- (cam.view_proj_rte(bounds), cam.eye())
- };
- set.constraints
- .iter()
- .filter(|c| c.enabled)
- .filter(|c| tab.scene.is_parametric_constraint_visible(scope, c.id))
- .filter_map(|c| {
- let (anchor, outward) =
- crate::scene::parametric_constraints::glyph_placement(
- &tab.scene.document,
- c,
- )?;
- let screen = crate::scene::pick::grip::project_rte(
- glam::DVec3::new(anchor.x, anchor.y, anchor.z),
- view_rot,
- eye,
- bounds,
- )?;
- let outward_screen = crate::scene::pick::grip::project_rte(
- glam::DVec3::new(
- anchor.x + outward.x,
- anchor.y + outward.y,
- anchor.z + outward.z,
- ),
- view_rot,
- eye,
- bounds,
- )?;
- let direction =
- (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y);
- let point =
- iced::Point::new(bounds.x + screen.x, bounds.y + screen.y);
- let is_conflicting =
- set.conflicts.iter().any(|(id, _)| *id == c.id);
- let label = if self.show_constraint_values {
- crate::scene::parametric_constraints::glyph_label(c)
- } else {
- c.kind.glyph_symbol().to_string()
- };
- point
- .x
- .is_finite()
- .then(|| (point, direction.to_array(), label, is_conflicting))
- })
- .collect()
- }
- _ => Vec::new(),
- }
+ tab.scene
+ .constraint_glyph_placements_screen(
+ scope,
+ sel_ref.vp_size,
+ self.show_constraint_values,
+ )
+ .into_iter()
+ .map(|(id, point, direction, label, is_conflicting)| {
+ let selected = tab.scene.selected_constraint == Some(id);
+ (point, direction, label, is_conflicting, selected)
+ })
+ .collect()
};
crate::ui::overlay::selection_overlay(
std::sync::Arc::clone(&tab.scene.selection),
@@ -1624,7 +1575,6 @@ bg={bg_ms:.1}ms n={view_count}"
} else {
34.0
};
-
// Quick Properties: stay near the selection cursor, flipping around
// it as needed to remain inside the visible drawing area.
if self.quick_properties && !tab.is_start {
@@ -1798,6 +1748,7 @@ bg={bg_ms:.1}ms n={view_count}"
command_line_inset,
has_cmd,
has_selection,
+ tab.scene.selected_constraint,
isolation_active,
last_cmds,
draworder_open,
diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs
index 3192b9128..6c53708ab 100644
--- a/src/app/view/overlay.rs
+++ b/src/app/view/overlay.rs
@@ -1114,6 +1114,7 @@ pub(super) fn viewport_context_menu_overlay(
bottom_inset: f32,
has_cmd: bool,
has_selection: bool,
+ selected_constraint: Option,
isolation_active: bool,
last_cmds: Vec,
draworder_open: bool,
@@ -1250,6 +1251,15 @@ pub(super) fn viewport_context_menu_overlay(
t!("Invert Selection").into_owned(),
Message::InvertSelection,
));
+ } else if let Some(id) = selected_constraint {
+ // A selected constraint-glyph pill gets its own minimal menu —
+ // none of the entity-selection actions above (Move/Copy/Isolate/
+ // Select Similar) apply to it.
+ items.push(item(
+ t!("Delete Constraint").into_owned(),
+ Message::PropConstraintDelete(id),
+ ));
+ items.push(sep());
}
if isolation_active {
items.push(item(
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/scene/mod.rs b/src/scene/mod.rs
index 928cc2844..6269866ce 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -1658,6 +1658,9 @@ pub struct Scene {
/// Currently selected entity handles.
pub selected: HashSet,
selected_order: Vec,
+ /// The parametric-constraint glyph currently selected in the viewport.
+ /// Entity selection changes clear it so the two selections stay exclusive.
+ pub selected_constraint: Option,
/// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF.
pub object_isolation: ObjectIsolationState,
/// Entity handles temporarily removed from the base render while an
@@ -2194,6 +2197,7 @@ impl Scene {
lighting_cache: RefCell::new(HashMap::default()),
selected: HashSet::default(),
selected_order: Vec::new(),
+ selected_constraint: None,
object_isolation: ObjectIsolationState::default(),
preview_hidden: HashSet::default(),
command_preview_hidden: HashSet::default(),
diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs
index fe55c5dc4..387dbaac6 100644
--- a/src/scene/parametric_constraints.rs
+++ b/src/scene/parametric_constraints.rs
@@ -790,6 +790,98 @@ impl super::Scene {
}
}
+ /// Screen-projected parametric-constraint glyph placements for `scope`:
+ /// `(id, anchor, outward_screen_direction, label, is_conflicting)` for
+ /// every enabled, visible constraint whose glyph projects on-screen.
+ /// `vp_size` is the full canvas size (as `SelectionState::vp_size`
+ /// reports it), matching what `viewport_edit_frame`/
+ /// `active_model_tile_bounds` expect. Mirrors the projection
+ /// `crate::app::view` builds its own render list with, and is reused by
+ /// [`constraint_glyph_hit`](Self::constraint_glyph_hit) — both feed the
+ /// same `(anchor, outward, label)` triples into
+ /// `crate::ui::overlay::constraint_glyph_box`/`constraint_glyph_offsets`,
+ /// so hit-testing can never drift from what's actually drawn.
+ pub fn constraint_glyph_placements_screen(
+ &self,
+ scope: ParametricScope,
+ vp_size: (f32, f32),
+ show_values: bool,
+ ) -> Vec<(ConstraintId, iced::Point, [f32; 2], String, bool)> {
+ let Some(set) = self.parametric_constraint_set(scope) else {
+ return Vec::new();
+ };
+ if set.constraints.is_empty() {
+ return Vec::new();
+ }
+ let edit_frame = self.viewport_edit_frame(vp_size);
+ let bounds = match &edit_frame {
+ Some((_, full)) => *full,
+ None => self.active_model_tile_bounds(vp_size.0, vp_size.1),
+ };
+ let (view_rot, eye) = if let Some((cam, _)) = &edit_frame {
+ (cam.view_proj_rte(bounds), cam.eye())
+ } else {
+ let cam = self.camera.borrow();
+ (cam.view_proj_rte(bounds), cam.eye())
+ };
+ set.constraints
+ .iter()
+ .filter(|c| c.enabled)
+ .filter(|c| self.is_parametric_constraint_visible(scope, c.id))
+ .filter_map(|c| {
+ let (anchor, outward) = glyph_placement(&self.document, c)?;
+ let screen = crate::scene::pick::grip::project_rte(
+ glam::DVec3::new(anchor.x, anchor.y, anchor.z),
+ view_rot,
+ eye,
+ bounds,
+ )?;
+ let outward_screen = crate::scene::pick::grip::project_rte(
+ glam::DVec3::new(
+ anchor.x + outward.x,
+ anchor.y + outward.y,
+ anchor.z + outward.z,
+ ),
+ view_rot,
+ eye,
+ bounds,
+ )?;
+ let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y);
+ let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y);
+ let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id);
+ let label = if show_values {
+ glyph_label(c)
+ } else {
+ c.kind.glyph_symbol().to_string()
+ };
+ point.x.is_finite().then_some((c.id, point, direction.to_array(), label, is_conflicting))
+ })
+ .collect()
+ }
+
+ /// Hit-tests screen point `p` (same coordinate space as `p_full` in the
+ /// viewport click handler) against the glyph pills from
+ /// [`constraint_glyph_placements_screen`](Self::constraint_glyph_placements_screen),
+ /// via `crate::ui::overlay::constraint_glyph_hit_test`'s shared layout
+ /// math, so a click only registers where the pill is actually drawn.
+ pub fn constraint_glyph_hit(
+ &self,
+ scope: ParametricScope,
+ vp_size: (f32, f32),
+ show_values: bool,
+ p: iced::Point,
+ ) -> Option {
+ let placements = self.constraint_glyph_placements_screen(scope, vp_size, show_values);
+ let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements
+ .iter()
+ .map(|(_, point, direction, label, is_conflicting)| {
+ (*point, *direction, label.clone(), *is_conflicting)
+ })
+ .collect();
+ let index = crate::ui::overlay::constraint_glyph_hit_test(&glyphs, p)?;
+ Some(placements[index].0)
+ }
+
/// Handle remapping lives in each command that duplicates
/// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR,
/// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own
diff --git a/src/scene/selection.rs b/src/scene/selection.rs
index 46fdc260f..d30b0de2e 100644
--- a/src/scene/selection.rs
+++ b/src/scene/selection.rs
@@ -72,6 +72,7 @@ impl Scene {
if handles.is_empty() {
return;
}
+ self.selected_constraint = None;
let expanded = self.expanded_with_leaders(handles);
let mut changed = false;
for handle in expanded {
@@ -111,6 +112,7 @@ impl Scene {
expanded
}
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
+ self.selected_constraint = None;
let handles = self.handles_expanded_for_leader_annotations(&[handle]);
let mut changed = false;
@@ -133,6 +135,7 @@ impl Scene {
}
pub fn deselect_all(&mut self) {
+ self.selected_constraint = None;
if self.selected.is_empty() {
return;
}
@@ -215,6 +218,7 @@ impl Scene {
/// only when its contents actually changed. History/file/command paths must
/// use this instead of assigning `selected` directly.
pub(crate) fn replace_selection(&mut self, selected: HashSet) {
+ self.selected_constraint = None;
let handles: Vec = selected.iter().copied().collect();
let selected: HashSet = self
.handles_expanded_for_leader_annotations(&handles)
diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs
index 4cd3418d2..b2d582f2a 100644
--- a/src/ui/overlay.rs
+++ b/src/ui/overlay.rs
@@ -94,6 +94,32 @@ fn constraint_glyph_offsets(glyphs: &[(Point, [f32; 2], String, bool)]) -> Vec Option {
+ let offsets = constraint_glyph_offsets(glyphs);
+ glyphs
+ .iter()
+ .zip(offsets)
+ .enumerate()
+ .rev()
+ .find_map(|(index, ((anchor, outward, label, _), tangent_offset))| {
+ let (top_left, size) = constraint_glyph_box(*anchor, *outward, label, tangent_offset);
+ let within = p.x >= top_left.x
+ && p.x <= top_left.x + size.width
+ && p.y >= top_left.y
+ && p.y <= top_left.y + size.height;
+ within.then_some(index)
+ })
+}
+
/// Convert CURSORSIZE to a screen-space arm length while keeping the original
/// 60 px cursor at the default value and the full-viewport result at 100.
pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 {
@@ -723,7 +749,7 @@ pub fn selection_overlay<'a>(
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions,
- constraint_glyphs: Vec<(Point, [f32; 2], String, bool)>,
+ constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool)>,
) -> Element<'a, Message> {
canvas(SelectionCanvas {
selection,
@@ -806,8 +832,10 @@ struct SelectionCanvas {
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions,
- /// Constraint glyph anchor, outward screen direction, label, and conflict state.
- constraint_glyphs: Vec<(Point, [f32; 2], String, bool)>,
+ /// Constraint glyph anchor, outward screen direction, label, conflict
+ /// state, and whether the pill itself is the current click-to-select
+ /// target (`Scene::selected_constraint`) — for the current scope.
+ constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool)>,
}
fn draw_grip_marker(
@@ -1703,9 +1731,19 @@ impl canvas::Program for SelectionCanvas {
frame.stroke(&b1, stroke.clone());
frame.stroke(&b2, stroke);
}
- // Constraint glyphs are visual-only and have no hit testing.
+ // Hit testing shares this layout math with the scene projection.
if !self.constraint_glyphs.is_empty() {
- let offsets = constraint_glyph_offsets(&self.constraint_glyphs);
+ // `constraint_glyph_offsets` only needs the anchor/outward/label/
+ // conflict quadruple it was written against; project away the
+ // trailing `is_selected` flag rather than widen its signature.
+ let glyphs_for_offsets: Vec<(Point, [f32; 2], String, bool)> = self
+ .constraint_glyphs
+ .iter()
+ .map(|(anchor, outward, label, is_conflicting, _)| {
+ (*anchor, *outward, label.clone(), *is_conflicting)
+ })
+ .collect();
+ let offsets = constraint_glyph_offsets(&glyphs_for_offsets);
let normal_bg = theme.palette().primary.base.color;
let normal_fg = theme.palette().primary.base.text;
// A redundant or conflicting constraint gets the danger palette
@@ -1714,7 +1752,8 @@ impl canvas::Program for SelectionCanvas {
// would show, surfaced right on the geometry.
let conflict_bg = theme.palette().danger.base.color;
let conflict_fg = theme.palette().danger.base.text;
- for ((anchor, outward, label, is_conflicting), tangent_offset) in
+ let selected_ring = theme.palette().primary.strong.color;
+ for ((anchor, outward, label, is_conflicting, is_selected), tangent_offset) in
self.constraint_glyphs.iter().zip(offsets)
{
if !anchor.x.is_finite() || !anchor.y.is_finite() {
@@ -1729,6 +1768,12 @@ impl canvas::Program for SelectionCanvas {
(size.height * 0.5).into(),
);
frame.fill(&pill, bg);
+ if *is_selected {
+ frame.stroke(
+ &pill,
+ canvas::Stroke::default().with_color(selected_ring).with_width(2.0),
+ );
+ }
frame.fill_text(canvas::Text {
content: label.clone(),
position: Point::new(
@@ -3424,6 +3469,9 @@ mod constraint_glyph_tests {
assert!(
(anchor.y - (left.y + right_size.height) - CONSTRAINT_GLYPH_GAP).abs() < 1e-6
);
+
+ let click = Point::new(left.x + left_size.width * 0.5, left.y + left_size.height * 0.5);
+ assert_eq!(constraint_glyph_hit_test(&glyphs, click), Some(0));
}
}
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",