diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 588b5697..c7e3d55c 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -17,9 +17,11 @@ serde = { version = "1", features = ["derive"], optional = true } bincode = { version = "1", optional = true } thiserror = { version = "1", optional = true } libloading = { version = "0.8", optional = true } +memmap2 = { version = "0.9", optional = true } +rkyv = { version = "0.7", features = ["validation", "std"], optional = true } [features] # Enables the runtime host surface (`HostApi` trait) and the out-of-process # plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers # leave it off. -host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"] +host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv"] diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index b7de1fca..ac17de82 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -112,6 +112,7 @@ pub trait HostApi { // ── Document ──────────────────────────────────────────────────────────── fn document(&self) -> &CadDocument; fn document_mut(&mut self) -> &mut CadDocument; + /// Add an entity to the active document, returning its handle. fn add_entity(&mut self, entity: EntityType) -> Handle; /// Mark the scene geometry dirty so it is re-tessellated next frame. @@ -143,14 +144,143 @@ pub trait HostApi { // ── Per-tab plugin state (object-safe; use the typed helpers below) ────── fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>; - fn plugin_state_any_mut(&mut self, plugin_id: &str) - -> Option<&mut (dyn Any + Send + Sync)>; + fn plugin_state_any_mut(&mut self, plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)>; /// Get the state for `plugin_id`, inserting `init()`'s result if absent. fn ensure_plugin_state_any( &mut self, plugin_id: &'static str, init: &mut dyn FnMut() -> Box, ) -> &mut (dyn Any + Send + Sync); + + // ── DocumentReader (added in API v3; appended at the end to keep vtable + // indices stable for API v2 plugins) ───────────────────────────────────── + + /// Read-only, zero-copy view of the active document. For out-of-process + /// plugins this is backed by host-owned shared memory; for in-process + /// plugins it wraps `document()`. + fn document_reader(&self) -> Box; + + /// Open (or refresh) the host-side shared document view and return the + /// information the plugin needs to map it. In-process hosts implement this; + /// out-of-process plugin proxies return `None`. + fn document_view(&mut self) -> Option { + None + } +} + +/// Simplified, read-only entity kind exposed by [`DocumentReader`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReaderEntityKind { + Point, + Line, + Circle, + Arc, + Polyline, + Text, + Other, +} + +/// A 3D point returned by [`DocumentReader`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ReaderPoint { + pub x: f64, + pub y: f64, + pub z: f64, +} + +/// A read-only view of one entity, borrowed from a [`DocumentReader`]. +pub struct ReaderEntity<'a> { + /// Entity handle in the host document. + pub handle: Handle, + /// Simplified entity type. + pub kind: ReaderEntityKind, + /// Name of the layer the entity lives on. + pub layer_name: &'a str, + /// If the entity is a point, its coordinates. + pub point: Option, +} + +/// Read-only, zero-copy view of a CAD document. +/// +/// For out-of-process plugins this is backed by host-owned shared memory. The +/// plugin receives only references into that mapping, so the document model is +/// not copied into the plugin's heap. +pub trait DocumentReader { + /// Total number of entities in the document. + fn entity_count(&self) -> usize; + + /// Iterate over all entities without allocating a full `CadDocument`. + fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>)); + + /// Look up a layer name by handle. + fn layer_name(&self, handle: Handle) -> Option<&str>; + + /// Look up an APPID name by handle. + fn app_id_name(&self, handle: Handle) -> Option<&str>; +} + +impl ReaderEntityKind { + /// Map a concrete `EntityType` to the simplified reader kind. + pub fn from_entity(entity: &EntityType) -> Self { + match entity { + EntityType::Point(_) => ReaderEntityKind::Point, + EntityType::Line(_) => ReaderEntityKind::Line, + EntityType::Circle(_) => ReaderEntityKind::Circle, + EntityType::Arc(_) => ReaderEntityKind::Arc, + EntityType::Polyline(_) + | EntityType::Polyline2D(_) + | EntityType::Polyline3D(_) + | EntityType::LwPolyline(_) => ReaderEntityKind::Polyline, + EntityType::Text(_) | EntityType::MText(_) => ReaderEntityKind::Text, + _ => ReaderEntityKind::Other, + } + } +} + +/// In-process `DocumentReader` implementation that wraps a borrowed `CadDocument`. +pub struct CadDocumentReader<'a>(pub &'a CadDocument); + +impl<'a> DocumentReader for CadDocumentReader<'a> { + fn entity_count(&self) -> usize { + self.0.entities().count() + } + + fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>)) { + for entity in self.0.entities() { + let kind = ReaderEntityKind::from_entity(entity); + let layer_name = entity.common().layer.as_str(); + let point = match entity { + EntityType::Point(p) => Some(ReaderPoint { + x: p.location.x, + y: p.location.y, + z: p.location.z, + }), + _ => None, + }; + f(ReaderEntity { + handle: entity.common().handle, + kind, + layer_name, + point, + }); + } + } + + fn layer_name(&self, handle: Handle) -> Option<&str> { + self.0 + .layers + .iter() + .find(|layer| layer.handle == handle) + .map(|layer| layer.name.as_str()) + } + + fn app_id_name(&self, handle: Handle) -> Option<&str> { + self.0 + .app_ids + .iter() + .find(|app_id| app_id.handle == handle) + .map(|app_id| app_id.name.as_str()) + } } /// Typed read of per-tab plugin state stored under `plugin_id`. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 0e94fb63..72b5a51c 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -3,6 +3,7 @@ use std::any::Any; use std::cell::{Cell, OnceCell, RefCell}; use std::collections::HashMap; +use std::path::Path; use std::rc::Rc; use acadrust::xdata::ExtendedDataRecord; @@ -10,11 +11,12 @@ use acadrust::{CadDocument, EntityType, Handle}; use interprocess::local_socket::traits::Stream as StreamTrait; use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; -use crate::host::{HostApi, InteractiveCommand}; +use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity}; use crate::ipc::protocol::{ HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, }; use crate::ipc::transport::{recv, send}; +use crate::shm::{DocumentViewInfo, SharedDocumentReader}; /// Shared registry of active interactive commands, keyed by host-assigned id. pub type InteractiveRegistry = Rc>>>; @@ -77,6 +79,9 @@ pub struct PluginHostApi { /// stable references without leaking on every call. Each distinct record is /// leaked once per plugin dispatch/interactive session. record_cache: RefCell>, + /// Shared-memory document view information, lazily fetched on first + /// `document_reader()` access. + doc_view: RefCell>, } impl PluginHostApi { @@ -88,6 +93,7 @@ impl PluginHostApi { interactive, next_command_id: Cell::new(1), record_cache: RefCell::new(HashMap::new()), + doc_view: RefCell::new(None), } } @@ -179,9 +185,7 @@ impl HostApi for PluginHostApi { { Ok(PluginResponse::Bool(b)) => { if b { - self.record_cache - .borrow_mut() - .remove(&(handle, app)); + self.record_cache.borrow_mut().remove(&(handle, app)); } b } @@ -221,10 +225,9 @@ impl HostApi for PluginHostApi { } fn push_undo(&mut self, label: &str) { - if let Err(e) = self - .client - .request(PluginRequest::PushUndo { label: label.to_string() }) - { + if let Err(e) = self.client.request(PluginRequest::PushUndo { + label: label.to_string(), + }) { eprintln!("[plugin] push_undo failed: {e}"); } } @@ -236,19 +239,28 @@ impl HostApi for PluginHostApi { } fn push_info(&mut self, msg: &str) { - if let Err(e) = self.client.request(PluginRequest::PushInfo(msg.to_string())) { + if let Err(e) = self + .client + .request(PluginRequest::PushInfo(msg.to_string())) + { eprintln!("[plugin] push_info failed: {e}"); } } fn push_output(&mut self, msg: &str) { - if let Err(e) = self.client.request(PluginRequest::PushOutput(msg.to_string())) { + if let Err(e) = self + .client + .request(PluginRequest::PushOutput(msg.to_string())) + { eprintln!("[plugin] push_output failed: {e}"); } } fn push_error(&mut self, msg: &str) { - if let Err(e) = self.client.request(PluginRequest::PushError(msg.to_string())) { + if let Err(e) = self + .client + .request(PluginRequest::PushError(msg.to_string())) + { eprintln!("[plugin] push_error failed: {e}"); } } @@ -285,6 +297,54 @@ impl HostApi for PluginHostApi { // state contract to work across processes. panic!("ensure_plugin_state is not supported for out-of-process plugins; keep state in the plugin crate") } + + fn document_reader(&self) -> Box { + { + let mut view = self.doc_view.borrow_mut(); + if view.is_none() { + match self.client.request(PluginRequest::OpenDocumentView) { + Ok(PluginResponse::DocumentView { path, version }) => { + *view = Some(DocumentViewInfo { path, version }); + } + Ok(other) => { + eprintln!("[plugin] unexpected OpenDocumentView response: {other:?}"); + } + Err(e) => { + eprintln!("[plugin] OpenDocumentView request failed: {e}"); + } + } + } + } + match self.doc_view.borrow().as_ref() { + Some(info) => match SharedDocumentReader::open(Path::new(&info.path)) { + Ok(reader) => Box::new(reader), + Err(e) => { + eprintln!( + "[plugin] failed to open document view at {}: {e}", + info.path + ); + Box::new(EmptyDocumentReader) + } + }, + None => Box::new(EmptyDocumentReader), + } + } +} + +/// Sentinel reader used when the shared-memory view could not be initialized. +struct EmptyDocumentReader; + +impl DocumentReader for EmptyDocumentReader { + fn entity_count(&self) -> usize { + 0 + } + fn for_each_entity(&self, _f: &mut dyn FnMut(ReaderEntity<'_>)) {} + fn layer_name(&self, _handle: Handle) -> Option<&str> { + None + } + fn app_id_name(&self, _handle: Handle) -> Option<&str> { + None + } } #[cfg(all(test, feature = "host"))] @@ -292,8 +352,8 @@ mod tests { use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; - use acadrust::{EntityType, Handle}; use acadrust::entities::Point; + use acadrust::{EntityType, Handle}; use interprocess::local_socket::{ traits::{Listener, Stream as StreamTrait}, GenericNamespaced, ListenerOptions, Stream, ToNsName, diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index e35cc7a9..08cb9c21 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -42,8 +42,7 @@ mod tests { .create_sync() .expect("create listener"); let client = thread::spawn(move || { - StreamTrait::connect(name.to_ns_name::().unwrap()) - .expect("connect") + StreamTrait::connect(name.to_ns_name::().unwrap()).expect("connect") }); let server = listener.accept().expect("accept"); let client = client.join().expect("client thread"); diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index ea1c0a30..fdfa368c 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -11,8 +11,8 @@ use crate::host::CommandStep; use crate::manifest::ApiVersion; use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; -pub use acadrust::{CadDocument, EntityType, Handle}; pub use acadrust::xdata::ExtendedDataRecord; +pub use acadrust::{CadDocument, EntityType, Handle}; /// Events the host forwards to an active plugin `InteractiveCommand`. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -27,10 +27,19 @@ pub enum InteractiveEvent { pub enum HostRequest { GetManifest, GetRibbon, - Dispatch { cmd: String }, - InteractiveEvent { command_id: u64, event: InteractiveEvent }, - GetPrompt { command_id: u64 }, - NeedsEntityPick { command_id: u64 }, + Dispatch { + cmd: String, + }, + InteractiveEvent { + command_id: u64, + event: InteractiveEvent, + }, + GetPrompt { + command_id: u64, + }, + NeedsEntityPick { + command_id: u64, + }, Shutdown, } @@ -53,13 +62,29 @@ pub enum PluginRequest { PushError(String), AddEntity(EntityType), BumpGeometry, - ReadRecord { handle: Handle, app_name: String }, - WriteRecord { handle: Handle, record: ExtendedDataRecord }, - RemoveRecord { handle: Handle, app_name: String }, - PushUndo { label: String }, + ReadRecord { + handle: Handle, + app_name: String, + }, + WriteRecord { + handle: Handle, + record: ExtendedDataRecord, + }, + RemoveRecord { + handle: Handle, + app_name: String, + }, + PushUndo { + label: String, + }, SetDirty, - StartInteractive { command_id: u64 }, + StartInteractive { + command_id: u64, + }, DocumentSnapshot, + /// Ask the host to create/refresh a shared-memory document view and return + /// the file path + current version. + OpenDocumentView, } /// Responses the host sends back for `PluginRequest`. @@ -71,6 +96,11 @@ pub enum PluginResponse { Record(Option), Document(CadDocument), Error(String), + /// Path to the memory-mapped file and the current snapshot version. + DocumentView { + path: String, + version: u64, + }, } /// 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 9262da01..d4a42033 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -35,9 +35,7 @@ pub fn handle_plugin_request( ReadRecord { handle, app_name } => { PluginResponse::Record(host.read_record(handle, &app_name).cloned()) } - WriteRecord { handle, record } => { - PluginResponse::Bool(host.write_record(handle, record)) - } + WriteRecord { handle, record } => PluginResponse::Bool(host.write_record(handle, record)), RemoveRecord { handle, app_name } => { PluginResponse::Bool(host.remove_record(handle, &app_name)) } @@ -54,5 +52,12 @@ pub fn handle_plugin_request( PluginResponse::Ok } DocumentSnapshot => PluginResponse::Document(host.document().clone()), + OpenDocumentView => match host.document_view() { + Some(info) => PluginResponse::DocumentView { + path: info.path, + version: info.version, + }, + None => PluginResponse::Error("shared document view unavailable".to_string()), + }, } } diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index 4140cc5e..8b06bfd2 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -32,15 +32,17 @@ pub mod ipc; #[cfg(feature = "host")] pub mod process; +/// Shared-memory document view — only built with the `host` feature. +#[cfg(feature = "host")] +pub mod shm; + /// Plugin runner implementation used by the host when it spawns itself in /// runner mode — only built with the `host` feature. #[cfg(feature = "host")] pub mod runner; -pub use manifest::{ApiVersion, PluginManifest, API_VERSION}; -pub use ribbon::{ - CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef, -}; +pub use manifest::{ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED}; +pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef}; #[cfg(feature = "host")] pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess}; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 4d0bda36..d6990113 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -3,9 +3,15 @@ /// Host plugin API version. Bump when the host runtime surface breaks /// compatibility. v2 added `HostApi::start_interactive`. v3 changes /// `document()` / `document_mut()` to local cached copies for out-of-process -/// plugins. +/// plugins and appends `document_reader` / `document_view` at the end of the +/// vtable so API v2 plugins keep working. pub const API_VERSION: u32 = 3; +/// 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 +/// current `HostApi` trait. +pub const API_VERSION_MIN_SUPPORTED: u32 = 2; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ApiVersion { pub major: u32, @@ -14,8 +20,11 @@ pub struct ApiVersion { impl ApiVersion { pub const CURRENT: Self = Self { major: API_VERSION }; - pub fn is_compatible_with(host: ApiVersion) -> bool { - Self::CURRENT.major == host.major + /// True when this plugin version can run on `host`. A plugin is compatible + /// with any host whose API major is the same or newer (new host methods are + /// appended at the end of the vtable, so old plugins ignore them). + pub fn is_compatible_with(&self, host: ApiVersion) -> bool { + self.major <= host.major } } @@ -30,11 +39,21 @@ mod tests { #[test] fn same_major_is_compatible() { - assert!(ApiVersion::is_compatible_with(ApiVersion::CURRENT)); - assert!(!ApiVersion::is_compatible_with(ApiVersion { + assert!(ApiVersion::CURRENT.is_compatible_with(ApiVersion::CURRENT)); + assert!(!ApiVersion::CURRENT.is_compatible_with(ApiVersion { + major: API_VERSION - 1, + })); + // Forward compatibility: a plugin compiled today runs on a future host + // that only appends new vtable entries. + assert!(ApiVersion::CURRENT.is_compatible_with(ApiVersion { major: API_VERSION + 1, })); } + + #[test] + fn api_v2_plugin_runs_on_api_v3_host() { + assert!(ApiVersion { major: 2 }.is_compatible_with(ApiVersion { major: 3 })); + } } /// Static metadata every plugin supplies at registration time. diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index e7d8399a..bae6ef87 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -61,22 +61,21 @@ pub struct PluginProcess { impl PluginProcess { /// Spawn the plugin cdylib in a separate process and connect to it. - pub fn spawn( - cdylib_path: &Path, - host: &mut dyn HostApi, - ) -> Result { + pub fn spawn(cdylib_path: &Path, host: &mut dyn HostApi) -> Result { let socket_name = generate_socket_name(); let socket_name_ref: interprocess::local_socket::Name = socket_name .clone() .to_ns_name::() .expect("valid namespaced name"); let runner_path = runner_executable()?; - eprintln!("[plugin] spawning runner {} for {}", runner_path.display(), cdylib_path.display()); + eprintln!( + "[plugin] spawning runner {} for {}", + runner_path.display(), + cdylib_path.display() + ); // Create the listener before spawning so the runner can connect immediately. - let listener = ListenerOptions::new() - .name(socket_name_ref) - .create_sync()?; + let listener = ListenerOptions::new().name(socket_name_ref).create_sync()?; let mut child = Command::new(&runner_path) .arg("--ocs-plugin-runner") @@ -387,11 +386,15 @@ fn distinct_runner_path(host: &Path) -> PathBuf { let mut runner = host.as_os_str().to_owned(); if let Some(ext) = host.extension().and_then(|s| s.to_str()) { let base = host.file_stem().unwrap_or_default(); - runner = std::ffi::OsString::from(format!("{}-plugin-runner.{}", base.to_string_lossy(), ext)); + runner = + std::ffi::OsString::from(format!("{}-plugin-runner.{}", base.to_string_lossy(), ext)); } else { runner.push("-plugin-runner"); } - let mut path = host.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(); + let mut path = host + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); path.push(runner); path } @@ -411,7 +414,10 @@ mod tests { fn distinct_runner_path_appends_suffix() { let host = PathBuf::from("/app/OpenCADStudio.exe"); let runner = distinct_runner_path(&host); - assert_eq!(runner, PathBuf::from("/app/OpenCADStudio-plugin-runner.exe")); + assert_eq!( + runner, + PathBuf::from("/app/OpenCADStudio-plugin-runner.exe") + ); } #[test] diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index f69b8c80..93c357f5 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -40,7 +40,9 @@ pub struct DispatchResult { impl PluginManager { /// Create an empty manager. pub fn new() -> Self { - Self { plugins: Vec::new() } + Self { + plugins: Vec::new(), + } } /// Spawn `cdylib_path` as a separate plugin process, build its ribbon @@ -165,6 +167,20 @@ mod tests { #[test] fn dispatch_with_no_plugins_is_not_handled() { + struct EmptyReader; + impl crate::host::DocumentReader for EmptyReader { + fn entity_count(&self) -> usize { + 0 + } + fn for_each_entity(&self, _f: &mut dyn FnMut(crate::host::ReaderEntity<'_>)) {} + fn layer_name(&self, _handle: acadrust::Handle) -> Option<&str> { + None + } + fn app_id_name(&self, _handle: acadrust::Handle) -> Option<&str> { + None + } + } + struct DummyHost; impl HostApi for DummyHost { fn tab_index(&self) -> usize { @@ -176,6 +192,9 @@ mod tests { fn document_mut(&mut self) -> &mut acadrust::CadDocument { panic!("not used") } + fn document_reader(&self) -> Box { + Box::new(EmptyReader) + } fn add_entity(&mut self, _entity: acadrust::EntityType) -> acadrust::Handle { panic!("not used") } @@ -194,11 +213,7 @@ mod tests { ) -> bool { false } - fn remove_record( - &mut self, - _handle: acadrust::Handle, - _app_name: &str, - ) -> bool { + fn remove_record(&mut self, _handle: acadrust::Handle, _app_name: &str) -> bool { false } fn push_undo(&mut self, _label: &str) {} @@ -206,11 +221,7 @@ mod tests { fn push_info(&mut self, _msg: &str) {} fn push_output(&mut self, _msg: &str) {} fn push_error(&mut self, _msg: &str) {} - fn start_interactive( - &mut self, - _command: Box, - ) { - } + fn start_interactive(&mut self, _command: Box) {} fn plugin_state_any( &self, _plugin_id: &str, diff --git a/crates/ocs_plugin_api/src/ribbon/owned.rs b/crates/ocs_plugin_api/src/ribbon/owned.rs index 153fb557..d2d2368a 100644 --- a/crates/ocs_plugin_api/src/ribbon/owned.rs +++ b/crates/ocs_plugin_api/src/ribbon/owned.rs @@ -41,7 +41,9 @@ pub enum OwnedRibbonItem { row2: Vec, row3: Vec, }, - PropertiesGroup { match_prop: OwnedToolDef }, + PropertiesGroup { + match_prop: OwnedToolDef, + }, StyleComboGroup { style_key: StyleKey, combo_id: String, @@ -114,17 +116,34 @@ impl From for OwnedRibbonItem { match item { RibbonItem::Tool(t) => OwnedRibbonItem::Tool(t.into()), RibbonItem::LargeTool(t) => OwnedRibbonItem::LargeTool(t.into()), - RibbonItem::Dropdown { id, icon, items, default } => OwnedRibbonItem::Dropdown { + RibbonItem::Dropdown { + id, + icon, + items, + default, + } => OwnedRibbonItem::Dropdown { id: id.to_string(), icon: icon.into(), - items: items.into_iter().map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())).collect(), + items: items + .into_iter() + .map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())) + .collect(), default: default.to_string(), }, - RibbonItem::LargeDropdown { id, label, icon, items, default } => OwnedRibbonItem::LargeDropdown { + RibbonItem::LargeDropdown { + id, + label, + icon, + items, + default, + } => OwnedRibbonItem::LargeDropdown { id: id.to_string(), label: label.to_string(), icon: icon.into(), - items: items.into_iter().map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())).collect(), + items: items + .into_iter() + .map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())) + .collect(), default: default.to_string(), }, RibbonItem::LayerComboGroup { row2, row3 } => OwnedRibbonItem::LayerComboGroup { @@ -134,11 +153,19 @@ impl From for OwnedRibbonItem { RibbonItem::PropertiesGroup { match_prop } => OwnedRibbonItem::PropertiesGroup { match_prop: match_prop.into(), }, - RibbonItem::StyleComboGroup { style_key, combo_id, manager_cmd, rows } => OwnedRibbonItem::StyleComboGroup { + RibbonItem::StyleComboGroup { + style_key, + combo_id, + manager_cmd, + rows, + } => OwnedRibbonItem::StyleComboGroup { style_key, combo_id: combo_id.to_string(), manager_cmd: manager_cmd.map(|s| s.to_string()), - rows: rows.into_iter().map(|r| r.into_iter().map(Into::into).collect()).collect(), + rows: rows + .into_iter() + .map(|r| r.into_iter().map(Into::into).collect()) + .collect(), }, } } @@ -149,17 +176,46 @@ impl OwnedRibbonItem { match self { OwnedRibbonItem::Tool(t) => RibbonItem::Tool(t.to_static()), OwnedRibbonItem::LargeTool(t) => RibbonItem::LargeTool(t.to_static()), - OwnedRibbonItem::Dropdown { id, icon, items, default } => RibbonItem::Dropdown { + OwnedRibbonItem::Dropdown { + id, + icon, + items, + default, + } => RibbonItem::Dropdown { id: &*Box::leak(id.into_boxed_str()), icon: icon.to_static(), - items: items.into_iter().map(|(a, b, i)| (&*Box::leak(a.into_boxed_str()), &*Box::leak(b.into_boxed_str()), i.to_static())).collect(), + items: items + .into_iter() + .map(|(a, b, i)| { + ( + &*Box::leak(a.into_boxed_str()), + &*Box::leak(b.into_boxed_str()), + i.to_static(), + ) + }) + .collect(), default: &*Box::leak(default.into_boxed_str()), }, - OwnedRibbonItem::LargeDropdown { id, label, icon, items, default } => RibbonItem::LargeDropdown { + OwnedRibbonItem::LargeDropdown { + id, + label, + icon, + items, + default, + } => RibbonItem::LargeDropdown { id: &*Box::leak(id.into_boxed_str()), label: &*Box::leak(label.into_boxed_str()), icon: icon.to_static(), - items: items.into_iter().map(|(a, b, i)| (&*Box::leak(a.into_boxed_str()), &*Box::leak(b.into_boxed_str()), i.to_static())).collect(), + items: items + .into_iter() + .map(|(a, b, i)| { + ( + &*Box::leak(a.into_boxed_str()), + &*Box::leak(b.into_boxed_str()), + i.to_static(), + ) + }) + .collect(), default: &*Box::leak(default.into_boxed_str()), }, OwnedRibbonItem::LayerComboGroup { row2, row3 } => RibbonItem::LayerComboGroup { @@ -169,11 +225,19 @@ impl OwnedRibbonItem { OwnedRibbonItem::PropertiesGroup { match_prop } => RibbonItem::PropertiesGroup { match_prop: match_prop.to_static(), }, - OwnedRibbonItem::StyleComboGroup { style_key, combo_id, manager_cmd, rows } => RibbonItem::StyleComboGroup { + OwnedRibbonItem::StyleComboGroup { + style_key, + combo_id, + manager_cmd, + rows, + } => RibbonItem::StyleComboGroup { style_key, combo_id: &*Box::leak(combo_id.into_boxed_str()), manager_cmd: manager_cmd.map(|s| &*Box::leak(s.into_boxed_str())), - rows: rows.into_iter().map(|r| r.into_iter().map(|t| t.to_static()).collect()).collect(), + rows: rows + .into_iter() + .map(|r| r.into_iter().map(|t| t.to_static()).collect()) + .collect(), }, } } @@ -198,11 +262,7 @@ impl OwnedRibbonGroup { } /// Convert owned ribbon groups into a `CadModule` by leaking the strings once. -pub fn to_module( - id: String, - title: String, - groups: Vec, -) -> Box { +pub fn to_module(id: String, title: String, groups: Vec) -> Box { struct M { id: &'static str, title: &'static str, @@ -224,7 +284,10 @@ pub fn to_module( Box::new(M { id, title, - groups: groups.into_iter().map(OwnedRibbonGroup::to_static).collect(), + groups: groups + .into_iter() + .map(OwnedRibbonGroup::to_static) + .collect(), }) } diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index b67104e9..10396f90 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -12,7 +12,9 @@ use std::rc::Rc; use crate::host::{BuiltinPlugin, InteractiveCommand}; use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi}; -use crate::ipc::protocol::{HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost}; +use crate::ipc::protocol::{ + HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, +}; use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedRibbonGroup; @@ -86,14 +88,13 @@ fn handle_host_request( Err(_) => HostResponse::Error("plugin dispatch panicked".to_string()), } } - HostRequest::InteractiveEvent { - command_id, - event, - } => { + HostRequest::InteractiveEvent { command_id, event } => { let step = { let mut registry = interactive.borrow_mut(); let Some(cmd) = registry.get_mut(&command_id) else { - return HostResponse::Error(format!("unknown interactive command {command_id}")); + return HostResponse::Error(format!( + "unknown interactive command {command_id}" + )); }; let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut(); std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event { @@ -126,7 +127,9 @@ fn handle_host_request( let result = { let registry = interactive.borrow(); registry.get(&command_id).map(|cmd| { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd.needs_object_pick())) + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cmd.needs_object_pick() + })) }) }; match result { @@ -142,18 +145,17 @@ fn handle_host_request( } } -unsafe fn load_plugin( - path: &Path, -) -> Result, Box> { +unsafe fn load_plugin(path: &Path) -> Result, Box> { let lib = libloading::Library::new(path)?; let version: libloading::Symbol u32> = lib .get(b"ocs_plugin_api_version") .map_err(|_| "missing ocs_plugin_api_version symbol")?; let v = version(); - if v != crate::API_VERSION { + if v < crate::API_VERSION_MIN_SUPPORTED || v > crate::API_VERSION { return Err(format!( - "API version {v} != host {}", + "API version {v} is incompatible (host supports {}-{})", + crate::API_VERSION_MIN_SUPPORTED, crate::API_VERSION ) .into()); diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs new file mode 100644 index 00000000..bd04ad7c --- /dev/null +++ b/crates/ocs_plugin_api/src/shm.rs @@ -0,0 +1,473 @@ +//! Shared-memory document view for out-of-process plugins. +//! +//! The host owns a memory-mapped file that contains a small, read-only, +//! rkyv-serialized view of the active document. The plugin maps the same file +//! read-only and reads entity/layer data directly from the mapping without +//! copying the full `CadDocument` into its own address space. + +use std::fs::OpenOptions; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, AtomicUsize, Ordering}; + +use acadrust::{CadDocument, EntityType, Handle}; +use memmap2::{Mmap, MmapMut}; +use rkyv::{check_archived_root, to_bytes, Archive, Deserialize, Serialize}; + +use crate::host::{DocumentReader, ReaderEntity, ReaderEntityKind, ReaderPoint}; + +/// Magic number identifying a valid control page. +const CONTROL_MAGIC: u32 = 0x4F_43_53_44; // "OCSD" + +/// Size of the control region at the start of the mapping. Must be enough for +/// `ControlPage` and aligned to a typical page boundary so the snapshot segments +/// that follow are naturally aligned for rkyv. +const CONTROL_SIZE: usize = 4096; + +/// Information sent to the plugin so it can open the shared mapping. +#[derive(Debug, Clone)] +pub struct DocumentViewInfo { + /// Absolute path to the memory-mapped file. + pub path: String, + /// Snapshot version at the time the view was opened. + pub version: u64, +} + +/// Host-side, file-backed double buffer for the document view. +pub struct DocumentSnapshotStore { + path: PathBuf, + mmap: MmapMut, + segment_size: usize, + current_version: u64, +} + +impl DocumentSnapshotStore { + /// Create a new store for `tab`. `segment_size` is the maximum size of one + /// snapshot buffer; the file is sized to hold two segments plus the control + /// page. + pub fn new(tab: usize, segment_size: usize) -> io::Result { + let segment_size = segment_size.next_multiple_of(4096); + static STORE_ID: AtomicUsize = AtomicUsize::new(0); + let id = STORE_ID.fetch_add(1, Ordering::Relaxed); + let path = Self::temp_path(tab, id); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&path)?; + let total = CONTROL_SIZE + 2 * segment_size; + file.set_len(total as u64)?; + + let mut mmap = unsafe { MmapMut::map_mut(&file)? }; + let control = ControlPage::from_bytes_mut(&mut mmap); + control.magic.store(CONTROL_MAGIC, Ordering::Relaxed); + control.version.store(0, Ordering::Relaxed); + control.active_segment.store(0, Ordering::Relaxed); + control.active_len.store(0, Ordering::Relaxed); + mmap.flush()?; + + Ok(Self { + path, + mmap, + segment_size, + current_version: 0, + }) + } + + fn temp_path(tab: usize, id: usize) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "ocs_plugin_doc_{}_{}_{}_{}.bin", + std::process::id(), + tab, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + id, + )); + path + } + + /// Path the plugin should open to access the mapping. + pub fn path(&self) -> &Path { + &self.path + } + + /// Serialize `doc` into the inactive segment and atomically publish it. + pub fn publish(&mut self, doc: &CadDocument) -> io::Result<()> { + let data = DocumentViewData::from(doc); + let bytes = to_bytes::<_, 256>(&data).map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, format!("rkyv serialize: {e}")) + })?; + if bytes.len() > self.segment_size { + return Err(io::Error::new( + io::ErrorKind::OutOfMemory, + format!( + "document view {} bytes exceeds segment size {}", + bytes.len(), + self.segment_size + ), + )); + } + + let inactive = { + let control = ControlPage::from_bytes_mut(&mut self.mmap); + let active = control.active_segment.load(Ordering::Acquire) as usize; + 1 - active + }; + let offset = CONTROL_SIZE + inactive * self.segment_size; + + self.mmap[offset..offset + bytes.len()].copy_from_slice(&bytes); + let control = ControlPage::from_bytes_mut(&mut self.mmap); + // Ensure the plugin sees the new length before it sees the new version. + control + .active_len + .store(bytes.len() as u64, Ordering::Release); + control + .active_segment + .store(inactive as u8, Ordering::Release); + self.current_version = self.current_version.wrapping_add(1); + control + .version + .store(self.current_version, Ordering::Release); + Ok(()) + } + + /// Current published version. + pub fn version(&self) -> u64 { + ControlPage::from_bytes(&self.mmap) + .version + .load(Ordering::Acquire) + } +} + +impl Drop for DocumentSnapshotStore { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// Plugin-side read-only mapping of the host's document view. +pub struct SharedDocumentReader { + mmap: Mmap, + segment_size: usize, + cached_version: u64, +} + +impl SharedDocumentReader { + /// Open the file at `path` read-only and map it. The mapping may initially + /// contain no valid snapshot; the caller should `refresh()` before use. + pub fn open(path: &Path) -> io::Result { + let file = OpenOptions::new().read(true).open(path)?; + let mmap = unsafe { Mmap::map(&file)? }; + let file_len = mmap.len(); + let segment_size = if file_len > CONTROL_SIZE { + (file_len - CONTROL_SIZE) / 2 + } else { + 0 + }; + Ok(Self { + mmap, + segment_size, + cached_version: 0, + }) + } + + /// Check whether the host has published a newer snapshot. + pub fn has_new_version(&self) -> bool { + let control = ControlPage::from_bytes(&self.mmap); + if control.magic.load(Ordering::Acquire) != CONTROL_MAGIC { + return false; + } + control.version.load(Ordering::Acquire) != self.cached_version + } + + /// Update the cached version after the caller has re-bound to a new snapshot. + pub fn refresh(&mut self) { + let control = ControlPage::from_bytes(&self.mmap); + self.cached_version = control.version.load(Ordering::Acquire); + } + + fn active_segment_bytes(&self) -> &[u8] { + let control = ControlPage::from_bytes(&self.mmap); + let active = control.active_segment.load(Ordering::Acquire) as usize; + let len = control.active_len.load(Ordering::Acquire) as usize; + let offset = CONTROL_SIZE + active * self.segment_size; + if offset + len > self.mmap.len() { + return &[]; + } + &self.mmap[offset..offset + len] + } + + fn archived(&self) -> Option<&ArchivedDocumentViewData> { + let bytes = self.active_segment_bytes(); + check_archived_root::(bytes).ok() + } +} + +impl DocumentReader for SharedDocumentReader { + fn entity_count(&self) -> usize { + self.archived().map(|doc| doc.entities.len()).unwrap_or(0) + } + + fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>)) { + let Some(doc) = self.archived() else { return }; + for entity in doc.entities.iter() { + let handle = Handle::new(entity.handle); + let kind = ReaderEntityKind::from_u8(entity.kind); + let layer_name: &str = entity.layer_name.as_str(); + let point = entity.point.as_ref().map(|p| ReaderPoint { + x: p.x, + y: p.y, + z: p.z, + }); + f(ReaderEntity { + handle, + kind, + layer_name, + point, + }); + } + } + + fn layer_name(&self, handle: Handle) -> Option<&str> { + let doc = self.archived()?; + let handle_val = handle.value(); + doc.layers + .iter() + .find(|layer| layer.handle == handle_val) + .map(|layer| layer.name.as_str()) + } + + fn app_id_name(&self, handle: Handle) -> Option<&str> { + let doc = self.archived()?; + let handle_val = handle.value(); + doc.app_ids + .iter() + .find(|app| app.handle == handle_val) + .map(|app| app.name.as_str()) + } +} + +/// Raw control page shared between host and plugin. +#[repr(C, align(8))] +struct ControlPage { + magic: AtomicU32, + _pad0: [u8; 4], + version: AtomicU64, + active_len: AtomicU64, + active_segment: AtomicU8, + _pad1: [u8; 7], +} + +impl ControlPage { + fn from_bytes(mmap: &[u8]) -> &Self { + assert!(mmap.len() >= std::mem::size_of::()); + assert_eq!(mmap.as_ptr() as usize % std::mem::align_of::(), 0); + unsafe { &*(mmap.as_ptr() as *const Self) } + } + + fn from_bytes_mut(mmap: &mut [u8]) -> &mut Self { + assert!(mmap.len() >= std::mem::size_of::()); + assert_eq!(mmap.as_ptr() as usize % std::mem::align_of::(), 0); + unsafe { &mut *(mmap.as_ptr() as *mut Self) } + } +} + +/// Serializable document view. This is the only data type placed in shared +/// memory, so it must contain no pointers into host memory. +#[derive(Archive, Serialize, Deserialize, Debug, Clone)] +#[archive(check_bytes)] +pub struct DocumentViewData { + pub layers: Vec, + pub app_ids: Vec, + pub entities: Vec, +} + +impl From<&CadDocument> for DocumentViewData { + fn from(doc: &CadDocument) -> Self { + Self { + layers: doc.layers.iter().map(LayerView::from).collect(), + app_ids: doc.app_ids.iter().map(AppIdView::from).collect(), + entities: doc.entities().map(EntityView::from).collect(), + } + } +} + +#[derive(Archive, Serialize, Deserialize, Debug, Clone)] +#[archive(check_bytes)] +pub struct LayerView { + pub handle: u64, + pub name: String, +} + +impl From<&acadrust::tables::Layer> for LayerView { + fn from(layer: &acadrust::tables::Layer) -> Self { + Self { + handle: layer.handle.value(), + name: layer.name.clone(), + } + } +} + +#[derive(Archive, Serialize, Deserialize, Debug, Clone)] +#[archive(check_bytes)] +pub struct AppIdView { + pub handle: u64, + pub name: String, +} + +impl From<&acadrust::tables::AppId> for AppIdView { + fn from(app_id: &acadrust::tables::AppId) -> Self { + Self { + handle: app_id.handle.value(), + name: app_id.name.clone(), + } + } +} + +#[derive(Archive, Serialize, Deserialize, Debug, Clone)] +#[archive(check_bytes)] +pub struct EntityView { + pub handle: u64, + pub kind: u8, + pub layer_name: String, + pub point: Option, +} + +impl From<&EntityType> for EntityView { + fn from(entity: &EntityType) -> Self { + let handle = entity.common().handle.value(); + let kind = ReaderEntityKind::from_entity(entity).to_u8(); + let layer_name = entity.common().layer.clone(); + let point = match entity { + EntityType::Point(p) => Some(PointView { + x: p.location.x, + y: p.location.y, + z: p.location.z, + }), + _ => None, + }; + Self { + handle, + kind, + layer_name, + point, + } + } +} + +#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy)] +#[archive(check_bytes)] +pub struct PointView { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl ReaderEntityKind { + /// Convert the simplified kind to a stable `u8` for the shared format. + pub fn to_u8(self) -> u8 { + match self { + ReaderEntityKind::Point => 1, + ReaderEntityKind::Line => 2, + ReaderEntityKind::Circle => 3, + ReaderEntityKind::Arc => 4, + ReaderEntityKind::Polyline => 5, + ReaderEntityKind::Text => 6, + ReaderEntityKind::Other => 0, + } + } + + /// Decode a stable `u8` back to the simplified kind. + pub fn from_u8(value: u8) -> Self { + match value { + 1 => ReaderEntityKind::Point, + 2 => ReaderEntityKind::Line, + 3 => ReaderEntityKind::Circle, + 4 => ReaderEntityKind::Arc, + 5 => ReaderEntityKind::Polyline, + 6 => ReaderEntityKind::Text, + _ => ReaderEntityKind::Other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host::{DocumentReader, ReaderEntityKind}; + use acadrust::entities::Point; + use acadrust::tables::Layer; + use acadrust::{CadDocument, EntityType}; + + fn sample_doc() -> CadDocument { + let mut doc = CadDocument::new(); + doc.layers.add(Layer::new("SURVEY")).unwrap(); + let mut point = Point::from_coords(10.0, 20.0, 5.0); + point.common.layer = "SURVEY".to_string(); + doc.add_entity(EntityType::Point(point)).unwrap(); + doc + } + + #[test] + fn shared_document_reader_roundtrip() { + let doc = sample_doc(); + let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap(); + store.publish(&doc).unwrap(); + + let reader = SharedDocumentReader::open(store.path()).unwrap(); + assert_eq!(reader.entity_count(), 1); + + let mut seen = Vec::new(); + reader.for_each_entity(&mut |e| { + seen.push((e.kind, e.layer_name.to_string(), e.point, e.handle)); + }); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, ReaderEntityKind::Point); + assert_eq!(seen[0].1, "SURVEY"); + assert_eq!( + seen[0].2, + Some(ReaderPoint { + x: 10.0, + y: 20.0, + z: 5.0 + }) + ); + assert!( + seen[0].3.is_valid(), + "reader entity should expose a valid handle" + ); + } + + #[test] + fn shared_document_reader_updates_after_publish() { + let doc = sample_doc(); + let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap(); + store.publish(&doc).unwrap(); + + let reader = SharedDocumentReader::open(store.path()).unwrap(); + assert_eq!(reader.entity_count(), 1); + + let mut doc2 = doc; + let mut point2 = Point::from_coords(1.0, 2.0, 3.0); + point2.common.layer = "SURVEY".to_string(); + doc2.add_entity(EntityType::Point(point2)).unwrap(); + store.publish(&doc2).unwrap(); + + assert_eq!(reader.entity_count(), 2); + } + + #[test] + fn layer_name_lookup_by_handle() { + let doc = sample_doc(); + let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap(); + store.publish(&doc).unwrap(); + + let survey = doc.layers.iter().find(|l| l.name == "SURVEY").unwrap(); + let reader = SharedDocumentReader::open(store.path()).unwrap(); + assert_eq!(reader.layer_name(survey.handle), Some("SURVEY")); + } +}