From 018ac455cb3a39976782d425bafd35316beb56b7 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 8 Oct 2026 08:41:54 +0300 Subject: [PATCH] feat(mcp): select by window or crossing box select takes window [x1, y1, x2, y2] in world XY, with crossing to also take what the box crosses. It runs the viewport's own box test, now one helper (box_selection_handles) that the drag and click-move-click gestures share; the drag gesture applies the selection filter as the click one already did. Co-Authored-By: Claude Opus 5.5 --- src/app/automation.rs | 36 +++++ src/app/update/viewport.rs | 311 +++++++++++++++---------------------- src/mcp.rs | 2 +- src/mcp_ops.rs | 2 + 4 files changed, 165 insertions(+), 186 deletions(-) diff --git a/src/app/automation.rs b/src/app/automation.rs index a9d588ecd..9d230d30c 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -871,6 +871,42 @@ impl OpenCADStudio { for h in explicit { self.tabs[i].scene.select_entity(h, false); } + // Or by a window / crossing box in world XY, through the + // viewport's own box test. + if let Some(window) = req["window"].as_array() { + let corner = |index: usize| window.get(index).and_then(Value::as_f64); + let (Some(x1), Some(y1), Some(x2), Some(y2)) = + (corner(0), corner(1), corner(2), corner(3)) + else { + return err("select: window needs [x1, y1, x2, y2]"); + }; + let crossing = req["crossing"].as_bool().unwrap_or(false); + let (vw, vh) = self.tabs[i].scene.selection.borrow().view.vp_size; + let tile = self.tabs[i].scene.active_model_tile_bounds(vw, vh); + let bounds = iced::Rectangle { + width: tile.width, + height: tile.height, + ..iced::Rectangle::default() + }; + let screen = |x: f64, y: f64| { + let camera = self.tabs[i].scene.camera.borrow(); + camera.project(glam::DVec3::new(x, y, 0.0), bounds) + }; + let (Some(a), Some(p)) = (screen(x1, y1), screen(x2, y2)) else { + return err("select: the window is not in view"); + }; + let (mut handles, _) = self.box_selection_handles( + i, + &None, + bounds, + iced::Point::new(a.x, a.y), + iced::Point::new(p.x, p.y), + crossing, + ); + handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); + self.tabs[i].scene.select_entities(&handles); + self.tabs[i].scene.expand_selection_for_groups(&handles); + } // Or by type / layer. let type_filter = req["type"].as_str(); let layer_filter = req["layer"].as_str(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 395502e3b..6b8f15afe 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -626,6 +626,109 @@ impl OpenCADStudio { /// cursor exactly where the GPU draws it; otherwise the model/paper camera /// and the normal hit-test wires. `bounds` is the pane-local rectangle. + /// The entities a selection box from `a` to `p` (tile pixels) catches, + /// as a window or crossing, through the wire, hatch and mesh tests — the + /// one box test the drag and click-move-click gestures and automation + /// share. The selection filter is the caller's. + pub(in crate::app) fn box_selection_handles( + &self, + i: usize, + edit_cam: &Option, + bounds: iced::Rectangle, + a: iced::Point, + p: iced::Point, + crossing: bool, + ) -> (Vec, BoxSelectTiming) { + let (view_rot, eye, all_wires) = self.pick_view(i, edit_cam, bounds); + let world_aabb = [a, iced::Point::new(a.x, p.y), p, iced::Point::new(p.x, a.y)] + .map(|point| self.cursor_model_point(i, edit_cam, point, bounds)) + .into_iter() + .fold( + [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY], + |mut aabb, world| { + aabb[0] = aabb[0].min(world.x); + aabb[1] = aabb[1].min(world.y); + aabb[2] = aabb[2].max(world.x); + aabb[3] = aabb[3].max(world.y); + aabb + }, + ); + // A large box selection was slow and two guesses about which step + // owns the time were both wrong, so each step is timed. + let elapsed = |start: Option| { + start.map_or(0.0, |start| start.elapsed().as_secs_f64() * 1000.0) + }; + let mut timing = BoxSelectTiming::default(); + let t_sel = crate::perf::enabled().then(Instant::now); + let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( + all_wires, + world_aabb, + [a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)], + view_rot, + eye, + bounds, + ); + timing.candidates = elapsed(t_sel); + let t_hit = crate::perf::enabled().then(Instant::now); + let scene = &self.tabs[i].scene; + let candidate_handles = scene.interaction_candidate_handles(&area_candidates); + timing.handles = elapsed(t_hit); + let mut handles: Vec = scene::pick::hit_test::box_hit( + a, + p, + crossing, + &area_candidates, + view_rot, + eye, + bounds, + ) + .into_iter() + .filter_map(|s| Scene::handle_from_wire_name(s)) + .collect(); + timing.wires = elapsed(t_hit); + handles.extend(scene::pick::hit_test::box_hit_hatch( + a, + p, + crossing, + &scene.visible_hatches_for_click(candidate_handles.as_ref()), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); + handles.extend(scene::pick::hit_test::box_hit_insert_hatch( + a, + p, + crossing, + scene.insert_hatches_for_click().as_ref(), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); + timing.hatch = elapsed(t_hit); + handles.extend(scene.mesh_box_hit( + a, + p, + crossing, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); + handles.extend(scene.block_mesh_box_hit( + a, + p, + crossing, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); + timing.hit = elapsed(t_hit); + (handles, timing) + } + pub(in crate::app) fn pick_view( &self, i: usize, @@ -4802,101 +4905,17 @@ impl OpenCADStudio { // it here on release. if let Some(a) = box_anchor { let crossing = box_crossing; - let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); - let world_aabb = - [a, iced::Point::new(a.x, p.y), p, iced::Point::new(p.x, a.y)] - .map(|point| self.cursor_model_point(i, &edit_cam, point, bounds)) - .into_iter() - .fold( - [ - f64::INFINITY, - f64::INFINITY, - f64::NEG_INFINITY, - f64::NEG_INFINITY, - ], - |mut aabb, world| { - aabb[0] = aabb[0].min(world.x); - aabb[1] = aabb[1].min(world.y); - aabb[2] = aabb[2].max(world.x); - aabb[3] = aabb[3].max(world.y); - aabb - }, - ); - // A 186 k-entity box selection sits at ~800 ms in this - // handler and two guesses about which step owns it have - // both been wrong. Split it. - let t_sel = crate::perf::enabled().then(Instant::now); - let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( - all_wires, - world_aabb, - [a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)], - view_rot, - eye, - bounds, - ); - let cand_ms = t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); - let t_hit = crate::perf::enabled().then(Instant::now); - let candidate_handles = self.tabs[i] - .scene - .interaction_candidate_handles(&area_candidates); - let mut handles: Vec = scene::pick::hit_test::box_hit( - a, - p, - crossing, - &area_candidates, - view_rot, - eye, - bounds, - ) - .into_iter() - .filter_map(|s| Scene::handle_from_wire_name(s)) - .collect(); - handles.extend(scene::pick::hit_test::box_hit_hatch( - a, - p, - crossing, - &self.tabs[i] - .scene - .visible_hatches_for_click(candidate_handles.as_ref()), - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - handles.extend(scene::pick::hit_test::box_hit_insert_hatch( - a, - p, - crossing, - self.tabs[i].scene.insert_hatches_for_click().as_ref(), - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - handles.extend(self.tabs[i].scene.mesh_box_hit( - a, - p, - crossing, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - handles.extend(self.tabs[i].scene.block_mesh_box_hit( - a, - p, - crossing, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); + let (mut handles, timing) = + self.box_selection_handles(i, &edit_cam, bounds, a, p, crossing); + // The drag gesture applies the selection filter as the click one does. + handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); + let cand_ms = timing.candidates; // Box/lasso accumulates like individual picks // (issue #83): a plain box adds to the current // selection, Shift+box removes the boxed // entities. Esc / empty-space click still clears. // PICKADD 0 (#226): a plain box REPLACES. - let hit_ms = t_hit.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let hit_ms = timing.hit; let t_apply = crate::perf::enabled().then(Instant::now); if self.shift_down || self.select_remove_mode { self.tabs[i].scene.deselect_entities(&handles); @@ -5208,101 +5227,11 @@ was_selected={}", } let crossing = box_crossing; - let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); - let world_aabb = [a, iced::Point::new(a.x, p.y), p, iced::Point::new(p.x, a.y)] - .map(|point| self.cursor_model_point(i, &edit_cam, point, bounds)) - .into_iter() - .fold( - [ - f64::INFINITY, - f64::INFINITY, - f64::NEG_INFINITY, - f64::NEG_INFINITY, - ], - |mut aabb, world| { - aabb[0] = aabb[0].min(world.x); - aabb[1] = aabb[1].min(world.y); - aabb[2] = aabb[2].max(world.x); - aabb[3] = aabb[3].max(world.y); - aabb - }, - ); - // This is the path a click-move-click window takes, as - // opposed to a press-drag; it is the one a large selection - // actually goes through. - let t_sel = crate::perf::enabled().then(Instant::now); - let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( - all_wires, - world_aabb, - [a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)], - view_rot, - eye, - bounds, - ); - let cand_ms = t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); - let t_hit = crate::perf::enabled().then(Instant::now); - let candidate_handles = self.tabs[i] - .scene - .interaction_candidate_handles(&area_candidates); - // `hit` covers five things, and which of them owns it has - // never been separated: resolving candidate handles, the wire - // box test, the two hatch tests and the two mesh tests. - let m_handles = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0); - let mut handles: Vec = scene::pick::hit_test::box_hit( - a, - p, - crossing, - &area_candidates, - view_rot, - eye, - bounds, - ) - .into_iter() - .filter_map(|s| Scene::handle_from_wire_name(s)) - .collect(); - let m_wires = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0); - handles.extend(scene::pick::hit_test::box_hit_hatch( - a, - p, - crossing, - &self.tabs[i] - .scene - .visible_hatches_for_click(candidate_handles.as_ref()), - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - handles.extend(scene::pick::hit_test::box_hit_insert_hatch( - a, - p, - crossing, - self.tabs[i].scene.insert_hatches_for_click().as_ref(), - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - let m_hatch = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0); - handles.extend(self.tabs[i].scene.mesh_box_hit( - a, - p, - crossing, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - handles.extend(self.tabs[i].scene.block_mesh_box_hit( - a, - p, - crossing, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - )); - let hit_ms = t_hit.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let (mut handles, timing) = + self.box_selection_handles(i, &edit_cam, bounds, a, p, crossing); + let (cand_ms, hit_ms) = (timing.candidates, timing.hit); + let (m_handles, m_wires, m_hatch) = + (Some(timing.handles), Some(timing.wires), Some(timing.hatch)); let t_filter = crate::perf::enabled().then(Instant::now); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); @@ -8026,3 +7955,15 @@ mod selection_preview_tests { ); } } + +/// Where a box selection spent its time, in milliseconds since each step's +/// start (perf builds only; zero otherwise). `handles`, `wires` and `hatch` +/// are cumulative within `hit`. +#[derive(Default)] +pub(in crate::app) struct BoxSelectTiming { + pub candidates: f64, + pub handles: f64, + pub wires: f64, + pub hatch: f64, + pub hit: f64, +} diff --git a/src/mcp.rs b/src/mcp.rs index c4fe3628d..116beb0a3 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -29,7 +29,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; /// changes, so schema drift (new params, renamed tools) is always a conscious, /// reviewed edit — and clients can detect a stale bridge by comparing digests. #[cfg(test)] -const TOOL_SCHEMA_DIGEST: &str = "2ec01401ca96bc0574a9be41958d24dbd9c27a497dce80064c8afcca5258256e"; +const TOOL_SCHEMA_DIGEST: &str = "8dc370c3e4bde86a3c2f6be8488dd1ed318850303326b00e2c28e8c033006472"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; diff --git a/src/mcp_ops.rs b/src/mcp_ops.rs index ca1af090f..22135a078 100644 --- a/src/mcp_ops.rs +++ b/src/mcp_ops.rs @@ -204,6 +204,8 @@ pub static SELECT: OpDef = OpDef { opt("handles", Ty::Handles, "Explicit entity handles to select."), opt("type", Ty::Str, "Entity type filter."), opt("layer", Ty::Str, "Layer name filter."), + opt("window", Ty::Raw(r#"{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4}"#), "World XY box [x1, y1, x2, y2]: selects what lies inside it, as a viewport window does."), + opt("crossing", Ty::Bool, "With window: also select what the box crosses."), opt("clear", Ty::Bool, "Clear existing selection before applying."), opt("where", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}"#)), "Property filter predicates."), ],