# 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](https://plugins.qgis.org/)-style extensibility: a small metadata file, a single entry point, an optional separate engine crate, and user-installable packages from a curated index. > Plugins are **out-of-process dynamic libraries** (`cdylib`). The host loads > first-party packages from its application resources and third-party packages > from the user plugins folder. Both use the generic runtime > (`src/plugin/`, `src/app/plugin_host.rs`) and the stable contract crate > (`crates/ocs_plugin_api`). A user package with the same id explicitly > overrides a bundled package. --- ## 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** | Third-party plugins remain independent; selected first-party plugins may build and ship with OCS. | | **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](#compatibility--abi). - Sandboxed scripting (Python/Lua); replacing the `opencadcodec` entity 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/opencadcodec │ └────────────────────────────────────────────────────────────────────┘ ``` | 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** 1. The host (`src/plugin/`) imports no plugin code — it only knows the contract. 2. Engine crates import neither `iced`, `opencadcodec`, nor `OpenCADStudio`. 3. A plugin never edits host source; it runs entirely from its own crate. --- ## The contract crate — `ocs_plugin_api` [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) is the semver-versioned API a plugin compiles against. Two tiers: - **Dependency-free core** (default): `PluginManifest` / `ApiVersion` and the ribbon vocabulary — `CadModule`, `ToolDef`, `RibbonGroup`, `RibbonItem`, `IconKind`, `ModuleEvent`, `StyleKey`. Engine crates and tooling depend on this cheaply. - **`host` feature** (pulls `opencadcodec`): the runtime surface — the `HostApi` trait, the `BuiltinPlugin` entry-point trait, the `export_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 the `opencadcodec` crate the host was built against. - `rustc_version` — the output of `rustc --version` for 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 also required for API v4 and later. Missing, empty, or mismatched metadata makes the package incompatible. ### `PluginManifest` ```rust 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 ```rust pub trait BuiltinPlugin: Send + Sync { fn manifest(&self) -> &'static PluginManifest; fn ribbon(&self) -> Box; // 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 ```rust 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` — constructs the plugin and hands ownership to the host. --- ## Writing a plugin A plugin is a standalone crate that builds a `cdylib`: ```toml # Cargo.toml [lib] crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] } # Match the host's opencadcodec so the loaded library is binary-compatible. [patch.crates-io] opencadcodec = { git = "https://github.com/HakanSeven12/opencadcodec", 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. ```rust // 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> = 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 { 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); ``` ```toml # 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 are filled at release-build time; see the CI step below. rustc_version = "rustc 1.98.0 (hash date)" acadrust_source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=#" ``` The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template); the live reference is the [`opencad-example-plugin`](https://github.com/HakanSeven12/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 `" "` back with the path's original case preserved. A leading `>` or the persistent literal-input toggle preserves both spaces and case. Automation and `ModuleEvent::PluginFileDialog` already preserve case without this prefix. ### 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`: ```rust 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.so | .dll | lib.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 `opencadcodec` package entry (`source = "git+...#"`) 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 `/OpenCADStudio/plugins//` for a `plugin.toml` + native library (`src/plugin/external.rs`): ``` /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 `). 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. `` 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.json`](../plugins/registry.json) in this repo lists discoverable plugins. The host fetches it from `main` at runtime and shows each entry under *Available plugins*. To list a plugin, open a PR adding `{ "repo", "name", "description" }` (see [`plugins/README.md`](../plugins/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 in `settings.json`. - **Install / upgrade / reinstall** — pick a release from the dropdown and *Install*; the host downloads the platform asset + `plugin.toml` into the plugins folder, checking `api_version` first. 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. One linked repository is one marketplace entry. Publish separate plugins from separate repositories so release and update histories stay unambiguous. --- ## Compatibility & ABI Plugins are loaded as `cdylib`s 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. The runner loads the plugin library in-process, however, so API v4+ requires matching `rustc_version` and `acadrust_source` metadata. 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: - [x] Stable `ocs_plugin_api` crate — dependency-free core + `host` feature (`HostApi` / `BuiltinPlugin` / `export_plugin!`). - [x] Runtime discovery + out-of-process loading (host spawns itself in runner mode) with an `api_version` gate and `interprocess` local-socket IPC. - [x] XDATA helpers, `ModuleEvent::PluginFileDialog`, per-tab plugin state. - [x] Marketplace — curated registry + manual repo link, install / upgrade / reinstall / uninstall, enable/disable. - [x] Interactive command round-trip over IPC (prompt, point/enter/object-pick). - [x] Spawn and per-call IPC timeouts so a stuck runner cannot freeze the host. - [x] 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, per `src/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 `CadDocument` over IPC. - [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries. - [ ] Trust: checksums / signatures before spawning plugin processes. - [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks. --- ## Reference | Piece | Location | |-------|----------| | Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | | Internal API architecture | [`crates/ocs_plugin_api/ARCHITECTURE.md`](../crates/ocs_plugin_api/ARCHITECTURE.md) | | Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) | | Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../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`](../plugins/registry.json) | | Template scaffold | [`docs/plugin-template/`](plugin-template) | | Live example plugin | [`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) |