diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 61bc16b05..c078173a6 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -44,12 +44,14 @@ The host and each plugin run in separate OS processes. The host re-executes itse ## Versioning and ABI stability -- `API_VERSION` (currently `5`) is the host's advertised major. +- `API_VERSION` (currently `6`) is the host's advertised major. - `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads. -- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `4` to disable V5). +- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `5` to disable V6). - A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end. - V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)). - V5 introduces `BuiltinPlugin::on_load` and tab-keyed document paths. +- V6 appends typed system-variable access to `HostApi`; the fork currently + supports `CLAYER` and `SNAPANG`. The runtime enforces three gates: diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index e1d29878d..d360b4daf 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -9,7 +9,7 @@ cheaply. The runtime host surface is enabled by the `host` feature. ## Plugin Architecture OCS is scriptable and extensible through a versioned plugin API. The API -supports four major generations: **V2**, **V3**, **V4**, and **V5**. Each +supports five major generations: **V2**, **V3**, **V4**, **V5**, and **V6**. Each generation keeps the previous ABI stable by appending new vtable entries and new enum variants at the end, so older plugins continue to load on newer hosts. @@ -19,10 +19,11 @@ enum variants at the end, so older plugins continue to load on newer hosts. | **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended | | **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait | | **V5** | V4 multiplexed runner | One-shot initialization + tab-keyed document paths | `BuiltinPlugin::on_load` and `HostApi::document_path` appended to the traits | +| **V6** | V4 multiplexed runner | Read and write supported host system variables | `HostApi::system_variable` and `HostApi::set_system_variable` appended to the trait; request/response variants appended | The host controls which generations are accepted at runtime through -`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `4` to disable V5). The -current host advertises [`API_VERSION = 5`](src/manifest.rs) and supports plugins +`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `5` to disable V6). The +current host advertises [`API_VERSION = 6`](src/manifest.rs) and supports plugins back to `API_VERSION_MIN_SUPPORTED = 2`. V5 calls `BuiltinPlugin::on_load` once after the runner connects and before the @@ -30,6 +31,10 @@ first user command. Plugins can cache `HostApi::plugin_request_sender()` there for worker-thread access. A panic in the callback fails plugin loading. `HostApi::document_path(tab_id)` returns the saved path for a document tab. +V6 adds a typed system-variable interface. This fork currently implements +`CLAYER` (current drawing layer) and `SNAPANG` (snap-angle degrees). Unknown +variables return an error rather than silently accepting a value. + ### High-level layout ```mermaid diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 7a284bc60..d8ed9a7b0 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -908,7 +908,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": acadrust.version.to_string(), "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 5, + "api_version": 6, "api_version_min_supported": 2, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 19130c596..4dad141b4 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -60,6 +60,13 @@ pub enum LogLevel { Error, } +/// Value of a host-managed drafting or document setting exposed to plugins. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum HostSettingValue { + Text(String), + Number(f64), +} + /// A notification the host sends to a plugin. These are best-effort, /// full-duplex messages correlated with an optional `command_id`. #[derive(Debug, Clone, PartialEq)] @@ -552,6 +559,22 @@ pub trait HostApi { let _ = tab_id; None } + + /// Read a named host setting without invoking the command dispatcher. + /// Names are case-insensitive. Hosts may support only a subset. + fn system_variable(&self, _name: &str) -> Option { + None + } + + /// Set a named host setting without re-entering command dispatch. + /// Returns the effective value after validation and normalization. + fn set_system_variable( + &mut self, + _name: &str, + _value: HostSettingValue, + ) -> Result { + Err("system variable is not supported by this host".to_owned()) + } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 3a617fc02..b78d90e61 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -11,7 +11,7 @@ use acadrust::{CadDocument, EntityType, Handle}; use interprocess::local_socket::traits::Stream as StreamTrait; use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; -use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity}; +use crate::host::{DocumentReader, HostApi, HostSettingValue, InteractiveCommand, ReaderEntity}; use crate::ipc::protocol::{ HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake, }; @@ -416,6 +416,34 @@ impl HostApi for PluginHostApi { } } } + + fn system_variable(&self, name: &str) -> Option { + match self.client.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) { + Ok(PluginResponse::SystemVariable(value)) => value, + _ => None, + } + } + + fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + match self.client.request(PluginRequest::SetSystemVariable { + name: name.to_owned(), + value, + }) { + Ok(PluginResponse::SystemVariableResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected system variable response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index c20cad1b2..8dcc7c588 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; -use crate::host::{CommandSource, CommandStep}; +use crate::host::{CommandSource, CommandStep, HostSettingValue}; use crate::manifest::ApiVersion; use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; @@ -138,6 +138,10 @@ pub enum PluginRequest { GetTabId, /// V5: ask the host for the filesystem path of the document in `tab_id`. DocumentPath { tab_id: u64 }, + /// Read a host-managed setting without nested command dispatch. + GetSystemVariable { name: String }, + /// Change a host-managed setting without nested command dispatch. + SetSystemVariable { name: String, value: HostSettingValue }, } /// Responses the host sends back for `PluginRequest`. @@ -164,6 +168,8 @@ pub enum PluginResponse { TabId(u64), /// V5: filesystem path of the document in the requested tab, if any. DocumentPath(Option), + SystemVariable(Option), + SystemVariableResult(Result), } /// 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 055880ef0..ff1c9e510 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -77,5 +77,11 @@ pub fn handle_plugin_request( DocumentPath { tab_id } => PluginResponse::DocumentPath( host.document_path(tab_id).map(|path| path.into_os_string()), ), + GetSystemVariable { name } => { + PluginResponse::SystemVariable(host.system_variable(&name)) + } + SetSystemVariable { name, value } => { + PluginResponse::SystemVariableResult(host.set_system_variable(&name, value)) + } } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 03cbf20ae..c77202060 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -17,7 +17,7 @@ use interprocess::TryClone; use crate::host::{ DocumentReader, ExecutionResult, HostApi, HostNotification, InteractiveCommand, - PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, + PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, HostSettingValue, }; use crate::ipc::protocol::{ HostRequest, HostResponse, PluginRequest, PluginResponse, RunnerHandshake, @@ -730,6 +730,34 @@ impl HostApi for V4PluginHostApi { } } } + + fn system_variable(&self, name: &str) -> Option { + match self.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) { + Ok(PluginResponse::SystemVariable(value)) => value, + _ => None, + } + } + + fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + match self.request(PluginRequest::SetSystemVariable { + name: name.to_owned(), + value, + }) { + Ok(PluginResponse::SystemVariableResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected system variable response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index b0e4b3dcd..5edc5671e 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -19,8 +19,9 @@ /// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 /// adds the `BuiltinPlugin::on_load` lifecycle callback and -/// `HostApi::document_path`. -pub const API_VERSION: u32 = 5; +/// `HostApi::document_path`. v6 appends generic host system-variable access +/// (`HostApi::system_variable` / `set_system_variable`). +pub const API_VERSION: u32 = 6; /// 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 @@ -28,7 +29,7 @@ pub const API_VERSION: u32 = 5; pub const API_VERSION_MIN_SUPPORTED: u32 = 2; /// Environment variable that caps the API major accepted by the host at -/// runtime. Set to `4` to disable V5 plugins, `3` to disable V4, or `2` for +/// runtime. Set to `5` to disable V6 plugins, `4` to disable V5, or `2` for /// V2-only mode. pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION"; diff --git a/src/app/automation.rs b/src/app/automation.rs index 9b12dcbb5..afdc0fc5e 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -739,6 +739,16 @@ impl OpenCADStudio { #[cfg(test)] mod tests { + #[test] + fn clayer_command_sets_layer_used_by_new_geometry() { + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LAYER NEW Annotations"}"#); + app.automation_op(r#"{"op":"run","cmd":"CLAYER Annotations"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); + let lines = app.automation_op(r#"{"op":"query","type":"Line"}"#); + assert_eq!(lines["entities"][0]["layer"], "Annotations"); + } #[cfg(not(target_arch = "wasm32"))] #[test] fn built_in_edits_advance_plugin_document_fingerprint_once() { diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 751d2e5d2..f8ea83953 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -2649,10 +2649,7 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref()); } else { - if self.tabs[i].scene.document.layers.contains(name_arg) { - self.tabs[i].scene.document.header.current_layer_name = - name_arg.to_string(); - self.tabs[i].dirty = true; + if self.set_current_layer_name(i, name_arg).is_ok() { self.command_line .push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref()); } else { diff --git a/src/app/layers.rs b/src/app/layers.rs index 6be69c8f3..e72d704b4 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -2,6 +2,27 @@ use super::OpenCADStudio; use crate::ui; impl OpenCADStudio { + /// Apply the current layer to the document header and all per-tab/UI + /// creation state. Both built-in commands and plugin settings use this. + pub(super) fn set_current_layer_name(&mut self, tab: usize, layer: &str) -> Result<(), String> { + let handle = self.tabs[tab] + .scene + .document + .layers + .get(layer) + .map(|entry| entry.handle) + .ok_or_else(|| format!("layer {layer:?} does not exist"))?; + self.tabs[tab].scene.document.header.current_layer_name = layer.to_owned(); + self.tabs[tab].scene.document.header.current_layer_handle = handle; + self.tabs[tab].active_layer = layer.to_owned(); + self.tabs[tab].layers.current_layer = layer.to_owned(); + self.tabs[tab].dirty = true; + if tab == self.active_tab { + self.ribbon.active_layer = layer.to_owned(); + self.refresh_layer_panel(); + } + Ok(()) + } pub(super) fn load_layer_state_editor(&mut self, selected: Option) { let i = self.active_tab; if let Some(name) = selected { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 25033addc..b572786ce 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -4,7 +4,7 @@ use std::any::Any; use acadrust::tables::AppId; use acadrust::xdata::ExtendedDataRecord; -use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi}; +use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi, HostSettingValue}; use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData}; #[cfg(not(target_arch = "wasm32"))] @@ -250,6 +250,41 @@ impl<'a> HostSession<'a> { self.app.tabs[self.tab].dirty = true; } + pub fn system_variable(&self, name: &str) -> Option { + match name.to_ascii_uppercase().as_str() { + "CLAYER" => Some(HostSettingValue::Text( + self.document().header.current_layer_name.clone(), + )), + "SNAPANG" => Some(HostSettingValue::Number(self.app.snap_angle_deg as f64)), + _ => None, + } + } + + pub fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + match (name.to_ascii_uppercase().as_str(), value) { + ("CLAYER", HostSettingValue::Text(layer)) => { + self.app.set_current_layer_name(self.tab, &layer)?; + Ok(HostSettingValue::Text(layer)) + } + ("SNAPANG", HostSettingValue::Number(angle)) if (angle as f32).is_finite() => { + self.app.snap_angle_deg = (angle as f32).rem_euclid(360.0); + Ok(HostSettingValue::Number(self.app.snap_angle_deg as f64)) + } + ("SNAPANG", HostSettingValue::Number(_)) => { + Err("SNAPANG: finite number required".to_owned()) + } + ("CLAYER", _) | ("SNAPANG", _) => Err(format!( + "{}: wrong value type", + name.to_ascii_uppercase() + )), + _ => Err(format!("unsupported system variable {name:?}")), + } + } + pub fn push_info(&mut self, msg: &str) { self.app.command_line.push_info(msg); } @@ -356,6 +391,16 @@ impl HostApi for HostSession<'_> { fn document_path(&self, tab_id: u64) -> Option { self.document_path(tab_id) } + fn system_variable(&self, name: &str) -> Option { + self.system_variable(name) + } + fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + self.set_system_variable(name, value) + } #[cfg(not(target_arch = "wasm32"))] fn document_view_v4(&mut self, tab_id: u64) -> Option { self.document_view_v4(tab_id) @@ -514,6 +559,35 @@ mod tests { use acadrust::xdata::XDataValue; use ocs_plugin_api::host::DocumentReader; + #[test] + fn system_variables_change_without_command_reentry() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + assert_eq!(host.system_variable("clayer"), Some(HostSettingValue::Text("0".into()))); + assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(0.0))); + assert!(host.set_system_variable("CLAYER", HostSettingValue::Text("Missing".into())).is_err()); + assert_eq!(host.system_variable("CLAYER"), Some(HostSettingValue::Text("0".into()))); + + let mut point = Point::new(); + point.common.layer = "Annotations".into(); + host.add_entity(EntityType::Point(point)); + assert_eq!(host.set_system_variable("clayer", HostSettingValue::Text("Annotations".into())), + Ok(HostSettingValue::Text("Annotations".into()))); + assert_eq!(host.system_variable("CLAYER"), Some(HostSettingValue::Text("Annotations".into()))); + assert_eq!(host.app.tabs[0].active_layer, "Annotations"); + assert_eq!(host.app.tabs[0].layers.current_layer, "Annotations"); + assert_eq!(host.app.ribbon.active_layer, "Annotations"); + assert!(host.app.tabs[0].dirty); + + assert_eq!(host.set_system_variable("snapang", HostSettingValue::Number(450.0)), + Ok(HostSettingValue::Number(90.0))); + assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(90.0))); + assert!(host.set_system_variable("SNAPANG", HostSettingValue::Number(f64::INFINITY)).is_err()); + assert!(host.set_system_variable("SNAPANG", HostSettingValue::Number(f64::MAX)).is_err()); + assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(90.0))); + } + #[test] fn xdata_record_round_trips_and_registers_appid() { let mut app = OpenCADStudio::new_for_test();