Add plugin host architecture and Storm Sewer add-on package

Generic plugin runtime (src/plugin/, HostSession, per-tab plugin_state, command router). QGIS-style add-on layout with plugin.toml and single PluginRegistration. Storm Sewer refactored as reference consumer with dispatch.rs, catchments, LandXML, headless tests (43 passing). Core update.rs decoupled from storm_sewer via CadCommand object-pick hooks. Docs: plugin-architecture.md, plugin-template, PR and issue-78 reply drafts.
This commit is contained in:
Michael Flynn 2026-06-09 13:41:21 -04:00
commit e46701456f
66 changed files with 4538 additions and 141 deletions

60
docs/PR-plugin-host.md Normal file
View file

@ -0,0 +1,60 @@
# PR: Add plugin host (Phase 1)
**Target:** `HakanSeven12/OpenCADStudio`
**Branch:** `feature/plugin-host` on `mf4633/OpenCADStudio`
**Related:** Issue #78 (Storm Sewer interest check / extension architecture)
## Summary
Introduces a QGIS-style add-on architecture so discipline-specific tools (storm sewer, sanitary, geotech, …) can ship **outside** the core application. Core ribbon tabs (Home, Model, View, …) are unchanged. Add-on packages register ribbon + commands through a single `PluginRegistration` hook.
**This PR contains only the generic host** — no Storm Sewer tab, no `stormsewer` engine crate, no civil/hydraulics code in `src/modules/`.
## What's included
| Area | Change |
|------|--------|
| `src/plugin/` | Manifest, registry, `BuiltinPlugin` trait, `try_dispatch` |
| `src/app/plugin_host.rs` | `HostSession` adapter (document, undo, tab state, command line) |
| `src/app/document.rs` | Per-tab `plugin_state` map keyed by plugin id |
| `src/app/commands.rs` | Plugin dispatch before legacy command match |
| `src/command/mod.rs` | Generic `ObjectPickHit` + acquisition hooks on `CadCommand` |
| `build.rs` | Skips dirs with `plugin.toml` (add-ons register via plugin host) |
| `src/ui/ribbon/mod.rs` | `all_ribbon_modules()` = core tabs + plugin tabs |
| `docs/plugin-architecture.md` | Accepted architecture spec |
| `docs/plugin-template/` | Scaffold for new add-on authors |
## Design principles
1. **`src/plugin/` must not import domain modules** — keeps core mergeable.
2. **One package, one registration** — `plugin.toml` + `register.rs` + `BuiltinPlugin`.
3. **DWG round-trip** — plugins persist domain data on entity XDATA (documented per add-on in `PLUGIN.md`).
4. **Phase 2 ready** — same `plugin.toml` layout for future dynamic `.dll` loading.
## How to add an add-on (after merge)
Copy `docs/plugin-template/` → `src/modules/<name>/`, implement `BuiltinPlugin`, add `pub mod <name>;` to `modules/mod.rs`. No edits to `commands.rs`.
External repos: depend on extracted `ocs_plugin_api` (Phase 1b, follow-up PR).
## Follow-up (not in this PR)
- `ocs_plugin_api` workspace crate (semver-stable host surface)
- Dynamic plugin loading (`%APPDATA%/OpenCADStudio/plugins/`)
- Python/scripting bindings over host API (#29)
- Storm Sewer add-on — separate repo/PR: `mf4633` branch `feature/storm-sewer-module`
## Testing
```powershell
cargo build
cargo test --lib
```
No domain plugin is registered in this branch; existing core tests should pass unchanged.
## Review questions
1. OK to add `inventory`-based plugin registration (same pattern as `CommandRegistration`)?
2. Should built-in add-ons ever live in the main repo, or only the host + template?
3. Priority for Phase 1b (`ocs_plugin_api` crate) vs Phase 2 (dynamic loading)?

44
docs/issue-78-reply.md Normal file
View file

@ -0,0 +1,44 @@
# Draft reply for Issue #78
Paste as a GitHub comment on https://github.com/HakanSeven12/OpenCADStudio/issues/78
---
@HakanSeven12 @schoeller — following up with a concrete architecture proposal and an implementation ready for review.
### Proposal
I've drafted a **QGIS-style add-on model** on my fork:
- **Spec:** [`docs/plugin-architecture.md`](https://github.com/mf4633/OpenCADStudio/blob/feature/plugin-host/docs/plugin-architecture.md)
- **Scaffold:** [`docs/plugin-template/`](https://github.com/mf4633/OpenCADStudio/tree/feature/plugin-host/docs/plugin-template)
- **Framework PR branch:** [`feature/plugin-host`](https://github.com/mf4633/OpenCADStudio/tree/feature/plugin-host) — **host only, no Storm Sewer in core**
**Three layers:** host core → add-on package (`plugin.toml`, ribbon, commands) → optional headless engine crate. Domain data lives on DWG entities (XDATA), not a proprietary project DB.
**Phase 1 (in this PR):** in-process plugins via `inventory::submit!(PluginRegistration)`, `HostSession` API, per-document plugin state, command routing without editing `commands.rs`.
**Phase 2:** user install folder + dynamic `.dll`/`.so` with the same `plugin.toml`.
### PR ready for review
I can open a PR against upstream with **only the generic plugin host** — no civil/hydraulics tab in core. Storm Sewer stays on a separate branch/repo as the reference consumer: [`feature/storm-sewer-module`](https://github.com/mf4633/OpenCADStudio/tree/feature/storm-sewer-module).
### Re: script languages (@schoeller, #29)
Agree this shouldn't be either/or. Suggested sequencing:
1. Native Rust add-ons + stable `HostSession` / `ocs_plugin_api`
2. Python (or similar) as **bindings over that same API** — one extension surface, two authoring paths
Phase 1 defers embedded scripting until the native API is stable.
### Questions for maintainers
1. `ocs_plugin_api` as a workspace crate with semver — OK?
2. Should the main repo ship **zero** discipline modules, or optional built-ins for dev?
3. Priority: extract API crate (1b) vs dynamic loading (2)?
Happy to open the framework PR whenever timing works. Storm Sewer can follow as a separate installable add-on once the host lands.
— Michael

339
docs/plugin-architecture.md Normal file
View file

@ -0,0 +1,339 @@
# 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](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.
> **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 `HostSession` / future `ocs_plugin_api` with semver, 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 | `HostSession` → future `ocs_plugin_api` crate |
| 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`.
```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`
```rust
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`
```rust
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)
```rust
// 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` |
Phase 1b: extract to `crates/ocs_plugin_api` with semver when the surface stabilizes.
### Command routing
```rust
// 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
```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 |
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)
- [x] `src/plugin/` runtime + `try_dispatch`
- [x] Per-tab `plugin_state`
- [x] Storm Sewer off `commands.rs` monolith
- [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`)
- [ ] Extract `ocs_plugin_api` crate
- [ ] 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/ # (phase 1b) stable host API
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.

View file

@ -0,0 +1,11 @@
# My Plugin (`opencad.my_plugin`)
## Commands
| Command | Description |
|---------|-------------|
| `MP_HELLO` | Example command |
## XDATA
Document entity record layouts here when your plugin tags geometry.

View file

@ -0,0 +1,31 @@
# Add-on plugin template
Copy this folder to `src/modules/<your_module>/` and rename placeholders.
## Quick start
1. Copy files into `src/modules/my_plugin/`.
2. Replace `MY_PLUGIN`, `my_plugin`, `opencad.my_plugin`, `MP_` throughout.
3. Add `pub mod my_plugin;` to `src/modules/mod.rs`.
4. `cargo build` — ribbon tab and commands register automatically.
## Required files
| File | Purpose |
|------|---------|
| `plugin.toml` | Metadata (QGIS-style); excludes dir from `build.rs` ribbon scan |
| `manifest.rs` | Compile-time `PluginManifest` — keep in sync with `plugin.toml` |
| `register.rs` | `inventory::submit!(PluginRegistration { … })` only |
| `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
| File | Purpose |
|------|---------|
| `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.

View file

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

View file

@ -0,0 +1,14 @@
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

@ -0,0 +1,34 @@
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

@ -0,0 +1,21 @@
use crate::plugin::host::{BuiltinPlugin, HostSession};
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 HostSession<'_>, cmd: &str) -> bool {
dispatch::handle(host, cmd)
}
}

View file

@ -0,0 +1,13 @@
[plugin]
id = "opencad.my_plugin"
name = "My Plugin"
version = "0.1.0"
description = "Short description of what this add-on does"
author = "Your Name"
license = "GPL-3.0-only"
[opencad]
api_version = 1
ribbon_order = 60
command_prefixes = ["MP_"]
xdata_apps = ["MYPLUGIN_RECORD"]

View file

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