OpenCADStudio ships no built-in plugins, so the spec and scaffold no longer describe the in-tree inventory path, demo_plugin, or the fictional Storm Sewer reference. The architecture doc now covers the external cdylib model end to end (ocs_plugin_api contract, export_plugin!, building + publishing per-platform releases, runtime loading, the marketplace + curated registry, the approach-B ABI caveat). The template becomes a standalone cdylib crate (Cargo.toml + src/lib.rs + release workflow) instead of an in-tree module. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 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
- Sandboxing or signature verification (installing a plugin runs native code; the user trusts the repos they install from).
- 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, libloading, dispatch │
└───────────────────────────────┬────────────────────────────────────┘
│ &mut dyn HostApi (ocs_plugin_api)
┌───────────────────────────────▼────────────────────────────────────┐
│ Layer B — Plugin package (external repo, cdylib) │
│ Cargo.toml · plugin.toml · src/lib.rs │
│ 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 |
everything |
| B — Plugin | a separate repo (cdylib) | 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, and theexport_plugin!macro.
A plugin enables the host feature.
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;
}
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(), 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*; use the ocs_plugin_api::host::plugin_state / plugin_state_mut / ensure_plugin_state helpers (keyed by manifest.id) |
| Command line | push_info, push_output, push_error |
| Undo / dirty | push_undo, set_dirty |
| Tab | tab_index() |
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" }
// 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" }
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
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 = 1
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
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.
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.
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 it dlopens the library (libloading), calls
ocs_plugin_api_version and refuses on mismatch, then ocs_plugin_register to
obtain the boxed BuiltinPlugin. Loaded libraries stay resident for the
session (ribbon tabs and dispatch hold their vtables, so they are never
reloaded mid-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).
<config> is %APPDATA% (Windows), ~/Library/Application Support (macOS), or
$XDG_CONFIG_HOME / ~/.config (Linux).
Marketplace
The Plugin Manager (PLUGINS / PLUGINMANAGER, or the Start-page button)
installs plugins from GitHub Releases:
- 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.txt(plugin_repos=). - 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.
Compatibility & ABI
Loading uses approach B: the plugin returns a boxed BuiltinPlugin across the
cdylib boundary. This is sound only when the plugin was built with the same
Rust toolchain and ocs_plugin_api version as the host. The
ocs_plugin_api_version symbol gates the API version; it does not detect a
toolchain mismatch. In practice CI built with current stable Rust matches a host
built the same way.
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 +
libloadingloading with anapi_versiongate. - XDATA helpers,
ModuleEvent::PluginFileDialog, per-tab plugin state. - Marketplace — curated registry + manual repo link, install / upgrade / reinstall / uninstall, enable/disable.
Next:
#[repr(C)]vtable / strict handshake for cross-toolchain binaries.- Trust: checksums / signatures before
dlopen. - Interchange (LandXML / SWMM) and live
on_entity_committedhooks. - External automation API (drive OCS headless from a process) — issue #29.
Reference
| Piece | Location |
|---|---|
| Contract crate | crates/ocs_plugin_api |
| Plugin runtime (host) | src/plugin/, src/app/plugin_host.rs |
| Marketplace + registry | src/plugin/marketplace.rs, plugins/registry.json |
| Template scaffold | docs/plugin-template/ |
| Live example plugin | opencad-example-plugin |