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 committed 2026-06-09 13:41:21 -04:00
1 parent 1199895f0e
commit e46701456f
66 files changed
+4538 -141

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field access).
pub(crate) use crate::app::plugin_host::HostSession;
use crate::modules::CadModule;
use super::manifest::PluginManifest;
/// Add-on package entry point (phase 1: in-tree, in-process).
///
/// One `PluginRegistration` per package — ribbon tab, manifest, and command
/// dispatch are owned here. See `docs/plugin-architecture.md`.
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;
}
+32
View File
@@ -0,0 +1,32 @@
// Plugin identity and capability declaration.
/// Host plugin API version. Bump when HostApi breaks compatibility.
pub const API_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ApiVersion {
pub major: u32,
}
impl ApiVersion {
pub const CURRENT: Self = Self { major: API_VERSION };
pub fn is_compatible_with(host: ApiVersion) -> bool {
Self::CURRENT.major == host.major
}
}
/// Static metadata every plugin supplies at registration time.
/// Keep fields in sync with `plugin.toml` beside the package.
#[derive(Clone, Copy, Debug)]
pub struct PluginManifest {
pub id: &'static str,
pub name: &'static str,
pub version: &'static str,
pub description: &'static str,
pub api_version: ApiVersion,
/// Sort key for add-on ribbon tabs (lower = further left among plugins).
pub ribbon_order: i32,
pub xdata_apps: &'static [&'static str],
pub command_prefixes: &'static [&'static str],
}
+13
View File
@@ -0,0 +1,13 @@
// Open CAD Studio plugin runtime (phase 1: built-in, in-process).
//
// Generic host only — no domain logic. See `docs/plugin-architecture.md`.
// Domain plugins (e.g. storm_sewer) live under `src/modules/<name>/` and
// register here via `inventory::submit!(PluginRegistration { … })`.
pub mod host;
pub mod manifest;
pub mod registry;
pub use registry::all_ribbon_modules;
pub(crate) use host::BuiltinPlugin;
pub(crate) use registry::try_dispatch;
+42
View File
@@ -0,0 +1,42 @@
// Compile-time plugin registry via `inventory`.
use super::host::{BuiltinPlugin, HostSession};
use crate::app::OpenCADStudio;
use crate::modules::{registry as core_registry, CadModule};
pub struct PluginRegistration {
pub construct: fn() -> Box<dyn BuiltinPlugin>,
}
inventory::collect!(PluginRegistration);
/// Construct every registered built-in plugin (once per process).
pub fn all_plugins() -> Vec<Box<dyn BuiltinPlugin>> {
inventory::iter::<PluginRegistration>
.into_iter()
.map(|r| (r.construct)())
.collect()
}
/// Core ribbon tabs plus add-on tabs (sorted by `manifest.ribbon_order`).
pub fn all_ribbon_modules() -> Vec<Box<dyn CadModule>> {
let mut core = core_registry::all_modules();
let mut addons: Vec<(i32, Box<dyn CadModule>)> = all_plugins()
.into_iter()
.map(|p| (p.manifest().ribbon_order, p.ribbon()))
.collect();
addons.sort_by_key(|(order, _)| *order);
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
core
}
/// Try each plugin until one handles `cmd`. Returns true if handled.
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
let mut host = HostSession::new(app, tab);
for plugin in all_plugins() {
if plugin.dispatch(&mut host, cmd) {
return true;
}
}
false
}