diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 40901c113..6fba1b930 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": opencadcodec.version.to_string(), "acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 8, + "api_version": 9, "api_version_min_supported": 3, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 7e68dc0bf..65e9dd4e7 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -1175,6 +1175,35 @@ pub trait HostApi { fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result, String> { Err("XRecord listing is not supported by this host".to_owned()) } + + // ── Reference-only designation (added in API v9; appended at the end to + // keep vtable indices stable for older plugins) ────────────────────────── + + /// Mark (or unmark) an entity as reference-only (API v9, additive). + /// + /// A reference-only entity is plugin-owned display geometry: it renders + /// normally, but the built-in edit paths treat it like an entity on a + /// locked layer — it is neither grip-editable nor click-pickable (a click + /// passes through to the editable entity underneath), window and box + /// selections skip it, and user-driven writes refuse it. The owning + /// plugin keeps full write access through this API: `update_entity`, + /// [`update_entities_transaction`](HostApi::update_entities_transaction), + /// `remove_entity` and the XDATA record methods keep working, so derived + /// display geometry can be refreshed in place. The designation survives + /// DWG/DXF save and reload. + /// + /// Fails when the entity does not exist or is on a locked layer (the same + /// policy as the XDATA record methods). A call that would not change the + /// current designation succeeds without recording an undo step. + /// + /// Undo composition — the entity-write contract: a change performed + /// inside a plugin-opened [`HostApi::push_undo`] scope joins that scope, + /// so an operation composed of several writes is a single undo step under + /// the plugin's own label. With no scope open the change is one undo step + /// of its own. Refusals record no undo step. + fn set_reference_only(&mut self, _handle: Handle, _reference_only: bool) -> Result<(), String> { + Err("The reference-only designation is not supported by this host".to_owned()) + } } /// Configuration properties for creating or modifying a layer. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 2d7c0e4fb..2f9d09c41 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -692,6 +692,23 @@ impl HostApi for PluginHostApi { Err(error) => Err(error.to_string()), } } + + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + match self.client.request(PluginRequest::SetReferenceOnly { + handle, + reference_only, + }) { + Ok(PluginResponse::SetReferenceOnlyResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected reference-only response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 51f206f0e..44a2f2ec0 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -226,6 +226,11 @@ pub enum PluginRequest { ListXRecords { dictionary_path: Vec, }, + /// V9 (additive): mark an entity reference-only (plugin-owned display data). + SetReferenceOnly { + handle: Handle, + reference_only: bool, + }, } /// Responses the host sends back for `PluginRequest`. @@ -272,6 +277,8 @@ pub enum PluginResponse { XRecordDeleteResult(Result), /// V8 (additive): XRecord names under a dictionary path. XRecordNames(Result, String>), + /// V9 (additive): outcome of the reference-only designation. + SetReferenceOnlyResult(Result<(), String>), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 874a44ab1..894dc5848 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -151,5 +151,11 @@ pub fn handle_plugin_request( let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); PluginResponse::XRecordNames(host.list_xrecords(&path)) } + SetReferenceOnly { + handle, + reference_only, + } => { + PluginResponse::SetReferenceOnlyResult(host.set_reference_only(handle, reference_only)) + } } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 93b080377..aaf2f448f 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -987,6 +987,23 @@ impl HostApi for V4PluginHostApi { Err(error) => Err(error.to_string()), } } + + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + match self.request(PluginRequest::SetReferenceOnly { + handle, + reference_only, + }) { + Ok(PluginResponse::SetReferenceOnlyResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected reference-only response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 450afbf99..cf98d0e63 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -519,6 +519,47 @@ mod tests { } } + #[test] + fn reference_only_request_and_response_roundtrip() { + // The V9 request marks an entity reference-only and the response + // carries the operation's outcome. + let bytes = bincode::serialize(&PluginRequest::SetReferenceOnly { + handle: codec::Handle::new(0x2A), + reference_only: true, + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::SetReferenceOnly { handle, reference_only } if + handle == codec::Handle::new(0x2A) && reference_only) + ); + let bytes = bincode::serialize(&PluginRequest::SetReferenceOnly { + handle: codec::Handle::new(0x2A), + reference_only: false, + }) + .unwrap(); + assert!(matches!( + bincode::deserialize::(&bytes).unwrap(), + PluginRequest::SetReferenceOnly { + reference_only: false, + .. + } + )); + let bytes = bincode::serialize(&PluginResponse::SetReferenceOnlyResult(Ok(()))).unwrap(); + assert!(matches!( + bincode::deserialize::(&bytes).unwrap(), + PluginResponse::SetReferenceOnlyResult(Ok(())) + )); + let bytes = bincode::serialize(&PluginResponse::SetReferenceOnlyResult(Err( + "entity is on a locked layer".to_owned(), + ))) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::SetReferenceOnlyResult(Err(error)) if error == "entity is on a locked layer") + ); + } + #[test] fn unknown_notification_deserializes_as_unknown() { // Build a payload with an unknown discriminant (99) followed by some diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 6a40f9ce3..27c371d73 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -24,8 +24,11 @@ /// undoable replacement of a set of existing entities. v8 appends name-addressed /// XRecord object CRUD under named-object-dictionary paths /// (`HostApi::create_xrecord` / `read_xrecord` / `update_xrecord` / -/// `delete_xrecord` / `list_xrecords`). -pub const API_VERSION: u32 = 8; +/// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity +/// designation (`HostApi::set_reference_only`): display-only geometry a plugin +/// still owns and refreshes while the built-in edit paths treat it like a +/// locked layer. +pub const API_VERSION: u32 = 9; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the