# 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) {} fn entity_classes(&self) -> Option<&'static EntityClasses> {} // API v10 } ``` ### `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`. ### Entity class display labels (API v10) A plugin can give its XDATA-stamped entities human type names in the host UI. The split between *identity* (in the drawing) and *labels* (in the plugin) is the point: - **Identity** — the plugin stamps one string value into an entity's XDATA record: `""`, e.g. `"CVIFC-CLASS:LINE"`. The key is stable, meaningless to the host, and round-trips the DWG/DXF verbatim. - **Labels** — the plugin declares a table and exposes it through `BuiltinPlugin::entity_classes()`: ```rust use ocs_plugin_api::manifest::EntityClasses; static ENTITY_CLASSES: EntityClasses = EntityClasses { app_id: "CVIFC", // one of the manifest's xdata_apps class_prefix: "CVIFC-CLASS:", labels: &[ ("LINE", "Linear Element"), ("CIRCULARARC", "Circular Arc Element"), ], }; impl BuiltinPlugin for MyPlugin { fn entity_classes(&self) -> Option<&'static EntityClasses> { Some(&ENTITY_CLASSES) } // ... } ``` The host resolves them at display time only: the single-entity Properties panel title becomes `"Linear Element (Line)"`, and the records/automation `display_type` field takes the same form. The rule is display-only — a label never governs behavior, and the raw schema fields (`type`, `dxf_name`, `record_type`, the command-line `LIST` output) stay native so existing scripts and filters keep working. **Gating.** Declarations are consulted through the live plugin manager, never cached: a plugin that is not installed, is disabled, is excluded by `OCS_PLUGIN_MAX_API_VERSION`, failed at load, has been removed, or whose process has died contributes nothing — its entities display as their bare native type ("Line"), immediately, with no re-open needed. An `app_id` claimed by more than one loaded plugin resolves through the first-loaded declaration; later duplicates are ignored (debug-logged). A class key that is empty, missing from the table, or mapped to an empty label falls back to the native type. Because labels are display text resolved from the loaded plugin, they are plugin-localizable and fixable without rewriting drawings — and `plugin.toml` (the pre-load discovery file) deliberately does not carry them. --- ## 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) |