Found while building an out-of-tree scripting plugin from scratch against this doc and the actual ocs_plugin_api/host source. Two categories: Fixed outright (docs/examples didn't match the real code): - The worked CadModule example returned Vec<RibbonGroup> from ribbon_groups(); the real trait requires &[RibbonGroup] - the example wouldn't compile. Fixed with the OnceLock pattern the host's own benchmark code already uses. - Reconciled a contradiction between "process isolation removes the need for a shared Rust ABI" and the exact-rustc_version requirement described a few paragraphs earlier in the same doc. - The roadmap's "Next" list still had external automation as unstarted; --mcp/--serve are real and operational (src/cli.rs, src/mcp.rs). - Added the missing plugin.toml to docs/plugin-template-v2 (its own DESIGN.md already claimed one could be installed). - Documented that plugin-template's CI floats rust-toolchain@stable, which doesn't guarantee matching the host's exact rustc - added acadrust_source to both templates' plugin.toml plus the release.yml step that fills it from Cargo.lock's resolved acadrust source, alongside the existing rustc_version fill. Flagging for your call, not fixed here (found the behavior, not sure it's the intended one, prefer to ask than guess at a fix): - acadrust_compatible()'s ABI gate only runs at all if plugin.toml declares acadrust_source - an undeclared field is silently treated as compatible, unlike rustc_version (undeclared = incompatible). The same shortcut appears in two places: ExternalPlugin::acadrust_compatible (src/plugin/external.rs) and the inline check inside fetch_release_info (src/plugin/marketplace.rs). If this is deliberate (e.g. back-compat with manifests from before the field existed), no action needed beyond a comment; if not, both call sites need the same fix. - The command line still uppercases typed/pasted input even with the `>` literal-spaces prefix (Message::CommandInput, src/app/update/mod.rs) - `>` only stops a space from auto-submitting the line, it doesn't skip `s.to_uppercase()`. A plugin command needing a case-sensitive typed argument has no escape today short of automation. - fetch_release_info (src/plugin/marketplace.rs) returns every release under a linked repo with no filter on the manifest's own id field (which it already parses to build ReleaseInfo) - a repo hosting more than one plugin under tag-namespaced releases surfaces all of them mixed together in one plugin's version list. Co-Authored-By: Claude Sonnet 5 <[email protected]>
25 KiB
Open CAD Studio — Plugin Architecture
Status: Accepted Author: Open CAD Studio contributors Date: June 2026
This document is the authoritative spec for how add-on packages integrate with Open CAD Studio. The model follows QGIS-style extensibility: a small metadata file, a single entry point, an optional separate engine crate, and user-installable packages from a curated index.
Open CAD Studio ships no built-in plugins. Every add-on is an external dynamic library (
cdylib) the host loads at runtime from the user plugins folder. The host source only contains the generic plugin runtime (src/plugin/,src/app/plugin_host.rs) and the stable contract crate (crates/ocs_plugin_api). Add-ons live in their own repositories and consume that contract.
Design goals
| Goal | Rationale |
|---|---|
| One package, one entry point | Manifest, ribbon tab and commands ship together in the plugin crate; no edits to the host. |
| Stable contract | Authors target the semver-versioned ocs_plugin_api crate, not OpenCADStudio internals. |
| Out-of-tree by default | A plugin is its own repo + crate; the host never recompiles to gain one. |
| DWG round-trip | Domain data lives on entities as XDATA, not in an opaque side database. |
| Engine reuse | A headless std-only engine crate can run in WASM/CLI without the CAD host. |
Non-goals
- Signature verification (installing a plugin runs native code; the user trusts the repos they install from). Process isolation limits the blast radius of a buggy or malicious plugin, but it is not a security sandbox.
- Cross-toolchain binary compatibility — see Compatibility.
- Sandboxed scripting (Python/Lua); replacing the
acadrustentity model.
Three layers
┌────────────────────────────────────────────────────────────────────┐
│ Layer A — Host (OpenCADStudio) │
│ iced UI · Scene · Document · Undo · Command line │
│ Core ribbon tabs: Home, Model, View, … (NOT plugins) │
│ Generic plugin runtime: discovery, spawn, dispatch │
└───────────────────────────────┬────────────────────────────────────┘
│ IPC over local socket (ocs_plugin_api)
┌───────────────────────────────▼────────────────────────────────────┐
│ Layer B — Plugin process (external repo, cdylib) │
│ host spawns itself in runner mode · Cargo.toml · plugin.toml │
│ PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin! │
└───────────────────────────────┬────────────────────────────────────┘
│ pure Rust API
┌───────────────────────────────▼────────────────────────────────────┐
│ Layer C — Domain engine crate (optional) │
│ hydraulics / COGO / … — `std` only, no iced/acadrust │
└────────────────────────────────────────────────────────────────────┘
| Layer | Lives in | May depend on |
|---|---|---|
| A — Host | this repo: src/, crates/ocs_plugin_api runtime |
everything |
| B — Plugin | a separate repo (cdylib), spawned by the host in runner mode | ocs_plugin_api + optional engine |
| C — Engine | the plugin's own crate or crates.io | std only (WASM/CLI-capable) |
Hard rules
- The host (
src/plugin/) imports no plugin code — it only knows the contract. - Engine crates import neither
iced,acadrust, norOpenCADStudio. - A plugin never edits host source; it runs entirely from its own crate.
The contract crate — ocs_plugin_api
crates/ocs_plugin_api is the semver-versioned API a
plugin compiles against. Two tiers:
- Dependency-free core (default):
PluginManifest/ApiVersionand the ribbon vocabulary —CadModule,ToolDef,RibbonGroup,RibbonItem,IconKind,ModuleEvent,StyleKey. Engine crates and tooling depend on this cheaply. hostfeature (pullsacadrust): the runtime surface — theHostApitrait, theBuiltinPluginentry-point trait, theexport_plugin!macro, and the out-of-process plugin runtime (PluginProcess,runner).
A plugin enables the host feature.
Build-time ABI metadata
For API v4 and later the host records two pieces of build-time metadata that affect whether a prebuilt plugin can safely be loaded:
acadrust_source— the exact git source of theacadrustcrate the host was built against.rustc_version— the output ofrustc --versionfor the compiler that built the host.
A plugin's plugin.toml declares the same values under [opencad]. For API v4
and later, rustc_version is required and the host refuses to load the plugin
unless it matches exactly — omitting it is treated as a mismatch, not skipped.
This turns the current silent runner crash into a one-line diagnosis such as:
Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required
acadrust_source is checked differently, and this matters: unlike
rustc_version, the check only runs if the plugin's plugin.toml declares
acadrust_source at all — the same if !acadrust_declared { return true; }
(or equivalent inline match) appears in two places,
ExternalPlugin::acadrust_compatible in src/plugin/external.rs (used when
loading an installed plugin) and the inline check inside
fetch_release_info in src/plugin/marketplace.rs (used when listing
releases in the Plugin Manager) — a manifest that omits the key entirely is treated
as compatible, not rejected. In practice this means the ABI gate is
opt-in per plugin rather than enforced across the board: a plugin.toml copied
from an older template that never added the field sails through unchecked.
Always declare acadrust_source (see the worked example below) so the gate
actually applies to your plugin; don't rely on the host to catch its absence.
PluginManifest
pub struct PluginManifest {
pub id: &'static str, // reverse-DNS: "opencad.example"
pub name: &'static str,
pub version: &'static str,
pub description: &'static str,
pub api_version: ApiVersion, // host ABI major; must match the host
pub ribbon_order: i32, // sort key among add-on tabs
pub xdata_apps: &'static [&'static str],
pub command_prefixes: &'static [&'static str],
}
BuiltinPlugin — the entry point
pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest;
fn ribbon(&self) -> Box<dyn CadModule>; // the ribbon tab
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
fn on_load(&mut self, host: &mut dyn HostApi) {}
}
HostApi — the plugin-facing runtime surface
dispatch receives &mut dyn HostApi, so a plugin never touches the host's
concrete types:
| Category | Methods |
|---|---|
| Document | document() / document_mut() return a local cached copy of the document (API v3); the first access in a dispatch clones the full CadDocument over IPC. Use add_entity() / write_record() for host-visible mutations. add_entity(), bump_geometry() |
| XDATA | read_record(handle, app), write_record(handle, record), remove_record(handle, app) — keyed by entity handle; write_record registers the APPID so data round-trips through DWG/DXF |
| Tab state | object-safe plugin_state_any* helpers exist for in-process use; out-of-process plugins should keep state inside the plugin crate because dyn Any is not serializable |
| Command line | push_info, push_output, push_error |
| Undo / dirty | push_undo, set_dirty |
| Tab | tab_index() |
| Document identity (V5) | document_path(tab_id) returns the saved path for a document tab. |
| Notifications (V4) | on_notification receives HostNotification::SelectionChangedV4 { tab_id, handles } when the active tab's selection changes, plus DocumentChangedV4 / DocumentTabClosed. |
export_plugin! — the C-ABI export
ocs_plugin_api::export_plugin!(MyPlugin);
emits the two symbols the loader looks for:
ocs_plugin_api_version() -> u32— checked first, so an API-incompatible build never runs its code.ocs_plugin_register() -> *mut Box<dyn BuiltinPlugin>— constructs the plugin and hands ownership to the host.
Writing a plugin
A plugin is a standalone crate that builds a cdylib:
# Cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] }
# Match the host's acadrust so the loaded library is binary-compatible.
[patch.crates-io]
acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" }
Your release build must also use the same rustc as the host. Record it in
plugin.toml (see below) and pin the toolchain in your CI matrix so every
published asset matches the host's compiler.
// src/lib.rs
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.example", name: "Example Plugin", version: "0.1.0",
description: "…", api_version: ApiVersion::CURRENT,
ribbon_order: 50, xdata_apps: &[], command_prefixes: &["EX_"],
};
struct ExampleModule;
impl CadModule for ExampleModule {
fn id(&self) -> &'static str { "example" }
fn title(&self) -> &'static str { "Example" }
// Returns a slice, not an owned Vec — build once into a static and hand
// back a reference, rather than rebuilding (and dropping) it every call.
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef {
id: "EX_HELLO", label: "Hello", icon: IconKind::Glyph("◆"),
event: ModuleEvent::Command("EX_HELLO".to_string()),
})]}]})
}
}
struct ExamplePlugin;
impl BuiltinPlugin for ExamplePlugin {
fn manifest(&self) -> &'static PluginManifest { &MANIFEST }
fn ribbon(&self) -> Box<dyn CadModule> { Box::new(ExampleModule) }
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
match cmd { "EX_HELLO" => { host.push_info("Hello"); true } _ => false }
}
}
ocs_plugin_api::export_plugin!(ExamplePlugin);
# plugin.toml — shipped beside the binary; values mirror MANIFEST
[plugin]
id = "opencad.example"
name = "Example Plugin"
version = "0.1.0"
description = "…"
[opencad]
api_version = 5
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
# Both filled at release-build time, not by hand — see the CI step below.
# Declare both; omitting acadrust_source silently skips that half of the
# compatibility check instead of failing it (see "Build-time ABI metadata"
# above), which defeats the point of declaring either field.
rustc_version = "rustc 1.98.0 (hash date)"
acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=<short>#<full-40-char-commit>"
The full, buildable scaffold is in docs/plugin-template/;
the live reference is the
opencad-example-plugin
repository.
Commands
A plugin owns its command_prefixes (e.g. EX_). The host's command router
calls try_dispatch first; a returning true consumes the command. A plugin
tool fires ModuleEvent::Command("EX_FOO"), which round-trips to
dispatch(host, "EX_FOO").
ModuleEvent::PluginFileDialog { command, title, filter_name, extensions } lets a
tool request a native file picker; on selection the host dispatches
"<command> <path>" back with the path's original case preserved.
Known gap: typed/pasted commands still get uppercased even when a leading
> is used to keep spaces literal. The interactive command line
(Message::CommandInput in src/app/update/mod.rs) unconditionally calls
s.to_uppercase() on typed input unless a free-form text prompt is active
(is_free_text_active() — cell text, TEXT/MTEXT bodies). The > prefix
and literal_spaces mode (added since this was first found) correctly stop
a typed space from auto-submitting the line early — solving multi-word
arguments — but neither one skips the uppercase call, so a plugin command
needing a case-sensitive argument (a file path with mixed case, an embedded
expression, anything not meant to be all-caps) still gets mangled when
typed or pasted directly, even with >. Automation (--serve/--mcp)
bypasses this input path entirely and is unaffected — this is specifically
a typed/pasted-into-the-command-line issue. ModuleEvent::PluginFileDialog
sidesteps it for file paths (dispatches back with original case intact, as
noted above) but there's no equivalent for a plugin command that needs
case-sensitive typed text more generally. Fix direction: extend whatever
condition currently gates literal_spaces/> to also skip the uppercase
call, rather than treating "keep spaces literal" and "keep case literal" as
solved by the same escape when a plugin command may need one without the
other.
Interactive (click-to-place) commands
For tools that collect points — placing a structure, drawing a pipe — call
host.start_interactive(Box::new(my_cmd)) from dispatch, where my_cmd
implements ocs_plugin_api::host::InteractiveCommand:
fn on_point(&mut self, pt: [f64; 3]) -> CommandStep {
// … return NeedPoint, Commit(entity), CommitAndEnd(entity), Done, or Cancel
}
The host drives it through its normal point-collection flow, so the same
command works by clicking in the viewport and by feeding coordinates over the
--serve automation API (run "MY_CMD 0,0 10,10"). This is what bumped the
API to v2 — the added HostApi method changes the contract's vtable, so v1
binaries are refused at load.
To reference existing geometry (e.g. connect a pipe between two structures),
set needs_object_pick() -> true; the host then calls
on_object_pick(handle, pt) with the clicked entity's handle (read its
XDATA/geometry via HostApi). Over --serve the pick is supplied as a hex
handle: run "MY_CMD 2F 30".
XDATA — domain persistence
Store domain data on entities as XDATA (under your xdata_apps ids), not in a
side database, so it round-trips through DWG/DXF. write_record also registers
the APPID. Document your schemas in the plugin's own PLUGIN.md.
Building & distribution
Build per platform and publish to GitHub Releases:
cargo build --release # → target/release/lib<crate>.so | <crate>.dll | lib<crate>.dylib
A release attaches one binary per platform plus plugin.toml, with the platform
in the asset name so the host can pick the right one:
opencad.example-linux-x86_64.so
opencad.example-windows-x86_64.dll
opencad.example-macos-aarch64.dylib
plugin.toml
A GitHub Actions matrix workflow (see the example repo / template) cross-builds
and uploads these on a v* tag. It also fills plugin.toml's rustc_version
and acadrust_source placeholders from the CI run's actual toolchain and
resolved dependency, rather than hand-editing them per release — see
docs/plugin-template/.github/workflows/release.yml for the exact sed
substitutions, including reading acadrust_source out of Cargo.lock's
resolved acadrust package entry (source = "git+...#<hash>") since that's
the same value the host embeds at its own build time. The template's CI pins
rust-toolchain@stable, which is a floating target — a real release pipeline
should pin the exact toolchain version the host was built with (check
crates/ocs_plugin_api/src/version_info.rs's embedded value, or the host's
own CI config) rather than trusting "whatever's current stable today" to
keep matching it.
Loading
On startup the host scans <config>/OpenCADStudio/plugins/<id>/ for a
plugin.toml + native library (src/plugin/external.rs):
<config>/OpenCADStudio/plugins/
opencad.example/
plugin.toml
libocs_example_plugin.so # any name with the platform extension
For each compatible package the host spawns itself in runner mode
(--ocs-plugin-runner <socket> <cdylib>). The child process loads the cdylib
in its own address space and connects back to the host over an interprocess
local socket. The runner checks ocs_plugin_api_version and refuses on
mismatch, then calls ocs_plugin_register to obtain the boxed BuiltinPlugin.
Each plugin runs in a separate OS process, so a plugin crash or memory
corruption cannot affect the host or other plugins. Plugin processes stay
resident for the session; external plugins merge into the same ribbon and
try_dispatch path the host uses and honour the enable/disable set
(disabled_plugins in settings.txt).
Two timeouts protect the host from a stuck runner:
| Timeout | Env var | Default | Floor |
|---|---|---|---|
| Spawn (connection) | OCS_PLUGIN_SPAWN_TIMEOUT_SECS |
30 s | — |
| Per-call | OCS_PLUGIN_CALL_TIMEOUT_SECS |
30 s | GetManifest/GetRibbon ≥ 5 s, Dispatch ≥ 10 s, interactive events/prompt/pick ≥ 2 s |
A call timeout covers the full round-trip, including any nested plugin→host
requests handled inline. When it fires the host kills the runner, marks the
plugin dead, and reports a CallTimeout error via the normal plugin error path.
<config> is %APPDATA% (Windows), ~/Library/Application Support (macOS), or
$XDG_CONFIG_HOME / ~/.config (Linux).
Failure handling
| Failure | Behavior |
|---|---|
| Plugin panics | Caught inside the plugin runner child; an error response is returned to the host and stays alive. |
| Plugin crash / hang / malformed message | The host detects a dead process via try_wait on the next dispatch or ribbon rebuild; the tab is dropped and an error is logged. |
| Slow or non-responsive call | The per-call timeout (OCS_PLUGIN_CALL_TIMEOUT_SECS) fires, kills the runner, and surfaces a CallTimeout error. |
| Spawn failure | Reported per-plugin during startup and surfaced in the Plugin Manager / command line. |
| Oversized message | The length-framed transport rejects messages larger than 64 MiB. |
Marketplace
The desktop Plugin Manager (PLUGINS / PLUGINMANAGER, or the Start-page
button) installs plugins from GitHub Releases. In the browser, the same entry
points show a desktop-download notice because marketplace packages are native
dynamic libraries:
- Curated registry —
plugins/registry.jsonin this repo lists discoverable plugins. The host fetches it frommainat runtime and shows each entry under Available plugins. To list a plugin, open a PR adding{ "repo", "name", "description" }(seeplugins/README.md); merged PRs reach every user with no app update. - Manual link — Add a repository (
owner/repo) for unlisted or private dev repos; linked repos persist insettings.json. - Install / upgrade / reinstall — pick a release from the dropdown and
Install; the host downloads the platform asset +
plugin.tomlinto the plugins folder, checkingapi_versionfirst. Reinstalling overwrites and clears any stale library; picking a newer release upgrades. Changes take effect on the next restart (the running library stays resident). - Uninstall — removes the package folder (effective next restart).
- Enable/disable — toggles a loaded plugin's ribbon tab + dispatch without uninstalling.
Known gap: one linked repo is treated as one plugin's release history, even
when it hosts several. fetch_releases/fetch_release_info
(src/plugin/marketplace.rs) fetch and parse every release under a linked
owner/repo into one flat Vec<ReleaseInfo>, with no filter on the parsed
manifest's own id field — which is already available at that point, since
each release's plugin.toml is downloaded and parsed to build ReleaseInfo
in the first place. A repo that ships more than one plugin (tag-namespaced
releases like pluginA-v1.0.0, pluginB-v1.0.0) will surface every other
plugin's releases as if they belonged to whichever one the user is looking
at, and any future "is there a newer version" check that walks this same
list by date/tag would need the same fix. A user can still install correctly
by picking the right tagged release (each release's own bundled plugin.toml
is what actually gets installed), but the discovery/version list itself
doesn't distinguish plugins sharing a repo. Fix direction: filter
fetch_release_info's result (or its caller) to releases whose manifest
id matches the plugin currently being viewed/installed, the same id
ReleaseInfo already carries.
Compatibility & ABI
Plugins are loaded as cdylibs by a plugin-runner child process. The host spawns
this child from its own executable (--ocs-plugin-runner mode), so the runner
and host always share the same ocs_plugin_api build. The runner checks
ocs_plugin_api_version before any plugin code runs. Each plugin runs in its
own OS process, so a plugin crash or memory-corruption bug cannot take down the
host process itself — but process isolation alone does not mean a
toolchain mismatch is safe to ignore: the plugin and host still exchange data
across the shared ocs_plugin_api types over IPC, and a struct-layout or ABI
difference between toolchain versions could corrupt that exchange in ways
subtler than an immediate crash. That's why, independently of process
isolation, API v4+ also enforces an exact rustc_version match (and,
correctly declared, acadrust_source — see above) before loading a plugin at
all, rather than relying on isolation to make a mismatch merely "someone
else's problem."
A future hardening step is a #[repr(C)] vtable (a true C ABI) so binaries built
by any toolchain interoperate — required before trusting prebuilt binaries from
arbitrary build environments.
Roadmap
Done:
- Stable
ocs_plugin_apicrate — dependency-free core +hostfeature (HostApi/BuiltinPlugin/export_plugin!). - Runtime discovery + out-of-process loading (host spawns itself in runner
mode) with an
api_versiongate andinterprocesslocal-socket IPC. - XDATA helpers,
ModuleEvent::PluginFileDialog, per-tab plugin state. - Marketplace — curated registry + manual repo link, install / upgrade / reinstall / uninstall, enable/disable.
- Interactive command round-trip over IPC (prompt, point/enter/object-pick).
- Spawn and per-call IPC timeouts so a stuck runner cannot freeze the host.
- External automation (
--mcp— local stdio MCP/JSON-RPC server for AI clients;--serve— headless JSON automation server; both drive OCS from a separate process without the GUI, persrc/cli.rs/src/mcp.rs/app::serve). This closes the "drive OCS headless from a process" goal of issue #29; this line previously and incorrectly listed it as not yet started.
Next:
- Incremental document snapshots instead of cloning
CadDocumentover IPC. #[repr(C)]vtable / strict handshake for cross-toolchain binaries.- Trust: checksums / signatures before spawning plugin processes.
- Interchange (LandXML / SWMM) and live
on_entity_committedhooks. - Close the
acadrust_sourceenforcement gap noted above (see "Build-time ABI metadata") — an omitted field is currently accepted as compatible instead of rejected, unlikerustc_version.
Reference
| Piece | Location |
|---|---|
| Contract crate + runtime | crates/ocs_plugin_api |
| Internal API architecture | crates/ocs_plugin_api/ARCHITECTURE.md |
| Plugin runner implementation | crates/ocs_plugin_api/src/runner.rs |
| Host spawn logic | crates/ocs_plugin_api/src/process.rs |
| Host plugin integration | src/plugin/, src/app/plugin_host.rs |
| Core module registry generator | build.rs (writes to OUT_DIR, included by src/modules/registry.rs) |
| Marketplace + registry | src/plugin/marketplace.rs, plugins/registry.json |
| Template scaffold | docs/plugin-template/ |
| Live example plugin | opencad-example-plugin |