forked from 1b-agent/opencadstudio-upstream
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:
1 parent
6256ad3bd5
commit
018ac455cb
4 files changed
+165
-186
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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."),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user