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",