diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 0c1e31246..40901c113 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": 7, + "api_version": 8, "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 ad9966ff1..aab59fa92 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -1085,6 +1085,85 @@ pub trait HostApi { let _ = cmd; false } + + // ── XRecord objects (added in API v8; appended at the end to keep vtable + // indices stable for older plugins) ──────────────────────────────────────── + + /// Create an XRecord named `name` under the dictionary addressed by + /// `dictionary_path`, storing `entries` (API v8, additive). + /// + /// The path is walked from the drawing's named-object dictionary root: an + /// empty path addresses the root itself, and missing intermediate + /// dictionaries are created, so the first call seeds a plugin's namespace. + /// Every refusal (empty or invalid names, a path fragment that names a + /// non-dictionary object, a name already in use — comparison is + /// case-insensitive like dictionary lookups) happens **before** any change, + /// so a failure leaves the drawing untouched. The record is registered in + /// its dictionary and owned by it; the returned handle stays stable across + /// updates. The change is one undo step. + fn create_xrecord( + &mut self, + _dictionary_path: &[&str], + _name: &str, + _entries: Vec, + ) -> Result { + Err("XRecord creation is not supported by this host".to_owned()) + } + + /// Read the entries of an existing XRecord by name (API v8, additive). + /// + /// Resolves `dictionary_path` under the named-object dictionary root and + /// returns the record's entries verbatim. Fails when the path, the name or + /// the record is missing, or when the name addresses a different object + /// kind. + fn read_xrecord( + &self, + _dictionary_path: &[&str], + _name: &str, + ) -> Result, String> { + Err("XRecord reads are not supported by this host".to_owned()) + } + + /// Atomically replace the entries of an existing XRecord (API v8, + /// additive). Returns the record's handle. + /// + /// Neither the path nor its dictionaries are created: an update must never + /// create anything silently. Identity (handle, owning dictionary) stays; + /// the entries and their DWG handle-reference stream are replaced and the + /// record's verbatim raw payloads are dropped, so subsequent saves emit the + /// new content. A refusal leaves the drawing untouched; a successful change + /// is one undo step whose undo restores the previous entries byte-exactly. + fn update_xrecord( + &mut self, + _dictionary_path: &[&str], + _name: &str, + _entries: Vec, + ) -> Result { + Err("XRecord updates are not supported by this host".to_owned()) + } + + /// Delete the XRecord named `name` and its dictionary entry (API v8, + /// additive). Returns the removed record, so audits keep the prior + /// content. A refusal leaves the drawing untouched; a successful delete is + /// one undo step. + fn delete_xrecord( + &mut self, + _dictionary_path: &[&str], + _name: &str, + ) -> Result { + Err("XRecord deletes are not supported by this host".to_owned()) + } + + /// List the XRecord names under the dictionary addressed by + /// `dictionary_path` (API v8, additive). + /// + /// Returns only the dictionary keys that address XRecord objects, in the + /// dictionary's own order, so consumers can audit and export without + /// knowing names in advance. Fails when the path does not address a + /// dictionary. + fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result, String> { + Err("XRecord listing 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 829a84255..2d7c0e4fb 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -603,6 +603,95 @@ impl HostApi for PluginHostApi { } } } + + fn create_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + entries: Vec, + ) -> Result { + match self.client.request(PluginRequest::CreateXRecord { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + name: name.to_string(), + entries, + }) { + Ok(PluginResponse::XRecordCreateResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn read_xrecord( + &self, + dictionary_path: &[&str], + name: &str, + ) -> Result, String> { + match self.client.request(PluginRequest::ReadXRecord { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + name: name.to_string(), + }) { + Ok(PluginResponse::XRecordEntries(result)) => result, + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn update_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + entries: Vec, + ) -> Result { + match self.client.request(PluginRequest::UpdateXRecord { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + name: name.to_string(), + entries, + }) { + Ok(PluginResponse::XRecordUpdateResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn delete_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + ) -> Result { + match self.client.request(PluginRequest::DeleteXRecord { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + name: name.to_string(), + }) { + Ok(PluginResponse::XRecordDeleteResult(result)) => result, + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn list_xrecords(&self, dictionary_path: &[&str]) -> Result, String> { + match self.client.request(PluginRequest::ListXRecords { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + }) { + Ok(PluginResponse::XRecordNames(result)) => result, + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. @@ -810,4 +899,68 @@ mod tests { assert!(api.remove_entity(Handle::new(7))); peer_handle.join().unwrap(); } + + #[test] + fn xrecord_requests_await_their_typed_responses() { + let (mut api, mut peer) = make_client(); + let peer_handle = thread::spawn(move || { + let msg = recv::(&mut peer).unwrap(); + match msg { + PluginToHost::Request(req) => match *req { + PluginRequest::CreateXRecord { + dictionary_path, + name, + entries, + } => { + assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]); + assert_eq!(name, "alpha"); + assert_eq!( + entries, + vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")] + ); + } + other => panic!("unexpected: {other:?}"), + }, + other => panic!("unexpected: {other:?}"), + } + send( + &mut peer, + &HostToPlugin::Response(Box::new(PluginResponse::XRecordCreateResult(Ok( + Handle::new(21), + )))), + ) + .unwrap(); + let msg = recv::(&mut peer).unwrap(); + match msg { + PluginToHost::Request(req) => match *req { + PluginRequest::ListXRecords { dictionary_path } => { + assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]); + } + other => panic!("unexpected: {other:?}"), + }, + other => panic!("unexpected: {other:?}"), + } + send( + &mut peer, + &HostToPlugin::Response(Box::new(PluginResponse::XRecordNames(Ok(vec![ + "alpha".to_owned() + ])))), + ) + .unwrap(); + }); + assert_eq!( + api.create_xrecord( + &["CVIFC"], + "alpha", + vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")] + ) + .unwrap(), + Handle::new(21) + ); + assert_eq!( + api.list_xrecords(&["CVIFC"]).unwrap(), + vec!["alpha".to_owned()] + ); + peer_handle.join().unwrap(); + } } diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 3832140af..51f206f0e 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -200,6 +200,32 @@ pub enum PluginRequest { RunCommand { request: crate::host::CommandRequest, }, + /// V8 (additive): create an XRecord under a named-object-dictionary path. + CreateXRecord { + dictionary_path: Vec, + name: String, + entries: Vec, + }, + /// V8 (additive): read the entries of an existing XRecord. + ReadXRecord { + dictionary_path: Vec, + name: String, + }, + /// V8 (additive): replace the entries of an existing XRecord. + UpdateXRecord { + dictionary_path: Vec, + name: String, + entries: Vec, + }, + /// V8 (additive): delete an XRecord and its dictionary entry. + DeleteXRecord { + dictionary_path: Vec, + name: String, + }, + /// V8 (additive): list the XRecord names under a dictionary path. + ListXRecords { + dictionary_path: Vec, + }, } /// Responses the host sends back for `PluginRequest`. @@ -236,6 +262,16 @@ pub enum PluginResponse { SolidResult(Result), TableResult(Result), CommandResult(Result), + /// V8 (additive): handle of a created XRecord. + XRecordCreateResult(Result), + /// V8 (additive): entries of a read XRecord. + XRecordEntries(Result, String>), + /// V8 (additive): handle of an updated XRecord. + XRecordUpdateResult(Result), + /// V8 (additive): the removed XRecord, with its prior content. + XRecordDeleteResult(Result), + /// V8 (additive): XRecord names under a dictionary path. + XRecordNames(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 2856700a2..874a44ab1 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -117,5 +117,39 @@ pub fn handle_plugin_request( PluginResponse::TableResult(host.table_operation(operation)) } RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)), + CreateXRecord { + dictionary_path, + name, + entries, + } => { + let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); + PluginResponse::XRecordCreateResult(host.create_xrecord(&path, &name, entries)) + } + ReadXRecord { + dictionary_path, + name, + } => { + let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); + PluginResponse::XRecordEntries(host.read_xrecord(&path, &name)) + } + UpdateXRecord { + dictionary_path, + name, + entries, + } => { + let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); + PluginResponse::XRecordUpdateResult(host.update_xrecord(&path, &name, entries)) + } + DeleteXRecord { + dictionary_path, + name, + } => { + let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); + PluginResponse::XRecordDeleteResult(host.delete_xrecord(&path, &name)) + } + ListXRecords { dictionary_path } => { + let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); + PluginResponse::XRecordNames(host.list_xrecords(&path)) + } } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 22cea2f08..93b080377 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -898,6 +898,95 @@ impl HostApi for V4PluginHostApi { } } } + + fn create_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + entries: Vec, + ) -> Result { + match self.request(PluginRequest::CreateXRecord { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + name: name.to_string(), + entries, + }) { + Ok(PluginResponse::XRecordCreateResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn read_xrecord( + &self, + dictionary_path: &[&str], + name: &str, + ) -> Result, String> { + match self.request(PluginRequest::ReadXRecord { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + name: name.to_string(), + }) { + Ok(PluginResponse::XRecordEntries(result)) => result, + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn update_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + entries: Vec, + ) -> Result { + match self.request(PluginRequest::UpdateXRecord { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + name: name.to_string(), + entries, + }) { + Ok(PluginResponse::XRecordUpdateResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn delete_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + ) -> Result { + match self.request(PluginRequest::DeleteXRecord { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + name: name.to_string(), + }) { + Ok(PluginResponse::XRecordDeleteResult(result)) => result, + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn list_xrecords(&self, dictionary_path: &[&str]) -> Result, String> { + match self.request(PluginRequest::ListXRecords { + dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(), + }) { + Ok(PluginResponse::XRecordNames(result)) => result, + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected XRecord list 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 0b4e206d9..450afbf99 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -388,6 +388,111 @@ mod tests { ); } + #[test] + fn xrecord_request_and_response_roundtrip() { + // The five XRecord request variants (API v8) serialize with their + // dictionary paths and typed entries, and the responses answer with + // handles, entries, records and names. + let entries = vec![ + crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}"), + crate::host::XRecordEntry::int16(70, 3), + ]; + let bytes = bincode::serialize(&PluginRequest::CreateXRecord { + dictionary_path: vec!["CVIFC".to_owned()], + name: "alpha".to_owned(), + entries: entries.clone(), + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::CreateXRecord { dictionary_path, name, entries: decoded } if + dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries) + ); + let bytes = bincode::serialize(&PluginRequest::ReadXRecord { + dictionary_path: vec!["CVIFC".to_owned()], + name: "alpha".to_owned(), + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::ReadXRecord { dictionary_path, name } if + dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha") + ); + let bytes = bincode::serialize(&PluginRequest::UpdateXRecord { + dictionary_path: vec!["CVIFC".to_owned()], + name: "alpha".to_owned(), + entries: entries.clone(), + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::UpdateXRecord { dictionary_path, name, entries: decoded } if + dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries) + ); + let bytes = bincode::serialize(&PluginRequest::DeleteXRecord { + dictionary_path: vec!["CVIFC".to_owned()], + name: "alpha".to_owned(), + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::DeleteXRecord { dictionary_path, name } if + dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha") + ); + let bytes = bincode::serialize(&PluginRequest::ListXRecords { + dictionary_path: vec!["CVIFC".to_owned()], + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::ListXRecords { dictionary_path } if + dictionary_path == vec!["CVIFC".to_owned()]) + ); + let bytes = bincode::serialize(&PluginResponse::XRecordCreateResult(Ok( + codec::Handle::new(42), + ))) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::XRecordCreateResult(Ok(handle)) if handle == codec::Handle::new(42)) + ); + let bytes = + bincode::serialize(&PluginResponse::XRecordEntries(Ok(entries.clone()))).unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::XRecordEntries(Ok(decoded)) if decoded == entries) + ); + let bytes = bincode::serialize(&PluginResponse::XRecordUpdateResult(Ok( + codec::Handle::new(42), + ))) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::XRecordUpdateResult(Ok(handle)) if handle == codec::Handle::new(42)) + ); + let bytes = bincode::serialize(&PluginResponse::XRecordNames(Ok(vec!["alpha".to_owned()]))) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::XRecordNames(Ok(names)) if names == vec!["alpha".to_owned()]) + ); + let deleted = crate::host::XRecord::named("alpha"); + let bytes = + bincode::serialize(&PluginResponse::XRecordDeleteResult(Ok(deleted.clone()))).unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::XRecordDeleteResult(Ok(record)) if record == deleted) + ); + let bytes = bincode::serialize(&PluginResponse::XRecordEntries(Err( + "XRecord reads are not supported by this host".to_owned(), + ))) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::XRecordEntries(Err(error)) if error.contains("not supported")) + ); + } + #[test] fn plugin_notification_roundtrip() { let frame = PluginToHostV4::Notification(NotificationEnvelope { diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 849e63a94..6a40f9ce3 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -21,8 +21,11 @@ /// adds the `BuiltinPlugin::on_load` lifecycle callback and /// `HostApi::document_path`. v6 appends generic host system-variable access /// (`HostApi::system_variable` / `set_system_variable`). v7 appends atomic, -/// undoable replacement of a set of existing entities. -pub const API_VERSION: u32 = 7; +/// 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; /// 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 diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 73e24d2fb..407b3dc5f 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -339,3 +339,21 @@ plugin gate (60 real-IPC host tests, 115 plugin API, 20 plugin crate, 16 Python `--no-fail-fast` (1,805 passed, 1 failed: `fonts_parse_and_resolve`, which fails on pristine `main` on this Mac too; `arc_grips_drive_center_start_and_end_but_not_midpoint` now passes, fixed upstream). The change is 72 files, +30,705 / -178 against current `main` (fork-only workflow excluded). +### 2026-10-11: XRecord objects (API v8, no Python surface yet) + +New additive v8 requests `CreateXRecord` / `ReadXRecord` / `UpdateXRecord` / `DeleteXRecord` / `ListXRecords` +give plugins a non-graphical record carrier: name-addressed typed `XRecordEntry` pairs under the drawing's +named-object dictionary, with no carrier entity. `dictionary_path` is walked from the NOD root (an empty path +is the root), creating missing dictionaries on create only — the `ensure_image_dictionary` bootstrap — so the +first call seeds a namespace; `create` refuses an existing name and `update` refuses a missing one, so neither +can silently overwrite or create. Names and path fragments are symbol-validated, entry group codes are checked +(1..=369, never the handle codes 5/105), and every refusal is decided before the undo step is recorded. Update +replaces the entries atomically (handle, owner and name stay, the record's verbatim raw payloads are dropped, so +saves emit the new content); delete returns the removed record; list reports only the keys that address +XRecords, in dictionary order. Undo: objects-map mutations are captured by the pending history's full structure +snapshot, so each mutating call is one undo step whose undo restores the objects map byte-exactly, the +auto-created dictionaries included. Evidence: `xrecord_crud_round_trips_refusals_and_list`, +`xrecord_mutations_are_single_undo_steps_that_restore_objects` (four steps, refusal paths record none), +`xrecords_survive_dxf_and_dwg_round_trips` (typed entries reload from both writers), the IPC round-trips +`xrecord_request_and_response_roundtrip` and `xrecord_requests_await_their_typed_responses`. The API is +host-level and stands on its own; a Python mapping (`doc.xrecords`-style) is future work. diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 09e0f1189..c1f94bfc9 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -26,6 +26,7 @@ without it keeps working, and the default `HostApi` methods refuse. | Selection | `selection`, `set_selection` | Ordered, tab-scoped, exact (missing or duplicate handles reject the request). | | Solids | `solid_operation` | Kernel-backed create, transform, booleans, extrude, region and surface from a profile, picture embedding. Every result is verified to lift back from its ACIS payload without loss; a refusal leaves the operands untouched. | | Tables | `table_operation` | Layers, text and dimension styles, blocks and their contents, linetypes, layouts (see below). | +| Objects | `create_xrecord`, `read_xrecord`, `update_xrecord`, `delete_xrecord`, `list_xrecords` | Name-addressed XRecords under the drawing's named-object dictionary (API v8, see below): a non-graphical record carrier with strict create-vs-update semantics and one undo step per change. | | Commands | `run_command` | Drives the real OCS command one step at a time (see below). | | Settings | `system_variable`, `set_system_variable` | `CLAYER`, `SNAPANG`, and a read-only `CTAB` (the current layout). | @@ -85,6 +86,22 @@ recorded, so a failure changes nothing and leaves the history alone. Style and layout operations act on the active document, because they use the application's shared style and view machinery. +## XRecords (API v8) + +`create_xrecord`, `read_xrecord`, `update_xrecord`, `delete_xrecord` and +`list_xrecords` address XRecords by name under a dictionary path walked from the +named-object dictionary root (an empty path is the root itself); missing +dictionaries are created on create only, so the first call seeds a plugin's +namespace. The payload is the codec's typed `XRecordEntry` pairs — the host puts +no opinion on the content. Names and path fragments are symbol-validated, and +dictionary addressing follows the codec's case-insensitive lookup. Create never +overwrites and update never creates; each refusal is decided before the undo step +is recorded, so a failure changes nothing, and each change is one undo step whose +undo restores the objects map byte-exactly, the auto-created dictionaries +included. `list_xrecords` reports only the keys that address XRecords, in the +dictionary's own order, so audits need no prior knowledge. Not yet mapped to +Python. + ## Commands `run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`, diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index d47a802b0..cb4451d92 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2777,6 +2777,324 @@ impl<'a> HostSession<'a> { self.publish_document_view(); true } + + // ── XRecord objects (API v8, additive) ──────────────────────────────────── + // + // XRecords are non-graphical objects: name-addressed typed group-code/value + // pairs stored under the drawing's named-object dictionary, like AutoCAD + // keeps application data. They give scripts a record carrier that lives in + // the file and survives model view entirely, with no carrier entity. + + /// The dictionary addressed by `dictionary_path` under the named-object + /// dictionary root (an empty path is the root itself). Nothing is created: + /// a missing fragment is an error, and so is a fragment that exists but + /// names a non-dictionary object. + fn xrecord_dictionary_at(&self, dictionary_path: &[&str]) -> Result { + use codec::objects::ObjectType; + let root = self.document().header.named_objects_dict_handle; + if !matches!( + self.document().objects.get(&root), + Some(ObjectType::Dictionary(_)) + ) { + return Err("the drawing has no named-object dictionary".to_owned()); + } + let mut current = root; + for fragment in dictionary_path { + let name = validated_symbol_name(fragment, "a dictionary name")?; + match self.document().objects.get(¤t) { + Some(ObjectType::Dictionary(dictionary)) => match dictionary.get(&name) { + Some(child) => { + if !matches!( + self.document().objects.get(&child), + Some(ObjectType::Dictionary(_)) + ) { + return Err(format!("dictionary entry {name:?} is not a dictionary")); + } + current = child; + } + None => return Err(format!("dictionary {name:?} does not exist")), + }, + _ => return Err(format!("dictionary {current:?} does not exist")), + } + } + Ok(current) + } + + /// The deepest ancestor of `dictionary_path` that already exists, and how + /// deep the path resolved. Errors when a fragment that exists names a + /// non-dictionary object. Creation calls this first so its refusals happen + /// before the undo step is recorded. + fn deepest_existing_xrecord_dictionary( + &self, + dictionary_path: &[&str], + ) -> Result<(Handle, usize), String> { + use codec::objects::ObjectType; + let root = self.document().header.named_objects_dict_handle; + if !matches!( + self.document().objects.get(&root), + Some(ObjectType::Dictionary(_)) + ) { + return Err("the drawing has no named-object dictionary".to_owned()); + } + let mut current = root; + let mut depth = 0usize; + for fragment in dictionary_path { + let name = validated_symbol_name(fragment, "a dictionary name")?; + match self.document().objects.get(¤t) { + Some(ObjectType::Dictionary(dictionary)) => match dictionary.get(&name) { + Some(child) => { + if !matches!( + self.document().objects.get(&child), + Some(ObjectType::Dictionary(_)) + ) { + return Err(format!("dictionary entry {name:?} is not a dictionary")); + } + current = child; + depth += 1; + } + None => break, + }, + _ => return Err(format!("dictionary {current:?} does not exist")), + } + } + Ok((current, depth)) + } + + /// Walk `dictionary_path` from the named-object dictionary root, creating + /// missing dictionaries on the way — the same bootstrap + /// `ensure_image_dictionary` uses for ACAD_IMAGE_DICT: each new dictionary + /// owns its handle, is owned by its parent and is registered in the parent + /// under its name. Mutates; only valid after the caller validated the path + /// and recorded its undo step. + fn xrecord_dictionary_at_or_create( + &mut self, + dictionary_path: &[&str], + ) -> Result { + use codec::objects::{Dictionary, ObjectType}; + let root = self.document().header.named_objects_dict_handle; + if !matches!( + self.document().objects.get(&root), + Some(ObjectType::Dictionary(_)) + ) { + return Err("the drawing has no named-object dictionary".to_owned()); + } + let mut current = root; + for fragment in dictionary_path { + let name = validated_symbol_name(fragment, "a dictionary name")?; + let existing = match self.document().objects.get(¤t) { + Some(ObjectType::Dictionary(dictionary)) => dictionary.get(&name), + _ => return Err(format!("dictionary {current:?} does not exist")), + }; + match existing { + Some(child) => { + if !matches!( + self.document().objects.get(&child), + Some(ObjectType::Dictionary(_)) + ) { + return Err(format!("dictionary entry {name:?} is not a dictionary")); + } + current = child; + } + None => { + let child = self.document_mut().allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = child; + dictionary.owner = current; + self.document_mut() + .objects + .insert(child, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(entries)) = + self.document_mut().objects.get_mut(¤t) + { + entries.add_entry(name, child); + } + current = child; + } + } + } + Ok(current) + } + + /// Create an XRecord named `name` under `dictionary_path`, storing + /// `entries` (API v8, additive). Missing dictionaries on the path are + /// created, so the first call seeds a plugin's namespace; the name must be + /// unused (dictionary lookup is case-insensitive). Every refusal happens + /// before the undo step is recorded, so a failure changes nothing; the + /// created record is registered in — and owned by — its dictionary, and + /// the new handle is returned. + pub fn create_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + entries: Vec, + ) -> Result { + use codec::objects::{ObjectType, XRecord}; + let name = validated_symbol_name(name, "an XRecord name")?; + validated_xrecord_entries(&entries)?; + // Pre-check the path: fragments that already exist must be + // dictionaries, and when the full path exists the name must be free — + // create must never silently overwrite a key. + let (deepest, depth) = self.deepest_existing_xrecord_dictionary(dictionary_path)?; + if depth == dictionary_path.len() { + if let Some(ObjectType::Dictionary(dictionary)) = self.document().objects.get(&deepest) + { + if dictionary.get(&name).is_some() { + return Err(format!("XRecord {name:?} already exists")); + } + } + } + self.push_undo("Create XRecord"); + // The pre-check ruled out every failure mode; the walk now only creates + // the dictionary fragments that were missing. + let dictionary = self + .xrecord_dictionary_at_or_create(dictionary_path) + .expect("validated dictionary path"); + let handle = self.document_mut().allocate_handle(); + let mut record = XRecord::named(name.clone()); + record.handle = handle; + record.owner = dictionary; + record.entries = entries; + record.synchronize_object_references(); + self.document_mut() + .objects + .insert(handle, ObjectType::XRecord(record)); + if let Some(ObjectType::Dictionary(entries)) = + self.document_mut().objects.get_mut(&dictionary) + { + entries.add_entry(name, handle); + } + self.set_dirty(); + self.publish_document_view(); + Ok(handle) + } + + /// Read the entries of an existing XRecord by name (API v8, additive). + /// The entries are returned verbatim; the path, the name and the target + /// kind are validated first, so a refusal changes nothing. + pub fn read_xrecord( + &self, + dictionary_path: &[&str], + name: &str, + ) -> Result, String> { + use codec::objects::ObjectType; + let (_, _, handle) = self.xrecord_lookup(dictionary_path, name)?; + match self.document().objects.get(&handle) { + Some(ObjectType::XRecord(record)) => Ok(record.entries.clone()), + _ => unreachable!("validated XRecord"), + } + } + + /// Atomically replace the entries of an existing XRecord (API v8, + /// additive). Neither the path nor its dictionaries are created — an + /// update must never create anything silently. Identity (handle, owning + /// dictionary, name) stays; the entries and their DWG handle-reference + /// stream are replaced and the record's verbatim raw payloads are dropped, + /// so later saves emit the new content. Every refusal happens before the + /// undo step is recorded. + pub fn update_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + entries: Vec, + ) -> Result { + use codec::objects::ObjectType; + validated_xrecord_entries(&entries)?; + let (_, _, handle) = self.xrecord_lookup(dictionary_path, name)?; + self.push_undo("Update XRecord"); + let record = match self.document_mut().objects.get_mut(&handle) { + Some(ObjectType::XRecord(record)) => record, + _ => unreachable!("validated XRecord"), + }; + record.entries = entries; + // Rebuild the DWG object-reference stream from the new 330-369 entries + // and drop the verbatim bytes an earlier load kept: the update fully + // defines the record now. + record.synchronize_object_references(); + record.preserve_object_reference_stream = false; + record.entries_complete = true; + record.raw_data.clear(); + record.raw_dwg_data = None; + record.raw_dwg_handle_bits = 0; + record.raw_dwg_version = None; + self.set_dirty(); + self.publish_document_view(); + Ok(handle) + } + + /// Delete the XRecord named `name` and its dictionary entry (API v8, + /// additive). Returns the removed record with its prior content, so audits + /// keep what they removed. Every refusal happens before the undo step is + /// recorded. + pub fn delete_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + ) -> Result { + use codec::objects::ObjectType; + let (dictionary, name, handle) = self.xrecord_lookup(dictionary_path, name)?; + self.push_undo("Delete XRecord"); + let removed = match self.document_mut().objects.remove(&handle) { + Some(ObjectType::XRecord(record)) => record, + _ => unreachable!("validated XRecord"), + }; + if let Some(ObjectType::Dictionary(entries)) = + self.document_mut().objects.get_mut(&dictionary) + { + entries + .entries + .retain(|(key, value)| !key.eq_ignore_ascii_case(&name) || *value != handle); + } + self.set_dirty(); + self.publish_document_view(); + Ok(removed) + } + + /// List the XRecord names under the dictionary addressed by + /// `dictionary_path` (API v8, additive): the dictionary keys that address + /// XRecord objects, in the dictionary's own order, so consumers can audit + /// and export without knowing names in advance. Non-XRecord entries are + /// not listed. + pub fn list_xrecords(&self, dictionary_path: &[&str]) -> Result, String> { + use codec::objects::ObjectType; + let dictionary = self.xrecord_dictionary_at(dictionary_path)?; + let Some(ObjectType::Dictionary(entries)) = self.document().objects.get(&dictionary) else { + return Err(format!("dictionary {dictionary:?} does not exist")); + }; + Ok(entries + .entries + .iter() + .filter_map(|(key, handle)| match self.document().objects.get(handle) { + Some(ObjectType::XRecord(_)) => Some(key.clone()), + _ => None, + }) + .collect()) + } + + /// Resolve an existing XRecord to its containing dictionary, its validated + /// name and its handle, after validating the path, the name and the target + /// kind. + fn xrecord_lookup( + &self, + dictionary_path: &[&str], + name: &str, + ) -> Result<(Handle, String, Handle), String> { + use codec::objects::ObjectType; + let dictionary = self.xrecord_dictionary_at(dictionary_path)?; + let name = validated_symbol_name(name, "an XRecord name")?; + let Some(ObjectType::Dictionary(entries)) = self.document().objects.get(&dictionary) else { + return Err(format!("dictionary {dictionary:?} does not exist")); + }; + let Some(handle) = entries.get(&name) else { + return Err(format!("XRecord {name:?} does not exist")); + }; + if !matches!( + self.document().objects.get(&handle), + Some(ObjectType::XRecord(_)) + ) { + return Err(format!("dictionary entry {name:?} is not an XRecord")); + } + Ok((dictionary, name, handle)) + } } /// True when block `outer` (transitively) contains an insert of `target`. @@ -2824,6 +3142,28 @@ fn validated_symbol_name(raw: &str, what: &str) -> Result { Ok(name.to_owned()) } +/// Validate the entries an XRecord create or update wants to store: each needs +/// a group code the codec model admits (1..=369; 5 and 105 are reserved for +/// object handles). Refused before any change so bad input never reaches a +/// file. +fn validated_xrecord_entries(entries: &[codec::objects::XRecordEntry]) -> Result<(), String> { + for entry in entries { + if !(1..=369).contains(&entry.code) { + return Err(format!( + "XRecord group code {} is out of range 1..=369", + entry.code + )); + } + if entry.code == 5 || entry.code == 105 { + return Err(format!( + "XRecord group code {} is reserved for the object handle", + entry.code + )); + } + } + Ok(()) +} + fn apply_text_style_config( style: &mut codec::tables::TextStyle, config: &ocs_plugin_api::host::TextStyleConfig, @@ -3100,6 +3440,39 @@ impl HostApi for HostSession<'_> { ) -> Result { self.run_command(request) } + fn create_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + entries: Vec, + ) -> Result { + self.create_xrecord(dictionary_path, name, entries) + } + fn read_xrecord( + &self, + dictionary_path: &[&str], + name: &str, + ) -> Result, String> { + self.read_xrecord(dictionary_path, name) + } + fn update_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + entries: Vec, + ) -> Result { + self.update_xrecord(dictionary_path, name, entries) + } + fn delete_xrecord( + &mut self, + dictionary_path: &[&str], + name: &str, + ) -> Result { + self.delete_xrecord(dictionary_path, name) + } + fn list_xrecords(&self, dictionary_path: &[&str]) -> Result, String> { + self.list_xrecords(dictionary_path) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -3600,6 +3973,308 @@ mod tests { ); } + #[test] + fn xrecord_crud_round_trips_refusals_and_list() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let entries = || { + vec![ + codec::objects::XRecordEntry::string(1, "{\"k\":\"road\",\"s\":1}"), + codec::objects::XRecordEntry::double(40, 0.125), + codec::objects::XRecordEntry::int16(70, 3), + codec::objects::XRecordEntry::int32(90, -45000), + codec::objects::XRecordEntry::bool(290, true), + ] + }; + + // Create seeds the namespace: the dictionary chain is auto-created and + // reading returns the entries verbatim. + let alpha = host.create_xrecord(&["CVIFC"], "alpha", entries()).unwrap(); + assert!(!alpha.is_null()); + assert_eq!(host.read_xrecord(&["CVIFC"], "alpha").unwrap(), entries()); + // Dictionary lookup is case-insensitive, both for the path … + assert_eq!(host.read_xrecord(&["cvifc"], "alpha").unwrap(), entries()); + // … and re-creating an existing name is refused without any change. + assert!(host + .create_xrecord(&["cvifc"], "ALPHA", entries()) + .unwrap_err() + .contains("already exists")); + + // Namespace reuse is idempotent: the root still holds one CVIFC entry. + let beta = host.create_xrecord(&["CVIFC"], "beta", entries()).unwrap(); + let root = host.document().header.named_objects_dict_handle; + let cvifc_entries = match host.document().objects.get(&root) { + Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary + .entries + .iter() + .filter(|(key, _)| key.eq_ignore_ascii_case("CVIFC")) + .count(), + _ => 0, + }; + assert_eq!(cvifc_entries, 1); + + // Listing names only the keys that address XRecords, in dictionary + // order; a nested dictionary is not an XRecord. + host.create_xrecord(&["CVIFC", "LAYOUTS"], "nested", entries()) + .unwrap(); + assert_eq!( + host.list_xrecords(&["CVIFC"]).unwrap(), + vec!["alpha".to_owned(), "beta".to_owned()] + ); + assert_eq!( + host.list_xrecords(&["CVIFC", "LAYOUTS"]).unwrap(), + vec!["nested".to_owned()] + ); + // The root dictionary holds non-XRecord entries (ACAD_LAYOUT et al.); + // listing only reports XRecords. + let root_names = host.list_xrecords(&[]).unwrap(); + assert!(!root_names + .iter() + .any(|n| n.eq_ignore_ascii_case("ACAD_LAYOUT"))); + + // Update refuses on a missing name and never creates anything. + assert!(host + .update_xrecord(&["CVIFC"], "gamma", entries()) + .unwrap_err() + .contains("does not exist")); + // Case-insensitive resolution finds the stored ALPHA and replaces its + // entries atomically; the handle is stable. + assert_eq!( + host.update_xrecord( + &["CVIFC"], + "ALPHA", + vec![codec::objects::XRecordEntry::string(1, "v2")] + ) + .unwrap(), + alpha + ); + assert_eq!( + host.read_xrecord(&["CVIFC"], "alpha").unwrap(), + vec![codec::objects::XRecordEntry::string(1, "v2")] + ); + + // Entries are validated before any change: reserved and out-of-range + // group codes refuse (5 and 105 are the object handle codes). + assert!(host + .create_xrecord( + &["CVIFC"], + "gamma", + vec![codec::objects::XRecordEntry::int16(5, 1)] + ) + .unwrap_err() + .contains("reserved")); + assert!(host + .create_xrecord( + &["CVIFC"], + "gamma", + vec![codec::objects::XRecordEntry::int16(400, 1)] + ) + .unwrap_err() + .contains("out of range")); + assert!(host + .update_xrecord( + &["CVIFC"], + "alpha", + vec![codec::objects::XRecordEntry::int16(105, 1)] + ) + .is_err()); + assert_eq!( + host.read_xrecord(&["CVIFC"], "alpha").unwrap(), + vec![codec::objects::XRecordEntry::string(1, "v2")] + ); + // Dictionary and record names are symbol names. + assert!(host.create_xrecord(&["CVIFC"], " ", entries()).is_err()); + assert!(host + .create_xrecord(&["CVIFC", "A|B"], "gamma", entries()) + .is_err()); + assert!(host.create_xrecord(&["CVIFC"], "a Option { + match doc.objects.get(&handle) { + Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()), + _ => None, + } + }; + let has_dictionary_entry = |doc: &CadDocument, name: &str| -> bool { + let root = doc.header.named_objects_dict_handle; + match doc.objects.get(&root) { + Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary + .entries + .iter() + .any(|(key, _)| key.eq_ignore_ascii_case(name)), + _ => false, + } + }; + + // Undo the delete: beta is back with its exact prior entries. + app.undo_steps(1); + let document = &app.tabs[0].scene.document; + assert_eq!(xrecord(document, beta).unwrap().entries, original_entries()); + // Undo the update: alpha's entries are restored byte-exactly. + app.undo_steps(1); + let document = &app.tabs[0].scene.document; + assert_eq!( + xrecord(document, alpha).unwrap().entries, + original_entries() + ); + // Undo beta's creation. + app.undo_steps(1); + let document = &app.tabs[0].scene.document; + assert!(xrecord(document, beta).is_none()); + assert!(xrecord(document, alpha).is_some()); + // Undo alpha's creation: the auto-created dictionary went with it, + // because the pending history snapshot restores the whole objects map. + app.undo_steps(1); + let document = &app.tabs[0].scene.document; + assert!(xrecord(document, alpha).is_none()); + assert!( + !has_dictionary_entry(document, "CVIFC"), + "undoing the first create removed the auto-created dictionary" + ); + // Redo re-applies the whole ladder. + app.redo_steps(4); + let document = &app.tabs[0].scene.document; + assert_eq!(xrecord(document, alpha).unwrap().entries, changed_entries()); + assert!(xrecord(document, beta).is_none()); + } + + #[test] + fn xrecords_survive_dxf_and_dwg_round_trips() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let entries = || { + vec![ + codec::objects::XRecordEntry::string(1, "{\"k\":\"road\",\"s\":1}"), + codec::objects::XRecordEntry::double(40, 0.125), + codec::objects::XRecordEntry::int16(70, 3), + codec::objects::XRecordEntry::int32(90, -45000), + codec::objects::XRecordEntry::bool(290, true), + ] + }; + // Create carries a first payload, then the update replaces it: if the + // writers preferred stale raw bytes over the typed entries, the loaded + // record would still read "stale". + host.create_xrecord( + &["CVIFC", "RECORDS"], + "alpha", + vec![codec::objects::XRecordEntry::string(1, "stale")], + ) + .unwrap(); + host.update_xrecord(&["CVIFC", "RECORDS"], "alpha", entries()) + .unwrap(); + + let find_xrecord = + |doc: &CadDocument, path: &[&str], name: &str| -> Option { + let mut current = doc.header.named_objects_dict_handle; + for fragment in path { + match doc.objects.get(¤t) { + Some(codec::objects::ObjectType::Dictionary(dictionary)) => { + current = dictionary.get(fragment)? + } + _ => return None, + } + } + let Some(codec::objects::ObjectType::Dictionary(dictionary)) = + doc.objects.get(¤t) + else { + return None; + }; + let handle = dictionary.get(name)?; + match doc.objects.get(&handle) { + Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()), + _ => None, + } + }; + + let dwg = crate::io::load_bytes( + "xrec.dwg", + codec::DwgWriter::write_to_vec(host.document()).unwrap(), + ) + .unwrap(); + let reloaded = find_xrecord(&dwg, &["CVIFC", "RECORDS"], "alpha") + .expect("XRecord in the reloaded DWG"); + assert_eq!(reloaded.entries, entries()); + + let dxf = crate::io::load_bytes( + "xrec.dxf", + codec::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(), + ) + .unwrap(); + let reloaded = find_xrecord(&dxf, &["CVIFC", "RECORDS"], "alpha") + .expect("XRecord in the reloaded DXF"); + assert_eq!(reloaded.entries, entries()); + } + /// Opt-in end-to-end check using the staged Python cdylib and the actual /// OCS executable as its out-of-process runner. Run with OCS_TEST_PYTHON_PLUGIN /// and OCS_PLUGIN_RUNNER_EXE set; unlike the local-socket protocol test,