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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-08 08:41:54 +03:00
1 parent 6256ad3bd5
commit 018ac455cb
4 files changed
+165 -186

No files matched your search

+36
View File
@@ -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();
+126 -185
View File
@@ -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<crate::scene::view::camera::Camera>,
bounds: iced::Rectangle,
a: iced::Point,
p: iced::Point,
crossing: bool,
) -> (Vec<Handle>, 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<Instant>| {
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<Handle> = 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<Handle> = 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<Handle> = 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,
}
+1 -1
View File
@@ -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;
+2
View File
@@ -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."),
],