From a3b8acb59e082ee95b5fbd79cd904940db23ef12 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 17 Jun 2026 10:28:12 +0300 Subject: [PATCH] feat(plugin): XDATA read/write/remove helpers on HostSession Plugins persisted domain data via the raw acadrust::xdata API with boilerplate and no APPID registration. Add read_record / write_record / remove_record keyed by entity handle; write_record replaces an existing record for the same app and registers the application in the APPID table so the data round-trips through DWG/DXF for other CAD apps. Part of the #100 extensibility epic (surfaced in #106). Co-Authored-By: Claude Opus 4.8 --- docs/plugin-architecture.md | 4 +- src/app/plugin_host.rs | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 80132dbb..cf976c08 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -178,6 +178,7 @@ Plugins use `HostSession`, not `OpenCADStudio`: | Category | Methods | |----------|---------| | Document | `document()`, `document_mut()`, `entities()`, `entities_mut()`, `add_entity()`, `bump_geometry()` | +| XDATA | `read_record(handle, app)`, `write_record(handle, record)`, `remove_record(handle, app)` — keyed by entity handle; `write_record` registers the APPID so the file stays standard | | Tab state | `plugin_state()`, `plugin_state_mut()`, `ensure_plugin_state()` keyed by `manifest.id` | | Command line | `push_info`, `push_output`, `push_error`, `set_active_command` | | Undo / dirty | `push_undo`, `set_dirty` | @@ -231,7 +232,7 @@ Domain persistence lives on entities. Document schemas in `PLUGIN.md`: | `STORMSEWER_PIPE` | `opencad.storm_sewer` | Pipe link between structures | | `STORMSEWER_CATCHMENT` | `opencad.storm_sewer` | Catchment boundary + hydrology | -Host may add `xdata::read_record` / `write_record` helpers later; plugins use `acadrust` XDATA APIs today. +`HostSession` provides `read_record` / `write_record` / `remove_record` helpers (keyed by entity handle) over the `acadrust` XDATA API; `write_record` also registers the application in the APPID table so the data survives a DWG/DXF round-trip. Plugins may still use the raw `acadrust` XDATA APIs directly. --- @@ -258,6 +259,7 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with - [x] Plugin manager UI (list installed, versions) — `PLUGINS` / `PLUGINMANAGER` command, or the Start-page "Plugins" button - [x] Enable/disable plugins from the manager — a disabled plugin drops its ribbon tab and command dispatch; persisted in `settings.txt` (`disabled_plugins=`) - [x] `ModuleEvent::PluginFileDialog` — a plugin tool requests a native file picker; the host opens it and dispatches `" "` back to the plugin with original case preserved (bypasses the command-line upper-casing) +- [x] XDATA convenience on `HostSession` — `read_record` / `write_record` / `remove_record`; `write_record` registers the APPID so plugin data round-trips through DWG/DXF ### Phase 2 — Dynamic loading (desktop) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index f5df2517..514ad21e 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2,6 +2,8 @@ use std::any::{Any, TypeId}; +use acadrust::tables::AppId; +use acadrust::xdata::ExtendedDataRecord; use acadrust::{CadDocument, EntityType, Handle}; use super::OpenCADStudio; @@ -46,6 +48,78 @@ impl<'a> HostSession<'a> { self.app.tabs[self.tab].scene.bump_geometry(); } + // ── XDATA convenience ────────────────────────────────────────────────── + // Plugins persist domain data as XDATA on plain entities so it round-trips + // through DWG/DXF. These wrap the `acadrust::xdata` API keyed by entity + // handle and keep the APPID table in sync. + + /// Read the XDATA record for `app_name` attached to entity `handle`, if any. + pub fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> { + self.document() + .get_entity(handle)? + .common() + .extended_data + .get_record(app_name) + } + + /// Attach `record` to entity `handle`, replacing any existing record for the + /// same application. Registers the application in the APPID table when + /// missing so the file stays valid for other CAD apps. Returns `false` when + /// the entity does not exist. + pub fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool { + let app = record.application_name.clone(); + self.ensure_app_id(&app); + let Some(entity) = self.document_mut().get_entity_mut(handle) else { + return false; + }; + let xd = &mut entity.common_mut().extended_data; + // Drop any existing record for this app, then append the new one. + let kept: Vec<_> = xd + .records() + .iter() + .filter(|r| r.application_name != app) + .cloned() + .collect(); + xd.clear(); + for r in kept { + xd.add_record(r); + } + xd.add_record(record); + true + } + + /// Remove the XDATA record for `app_name` from entity `handle`. Returns + /// `true` when a record was actually removed. + pub fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool { + let Some(entity) = self.document_mut().get_entity_mut(handle) else { + return false; + }; + let xd = &mut entity.common_mut().extended_data; + let kept: Vec<_> = xd + .records() + .iter() + .filter(|r| r.application_name != app_name) + .cloned() + .collect(); + if kept.len() == xd.records().len() { + return false; + } + xd.clear(); + for r in kept { + xd.add_record(r); + } + true + } + + /// Register `name` in the APPID table if it is not already present, so XDATA + /// written under it survives a DWG/DXF round-trip. + fn ensure_app_id(&mut self, name: &str) { + let doc = self.document_mut(); + if !doc.app_ids.contains(name) { + let _ = doc.app_ids.add(AppId::new(name)); + } + } + pub fn push_undo(&mut self, label: &str) { self.app.push_undo_snapshot(self.tab, label); } @@ -91,4 +165,41 @@ impl<'a> HostSession<'a> { ) -> &mut T { self.app.tabs[self.tab].ensure_plugin_state(plugin_id, init) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::OpenCADStudio; + use acadrust::entities::Point; + use acadrust::xdata::XDataValue; + + #[test] + fn xdata_record_round_trips_and_registers_appid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let h = host.add_entity(EntityType::Point(Point::new())); + + let mut rec = ExtendedDataRecord::new("DEMO_SURVEY"); + rec.add_value(XDataValue::String("PNT-1".to_string())); + rec.add_value(XDataValue::Integer32(42)); + assert!(host.write_record(h, rec)); + + let got = host.read_record(h, "DEMO_SURVEY").expect("record missing"); + assert_eq!(got.values.len(), 2); + // APPID registered so the XDATA survives a DWG/DXF round-trip. + assert!(host.document().app_ids.contains("DEMO_SURVEY")); + + // A second write replaces rather than duplicates the record. + let mut rec2 = ExtendedDataRecord::new("DEMO_SURVEY"); + rec2.add_value(XDataValue::String("PNT-2".to_string())); + assert!(host.write_record(h, rec2)); + let got = host.read_record(h, "DEMO_SURVEY").unwrap(); + assert_eq!(got.values.len(), 1); + + // Removal reports whether anything was dropped. + assert!(host.remove_record(h, "DEMO_SURVEY")); + assert!(host.read_record(h, "DEMO_SURVEY").is_none()); + assert!(!host.remove_record(h, "DEMO_SURVEY")); + } } \ No newline at end of file