docs(plugin): rewrite architecture + template for the external-only model

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>
This commit is contained in:
Hakan Seven 2026-06-17 16:02:16 +03:00
commit 1175675c5e
10 changed files with 399 additions and 361 deletions

View file

@ -1,12 +1,20 @@
# Open CAD Studio — Plugin Architecture # Open CAD Studio — Plugin Architecture
**Status:** Accepted (phase 1) **Status:** Accepted
**Author:** Open CAD Studio contributors **Author:** Open CAD Studio contributors
**Date:** June 2026 **Date:** June 2026
This document is the **authoritative spec** for how add-on packages integrate with Open CAD Studio. It follows patterns familiar from [QGIS](https://plugins.qgis.org/) and other open-source extensibility models: a small metadata file, a single entry-point registration, optional separate engine crate, and user-installable packages in a later phase. 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.
> **Scope:** Generic host runtime only (`src/plugin/`, `src/app/plugin_host.rs`). Domain plugins (Storm Sewer, future sanitary/geotech) live under `src/modules/<name>/` and optional `crates/<engine>/`. They **consume** this API; they are **not** part of the framework source. > **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.
--- ---
@ -14,357 +22,330 @@ This document is the **authoritative spec** for how add-on packages integrate wi
| Goal | Rationale | | Goal | Rationale |
|------|-----------| |------|-----------|
| **One package, one registration** | Ribbon tab, commands, and manifest ship together — no duplicate hooks in `build.rs` and `commands.rs`. | | **One package, one entry point** | Manifest, ribbon tab and commands ship together in the plugin crate; no edits to the host. |
| **Stable host surface** | Plugin authors target the semver-versioned `ocs_plugin_api` crate (manifest + ribbon today) and `HostSession`, not `OpenCADStudio` internals. | | **Stable contract** | Authors target the semver-versioned `ocs_plugin_api` crate, not `OpenCADStudio` internals. |
| **Open-source add-on ergonomics** | Separate git repo + workspace crate is supported; in-tree built-ins use the same layout. | | **Out-of-tree by default** | A plugin is its own repo + crate; the host never recompiles to gain one. |
| **DWG round-trip** | Domain data on entities (XDATA), not opaque plugin databases. | | **DWG round-trip** | Domain data lives on entities as XDATA, not in an opaque side database. |
| **Engine reuse** | Headless crates (`stormsewer`, …) run in WASM/CLI without the CAD host. | | **Engine reuse** | A headless `std`-only engine crate can run in WASM/CLI without the CAD host. |
## Non-goals (phase 1) ## Non-goals
- Sandboxed scripting (Python/Lua). - Sandboxing or signature verification (installing a plugin runs native code; the
- Replacing the `acadrust` entity model. user trusts the repos they install from).
- Full Autodesk CUI XML import. - Cross-toolchain binary compatibility — see [Compatibility](#compatibility--abi).
- Sandboxed scripting (Python/Lua); replacing the `acadrust` entity model.
--- ---
## Three layers (do not mix) ## Three layers
``` ```
┌────────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────────┐
│ Layer A — Host core │ Layer A — Host (OpenCADStudio)
│ iced UI · Scene · Document · Undo · Command line │ │ iced UI · Scene · Document · Undo · Command line │
│ Built-in ribbon tabs: Home, Model, View, … (NOT plugins) │ │ Core ribbon tabs: Home, Model, View, … (NOT plugins) │
│ Generic plugin runtime: discovery, libloading, dispatch │
└───────────────────────────────┬────────────────────────────────────┘ └───────────────────────────────┬────────────────────────────────────┘
HostSession (stable adapter) &mut dyn HostApi (ocs_plugin_api)
┌───────────────────────────────▼────────────────────────────────────┐ ┌───────────────────────────────▼────────────────────────────────────┐
│ Layer B — Add-on plugin package │ Layer B — Plugin package (external repo, cdylib)
plugin.toml · manifest.rs · register.rs · plugin.rs · dispatch.rs Cargo.toml · plugin.toml · src/lib.rs
Optional ribbon (CadModule) · per-tab state · XDATA schemas PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin!
└───────────────────────────────┬────────────────────────────────────┘ └───────────────────────────────┬────────────────────────────────────┘
│ pure Rust API │ pure Rust API
┌───────────────────────────────▼────────────────────────────────────┐ ┌───────────────────────────────▼────────────────────────────────────┐
│ Layer C — Domain engine crate (optional) │ │ Layer C — Domain engine crate (optional) │
crates/stormsewer — hydraulics, IO, no iced/acadrust dependency hydraulics / COGO / … — `std` only, no iced/acadrust
└────────────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────┘
``` ```
| Layer | Examples | May depend on | | Layer | Lives in | May depend on |
|-------|----------|---------------| |-------|----------|---------------|
| **A — Host core** | `src/app/`, `src/ui/`, `src/modules/home/` | Everything in the app | | **A — Host** | this repo: `src/`, `crates/ocs_plugin_api` | everything |
| **B — Plugin package** | `src/modules/storm_sewer/` | Host + optional engine crate | | **B — Plugin** | a separate repo (cdylib) | `ocs_plugin_api` + optional engine |
| **C — Engine** | `crates/stormsewer/` | `std` only (target: WASM/CLI too) | | **C — Engine** | the plugin's own crate or crates.io | `std` only (WASM/CLI-capable) |
**Hard rules** **Hard rules**
1. `src/plugin/` must **not** import any domain module (`storm_sewer`, …). 1. The host (`src/plugin/`) imports no plugin code — it only knows the contract.
2. Engine crates must **not** import `iced`, `acadrust`, or `OpenCADStudio`. 2. Engine crates import neither `iced`, `acadrust`, nor `OpenCADStudio`.
3. Add-on plugins must **not** edit `src/app/commands.rs` for new commands. 3. A plugin never edits host source; it runs entirely from its own crate.
--- ---
## Comparison to QGIS ## The contract crate — `ocs_plugin_api`
| QGIS | Open CAD Studio | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) is the semver-versioned API a
|------|-----------------| plugin compiles against. Two tiers:
| `metadata.txt` (name, version, author, …) | `plugin.toml` beside the package |
| `classFactory(iface)` in `__init__.py` | `inventory::submit!(PluginRegistration { construct })` in `register.rs` |
| `iface` stable API | `ocs_plugin_api` crate (manifest + ribbon) + `HostSession` |
| User folder `…/python/plugins/<id>/` | Phase 2: `%APPDATA%/OpenCADStudio/plugins/<id>/` |
| Plugin repository (plugins.qgis.org) | Future: curated index; today = git + in-tree |
| `qgisMinimumVersion` | `api_version` in manifest (host ABI major) |
| Processing algorithms | Headless engine crates + `SS_ANALYZE`-style commands |
| Vector layer provider | XDATA on DWG entities (`STORMSEWER_*`) |
QGIS separates **core application** from **Python plugins** loaded at runtime. Open CAD Studio phase 1 compiles add-ons **in-tree** (same ergonomics, static linking). Phase 2 adds dynamic `.dll`/`.so` with the **same** `plugin.toml` and C ABI entry point. - **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.
--- A plugin enables the `host` feature.
## Add-on package layout
Every add-on — whether in-tree or external — uses this directory shape:
```
<plugin_id>/ # e.g. storm_sewer or opencad-storm-sewer repo
plugin.toml # human metadata (mirrors manifest.rs)
PLUGIN.md # XDATA schemas, command reference
register.rs # ONLY inventory::submit! — no domain logic
plugin.rs # thin BuiltinPlugin impl
manifest.rs # static PluginManifest (compile-time truth)
dispatch.rs # command routing for this plugin
state.rs # per-document tab state (optional)
mod.rs # CadModule ribbon (if the plugin has a tab)
icons/ # SVG assets
… # domain modules (data.rs, preview.rs, …)
crates/<engine>/ # optional, separate workspace member
Cargo.toml
src/
```
### `plugin.toml` (metadata file)
Source of truth for **humans and phase-2 loader**. Values must match `manifest.rs`.
```toml
[plugin]
id = "opencad.storm_sewer"
name = "Storm Sewer"
version = "0.2.0"
description = "Gravity storm-drain network design and analysis"
author = "Open CAD Studio contributors"
license = "GPL-3.0-only"
homepage = "https://github.com/…/storm-sewer"
[opencad]
api_version = 1
ribbon_order = 50
command_prefixes = ["SS_"]
xdata_apps = ["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"]
```
**Discovery rule:** If `src/modules/<dir>/plugin.toml` exists, `build.rs` **excludes** that directory from the auto-generated ribbon registry. The tab is registered only via `BuiltinPlugin::ribbon()`.
---
## Host runtime API (phase 1)
### `PluginManifest` ### `PluginManifest`
```rust ```rust
pub struct PluginManifest { pub struct PluginManifest {
pub id: &'static str, // reverse-DNS: "opencad.storm_sewer" pub id: &'static str, // reverse-DNS: "opencad.example"
pub name: &'static str, pub name: &'static str,
pub version: &'static str, pub version: &'static str,
pub description: &'static str, pub description: &'static str,
pub api_version: ApiVersion, // host ABI major; must match host pub api_version: ApiVersion, // host ABI major; must match the host
pub ribbon_order: i32, // sort key among add-on tabs pub ribbon_order: i32, // sort key among add-on tabs
pub xdata_apps: &'static [&'static str], pub xdata_apps: &'static [&'static str],
pub command_prefixes: &'static [&'static str], pub command_prefixes: &'static [&'static str],
} }
``` ```
### `BuiltinPlugin` ### `BuiltinPlugin` — the entry point
```rust ```rust
pub trait BuiltinPlugin: Send + Sync { pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest; fn manifest(&self) -> &'static PluginManifest;
fn ribbon(&self) -> Box<dyn CadModule>; fn ribbon(&self) -> Box<dyn CadModule>; // the ribbon tab
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
} }
``` ```
### Registration (single entry point) ### `HostApi` — the plugin-facing runtime surface
```rust `dispatch` receives `&mut dyn HostApi`, so a plugin never touches the host's
// register.rs — keep this file free of domain logic concrete types:
inventory::submit! {
crate::plugin::registry::PluginRegistration {
construct: || Box::new(MyPlugin),
}
}
```
Host startup:
1. `inventory::iter::<PluginRegistration>` constructs all plugins.
2. `try_dispatch` routes commands before the legacy `commands.rs` match.
3. `all_ribbon_modules()` = core tabs from `build.rs` + plugin tabs sorted by `ribbon_order`.
### `HostSession` — plugin-facing surface
Plugins use `HostSession`, not `OpenCADStudio`:
| Category | Methods | | Category | Methods |
|----------|---------| |----------|---------|
| Document | `document()`, `document_mut()`, `entities()`, `entities_mut()`, `add_entity()`, `bump_geometry()` | | 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 the file stays standard | | 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 | `plugin_state()`, `plugin_state_mut()`, `ensure_plugin_state()` keyed by `manifest.id` | | 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`, `set_active_command` | | Command line | `push_info`, `push_output`, `push_error` |
| Undo / dirty | `push_undo`, `set_dirty` | | Undo / dirty | `push_undo`, `set_dirty` |
| Tab | `tab_index()` |
**Status:** The whole contract now lives in the standalone, semver-versioned ### `export_plugin!` — the C-ABI export
[`crates/ocs_plugin_api`](../crates/ocs_plugin_api) crate:
- **Dependency-free core**`PluginManifest` / `ApiVersion` (manifest) and
`CadModule` + ribbon types (`ToolDef`, `RibbonGroup`, …). Engine crates and
external tooling depend on this cheaply.
- **`host` feature** — the `acadrust`-typed `HostApi` trait (the runtime surface
in the table above). `HostSession` in the binary implements it; a plugin's
`dispatch` receives `&mut dyn HostApi`, so an out-of-tree add-on compiles
against this crate alone. Per-tab plugin state is reached through the
object-safe `plugin_state*` helpers. `set_active_command` (interactive
acquisition) stays host-side for now — see Command routing.
The host re-exports both (`crate::plugin::manifest`, `crate::modules`,
`crate::plugin::host::HostApi`) so in-tree paths are unchanged.
### Command routing
```rust ```rust
// app/commands.rs — plugins run first ocs_plugin_api::export_plugin!(MyPlugin);
if crate::plugin::try_dispatch(self, tab_index, cmd) {
return Task::none();
}
// … legacy core commands …
``` ```
Plugins own: emits the two symbols the loader looks for:
- One-shot commands (`SS_ANALYZE`) - `ocs_plugin_api_version() -> u32` — checked **first**, so an API-incompatible
- Interactive acquisition (`SS_PIPE``PlacePipe`) build never runs its code.
- Subcommands (`SS_PARAMS RP 25`) - `ocs_plugin_register() -> *mut Box<dyn BuiltinPlugin>` — constructs the plugin
and hands ownership to the host.
Autocomplete: each plugin submits `inventory::submit!(CommandRegistration { names: &[…] })` in `mod.rs` or `register.rs`.
Interactive acquisition (C3D-style orange ObjectPick) stays in the **host** via generic `CadCommand` hooks — `resolve_object_pick`, `object_pick_hover_previews`, `entity_pick_acquire_previews` — so `app/update.rs` never imports domain modules.
### Per-document state
```rust
DocumentTab {
plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
}
```
Store under `manifest.id` (e.g. `opencad.storm_sewer`), not ad hoc globals.
### XDATA contract
Domain persistence lives on entities. Document schemas in `PLUGIN.md`:
| App id | Owner | Purpose |
|--------|-------|---------|
| `STORMSEWER_STRUCT` | `opencad.storm_sewer` | Inlet / junction / outfall |
| `STORMSEWER_PIPE` | `opencad.storm_sewer` | Pipe link between structures |
| `STORMSEWER_CATCHMENT` | `opencad.storm_sewer` | Catchment boundary + hydrology |
`HostSession` provides `read_record` / `write_record` / `remove_record` helpers (keyed by entity handle) over the `acadrust` XDATA API; `write_record` also registers the application in the APPID table so the data survives a DWG/DXF round-trip. Plugins may still use the raw `acadrust` XDATA APIs directly.
--- ---
## Core ribbon vs add-on ribbon ## Writing a plugin
| Kind | Location | Registration | A plugin is a standalone crate that builds a `cdylib`:
|------|----------|--------------|
| **Core tab** | `src/modules/home/`, `view/`, … | `build.rs` auto-discovers `mod.rs` (no `plugin.toml`) |
| **Add-on tab** | `src/modules/storm_sewer/`, … | `plugin.toml` + `BuiltinPlugin::ribbon()` |
This mirrors QGIS: the application ships core menus; plugins add tabs/tools without patching the host binary. ```toml
# 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" }
```
```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" }
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);
```
```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 = 1
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
```
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
`"<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`.
--- ---
## Phased rollout ## Building & distribution
### Phase 1 — Built-in add-ons (current) Build per platform and publish to **GitHub Releases**:
- [x] `src/plugin/` runtime + `try_dispatch` ```
- [x] Per-tab `plugin_state` cargo build --release # → target/release/lib<crate>.so | <crate>.dll | lib<crate>.dylib
- [x] Storm Sewer off `commands.rs` monolith ```
- [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`)
- [x] Extract `ocs_plugin_api` crate — dependency-free manifest + ribbon/`CadModule`, plus the `acadrust`-typed `HostApi` trait behind the optional `host` feature; `dispatch` takes `&mut dyn HostApi`
- [x] Plugin manager UI (list installed, versions) — `PLUGINS` / `PLUGINMANAGER` command, or the Start-page "Plugins" button
- [x] Enable/disable plugins from the manager — a disabled plugin drops its ribbon tab and command dispatch; persisted in `settings.txt` (`disabled_plugins=`)
- [x] `ModuleEvent::PluginFileDialog` — a plugin tool requests a native file picker; the host opens it and dispatches `"<command> <path>"` back to the plugin with original case preserved (bypasses the command-line upper-casing)
- [x] XDATA convenience on `HostSession``read_record` / `write_record` / `remove_record`; `write_record` registers the APPID so plugin data round-trips through DWG/DXF
### Phase 2 — Dynamic loading (desktop) 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/ <config>/OpenCADStudio/plugins/
opencad.example/ opencad.example/
plugin.toml plugin.toml
libocs_example_plugin.so # cdylib (.dll / .dylib per platform) libocs_example_plugin.so # any name with the platform extension
``` ```
- [x] Discover packages — scan the plugins folder, read `plugin.toml`, check for For each compatible package it `dlopen`s the library (`libloading`), calls
a native library, gate on `api_version`. Surfaced in the Plugin Manager with `ocs_plugin_api_version` and refuses on mismatch, then `ocs_plugin_register` to
a status pill (`src/plugin/external.rs`). obtain the boxed `BuiltinPlugin`. Loaded libraries stay **resident for the
- [x] Load via `libloading` — each cdylib exports two C symbols (via the session** (ribbon tabs and dispatch hold their vtables, so they are never
`ocs_plugin_api::export_plugin!` macro): `ocs_plugin_api_version` (checked reloaded mid-session). External plugins merge into the same ribbon and
first, so an incompatible build never runs) and `ocs_plugin_register` `try_dispatch` path the host uses and honour the enable/disable set
`*mut Box<dyn BuiltinPlugin>`. Loaded once at startup; the library stays (`disabled_plugins` in `settings.txt`).
resident for the session (ribbon tabs / dispatch hold its vtables). External
plugins merge into the same ribbon + `try_dispatch` path as built-ins and
honour the enable/disable set.
- [x] `api_version` compatibility gate at load time.
- [x] Enable/disable in settings (like QGIS plugin manager) — landed in phase 1.
**ABI approach:** the plugin hands back a boxed `BuiltinPlugin` (not a `repr(C)` `<config>` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or
vtable). This assumes the package was built against the same toolchain and `$XDG_CONFIG_HOME` / `~/.config` (Linux).
`ocs_plugin_api` version; the version symbol enforces the latter. Reference
implementation: [`crates/ocs_example_plugin`](../crates/ocs_example_plugin).
### Phase 3 — Interchange & QA
- LandXML / SWMM export as plugins or engine features
- Golden-file tests per plugin
- Public plugin index (optional)
### Phase 4 — Live analysis & WASM
- `on_entity_committed` hooks
- WASM-hosted engines on hydrocomplete.com
--- ---
## Authoring a new add-on (checklist) ## Marketplace
1. Copy `docs/plugin-template/` into `src/modules/<name>/`. The **Plugin Manager** (`PLUGINS` / `PLUGINMANAGER`, or the Start-page button)
2. Fill `plugin.toml` and `manifest.rs` (keep in sync). installs plugins from GitHub Releases:
3. Implement `CadModule` in `mod.rs` (ribbon).
4. Implement `dispatch.rs` (all commands for your prefixes).
5. Add `plugin.rs` + `register.rs`.
6. Add `pub mod <name>;` to `src/modules/mod.rs`.
7. Document XDATA in `PLUGIN.md`.
8. Optional: add `crates/<engine>/` and depend from the plugin package only.
9. `cargo build` — tab appears via plugin registry; `commands.rs` untouched.
**External repo:** Publish the engine crate to crates.io; depend on `ocs_plugin_api` (when extracted) + ship a `cdylib` for phase 2. In-tree path: add as a git submodule under `src/modules/<name>/` or `plugins/`. - **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.
--- ---
## Reference implementation: Storm Sewer ## Compatibility & ABI
| Piece | Path | 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
| Metadata | `storm_sewer/plugin.toml`, `manifest.rs` | Rust toolchain and `ocs_plugin_api` version** as the host. The
| Registration | `storm_sewer/register.rs` | `ocs_plugin_api_version` symbol gates the API version; it does **not** detect a
| Adapter | `storm_sewer/plugin.rs` | toolchain mismatch. In practice CI built with current stable Rust matches a host
| Commands | `storm_sewer/dispatch.rs` | built the same way.
| Ribbon | `storm_sewer/mod.rs` |
| Tab state | `storm_sewer/state.rs` | A future hardening step is a `#[repr(C)]` vtable (a true C ABI) so binaries built
| XDATA | `storm_sewer/data.rs`, `PLUGIN.md` | by any toolchain interoperate — required before trusting prebuilt binaries from
| Engine | `crates/stormsewer/` | arbitrary build environments.
--- ---
## Workspace layout ## Roadmap
``` Done:
OpenCADStudio/
docs/ - [x] Stable `ocs_plugin_api` crate — dependency-free core + `host` feature
plugin-architecture.md # this file (`HostApi` / `BuiltinPlugin` / `export_plugin!`).
plugin-template/ # scaffold for new add-ons - [x] Runtime discovery + `libloading` loading with an `api_version` gate.
src/ - [x] XDATA helpers, `ModuleEvent::PluginFileDialog`, per-tab plugin state.
plugin/ # Layer A runtime (generic) - [x] Marketplace — curated registry + manual repo link, install / upgrade /
modules/ reinstall / uninstall, enable/disable.
home/ # core ribbon (no plugin.toml)
storm_sewer/ # add-on (has plugin.toml) Next:
crates/
stormsewer/ # Layer C engine - [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries.
ocs_plugin_api/ # stable contract: manifest + ribbon (host API: phase 1b) - [ ] Trust: checksums / signatures before `dlopen`.
plugins/ # (phase 2) third-party cdylibs - [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks.
``` - [ ] External automation API (drive OCS headless from a process) — issue #29.
--- ---
## Appendix: Civil 3D / SSA contrast ## Reference
| SSA / Civil 3D | Open CAD Studio add-on | | Piece | Location |
|----------------|------------------------| |-------|----------|
| Proprietary project DB | DWG + XDATA | | Contract crate | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) |
| Vendor-only hydraulics | Pluggable `stormsewer` engine | | Plugin runtime (host) | `src/plugin/`, `src/app/plugin_host.rs` |
| Monolithic install | QGIS-style optional packages | | Marketplace + registry | `src/plugin/marketplace.rs`, [`plugins/registry.json`](../plugins/registry.json) |
| Closed API | Documented `HostSession` + `PLUGIN.md` | | Template scaffold | [`docs/plugin-template/`](plugin-template) |
| Live example plugin | [`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) |
This positions Open CAD Studio as an **open, inspectable** civil CAD platform rather than a single-vendor clone.

View file

@ -0,0 +1,44 @@
name: release
# Build the plugin cdylib for each desktop platform and attach the binaries
# (plus plugin.toml) to a GitHub Release on a v* tag. Open CAD Studio's Plugin
# Manager reads the assets, picks the one matching the user's OS/arch, and
# installs it. Rename the `asset:` files to your plugin id.
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
ext: so
asset: opencad.my_plugin-linux-x86_64.so
- os: windows-latest
ext: dll
asset: opencad.my_plugin-windows-x86_64.dll
- os: macos-latest
ext: dylib
asset: opencad.my_plugin-macos-aarch64.dylib
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --release
- name: Stage release asset
shell: bash
run: |
mkdir -p dist
cp target/release/*.${{ matrix.ext }} "dist/${{ matrix.asset }}"
cp plugin.toml dist/plugin.toml
- uses: softprops/action-gh-release@v2
with:
files: |
dist/${{ matrix.asset }}
dist/plugin.toml

View file

@ -0,0 +1,19 @@
[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"
description = "An Open CAD Studio add-on."
license = "GPL-3.0-only"
# A plugin is a C-ABI dynamic library the host loads at runtime.
[lib]
crate-type = ["cdylib"]
[dependencies]
# The stable contract, with the runtime `host` surface (HostApi / BuiltinPlugin).
ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] }
# Match the host's acadrust so the loaded library is binary-compatible
# (approach B assumes the same toolchain + dependency versions).
[patch.crates-io]
acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" }

View file

@ -1,31 +1,47 @@
# Add-on plugin template # Open CAD Studio plugin template
Copy this folder to `src/modules/<your_module>/` and rename placeholders. A complete scaffold for an **external** Open CAD Studio add-on. A plugin is its
own repository that builds a `cdylib`; the host loads it at runtime. Copy this
folder into a new repo and rename the placeholders.
## Files
| File | Purpose |
|------|---------|
| `Cargo.toml` | `cdylib` crate depending on `ocs_plugin_api` (`host` feature) |
| `src/lib.rs` | manifest + `CadModule` ribbon + `BuiltinPlugin` + `export_plugin!` |
| `plugin.toml` | metadata read by the host (mirrors the manifest) |
| `.github/workflows/release.yml` | cross-builds the cdylib and publishes a release |
| `PLUGIN.md` | your command reference + XDATA schemas |
## Quick start ## Quick start
1. Copy files into `src/modules/my_plugin/`. 1. Copy this folder into a new repository.
2. Replace `MY_PLUGIN`, `my_plugin`, `opencad.my_plugin`, `MP_` throughout. 2. Rename `my-plugin` / `My Plugin` / `opencad.my_plugin` / `my_plugin` / `MP_`
3. Add `pub mod my_plugin;` to `src/modules/mod.rs`. throughout (`Cargo.toml`, `src/lib.rs`, `plugin.toml`, the workflow `asset:`
4. `cargo build` — ribbon tab and commands register automatically. names), keeping `plugin.toml` and the `MANIFEST` in sync.
3. `cargo build` to check it compiles.
## Required files ## Test locally
| File | Purpose | ```sh
|------|---------| cargo build --release
| `plugin.toml` | Metadata (QGIS-style); excludes dir from `build.rs` ribbon scan | mkdir -p "<config>/OpenCADStudio/plugins/opencad.my_plugin"
| `manifest.rs` | Compile-time `PluginManifest` — keep in sync with `plugin.toml` | cp target/release/*my_plugin*.so "<config>/OpenCADStudio/plugins/opencad.my_plugin/"
| `register.rs` | `inventory::submit!(PluginRegistration { … })` only | cp plugin.toml "<config>/OpenCADStudio/plugins/opencad.my_plugin/"
| `plugin.rs` | Thin `BuiltinPlugin` impl | ```
| `dispatch.rs` | All command handlers |
| `mod.rs` | `CadModule` ribbon + `CommandRegistration` for autocomplete |
| `PLUGIN.md` | XDATA schemas and command reference |
## Optional Restart Open CAD Studio: the ribbon tab appears and `MP_` commands route to your
plugin. (`<config>` = `%APPDATA%` / `~/Library/Application Support` /
`$XDG_CONFIG_HOME`.)
| File | Purpose | ## Publish
|------|---------|
| `state.rs` | Per-document tab state via `host.ensure_plugin_state(PLUGIN_ID, …)` |
| `crates/my_engine/` | Headless domain logic (no iced/acadrust) |
See `docs/plugin-architecture.md` for the full spec. Push a `v*` tag — the workflow builds the cdylib on Linux/Windows/macOS and
uploads each binary plus `plugin.toml` to a GitHub Release. Users install it from
the **Plugin Manager** by linking your `owner/repo`, or — once your repo is added
to [`plugins/registry.json`](https://github.com/HakanSeven12/OpenCADStudio/blob/main/plugins/registry.json)
via PR — straight from *Available plugins*.
> The binary must be built with the same toolchain and `ocs_plugin_api` version
> as the host (approach B). See `docs/plugin-architecture.md`.

View file

@ -1,14 +0,0 @@
use crate::plugin::host::HostApi;
use super::manifest::PLUGIN_ID;
pub fn handle(host: &mut dyn HostApi, cmd: &str) -> bool {
let _ = (host, PLUGIN_ID);
match cmd {
"MP_HELLO" => {
host.push_info("Hello from my_plugin");
true
}
_ => false,
}
}

View file

@ -1,14 +0,0 @@
use crate::plugin::manifest::{ApiVersion, PluginManifest};
pub const PLUGIN_ID: &str = "opencad.my_plugin";
pub static MANIFEST: PluginManifest = PluginManifest {
id: PLUGIN_ID,
name: "My Plugin",
version: "0.1.0",
description: "Short description of what this add-on does",
api_version: ApiVersion::CURRENT,
ribbon_order: 60,
xdata_apps: &["MYPLUGIN_RECORD"],
command_prefixes: &["MP_"],
};

View file

@ -1,34 +0,0 @@
pub mod dispatch;
pub mod manifest;
pub mod plugin;
pub mod register;
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
inventory::submit!(crate::command::CommandRegistration {
names: &["MP_HELLO"]
});
pub struct MyPluginModule;
impl CadModule for MyPluginModule {
fn id(&self) -> &'static str {
"my_plugin"
}
fn title(&self) -> &'static str {
"My Plugin"
}
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
vec![RibbonGroup {
title: "Tools",
tools: vec![RibbonItem::LargeTool(ToolDef {
id: "MP_HELLO",
label: "Hello",
icon: IconKind::Glyph(""),
event: ModuleEvent::Command("MP_HELLO".to_string()),
})],
}]
}
}

View file

@ -1,21 +0,0 @@
use crate::plugin::host::{BuiltinPlugin, HostApi};
use crate::plugin::manifest::PluginManifest;
use super::dispatch;
use super::manifest;
pub struct MyPlugin;
impl BuiltinPlugin for MyPlugin {
fn manifest(&self) -> &'static PluginManifest {
&manifest::MANIFEST
}
fn ribbon(&self) -> Box<dyn crate::modules::CadModule> {
Box::new(super::MyPluginModule)
}
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
dispatch::handle(host, cmd)
}
}

View file

@ -1,7 +0,0 @@
use super::plugin::MyPlugin;
inventory::submit! {
crate::plugin::registry::PluginRegistration {
construct: || Box::new(MyPlugin),
}
}

View file

@ -0,0 +1,68 @@
//! Open CAD Studio add-on template.
//!
//! Rename the crate (`Cargo.toml`), the ids/strings below, and `plugin.toml` to
//! match. Build with `cargo build --release` and ship the resulting cdylib plus
//! `plugin.toml` as GitHub Release assets (see `.github/workflows/release.yml`).
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};
// Keep these fields in sync with `plugin.toml`.
static MANIFEST: PluginManifest = PluginManifest {
id: "opencad.my_plugin",
name: "My Plugin",
version: "0.1.0",
description: "What this plugin does.",
api_version: ApiVersion::CURRENT,
ribbon_order: 50,
xdata_apps: &[],
command_prefixes: &["MP_"],
};
/// The ribbon tab.
struct MyModule;
impl CadModule for MyModule {
fn id(&self) -> &'static str {
"my_plugin"
}
fn title(&self) -> &'static str {
"My Plugin"
}
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
vec![RibbonGroup {
title: "Tools",
tools: vec![RibbonItem::LargeTool(ToolDef {
id: "MP_HELLO",
label: "Hello",
icon: IconKind::Glyph(""),
event: ModuleEvent::Command("MP_HELLO".to_string()),
})],
}]
}
}
/// The plugin entry point.
struct MyPlugin;
impl BuiltinPlugin for MyPlugin {
fn manifest(&self) -> &'static PluginManifest {
&MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(MyModule)
}
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
match cmd {
"MP_HELLO" => {
host.push_info("Hello from My Plugin");
true
}
_ => false,
}
}
}
// Emit the C-ABI symbols the host loader looks for.
ocs_plugin_api::export_plugin!(MyPlugin);