From 43bd5aa427a5454f5c6496cdc3c1e183694a7982 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 7 Oct 2026 12:51:32 +0300 Subject: [PATCH] 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 --- src/app/control/actions.rs | 113 +++++++++++++++++++++++++++++++++++++ src/app/control/mod.rs | 4 ++ src/mcp.rs | 7 ++- src/mcp_ops.rs | 27 +++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index b9147c084..1ea345409 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -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::>()})).collect::>()}) } + /// 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::>(); + 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, 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, 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, Value> { self.refresh_properties(); let field = string(req, "field")?; diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index c1203877c..76badd918 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -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"))] diff --git a/src/mcp.rs b/src/mcp.rs index 4977c3096..c4fe3628d 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 = "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", diff --git a/src/mcp_ops.rs b/src/mcp_ops.rs index c7ef661d8..ca1af090f 100644 --- a/src/mcp_ops.rs +++ b/src/mcp_ops.rs @@ -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,