| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .. | ||
| src | ||
| build.rs | ||
| Cargo.toml | ||
| README.md | ||
| REPORT.md | ||
ocs_plugin_api
Versioned, out-of-process plugin API for Open CAD Studio. This crate defines the
contract between the host CAD application and third-party plugins. It is
designed to stay small and dependency-free in its default configuration so that
plugin crates and external tooling can depend on the manifest/ribbon surface
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 three major protocol generations: V2, V3, and V4. 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.
| Version | Delivery model | Key capability | ABI / wire change |
|---|---|---|---|
| V2 | Out-of-process runner | Synchronous commands + interactive point collection (HostApi::start_interactive) |
Baseline |
| 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 |
The host controls which generations are accepted at runtime through
OCS_PLUGIN_MAX_API_VERSION (e.g. 2 for V2-only mode, 3 to disable V4). The
current host advertises API_VERSION = 4 and supports plugins
back to API_VERSION_MIN_SUPPORTED = 2.
High-level layout
flowchart LR
subgraph HostProcess["Host process"]
App[OpenCADStudio]
PM[PluginManager]
PP[PluginProcess]
HA[HostApi impl]
end
subgraph RunnerProcess["Runner process"]
RL[Runner loop]
V4C[V4Client]
Proxy[HostApi proxy]
end
subgraph Library["Plugin cdylib"]
BP[BuiltinPlugin impl]
end
App --> PM
PM --> PP
PP -- local socket --> RL
RL --> V4C
V4C --> Proxy
Proxy --> BP
PP -. document view / shared memory .-> HA
A plugin is a Rust cdylib (or any language exposing the C symbols) that:
- Exports
ocs_plugin_api_version()— returns the API major the plugin was built against. - Exports
ocs_plugin_register()— returns aBox<dyn BuiltinPlugin>. - Implements
BuiltinPlugin(manifest, ribbon, dispatch, optional REPL execution, optional notifications).
The host does not load the plugin into its own address space. It re-executes
itself as --ocs-runner <cdylib-path> <token> and talks to the runner over a
local socket. This isolates plugin crashes from the CAD process.
Version handshake
sequenceDiagram
participant H as Host
participant R as Runner process
participant L as Plugin cdylib
H->>R: spawn --ocs-runner path token
R->>L: dlopen, ocs_plugin_register()
L-->>R: Box<dyn BuiltinPlugin>
R->>H: V3 or V4 handshake token
H->>R: GetManifest
R-->>H: PluginManifest { api_version }
alt api_version accepted
H->>R: Dispatch / ExecuteCode / InteractiveEvent ...
else api_version rejected
H->>R: Shutdown
end
V2/V3 wire protocol
V2 and V3 share the same request/response framing. The host sends a
HostToPlugin::Request(HostRequest::*); the runner executes it and returns a
PluginToHost::Response(HostResponse::*). Nested plugin-to-host requests (for
example add_entity) are allowed inside a dispatch: the runner creates a
temporary HostApi proxy, forwards the PluginRequest, awaits the host reply,
and resumes the original call.
sequenceDiagram
participant H as Host
participant R as V2/V3 Runner
participant P as BuiltinPlugin
H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" })
R->>P: dispatch(host, "LINE")
P->>R: PluginToHost::Request(AddEntity(...))
R->>H: forward AddEntity request
H-->>R: handle
R-->>P: handle
P-->>R: true
R-->>H: PluginToHost::Response(Bool(true))
V4 wire protocol
V4 replaces the simple request/response pipe with a multiplexed local socket. The frame format supports:
- Host → runner requests (
HostToPluginV4::Request { id, payload: HostRequest }) - Runner → host responses (
PluginToHostV4::Response { id, payload: HostResponse }) - Bi-directional, best-effort notifications (
NotificationEnvelope)
Because the socket is full-duplex, the host can push
HostNotification::DocumentChanged or InputLine to a plugin at any time, and
the plugin can stream PluginNotification::Output/Progress/Log back while a
long command is still running.
sequenceDiagram
participant H as Host
participant R as V4 Runner
participant P as BuiltinPlugin
H->>R: Request(id=7, ExecuteCode { code: "1+1", tab_index: 2 })
R->>P: start_execute_code(host, 7, "1+1", Editor, respond)
P-->>R: true (async)
Note over P: spawned on worker thread
P->>R: Notification(Output { text: "calculating..." })
R->>H: forward Output
P->>R: Response(id=7, CodeExecutionResult { success: true, output: "2" })
R->>H: delayed response
V4 asynchronous REPL execution
V4 introduces BuiltinPlugin::start_execute_code. It is the plugin's entry point
for REPL-style code evaluation. The signature receives the active tab's
HostApi so the REPL session is tied to the document tab that invoked it, plus a
respond callback that the plugin calls when evaluation finishes (possibly from a
background thread).
V4 also introduces PluginRequestSender: a thread-safe, 'static interface that
lets a plugin send PluginRequests back to the host from any thread. This is
useful when the plugin spawns a background worker (for example, the Python REPL's
mutation thread) that needs to read the document snapshot or apply mutations
without blocking the runner loop. Obtain it from HostApi::plugin_request_sender();
it is only available when the plugin is hosted out-of-process by a V4 runner.
use ocs_plugin_api::host::{
BuiltinPlugin, CommandSource, ExecutionResult, HostApi, execute_code_guard,
PluginRequestSender,
};
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
struct ReplPlugin;
impl BuiltinPlugin for ReplPlugin {
// ... manifest, ribbon, dispatch ...
fn start_execute_code(
&mut self,
host: &mut dyn HostApi,
_command_id: u64,
code: &str,
_source: CommandSource,
respond: Box<dyn FnOnce(ExecutionResult) + Send>,
) -> bool {
let tab = host.tab_index();
let code = code.to_string();
// A V4 host can provide a sender for background threads.
let sender: Option<Arc<dyn PluginRequestSender>> = host.plugin_request_sender().map(Arc::from);
execute_code_guard(respond, move || {
// Long-running evaluation happens here without blocking the runner loop.
// If a sender is available, worker threads can use it to call back into
// the host, e.g. to refresh the document snapshot.
let result = evaluate(&code, tab, sender.as_deref());
ExecutionResult {
success: result.is_ok(),
output: result.ok(),
error: result.err(),
error_type: None,
traceback: None,
line_number: None,
column_number: None,
duration_ms: 0.0,
}
});
true
}
}
// Used from a background thread.
fn refresh_snapshot(sender: &dyn PluginRequestSender) -> Result<String, Box<dyn Error>> {
match sender.request(PluginRequest::OpenDocumentView)? {
PluginResponse::DocumentView { path, version } => Ok(path),
other => Err(format!("unexpected response: {other:?}").into()),
}
}
The host side calls it synchronously but with a long timeout:
use ocs_plugin_api::host::CommandSource;
use ocs_plugin_api::process::{PluginError, PluginProcess};
fn run_editor_snippet(
plugin: &PluginProcess,
host: &mut dyn HostApi,
) -> Result<String, PluginError> {
let result = plugin.execute_code(host, 42, CommandSource::Editor, "1+1")?;
Ok(result.output.unwrap_or_default())
}
PluginProcess::execute_code reads host.tab_index() and forwards it in the
ExecuteCode request, so the plugin always knows which document tab the REPL
session belongs to.
Embedded build-time metadata
ocs_plugin_api embeds two JSON blobs at build time:
- Type registry — a stable, language-binding-friendly schema generated by
tracing a curated allow-list of
acadrusttypes withserde-reflection. - Version info —
ocs_version,ocs_plugin_api_version,acadrust_version,api_version, etc.
Access them without pulling the host feature:
let registry_json = ocs_plugin_api::get_embedded_type_registry_json();
let version_json = ocs_plugin_api::get_embedded_version_info_json();
Minimal plugin example
use ocs_plugin_api::export_plugin;
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginManifest};
use ocs_plugin_api::manifest::ApiVersion;
use ocs_plugin_api::ribbon::{
CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef,
};
static MANIFEST: PluginManifest = PluginManifest {
id: "com.example.hello",
name: "Hello Plugin",
version: "0.1.0",
description: "A minimal example plugin.",
api_version: ApiVersion { major: 4 },
ribbon_order: 100,
xdata_apps: &[],
command_prefixes: &["HELLO"],
};
struct HelloModule;
impl CadModule for HelloModule {
fn id(&self) -> &'static str { MANIFEST.id }
fn title(&self) -> &'static str { "Hello" }
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: &[RibbonGroup] = &[RibbonGroup {
title: "Example",
tools: vec![RibbonItem::Tool(ToolDef {
id: "HELLO",
label: "Say Hello",
icon: IconKind::Glyph("H"),
event: ModuleEvent::Command("HELLO".to_string()),
})],
}];
GROUPS
}
}
struct HelloPlugin;
impl BuiltinPlugin for HelloPlugin {
fn manifest(&self) -> &'static PluginManifest { &MANIFEST }
fn ribbon(&self) -> Box<dyn CadModule> { Box::new(HelloModule) }
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
if cmd == "HELLO" {
host.push_info("Hello from the plugin!");
return true;
}
false
}
}
export_plugin!(HelloPlugin);
Host-side loading example
use std::path::Path;
use std::sync::Arc;
use ocs_plugin_api::process::{PluginError, PluginManager};
fn main() -> Result<(), PluginError> {
let mut manager = PluginManager::new();
manager.set_notification_handler(Arc::new(|_id, _cmd, _notif| {}));
let mut host_api = /* host-provided HostApi implementation */;
let _plugin_id = manager.load(
Path::new("./target/release/libhello_plugin.dll"),
&mut host_api,
)?;
let result = manager.dispatch(&mut host_api, "HELLO", |_| false);
if result.handled {
println!("Plugin handled HELLO");
}
Ok(())
}
V4 shared-memory document view example
V4 plugins can request a zero-copy snapshot of the active document and mutate it
from a background thread through PluginRequestSender.
use std::sync::Arc;
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginRequestSender};
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
use ocs_plugin_api::shm::DocumentViewDataV4;
use ocs_plugin_api::shm::SharedDocumentReader;
struct SnapshotWorker {
reader: SharedDocumentReader<DocumentViewDataV4>,
sender: Arc<dyn PluginRequestSender>,
}
impl SnapshotWorker {
fn add_point(&self, x: f64, y: f64, z: f64) -> anyhow::Result<u64> {
use acadrust::entities::Point;
use acadrust::EntityType;
let mut p = Point::from_coords(x, y, z);
p.common.layer = "0".to_string();
let entity = EntityType::Point(p);
match self.sender.request(PluginRequest::AddEntity(entity))? {
PluginResponse::Handle(h) => Ok(h.value()),
other => Err(anyhow::anyhow!("unexpected add response: {other:?}")),
}
}
}
In the BuiltinPlugin dispatch method:
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
if cmd != "SNAPSHOT_DEMO" {
return false;
}
let Some(view) = host.document_view_v4(host.tab_id()) else {
host.push_error("host does not support V4 document views");
return true;
};
let Some(sender) = host.plugin_request_sender() else {
host.push_error("host does not provide a V4 request sender");
return true;
};
let reader = SharedDocumentReader::<DocumentViewDataV4>::open(Path::new(&view.path))
.expect("open snapshot");
let worker = SnapshotWorker {
reader,
sender: Arc::from(sender),
};
std::thread::spawn(move || {
let _ = worker.add_point(10.0, 20.0, 0.0);
});
true
}
V4 request proxy pattern
When a plugin spawns a child process (for example a Python REPL) that needs to
mutate the host document, the plugin can run a local TCP request proxy and pass
the port to the child. The child connects to the proxy; the proxy forwards
PluginRequest frames to the host's PluginRequestSender and returns the
responses.
use std::net::TcpListener;
use ocs_plugin_api::ipc::proxy::run_request_proxy_with_shutdown;
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let sender: Arc<dyn PluginRequestSender> = Arc::from(host.plugin_request_sender().unwrap());
std::thread::spawn(move || {
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
run_request_proxy_with_shutdown(listener, sender, shutdown_rx).ok();
});
// Pass `port` to the child process via environment variable.
Further reading
- Plugin template:
../../docs/plugin-template - Plugin marketplace registry:
../../plugins/README.md - REPL design notes:
../DESIGN_REPL.md - Python REPL plugin:
../ocs_python_repl/README.md