cad-editor/docs/plugin-architecture.md
Hakan Seven 72ec1146df refactor(plugin): extract ocs_plugin_api crate for the stable add-on contract
Move the dependency-free, semver-versioned half of the plugin contract into a
standalone workspace crate `crates/ocs_plugin_api`:

- manifest types: PluginManifest, ApiVersion, API_VERSION
- ribbon vocabulary: CadModule trait + ToolDef/RibbonGroup/RibbonItem/IconKind/
  ModuleEvent/StyleKey

The host re-exports them from `crate::plugin::manifest` and `crate::modules`, so
every existing call site is unchanged. The acadrust-typed runtime surface
(HostSession) stays in the host binary; lifting it behind a HostApi trait in the
same crate is the remaining phase-1b step.

Part of #100. Docs updated in docs/plugin-architecture.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:36:04 +03:00

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 under src/modules/<name>/ and optional crates/<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 acadrust entity 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

  1. src/plugin/ must not import any domain module (storm_sewer, …).
  2. Engine crates must not import iced, acadrust, or OpenCADStudio.
  3. Add-on plugins must not edit src/app/commands.rs for 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:

  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
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_PIPEPlacePipe)
  • 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.rs monolith
  • Single registration (plugin.toml + BuiltinPlugin::ribbon)
  • [~] Extract ocs_plugin_api crate — manifest + ribbon/CadModule done; acadrust-typed host surface pending
  • Plugin manager UI stub (list installed, versions)

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 PluginVTable
  • api_version compatibility 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_committed hooks
  • WASM-hosted engines on hydrocomplete.com

Authoring a new add-on (checklist)

  1. Copy docs/plugin-template/ into src/modules/<name>/.
  2. Fill plugin.toml and manifest.rs (keep in sync).
  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/.


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.