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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-17 10:28:12 +03:00
commit a3b8acb59e
2 changed files with 114 additions and 1 deletions

View file

@ -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 `"<command> <path>"` 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)

View file

@ -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"));
}
}