Files
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:34:23 +03:00

451 lines
16 KiB
Markdown

# `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 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.
| 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 |
| **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, `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
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
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:
1. Exports `ocs_plugin_api_version()` — returns the API major the plugin was
built against.
2. Exports `ocs_plugin_register()` — returns a `Box<dyn BuiltinPlugin>`.
3. 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
```mermaid
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.
```mermaid
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::DocumentChangedV4`, `SelectionChangedV4`, or
`DocumentTabClosed` to a plugin at any time, and the plugin can stream
`PluginNotification::Output`/`Progress`/`Log` back while a long command is still
running.
```mermaid
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 `PluginRequest`s 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.
```rust
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
}
}
```
```rust
// 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:
```rust
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 every editor record root and its dependent types with `serde-reflection`.
- **Version info** — `ocs_version`, `ocs_plugin_api_version`, `acadrust_version`,
`api_version`, etc.
Access them without pulling the `host` feature:
```rust
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
```rust
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::CURRENT,
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
```rust
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`.
```rust
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 opencadcodec::entities::Point;
use opencadcodec::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:
```rust
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.
```rust
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.
```
### Command execution and layer management
Plugins can execute native CAD commands and manage drawing tables through `HostApi`:
- **Command Execution (`HostApi::execute_command`)**:
Executes AutoCAD-compatible commands, scripts, and tokens on the active document tab's command driver (e.g. `LINE 0,0 10,10`, `SETVAR PDMODE 3`, `VSCURRENT FLATSHADED`, `GRID ON`). The host automatically executes any resulting asynchronous headless tasks (such as render mode updates and grid state changes) through the application message loop without freezing the UI.
- **Layer Creation (`HostApi::add_layer`)**:
Creates a new layer in the document table with optional color, visibility, and line weight settings. Returns `Some(Handle)` on success, or `None` if the layer name already exists or is invalid.
- **Layer Modification (`HostApi::modify_layer`)**:
Updates properties of an existing layer in-place. Properties left as `None` remain unchanged. Returns `true` on success, or `false` if the layer does not exist.
### Further reading
- Internal architecture & invariants: [`ARCHITECTURE.md`](./ARCHITECTURE.md)
- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md)
- Plugin template: [`../../docs/plugin-template`](../../docs/plugin-template)
- Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md)
- REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md)