Read-only inventory of the add-ons compiled into the build — name, version, id, API level, description, command prefixes — built from the extracted `ocs_plugin_api` manifest. Opened with the `PLUGINS` / `PLUGINMANAGER` command or the Start-page "Plugins" button. Adds `installed_manifests()` to the plugin registry and wires the window the same way as the existing About / Shortcuts windows. Part of #100 (phase-1 plugin manager UI stub). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
Open CAD Studio — Plugin Architecture
Status: Accepted (phase 1)
Author: Open CAD Studio contributors
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 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.
Scope: Generic host runtime only (
src/plugin/,src/app/plugin_host.rs). Domain plugins (Storm Sewer, future sanitary/geotech) live undersrc/modules/<name>/and optionalcrates/<engine>/. They consume this API; they are not part of the framework source.
Design goals
| Goal | Rationale |
|---|---|
| One package, one registration | Ribbon tab, commands, and manifest ship together — no duplicate hooks in build.rs and commands.rs. |
| Stable host surface | Plugin authors target the semver-versioned ocs_plugin_api crate (manifest + ribbon today) and HostSession, not OpenCADStudio internals. |
| Open-source add-on ergonomics | Separate git repo + workspace crate is supported; in-tree built-ins use the same layout. |
| DWG round-trip | Domain data on entities (XDATA), not opaque plugin databases. |
| Engine reuse | Headless crates (stormsewer, …) run in WASM/CLI without the CAD host. |
Non-goals (phase 1)
- Sandboxed scripting (Python/Lua).
- Replacing the
acadrustentity model. - Full Autodesk CUI XML import.
Three layers (do not mix)
┌────────────────────────────────────────────────────────────────────┐
│ Layer A — Host core │
│ iced UI · Scene · Document · Undo · Command line │
│ Built-in ribbon tabs: Home, Model, View, … (NOT plugins) │
└───────────────────────────────┬────────────────────────────────────┘
│ HostSession (stable adapter)
┌───────────────────────────────▼────────────────────────────────────┐
│ Layer B — Add-on plugin package │
│ plugin.toml · manifest.rs · register.rs · plugin.rs · dispatch.rs │
│ Optional ribbon (CadModule) · per-tab state · XDATA schemas │
└───────────────────────────────┬────────────────────────────────────┘
│ pure Rust API
┌───────────────────────────────▼────────────────────────────────────┐
│ Layer C — Domain engine crate (optional) │
│ crates/stormsewer — hydraulics, IO, no iced/acadrust dependency │
└────────────────────────────────────────────────────────────────────┘
| Layer | Examples | May depend on |
|---|---|---|
| A — Host core | src/app/, src/ui/, src/modules/home/ |
Everything in the app |
| B — Plugin package | src/modules/storm_sewer/ |
Host + optional engine crate |
| C — Engine | crates/stormsewer/ |
std only (target: WASM/CLI too) |
Hard rules
src/plugin/must not import any domain module (storm_sewer, …).- Engine crates must not import
iced,acadrust, orOpenCADStudio. - Add-on plugins must not edit
src/app/commands.rsfor new commands.
Comparison to QGIS
| QGIS | Open CAD Studio |
|---|---|
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.
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.
[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
pub struct PluginManifest {
pub id: &'static str, // reverse-DNS: "opencad.storm_sewer"
pub name: &'static str,
pub version: &'static str,
pub description: &'static str,
pub api_version: ApiVersion, // host ABI major; must match host
pub ribbon_order: i32, // sort key among add-on tabs
pub xdata_apps: &'static [&'static str],
pub command_prefixes: &'static [&'static str],
}
BuiltinPlugin
pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest;
fn ribbon(&self) -> Box<dyn CadModule>;
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool;
}
Registration (single entry point)
// register.rs — keep this file free of domain logic
inventory::submit! {
crate::plugin::registry::PluginRegistration {
construct: || Box::new(MyPlugin),
}
}
Host startup:
inventory::iter::<PluginRegistration>constructs all plugins.try_dispatchroutes commands before the legacycommands.rsmatch.all_ribbon_modules()= core tabs frombuild.rs+ plugin tabs sorted byribbon_order.
HostSession — plugin-facing surface
Plugins use HostSession, not OpenCADStudio:
| Category | Methods |
|---|---|
| Document | document(), document_mut(), entities(), entities_mut(), add_entity(), bump_geometry() |
| Tab state | plugin_state(), plugin_state_mut(), ensure_plugin_state() keyed by manifest.id |
| Command line | push_info, push_output, push_error, set_active_command |
| Undo / dirty | push_undo, set_dirty |
Status: The dependency-free half of the contract — PluginManifest /
ApiVersion (manifest) and CadModule + the ribbon types (ToolDef,
RibbonGroup, …) — now lives in the standalone, semver-versioned
crates/ocs_plugin_api crate. The host re-exports it
(crate::plugin::manifest, crate::modules) so in-tree paths are unchanged.
The acadrust-typed runtime surface in the table above (document_mut,
add_entity, set_active_command, …) stays in the host binary for now; lifting
it behind a HostApi trait in the same crate is the remaining phase-1b step.
Command routing
// app/commands.rs — plugins run first
if crate::plugin::try_dispatch(self, tab_index, cmd) {
return Task::none();
}
// … legacy core commands …
Plugins own:
- One-shot commands (
SS_ANALYZE) - Interactive acquisition (
SS_PIPE→PlacePipe) - Subcommands (
SS_PARAMS RP 25)
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
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 |
Host may add xdata::read_record / write_record helpers later; plugins use acadrust XDATA APIs today.
Core ribbon vs add-on ribbon
| Kind | Location | Registration |
|---|---|---|
| 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.
Phased rollout
Phase 1 — Built-in add-ons (current)
src/plugin/runtime +try_dispatch- Per-tab
plugin_state - Storm Sewer off
commands.rsmonolith - Single registration (
plugin.toml+BuiltinPlugin::ribbon) - [~] Extract
ocs_plugin_apicrate — manifest + ribbon/CadModuledone;acadrust-typed host surface pending - Plugin manager UI stub (list installed, versions) —
PLUGINS/PLUGINMANAGERcommand, or the Start-page "Plugins" button
Phase 2 — Dynamic loading (desktop)
%APPDATA%/OpenCADStudio/plugins/
opencad.storm_sewer/
plugin.toml
opencad_storm_sewer.dll # cdylib
libloading+#[no_mangle] extern "C" fn ocs_plugin_register() -> *const PluginVTableapi_versioncompatibility gate at load time- Enable/disable in settings (like QGIS plugin manager)
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_committedhooks- WASM-hosted engines on hydrocomplete.com
Authoring a new add-on (checklist)
- Copy
docs/plugin-template/intosrc/modules/<name>/. - Fill
plugin.tomlandmanifest.rs(keep in sync). - Implement
CadModuleinmod.rs(ribbon). - Implement
dispatch.rs(all commands for your prefixes). - Add
plugin.rs+register.rs. - Add
pub mod <name>;tosrc/modules/mod.rs. - Document XDATA in
PLUGIN.md. - Optional: add
crates/<engine>/and depend from the plugin package only. cargo build— tab appears via plugin registry;commands.rsuntouched.
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/.
Reference implementation: Storm Sewer
| Piece | Path |
|---|---|
| Metadata | storm_sewer/plugin.toml, manifest.rs |
| Registration | storm_sewer/register.rs |
| Adapter | storm_sewer/plugin.rs |
| Commands | storm_sewer/dispatch.rs |
| Ribbon | storm_sewer/mod.rs |
| Tab state | storm_sewer/state.rs |
| XDATA | storm_sewer/data.rs, PLUGIN.md |
| Engine | crates/stormsewer/ |
Workspace layout
OpenCADStudio/
docs/
plugin-architecture.md # this file
plugin-template/ # scaffold for new add-ons
src/
plugin/ # Layer A runtime (generic)
modules/
home/ # core ribbon (no plugin.toml)
storm_sewer/ # add-on (has plugin.toml)
crates/
stormsewer/ # Layer C engine
ocs_plugin_api/ # stable contract: manifest + ribbon (host API: phase 1b)
plugins/ # (phase 2) third-party cdylibs
Appendix: Civil 3D / SSA contrast
| SSA / Civil 3D | Open CAD Studio add-on |
|---|---|
| Proprietary project DB | DWG + XDATA |
| Vendor-only hydraulics | Pluggable stormsewer engine |
| Monolithic install | QGIS-style optional packages |
| Closed API | Documented HostSession + PLUGIN.md |
This positions Open CAD Studio as an open, inspectable civil CAD platform rather than a single-vendor clone.