2026-06-09 13:41:21 -04:00
# Open CAD Studio — Plugin Architecture
2026-06-17 16:02:16 +03:00
**Status:** Accepted
**Author:** Open CAD Studio contributors
2026-06-09 13:41:21 -04:00
**Date:** June 2026
2026-06-17 16:02:16 +03:00
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.
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
> **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.
2026-06-09 13:41:21 -04:00
---
## Design goals
| Goal | Rationale |
|------|-----------|
2026-06-17 16:02:16 +03:00
| **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. |
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
## Non-goals
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
- Sandboxing or signature verification (installing a plugin runs native code; the
user trusts the repos they install from).
- Cross-toolchain binary compatibility — see [Compatibility ](#compatibility--abi ).
- Sandboxed scripting (Python/Lua); replacing the `acadrust` entity model.
2026-06-09 13:41:21 -04:00
---
2026-06-17 16:02:16 +03:00
## Three layers
2026-06-09 13:41:21 -04:00
```
┌────────────────────────────────────────────────────────────────────┐
2026-06-17 16:02:16 +03:00
│ Layer A — Host (OpenCADStudio) │
2026-06-09 13:41:21 -04:00
│ iced UI · Scene · Document · Undo · Command line │
2026-06-17 16:02:16 +03:00
│ Core ribbon tabs: Home, Model, View, … (NOT plugins) │
│ Generic plugin runtime: discovery, libloading, dispatch │
2026-06-09 13:41:21 -04:00
└───────────────────────────────┬────────────────────────────────────┘
2026-06-17 16:02:16 +03:00
│ &mut dyn HostApi (ocs_plugin_api)
2026-06-09 13:41:21 -04:00
┌───────────────────────────────▼────────────────────────────────────┐
2026-06-17 16:02:16 +03:00
│ Layer B — Plugin package (external repo, cdylib) │
│ Cargo.toml · plugin.toml · src/lib.rs │
│ PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin! │
2026-06-09 13:41:21 -04:00
└───────────────────────────────┬────────────────────────────────────┘
│ pure Rust API
┌───────────────────────────────▼────────────────────────────────────┐
2026-06-17 16:02:16 +03:00
│ Layer C — Domain engine crate (optional) │
│ hydraulics / COGO / … — `std` only, no iced/acadrust │
2026-06-09 13:41:21 -04:00
└────────────────────────────────────────────────────────────────────┘
```
2026-06-17 16:02:16 +03:00
| Layer | Lives in | May depend on |
2026-06-09 13:41:21 -04:00
|-------|----------|---------------|
2026-06-17 16:02:16 +03:00
| **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) |
2026-06-09 13:41:21 -04:00
**Hard rules**
2026-06-17 16:02:16 +03:00
1. The host (`src/plugin/` ) imports no plugin code — it only knows the contract.
2. Engine crates import neither `iced` , `acadrust` , nor `OpenCADStudio` .
3. A plugin never edits host source; it runs entirely from its own crate.
2026-06-09 13:41:21 -04:00
---
2026-06-17 16:02:16 +03:00
## The contract crate — `ocs_plugin_api`
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
[`crates/ocs_plugin_api` ](../crates/ocs_plugin_api ) is the semver-versioned API a
plugin compiles against. Two tiers:
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
- **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 `acadrust` ): the runtime surface — the `HostApi`
trait, the `BuiltinPlugin` entry-point trait, and the `export_plugin!` macro.
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
A plugin enables the `host` feature.
2026-06-09 13:41:21 -04:00
### `PluginManifest`
```rust
pub struct PluginManifest {
2026-06-17 16:02:16 +03:00
pub id: &'static str, // reverse-DNS: "opencad.example"
2026-06-09 13:41:21 -04:00
pub name: &'static str,
pub version: &'static str,
pub description: &'static str,
2026-06-17 16:02:16 +03:00
pub api_version: ApiVersion, // host ABI major; must match the host
2026-06-09 13:41:21 -04:00
pub ribbon_order: i32, // sort key among add-on tabs
pub xdata_apps: &'static [&'static str],
pub command_prefixes: &'static [&'static str],
}
```
2026-06-17 16:02:16 +03:00
### `BuiltinPlugin` — the entry point
2026-06-09 13:41:21 -04:00
```rust
pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest;
2026-06-17 16:02:16 +03:00
fn ribbon(&self) -> Box<dyn CadModule>; // the ribbon tab
2026-06-17 11:31:05 +03:00
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
2026-06-09 13:41:21 -04:00
}
```
2026-06-17 16:02:16 +03:00
### `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
2026-06-09 13:41:21 -04:00
```rust
2026-06-17 16:02:16 +03:00
ocs_plugin_api::export_plugin!(MyPlugin);
2026-06-09 13:41:21 -04:00
```
2026-06-17 16:02:16 +03:00
emits the two symbols the loader looks for:
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
- `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.
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
---
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
## Writing a plugin
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
A plugin is a standalone crate that builds a `cdylib` :
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
```toml
# Cargo.toml
[lib]
crate-type = ["cdylib"]
2026-06-17 11:31:05 +03:00
2026-06-17 16:02:16 +03:00
[dependencies]
ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] }
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
# Match the host's acadrust so the loaded library is binary-compatible.
[patch.crates-io]
acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" }
```
2026-06-09 13:41:21 -04:00
```rust
2026-06-17 16:02:16 +03:00
// 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()),
})]}]
}
2026-06-09 13:41:21 -04:00
}
2026-06-17 16:02:16 +03:00
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 }
}
}
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
ocs_plugin_api::export_plugin!(ExamplePlugin);
```
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
```toml
# plugin.toml — shipped beside the binary; values mirror MANIFEST
[plugin]
id = "opencad.example"
name = "Example Plugin"
version = "0.1.0"
description = "…"
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
[opencad]
api_version = 1
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
2026-06-09 13:41:21 -04:00
```
2026-06-17 16:02:16 +03:00
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.
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
### Commands
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
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")` .
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
`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.
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
### 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` .
2026-06-09 13:41:21 -04:00
---
2026-06-17 16:02:16 +03:00
## Building & distribution
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
Build per platform and publish to **GitHub Releases ** :
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
```
cargo build --release # → target/release/lib<crate>.so | <crate>.dll | lib<crate>.dylib
```
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
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:
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
```
opencad.example-linux-x86_64.so
opencad.example-windows-x86_64.dll
opencad.example-macos-aarch64.dylib
plugin.toml
```
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
A GitHub Actions matrix workflow (see the example repo / template) cross-builds
and uploads these on a `v*` tag.
---
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
## Loading
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
On startup the host scans `<config>/OpenCADStudio/plugins/<id>/` for a
`plugin.toml` + native library (`src/plugin/external.rs` ):
2026-06-09 13:41:21 -04:00
```
2026-06-17 14:48:56 +03:00
<config>/OpenCADStudio/plugins/
opencad.example/
2026-06-09 13:41:21 -04:00
plugin.toml
2026-06-17 16:02:16 +03:00
libocs_example_plugin.so # any name with the platform extension
2026-06-09 13:41:21 -04:00
```
2026-06-17 16:02:16 +03:00
For each compatible package it `dlopen` s 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` ).
2026-06-17 14:48:56 +03:00
2026-06-17 16:02:16 +03:00
`<config>` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or
`$XDG_CONFIG_HOME` / `~/.config` (Linux).
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
---
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
## Marketplace
The **Plugin Manager ** (`PLUGINS` / `PLUGINMANAGER` , or the Start-page button)
installs plugins from GitHub Releases:
- **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.txt` (`plugin_repos=` ).
- **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.
2026-06-09 13:41:21 -04:00
---
2026-06-17 16:02:16 +03:00
## Compatibility & ABI
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
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.
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
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.
2026-06-09 13:41:21 -04:00
---
2026-06-17 16:02:16 +03:00
## Roadmap
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
Done:
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
- [x] Stable `ocs_plugin_api` crate — dependency-free core + `host` feature
(`HostApi` / `BuiltinPlugin` / `export_plugin!` ).
- [x] Runtime discovery + `libloading` loading with an `api_version` gate.
- [x] XDATA helpers, `ModuleEvent::PluginFileDialog` , per-tab plugin state.
- [x] Marketplace — curated registry + manual repo link, install / upgrade /
reinstall / uninstall, enable/disable.
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
Next:
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
- [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries.
- [ ] Trust: checksums / signatures before `dlopen` .
- [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks.
- [ ] External automation API (drive OCS headless from a process) — issue #29 .
2026-06-09 13:41:21 -04:00
---
2026-06-17 16:02:16 +03:00
## Reference
2026-06-09 13:41:21 -04:00
2026-06-17 16:02:16 +03:00
| Piece | Location |
|-------|----------|
| Contract crate | [`crates/ocs_plugin_api` ](../crates/ocs_plugin_api ) |
| Plugin runtime (host) | `src/plugin/` , `src/app/plugin_host.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 ) |