feat(mcp): grips, grip_drag and click

grips lists the selection's grips; grip_drag moves one to a world point
through the viewport's own grip gesture with snaps, ortho and polar off;
click presses the left button where a world point shows, optionally with
Ctrl and/or Shift held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-07 12:51:32 +03:00
1 parent d3033caf24
commit 43bd5aa427
4 files changed
+150 -1

No files matched your search

+113
View File
@@ -733,6 +733,119 @@ impl OpenCADStudio {
self.refresh_properties();
json!({"ok":true,"sections":self.tabs[self.active_tab].properties.sections.iter().map(|s|json!({"title":s.title,"properties":s.props.iter().map(property_json).collect::<Vec<_>>()})).collect::<Vec<_>>()})
}
/// The grips of the current selection, as the viewport shows them.
pub(super) fn control_grips(&self) -> Value {
let tab = &self.tabs[self.active_tab];
let grips = tab
.selected_grip_handles
.iter()
.zip(&tab.selected_grips)
.map(|(owner, grip)| {
json!({
"handle": format!("{:X}", owner.value()),
"grip_id": grip.id,
"point": [grip.world.x, grip.world.y, grip.world.z],
"shape": format!("{:?}", grip.shape),
"moves_entity": grip.is_midpoint,
})
})
.collect::<Vec<_>>();
json!({"ok": true, "grips": grips})
}
/// Drag one grip of an entity to a world point through the viewport's
/// own grip gesture: select the entity, engage the grip, move, place.
/// Object snaps, grid, ortho, polar and tracking are off for the move so
/// the grip lands on `point`.
pub(super) fn control_grip_drag(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let i = self.active_tab;
let target = handle(req)?;
let grip_id = req["grip_id"]
.as_u64()
.ok_or_else(|| failure("invalid_request", "Missing grip_id"))? as usize;
let to = point(req)?;
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(target, false);
self.refresh_properties();
let (world, moves_entity) = self.tabs[i]
.selected_grip_handles
.iter()
.zip(&self.tabs[i].selected_grips)
.find(|(owner, grip)| **owner == target && grip.id == grip_id)
.map(|(_, grip)| (grip.world, grip.is_midpoint))
.ok_or_else(|| failure("unknown_grip", "Read grips for the entity's grip ids"))?;
let edit = self.grip_edit_for_hit(i, target, grip_id, moves_entity, world);
self.tabs[i].active_grip = Some(edit);
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 local = iced::Rectangle {
width: tile.width,
height: tile.height,
..iced::Rectangle::default()
};
let Some(screen) = self.tabs[i].scene.camera.borrow().project(to, local) else {
self.cancel_active_grip_edit();
return Err(failure("invalid_point", "The point is not in view"));
};
let saved = (
self.snapper.snap_enabled,
self.snapper.snap3d_enabled,
self.snapper.grid_snap_on,
self.snapper.otrack_enabled,
self.ortho_mode,
self.polar_mode,
);
self.snapper.snap_enabled = false;
self.snapper.snap3d_enabled = false;
self.snapper.grid_snap_on = false;
self.snapper.otrack_enabled = false;
self.ortho_mode = false;
self.polar_mode = false;
let _ = self.on_viewport_move(iced::Point::new(screen.x + tile.x, screen.y + tile.y));
(
self.snapper.snap_enabled,
self.snapper.snap3d_enabled,
self.snapper.grid_snap_on,
self.snapper.otrack_enabled,
self.ortho_mode,
self.polar_mode,
) = saved;
Ok(self.commit_active_grip_edit())
}
/// Click the viewport where `point` (world) shows, as the left button
/// would, holding Ctrl and/or Shift when `modifiers` names them.
pub(super) fn control_click(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let i = self.active_tab;
let at = point(req)?;
let modifiers = req["modifiers"].as_str().unwrap_or("").to_ascii_lowercase();
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 local = iced::Rectangle {
width: tile.width,
height: tile.height,
..iced::Rectangle::default()
};
let screen = self.tabs[i]
.scene
.camera
.borrow()
.project(at, local)
.ok_or_else(|| failure("invalid_point", "The point is not in view"))?;
let saved = (self.ctrl_down, self.shift_down);
self.ctrl_down = modifiers.contains("ctrl");
self.shift_down = modifiers.contains("shift");
let cursor = iced::Point::new(screen.x + tile.x, screen.y + tile.y);
let tasks = [
self.update(Message::ViewportMove(cursor)),
self.update(Message::ViewportLeftPress),
self.update(Message::ViewportLeftRelease),
];
(self.ctrl_down, self.shift_down) = saved;
Ok(Task::batch(tasks))
}
pub(super) fn control_set_property(&mut self, req: &Value) -> Result<Task<Message>, Value> {
self.refresh_properties();
let field = string(req, "field")?;
+4
View File
@@ -460,6 +460,7 @@ impl OpenCADStudio {
| "properties"
| "measure"
| "snap"
| "grips"
| "query"
| "records"
| "record_schema"
@@ -742,6 +743,7 @@ impl OpenCADStudio {
None => json!({"ok":true,"snap":null}),
}
}
"grips" => self.control_grips(),
"xdata_get" => self.xdata_read(&req),
"get_selection" => self.control_get_selection(),
"text_search" => self.control_text_search(&req).unwrap_or_else(|e| e),
@@ -980,6 +982,8 @@ impl OpenCADStudio {
Task::none()
}
"property" => self.control_set_property(req)?,
"grip_drag" => self.control_grip_drag(req)?,
"click" => self.control_click(req)?,
"set_properties" => self.control_set_record_properties(req)?,
"action" => self.control_ui_action(req)?,
#[cfg(not(target_arch = "wasm32"))]
+6 -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 = "bbecd61a9295ecd8e3fefe6a223252107d80d173da7544edacaa2880139683ad";
const TOOL_SCHEMA_DIGEST: &str = "2ec01401ca96bc0574a9be41958d24dbd9c27a497dce80064c8afcca5258256e";
const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000;
@@ -52,6 +52,7 @@ const READ_OPS: &[&str] = &[
"properties",
"measure",
"snap",
"grips",
"history",
"commands",
"events",
@@ -75,6 +76,8 @@ const EXECUTE_OPS: &[&str] = &[
"redo",
"select",
"property",
"grip_drag",
"click",
"set_properties",
"action",
"embed_image",
@@ -117,6 +120,8 @@ const BATCH_STEP_OPS: &[&str] = &[
"redo",
"select",
"property",
"grip_drag",
"click",
"set_properties",
"action",
"embed_image",
+27
View File
@@ -223,6 +223,31 @@ pub static PROPERTY: OpDef = OpDef {
example: r#"{"op":"property","field":"color","value":1}"#,
};
pub static GRIP_DRAG: OpDef = OpDef {
name: "grip_drag",
doc: "Drag one grip of an entity to a world point the way a grip pick-and-place in the viewport does. Read grips for the ids; object snaps, ortho and polar are off for the move.",
batchable: true,
params: &[
req("handle", Ty::Handle, "Entity whose grip moves."),
req("grip_id", Ty::Int, "Grip id from the grips read."),
req("point", Ty::Point, "World point [x, y, z] the grip moves to."),
],
rules: &[],
example: r#"{"op":"grip_drag","handle":"2A","grip_id":0,"point":[10,0,0]}"#,
};
pub static CLICK: OpDef = OpDef {
name: "click",
doc: "Left-click the viewport where a world point shows, as the mouse would. modifiers holds ctrl and/or shift (e.g. Ctrl+click picks the solid of a composite under the cursor).",
batchable: true,
params: &[
req("point", Ty::Point, "World point [x, y, z] to click on."),
opt("modifiers", Ty::Str, "Keys held during the click: \"ctrl\", \"shift\" or \"ctrl+shift\"."),
],
rules: &[],
example: r#"{"op":"click","point":[10,0,0],"modifiers":"ctrl"}"#,
};
pub static SET_PROPERTIES: OpDef = OpDef {
name: "set_properties",
doc: "Atomically update record properties by RFC 6901 JSON pointer paths.",
@@ -635,6 +660,8 @@ pub static OPS: &[&OpDef] = &[
&REDO,
&SELECT,
&PROPERTY,
&GRIP_DRAG,
&CLICK,
&SET_PROPERTIES,
&ACTION,
&EMBED_IMAGE,