forked from 1b-agent/opencadstudio-upstream
Merge PR #1279 and complete parametric UI actions
This commit is contained in:
commit
adc8555394
22 files changed
+672
-217
No files matched your search
@@ -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!(
|
||||
|
||||
@@ -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<Task<Message>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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<acadrust::Handle>),
|
||||
/// 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+164
-142
@@ -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<Handle> = 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<Handle> = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-63
@@ -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,
|
||||
|
||||
@@ -1114,6 +1114,7 @@ pub(super) fn viewport_context_menu_overlay(
|
||||
bottom_inset: f32,
|
||||
has_cmd: bool,
|
||||
has_selection: bool,
|
||||
selected_constraint: Option<crate::scene::parametric_constraints::ConstraintId>,
|
||||
isolation_active: bool,
|
||||
last_cmds: Vec<String>,
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user