Fix 5: Allow backward-compatibility and add plugin-template-v2
This commit is contained in:
parent
2b898d6f22
commit
d0003534a0
12 changed files with 568 additions and 60 deletions
|
|
@ -41,7 +41,9 @@ pub mod shm;
|
|||
#[cfg(feature = "host")]
|
||||
pub mod runner;
|
||||
|
||||
pub use manifest::{ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED};
|
||||
pub use manifest::{
|
||||
host_accepts_plugin_version, ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED,
|
||||
};
|
||||
pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef};
|
||||
|
||||
#[cfg(feature = "host")]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ impl ApiVersion {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when a plugin built against `plugin_major` can be loaded by this host.
|
||||
/// The host supports majors from `API_VERSION_MIN_SUPPORTED` up to
|
||||
/// `API_VERSION`.
|
||||
pub fn host_accepts_plugin_version(plugin_major: u32) -> bool {
|
||||
plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= API_VERSION
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std
|
|||
.get(b"ocs_plugin_api_version")
|
||||
.map_err(|_| "missing ocs_plugin_api_version symbol")?;
|
||||
let v = version();
|
||||
if v < crate::API_VERSION_MIN_SUPPORTED || v > crate::API_VERSION {
|
||||
if !crate::host_accepts_plugin_version(v) {
|
||||
return Err(format!(
|
||||
"API version {v} is incompatible (host supports {}-{})",
|
||||
crate::API_VERSION_MIN_SUPPORTED,
|
||||
|
|
|
|||
13
crates/plugin-template-api2/Cargo.toml
Normal file
13
crates/plugin-template-api2/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "plugin-template-api2"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "API v2 compatibility fixture for the out-of-process plugin path."
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] }
|
||||
acadrust = "0.3.4"
|
||||
76
crates/plugin-template-api2/src/lib.rs
Normal file
76
crates/plugin-template-api2/src/lib.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! API v2 compatibility fixture.
|
||||
//!
|
||||
//! Mimics an old plugin compiled against `ocs_plugin_api` v2: it reports API
|
||||
//! major 2 from `ocs_plugin_api_version()` and implements only the v2 surface
|
||||
//! (`HostApi` methods up to `start_interactive`). The current host must still
|
||||
//! be able to load and dispatch it.
|
||||
|
||||
use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
|
||||
use ocs_plugin_api::manifest::{ApiVersion, PluginManifest};
|
||||
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
static MANIFEST: PluginManifest = PluginManifest {
|
||||
id: "opencad.my_plugin",
|
||||
name: "My Plugin",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
description: "API v2 fixture plugin.",
|
||||
api_version: ApiVersion { major: 2 },
|
||||
ribbon_order: 60,
|
||||
xdata_apps: &[],
|
||||
command_prefixes: &["MP_"],
|
||||
};
|
||||
|
||||
struct MyModule;
|
||||
|
||||
impl CadModule for MyModule {
|
||||
fn id(&self) -> &'static str {
|
||||
"my_plugin"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"My Plugin"
|
||||
}
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
vec![RibbonGroup {
|
||||
title: "Tools",
|
||||
tools: vec![RibbonItem::LargeTool(ToolDef {
|
||||
id: "MP_HELLO",
|
||||
label: "Hello",
|
||||
icon: IconKind::Glyph("*"),
|
||||
event: ModuleEvent::Command("MP_HELLO".to_string()),
|
||||
})],
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
struct MyPlugin;
|
||||
|
||||
impl BuiltinPlugin for MyPlugin {
|
||||
fn manifest(&self) -> &'static PluginManifest {
|
||||
&MANIFEST
|
||||
}
|
||||
fn ribbon(&self) -> Box<dyn CadModule> {
|
||||
Box::new(MyModule)
|
||||
}
|
||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
|
||||
match cmd {
|
||||
"MP_HELLO" => {
|
||||
host.push_info("Hello from API v2 plugin");
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom C-ABI export that reports API v2, emulating an older build of
|
||||
// `ocs_plugin_api::export_plugin!`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn ocs_plugin_api_version() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn ocs_plugin_register() -> *mut Box<dyn BuiltinPlugin> {
|
||||
let plugin: Box<dyn BuiltinPlugin> = Box::new(MyPlugin);
|
||||
Box::into_raw(Box::new(plugin))
|
||||
}
|
||||
13
docs/plugin-template-v2/Cargo.toml
Normal file
13
docs/plugin-template-v2/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "plugin-template-v2"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Example OpenCAD Studio plugin demonstrating the shared-memory DocumentReader API."
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] }
|
||||
acadrust = "0.3.4"
|
||||
78
docs/plugin-template-v2/DESIGN.md
Normal file
78
docs/plugin-template-v2/DESIGN.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# `plugin-template-v2` — Shared-memory `DocumentReader` example
|
||||
|
||||
This crate is a runnable plugin template that demonstrates the V2 host/plugin
|
||||
contract:
|
||||
|
||||
- **Zero-copy reads** through [`ocs_plugin_api::host::DocumentReader`].
|
||||
- **Validated writes** through the existing `HostApi` RPCs (`add_entity`,
|
||||
`write_record`).
|
||||
- **Read→write round-trips**: read an entity handle from the shared document
|
||||
view and attach XDATA to it.
|
||||
|
||||
It lives under `docs/` (not `crates/`) because it is documentation/example code
|
||||
rather than a runtime dependency of the host.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `COUNT_SURVEY_POINTS` | Count point entities on the `SURVEY` layer via `document_reader()`. |
|
||||
| `ADD_SURVEY_POINT` | Add a new point entity on the `SURVEY` layer via `host.add_entity()`. |
|
||||
| `MARK_FIRST_SURVEY_POINT` | Read the first `SURVEY` point and mark it with an XDATA record. |
|
||||
| `COUNT_MARKED_SURVEY_POINTS` | Count `SURVEY` points that carry the `SURVEYMARK` XDATA record. |
|
||||
|
||||
---
|
||||
|
||||
## Design notes
|
||||
|
||||
### Reads are zero-copy; writes are RPCs
|
||||
|
||||
`document_reader()` returns a read-only view backed by host-owned shared memory
|
||||
(for out-of-process plugins) or by `&CadDocument` (for in-process plugins). The
|
||||
plugin iterates entities without copying the model.
|
||||
|
||||
All mutations still cross the validated RPC boundary. This preserves the
|
||||
host's crash-safety boundary: a buggy plugin cannot corrupt the host document
|
||||
because it never holds a mutable reference to host memory.
|
||||
|
||||
### Entity handles bridge reads and writes
|
||||
|
||||
Each [`ReaderEntity`](ocs_plugin_api::host::ReaderEntity) exposes the entity
|
||||
`handle`. A plugin can therefore:
|
||||
|
||||
1. Find an entity of interest with `document_reader().for_each_entity(...)`.
|
||||
2. Use that handle with `host.write_record(handle, record)` to attach XDATA.
|
||||
3. Read the record back later with `host.read_record(handle, app_name)`.
|
||||
|
||||
This is the primary read→write round-trip pattern the template demonstrates.
|
||||
|
||||
### Ribbon integration
|
||||
|
||||
The plugin registers one ribbon module (`SurveyTools`) with one group
|
||||
(`Survey`). Each tool emits a `ModuleEvent::Command(...)` carrying the command
|
||||
name. The host routes the command to `BuiltinPlugin::dispatch`.
|
||||
|
||||
---
|
||||
|
||||
## Building and running
|
||||
|
||||
From the workspace root:
|
||||
|
||||
```bash
|
||||
cargo build -p plugin-template-v2
|
||||
```
|
||||
|
||||
The resulting cdylib plus `plugin.toml` can be installed into the host's
|
||||
plugins folder (see `docs/plugin-architecture.md`).
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
The template is built against the current `ocs_plugin_api` and declares
|
||||
`ApiVersion::CURRENT`. Older plugins compiled against API v2 continue to load
|
||||
on an API v3 host because new `HostApi` methods are appended at the end of the
|
||||
trait and the host accepts plugin majors from `API_VERSION_MIN_SUPPORTED` up to
|
||||
`API_VERSION`.
|
||||
175
docs/plugin-template-v2/src/lib.rs
Normal file
175
docs/plugin-template-v2/src/lib.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
//! OpenCAD Studio plugin template v2.
|
||||
//!
|
||||
//! Demonstrates read/write round-trips between the plugin and the host using
|
||||
//! the zero-copy `DocumentReader` API and the validated `HostApi` RPCs.
|
||||
|
||||
use acadrust::entities::Point;
|
||||
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
|
||||
use acadrust::{EntityType, Handle};
|
||||
use ocs_plugin_api::export_plugin;
|
||||
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, ReaderEntityKind};
|
||||
use ocs_plugin_api::manifest::{ApiVersion, PluginManifest};
|
||||
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
static MANIFEST: PluginManifest = PluginManifest {
|
||||
id: "com.example.plugin-template-v2",
|
||||
name: "Plugin Template v2",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
description: "Demonstrates read/write object round-trips with the host.",
|
||||
api_version: ApiVersion::CURRENT,
|
||||
ribbon_order: 100,
|
||||
xdata_apps: &["SURVEYMARK"],
|
||||
command_prefixes: &[
|
||||
"COUNT_SURVEY_POINTS",
|
||||
"ADD_SURVEY_POINT",
|
||||
"MARK_FIRST_SURVEY_POINT",
|
||||
"COUNT_MARKED_SURVEY_POINTS",
|
||||
],
|
||||
};
|
||||
|
||||
struct PluginTemplateV2;
|
||||
|
||||
impl BuiltinPlugin for PluginTemplateV2 {
|
||||
fn manifest(&self) -> &'static PluginManifest {
|
||||
&MANIFEST
|
||||
}
|
||||
|
||||
fn ribbon(&self) -> Box<dyn CadModule> {
|
||||
Box::new(TemplateModule)
|
||||
}
|
||||
|
||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
|
||||
match cmd {
|
||||
"COUNT_SURVEY_POINTS" => {
|
||||
let count = count_survey_points(host);
|
||||
host.push_info(&format!("SURVEY points: {count}"));
|
||||
true
|
||||
}
|
||||
"ADD_SURVEY_POINT" => {
|
||||
let handle = add_survey_point(host);
|
||||
host.push_info(&format!("Added SURVEY point {handle}"));
|
||||
true
|
||||
}
|
||||
"MARK_FIRST_SURVEY_POINT" => {
|
||||
if let Some(handle) = first_survey_point_handle(host) {
|
||||
mark_point(host, handle);
|
||||
} else {
|
||||
host.push_info("No SURVEY point to mark");
|
||||
}
|
||||
true
|
||||
}
|
||||
"COUNT_MARKED_SURVEY_POINTS" => {
|
||||
let count = count_marked_survey_points(host);
|
||||
host.push_info(&format!("Marked SURVEY points: {count}"));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read: count point entities on the SURVEY layer via the zero-copy reader.
|
||||
fn count_survey_points(host: &mut dyn HostApi) -> usize {
|
||||
let reader = host.document_reader();
|
||||
let mut count = 0usize;
|
||||
reader.for_each_entity(&mut |e| {
|
||||
if e.kind == ReaderEntityKind::Point && e.layer_name.eq_ignore_ascii_case("SURVEY") {
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
count
|
||||
}
|
||||
|
||||
/// Write: add a new point entity on the SURVEY layer through a validated RPC.
|
||||
fn add_survey_point(host: &mut dyn HostApi) -> Handle {
|
||||
let mut point = Point::from_coords(0.0, 0.0, 0.0);
|
||||
point.common.layer = "SURVEY".to_string();
|
||||
host.add_entity(EntityType::Point(point))
|
||||
}
|
||||
|
||||
/// Read: locate the first SURVEY point and return its handle.
|
||||
fn first_survey_point_handle(host: &mut dyn HostApi) -> Option<Handle> {
|
||||
let reader = host.document_reader();
|
||||
let mut handle = None;
|
||||
reader.for_each_entity(&mut |e| {
|
||||
if handle.is_none()
|
||||
&& e.kind == ReaderEntityKind::Point
|
||||
&& e.layer_name.eq_ignore_ascii_case("SURVEY")
|
||||
{
|
||||
handle = Some(e.handle);
|
||||
}
|
||||
});
|
||||
handle
|
||||
}
|
||||
|
||||
/// Write: attach an XDATA record to the entity, registering the APPID.
|
||||
fn mark_point(host: &mut dyn HostApi, handle: Handle) {
|
||||
let mut record = ExtendedDataRecord::new("SURVEYMARK");
|
||||
record.add_value(XDataValue::Integer32(1));
|
||||
if host.write_record(handle, record) {
|
||||
host.push_info(&format!("Marked SURVEY point {handle}"));
|
||||
} else {
|
||||
host.push_error(&format!("Failed to mark SURVEY point {handle}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Read+write round-trip: count SURVEY points that have the SURVEYMARK XDATA.
|
||||
fn count_marked_survey_points(host: &mut dyn HostApi) -> usize {
|
||||
let reader = host.document_reader();
|
||||
let mut handles = Vec::new();
|
||||
reader.for_each_entity(&mut |e| {
|
||||
if e.kind == ReaderEntityKind::Point && e.layer_name.eq_ignore_ascii_case("SURVEY") {
|
||||
handles.push(e.handle);
|
||||
}
|
||||
});
|
||||
handles
|
||||
.into_iter()
|
||||
.filter(|h| host.read_record(*h, "SURVEYMARK").is_some())
|
||||
.count()
|
||||
}
|
||||
|
||||
struct TemplateModule;
|
||||
|
||||
impl CadModule for TemplateModule {
|
||||
fn id(&self) -> &'static str {
|
||||
MANIFEST.id
|
||||
}
|
||||
|
||||
fn title(&self) -> &'static str {
|
||||
"Template v2"
|
||||
}
|
||||
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
vec![RibbonGroup {
|
||||
title: "Survey",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(ToolDef {
|
||||
id: "COUNT_SURVEY_POINTS",
|
||||
label: "Count",
|
||||
icon: IconKind::Glyph("C"),
|
||||
event: ModuleEvent::Command("COUNT_SURVEY_POINTS".to_string()),
|
||||
}),
|
||||
RibbonItem::LargeTool(ToolDef {
|
||||
id: "ADD_SURVEY_POINT",
|
||||
label: "Add Point",
|
||||
icon: IconKind::Glyph("+"),
|
||||
event: ModuleEvent::Command("ADD_SURVEY_POINT".to_string()),
|
||||
}),
|
||||
RibbonItem::LargeTool(ToolDef {
|
||||
id: "MARK_FIRST_SURVEY_POINT",
|
||||
label: "Mark First",
|
||||
icon: IconKind::Glyph("M"),
|
||||
event: ModuleEvent::Command("MARK_FIRST_SURVEY_POINT".to_string()),
|
||||
}),
|
||||
RibbonItem::LargeTool(ToolDef {
|
||||
id: "COUNT_MARKED_SURVEY_POINTS",
|
||||
label: "Marked",
|
||||
icon: IconKind::Glyph("*"),
|
||||
event: ModuleEvent::Command("COUNT_MARKED_SURVEY_POINTS".to_string()),
|
||||
}),
|
||||
],
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
export_plugin!(PluginTemplateV2);
|
||||
|
|
@ -13,11 +13,16 @@ use super::OpenCADStudio;
|
|||
pub(crate) struct HostSession<'a> {
|
||||
app: &'a mut OpenCADStudio,
|
||||
tab: usize,
|
||||
doc_store: Option<ocs_plugin_api::shm::DocumentSnapshotStore>,
|
||||
}
|
||||
|
||||
impl<'a> HostSession<'a> {
|
||||
pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self {
|
||||
Self { app, tab }
|
||||
Self {
|
||||
app,
|
||||
tab,
|
||||
doc_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_index(&self) -> usize {
|
||||
|
|
@ -32,8 +37,36 @@ impl<'a> HostSession<'a> {
|
|||
&mut self.app.tabs[self.tab].scene.document
|
||||
}
|
||||
|
||||
pub fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||
use ocs_plugin_api::shm::DocumentSnapshotStore;
|
||||
if self.doc_store.is_none() {
|
||||
let mut store = DocumentSnapshotStore::new(self.tab, 8 * 1024 * 1024).ok()?;
|
||||
store.publish(self.document()).ok()?;
|
||||
self.doc_store = Some(store);
|
||||
}
|
||||
let store = self.doc_store.as_ref()?;
|
||||
Some(ocs_plugin_api::shm::DocumentViewInfo {
|
||||
path: store.path().to_string_lossy().to_string(),
|
||||
version: store.version(),
|
||||
})
|
||||
}
|
||||
|
||||
fn publish_document_view(&mut self) {
|
||||
let doc = &self.app.tabs[self.tab].scene.document;
|
||||
if let Some(store) = self.doc_store.as_mut() {
|
||||
if let Err(e) = store.publish(doc) {
|
||||
eprintln!(
|
||||
"[host] failed to publish document view for tab {}: {e}",
|
||||
self.tab
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||
self.app.tabs[self.tab].scene.add_entity(entity)
|
||||
let handle = self.app.tabs[self.tab].scene.add_entity(entity);
|
||||
self.publish_document_view();
|
||||
handle
|
||||
}
|
||||
|
||||
pub fn bump_geometry(&mut self) {
|
||||
|
|
@ -77,6 +110,7 @@ impl<'a> HostSession<'a> {
|
|||
xd.add_record(r);
|
||||
}
|
||||
xd.add_record(record);
|
||||
self.publish_document_view();
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +134,7 @@ impl<'a> HostSession<'a> {
|
|||
for r in kept {
|
||||
xd.add_record(r);
|
||||
}
|
||||
self.publish_document_view();
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +166,6 @@ impl<'a> HostSession<'a> {
|
|||
pub fn push_error(&mut self, msg: &str) {
|
||||
self.app.command_line.push_error(msg);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// The stable contract a plugin's `dispatch` sees. Each method forwards to the
|
||||
|
|
@ -179,10 +213,7 @@ impl HostApi for HostSession<'_> {
|
|||
fn push_error(&mut self, msg: &str) {
|
||||
self.push_error(msg)
|
||||
}
|
||||
fn start_interactive(
|
||||
&mut self,
|
||||
command: Box<dyn ocs_plugin_api::host::InteractiveCommand>,
|
||||
) {
|
||||
fn start_interactive(&mut self, command: Box<dyn ocs_plugin_api::host::InteractiveCommand>) {
|
||||
self.app.tabs[self.tab].active_cmd =
|
||||
Some(Box::new(PluginInteractiveAdapter { inner: command }));
|
||||
}
|
||||
|
|
@ -192,10 +223,7 @@ impl HostApi for HostSession<'_> {
|
|||
.get(plugin_id)
|
||||
.map(|b| b.as_ref())
|
||||
}
|
||||
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)> {
|
||||
self.app.tabs[self.tab]
|
||||
.plugin_state
|
||||
.get_mut(plugin_id)
|
||||
|
|
@ -212,6 +240,12 @@ impl HostApi for HostSession<'_> {
|
|||
.or_insert_with(|| init())
|
||||
.as_mut()
|
||||
}
|
||||
fn document_reader(&self) -> Box<dyn ocs_plugin_api::host::DocumentReader + '_> {
|
||||
Box::new(ocs_plugin_api::host::CadDocumentReader(self.document()))
|
||||
}
|
||||
fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||
self.document_view()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
|
||||
|
|
@ -343,9 +377,7 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
fn plugin_step_to_result(
|
||||
step: ocs_plugin_api::host::CommandStep,
|
||||
) -> crate::command::CmdResult {
|
||||
fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::command::CmdResult {
|
||||
use crate::command::CmdResult;
|
||||
use ocs_plugin_api::host::CommandStep;
|
||||
match step {
|
||||
|
|
@ -362,6 +394,7 @@ mod tests {
|
|||
use crate::app::OpenCADStudio;
|
||||
use acadrust::entities::Point;
|
||||
use acadrust::xdata::XDataValue;
|
||||
use ocs_plugin_api::host::DocumentReader;
|
||||
|
||||
#[test]
|
||||
fn xdata_record_round_trips_and_registers_appid() {
|
||||
|
|
@ -403,9 +436,15 @@ mod tests {
|
|||
assert!(host::plugin_state::<u32>(&*host, "opencad.demo").is_none());
|
||||
// Insert via ensure, then mutate.
|
||||
*host::ensure_plugin_state(host, "opencad.demo", || 7u32) += 1;
|
||||
assert_eq!(*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(), 8);
|
||||
assert_eq!(
|
||||
*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(),
|
||||
8
|
||||
);
|
||||
*host::plugin_state_mut::<u32>(host, "opencad.demo").unwrap() = 100;
|
||||
assert_eq!(*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(), 100);
|
||||
assert_eq!(
|
||||
*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(),
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
/// A plugin command: second point commits a Point and ends.
|
||||
|
|
@ -444,7 +483,10 @@ mod tests {
|
|||
let _ = app.apply_cmd_result(r);
|
||||
}
|
||||
assert_eq!(app.tabs[0].scene.document.entities().count(), 1);
|
||||
assert!(app.tabs[0].active_cmd.is_none(), "command should have ended");
|
||||
assert!(
|
||||
app.tabs[0].active_cmd.is_none(),
|
||||
"command should have ended"
|
||||
);
|
||||
}
|
||||
|
||||
/// A plugin command that picks an existing object, then marks it.
|
||||
|
|
@ -464,9 +506,8 @@ mod tests {
|
|||
_handle: acadrust::Handle,
|
||||
pt: [f64; 3],
|
||||
) -> ocs_plugin_api::host::CommandStep {
|
||||
let p = acadrust::entities::Point::at(acadrust::types::Vector3::new(
|
||||
pt[0], pt[1], pt[2],
|
||||
));
|
||||
let p =
|
||||
acadrust::entities::Point::at(acadrust::types::Vector3::new(pt[0], pt[1], pt[2]));
|
||||
ocs_plugin_api::host::CommandStep::CommitAndEnd(acadrust::EntityType::Point(p))
|
||||
}
|
||||
}
|
||||
|
|
@ -477,18 +518,14 @@ mod tests {
|
|||
app.tabs[0].is_start = false;
|
||||
let target = {
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let h = host.add_entity(acadrust::EntityType::Point(
|
||||
acadrust::entities::Point::at(acadrust::types::Vector3::new(3.0, 4.0, 0.0)),
|
||||
));
|
||||
let h = host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
|
||||
acadrust::types::Vector3::new(3.0, 4.0, 0.0),
|
||||
)));
|
||||
host.start_interactive(Box::new(PickThenMark));
|
||||
h
|
||||
};
|
||||
// The command requested an entity pick, not a free point.
|
||||
assert!(app.tabs[0]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.needs_entity_pick());
|
||||
assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick());
|
||||
let r = app.tabs[0]
|
||||
.active_cmd
|
||||
.as_mut()
|
||||
|
|
@ -498,4 +535,101 @@ mod tests {
|
|||
// Original point + the mark the command committed.
|
||||
assert_eq!(app.tabs[0].scene.document.entities().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_document_reader_sees_entities() {
|
||||
use ocs_plugin_api::host::ReaderEntityKind;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
|
||||
acadrust::types::Vector3::new(7.0, 8.0, 0.0),
|
||||
)));
|
||||
let reader = host.document_reader();
|
||||
assert_eq!(reader.entity_count(), 1);
|
||||
let mut kinds = Vec::new();
|
||||
reader.for_each_entity(&mut |e| kinds.push(e.kind));
|
||||
assert_eq!(kinds, vec![ReaderEntityKind::Point]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_document_view_publish_and_read_shared() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let info = host.document_view().unwrap();
|
||||
let reader =
|
||||
ocs_plugin_api::shm::SharedDocumentReader::open(std::path::Path::new(&info.path))
|
||||
.unwrap();
|
||||
assert_eq!(reader.entity_count(), 0);
|
||||
|
||||
host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
|
||||
acadrust::types::Vector3::new(1.0, 2.0, 0.0),
|
||||
)));
|
||||
|
||||
assert_eq!(reader.entity_count(), 1);
|
||||
}
|
||||
|
||||
/// Read an entity handle from the live document, write XDATA for that
|
||||
/// handle, read it back, and remove it.
|
||||
#[test]
|
||||
fn document_reader_to_xdata_roundtrip() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
|
||||
7.0, 8.0, 0.0,
|
||||
))));
|
||||
|
||||
{
|
||||
let reader = host.document_reader();
|
||||
assert_eq!(reader.entity_count(), 1);
|
||||
let mut handles = Vec::new();
|
||||
reader.for_each_entity(&mut |e| handles.push(e.handle));
|
||||
assert_eq!(handles, vec![h]);
|
||||
}
|
||||
|
||||
let mut rec = ExtendedDataRecord::new("ROUNDTRIP");
|
||||
rec.add_value(XDataValue::String("from-reader".to_string()));
|
||||
assert!(host.write_record(h, rec));
|
||||
|
||||
let got = host.read_record(h, "ROUNDTRIP").expect("record missing");
|
||||
assert_eq!(got.values.len(), 1);
|
||||
assert!(matches!(got.values[0], XDataValue::String(ref s) if s == "from-reader"));
|
||||
|
||||
assert!(host.remove_record(h, "ROUNDTRIP"));
|
||||
assert!(host.read_record(h, "ROUNDTRIP").is_none());
|
||||
}
|
||||
|
||||
/// Publish a shared document view, read the entity handle from shared
|
||||
/// memory, then write and read-back XDATA through the normal HostApi RPCs.
|
||||
#[test]
|
||||
fn shared_document_view_read_then_write_xdata_roundtrip() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let info = host.document_view().unwrap();
|
||||
let reader =
|
||||
ocs_plugin_api::shm::SharedDocumentReader::open(std::path::Path::new(&info.path))
|
||||
.unwrap();
|
||||
|
||||
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
|
||||
1.0, 2.0, 0.0,
|
||||
))));
|
||||
assert_eq!(reader.entity_count(), 1);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
reader.for_each_entity(&mut |e| handles.push(e.handle));
|
||||
assert_eq!(handles, vec![h]);
|
||||
|
||||
let mut rec = ExtendedDataRecord::new("SHM_ROUNDTRIP");
|
||||
rec.add_value(XDataValue::Integer32(123));
|
||||
assert!(host.write_record(h, rec));
|
||||
|
||||
let got = host
|
||||
.read_record(h, "SHM_ROUNDTRIP")
|
||||
.expect("record missing");
|
||||
assert_eq!(got.values.len(), 1);
|
||||
assert!(matches!(got.values[0], XDataValue::Integer32(123)));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@
|
|||
//! Layout (mirrors the spec in `docs/plugin-architecture.md`):
|
||||
//! ```text
|
||||
//! <config>/OpenCADStudio/plugins/
|
||||
//! opencad.storm_sewer/
|
||||
//! <plugin-id>/
|
||||
//! plugin.toml
|
||||
//! <libopencad_storm_sewer.so | .dll | .dylib>
|
||||
//! <lib<name>.so | .dll | .dylib>
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -40,9 +40,9 @@ pub struct ExternalPlugin {
|
|||
}
|
||||
|
||||
impl ExternalPlugin {
|
||||
/// True when the package's API version matches the host ABI major.
|
||||
/// True when the package's API version is supported by this host.
|
||||
pub fn api_compatible(&self) -> bool {
|
||||
self.api_version == ocs_plugin_api::API_VERSION
|
||||
ocs_plugin_api::host_accepts_plugin_version(self.api_version)
|
||||
}
|
||||
|
||||
/// True when the package can be loaded today: compatible API *and* a native
|
||||
|
|
@ -85,7 +85,9 @@ pub fn plugins_dir() -> Option<PathBuf> {
|
|||
/// session (the library is resident); the removal takes effect on next start.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn uninstall(id: &str) -> Result<(), String> {
|
||||
let dir = plugins_dir().ok_or("cannot locate the plugins folder")?.join(id);
|
||||
let dir = plugins_dir()
|
||||
.ok_or("cannot locate the plugins folder")?
|
||||
.join(id);
|
||||
if dir.is_dir() {
|
||||
std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
|
@ -254,7 +256,10 @@ mod loader {
|
|||
continue;
|
||||
}
|
||||
let Some(path) = lib_file(&d.dir) else {
|
||||
out.push((d.id.clone(), Err("no native library in package".to_string())));
|
||||
out.push((
|
||||
d.id.clone(),
|
||||
Err("no native library in package".to_string()),
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let mut host = crate::app::plugin_host::HostSession::new(app, 0);
|
||||
|
|
@ -309,27 +314,24 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_documented_keys() {
|
||||
fn api_v2_plugin_from_template_is_compatible() {
|
||||
let toml = r#"
|
||||
[plugin]
|
||||
id = "opencad.storm_sewer"
|
||||
name = "Storm Sewer"
|
||||
version = "0.2.0"
|
||||
description = "Gravity storm-drain design"
|
||||
id = "opencad.my_plugin"
|
||||
name = "My Plugin"
|
||||
version = "0.1.0"
|
||||
description = "Template plugin"
|
||||
|
||||
[opencad]
|
||||
api_version = 1
|
||||
ribbon_order = 50
|
||||
command_prefixes = ["SS_", "STORM_"]
|
||||
api_version = 2
|
||||
ribbon_order = 60
|
||||
command_prefixes = ["MP_"]
|
||||
xdata_apps = ["MYPLUGIN_RECORD"]
|
||||
"#;
|
||||
let p = parse_plugin_toml(toml).expect("parsed");
|
||||
assert_eq!(p.id, "opencad.storm_sewer");
|
||||
assert_eq!(p.name, "Storm Sewer");
|
||||
assert_eq!(p.version, "0.2.0");
|
||||
assert_eq!(p.api_version, 1);
|
||||
assert_eq!(p.ribbon_order, 50);
|
||||
assert_eq!(p.command_prefixes, vec!["SS_", "STORM_"]);
|
||||
assert!(!p.api_compatible());
|
||||
assert_eq!(p.api_version, 2);
|
||||
assert!(p.command_prefixes.contains(&"MP_".to_string()));
|
||||
assert!(p.api_compatible(), "API v2 plugins must run on API v3 host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -361,7 +363,11 @@ command_prefixes = ["SS_", "STORM_"]
|
|||
std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
|
||||
.unwrap_or_else(|| std::env::current_exe().unwrap().into_os_string()),
|
||||
);
|
||||
assert!(host_exe.exists(), "host exe not found: {}", host_exe.display());
|
||||
assert!(
|
||||
host_exe.exists(),
|
||||
"host exe not found: {}",
|
||||
host_exe.display()
|
||||
);
|
||||
std::env::set_var("OCS_PLUGIN_RUNNER_EXE", &host_exe);
|
||||
|
||||
let mut app = crate::app::OpenCADStudio::new_for_test();
|
||||
|
|
|
|||
|
|
@ -57,7 +57,11 @@ impl Release {
|
|||
self.assets
|
||||
.iter()
|
||||
.find(|a| a.name.ends_with(&suffix))
|
||||
.or_else(|| self.assets.iter().find(|a| a.name.ends_with(&format!(".{ext}"))))
|
||||
.or_else(|| {
|
||||
self.assets
|
||||
.iter()
|
||||
.find(|a| a.name.ends_with(&format!(".{ext}")))
|
||||
})
|
||||
}
|
||||
|
||||
fn toml_asset(&self) -> Option<&Asset> {
|
||||
|
|
@ -176,12 +180,12 @@ pub fn install(release: &Release) -> Result<String, String> {
|
|||
let toml = release.toml_asset().ok_or("release has no plugin.toml")?;
|
||||
|
||||
let toml_text = download_string(&toml.url)?;
|
||||
let manifest =
|
||||
external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?;
|
||||
if manifest.api_version != ocs_plugin_api::API_VERSION {
|
||||
let manifest = external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?;
|
||||
if !ocs_plugin_api::host_accepts_plugin_version(manifest.api_version) {
|
||||
return Err(format!(
|
||||
"API version {} is incompatible (host is {})",
|
||||
"API version {} is incompatible (host supports {}-{})",
|
||||
manifest.api_version,
|
||||
ocs_plugin_api::API_VERSION_MIN_SUPPORTED,
|
||||
ocs_plugin_api::API_VERSION
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ pub(super) fn render_small<'a>(
|
|||
let active = active_tool.as_deref() == Some(id)
|
||||
|| items
|
||||
.iter()
|
||||
.any(|(cmd, _, _)| active_tool.as_deref() == Some(cmd));
|
||||
.any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd));
|
||||
let dd_open = open_dd.as_deref() == Some(id);
|
||||
let last = last_cmd.get(id).copied().unwrap_or(default);
|
||||
let cur_icon = last_cmd
|
||||
|
|
@ -464,7 +464,7 @@ pub(super) fn render_large<'a>(
|
|||
let active = active_tool.as_deref() == Some(id)
|
||||
|| items
|
||||
.iter()
|
||||
.any(|(cmd, _, _)| active_tool.as_deref() == Some(cmd));
|
||||
.any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd));
|
||||
let dd_open = open_dd.as_deref() == Some(id);
|
||||
let last = last_cmd.get(id).copied().unwrap_or(default);
|
||||
let cur_icon = last_cmd
|
||||
|
|
|
|||
Loading…
Reference in a new issue