diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 779197aba..06219879b 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -108,6 +108,9 @@ pub(super) struct State { pub enabled: bool, serial: u64, events: VecDeque, + /// Monotonic counter bumped on every real selection change, so clients + /// can detect highlight changes without polling the full selection. + selection_revision: u64, pub(super) routing: bool, /// Session-scoped named handle sets for selection_set_save/load. selection_sets: std::collections::BTreeMap>, @@ -423,6 +426,34 @@ impl OpenCADStudio { self.control.pending.is_some() } + /// Mirror a real selection change into the automation event stream. The + /// plugin host already publishes the V4 selection event; automation clients + /// need the same signal instead of polling `state`. + pub(super) fn control_record_selection_event(&mut self, handles: &[codec::Handle]) { + let (document_id, revision) = { + let tab = &self.tabs[self.active_tab]; + (tab.id, tab.edit_revision) + }; + let selection: Vec = handles + .iter() + .map(|handle| format!("{:X}", handle.value())) + .collect(); + self.control.selection_revision = self.control.selection_revision.wrapping_add(1); + let selection_revision = self.control.selection_revision; + self.control.serial += 1; + self.control.events.push_back(json!({ + "sequence": self.control.serial, + "kind": "selection", + "document_id": document_id, + "revision": revision, + "selection_revision": selection_revision, + "selection": selection, + })); + while self.control.events.len() > 128 { + self.control.events.pop_front(); + } + } + pub(super) fn control_state(&self) -> Value { let tab = &self.tabs[self.active_tab]; let command = tab.active_cmd.as_deref().map(active_command_metadata); @@ -435,6 +466,7 @@ impl OpenCADStudio { "plugins":plugin_ids(), "documents":self.tabs.iter().map(|t|json!({"id":t.id,"title":t.tab_title,"path":t.current_path,"dirty":t.dirty,"revision":t.edit_revision,"start":t.is_start})).collect::>(), "selection":tab.scene.selected_handles_in_order().iter().map(|h|format!("{:X}",h.value())).collect::>(), + "selection_revision":self.control.selection_revision, "command":command,"modal":self.active_modal.as_ref().map(|m|format!("{m:?}")), "layout":tab.scene.current_layout, "ucs":tab.active_ucs.as_ref().map(|u|json!({"name":u.name,"origin":[u.origin.x,u.origin.y,u.origin.z],"x_axis":[u.x_axis.x,u.x_axis.y,u.x_axis.z],"y_axis":[u.y_axis.x,u.y_axis.y,u.y_axis.z],"elevation":u.elevation})), @@ -1720,6 +1752,40 @@ mod tests { request(&mut app, json!({"op":"undo"})); assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0); } + #[test] + fn selection_changes_are_published_to_the_event_stream() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"})); + let handle = request(&mut app, json!({"op":"query","type":"Line"}))["entities"][0]["handle"] + .as_str() + .unwrap() + .to_string(); + let before = app.control_state()["selection_revision"].as_u64().unwrap(); + let selected = app.automation_op(&format!( + r#"{{"op":"select","handles":["{handle}"]}}"# + )); + assert_eq!(selected["ok"], true, "{selected}"); + let state = app.control_state(); + assert_eq!( + state["selection_revision"].as_u64().unwrap(), + before + 1, + "selection revision should increment once: {state}" + ); + assert!( + state["selection"].as_array().unwrap().contains(&json!(handle)), + "{state}" + ); + let events = app.control_request(json!({"op":"events"})).0; + let last = events["events"].as_array().unwrap().last().unwrap(); + assert_eq!(last["kind"], "selection", "{events}"); + assert_eq!(last["document_id"], state["document_id"], "{events}"); + assert!( + last["selection"].as_array().unwrap().contains(&json!(handle)), + "{events}" + ); + } + #[test] fn command_discovery_explains_batch_and_interactive_use() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ae3330e03..9595403e8 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -299,7 +299,8 @@ impl OpenCADStudio { } self.last_plugin_selection = Some(key); let handles = self.tabs[i].scene.selected_handles_in_order(); - crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles); + crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles.clone()); + self.control_record_selection_event(&handles); } /// Refresh the shared V4 document after built-in geometry edits. Plugin diff --git a/src/mcp.rs b/src/mcp.rs index 35116ce18..2665c59d2 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1487,6 +1487,7 @@ fn compact_state(state: &Value) -> Value { "geometry_revision", "camera_revision", "selection", + "selection_revision", "command", "modal", "event_cursor",