From 72ec1146dfc55aaace0113db95b910e107b251ee Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 15 Jun 2026 01:36:04 +0300 Subject: [PATCH] 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 --- Cargo.lock | 5 + Cargo.toml | 6 + crates/ocs_plugin_api/Cargo.toml | 8 ++ crates/ocs_plugin_api/src/lib.rs | 25 ++++ crates/ocs_plugin_api/src/manifest.rs | 51 +++++++++ crates/ocs_plugin_api/src/ribbon.rs | 159 ++++++++++++++++++++++++++ docs/plugin-architecture.md | 17 ++- src/modules/mod.rs | 126 ++------------------ src/plugin/manifest.rs | 37 +----- 9 files changed, 280 insertions(+), 154 deletions(-) create mode 100644 crates/ocs_plugin_api/Cargo.toml create mode 100644 crates/ocs_plugin_api/src/lib.rs create mode 100644 crates/ocs_plugin_api/src/manifest.rs create mode 100644 crates/ocs_plugin_api/src/ribbon.rs diff --git a/Cargo.lock b/Cargo.lock index 3ba87001..ab398c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,7 @@ dependencies = [ "image", "inventory", "lzma-sys", + "ocs_plugin_api", "open", "printpdf", "rayon", @@ -3646,6 +3647,10 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "ocs_plugin_api" +version = "0.1.0" + [[package]] name = "once_cell" version = "1.21.4" diff --git a/Cargo.toml b/Cargo.toml index 9653be38..70938209 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,13 @@ version = "0.5.6" edition = "2021" build = "build.rs" +[workspace] +members = ["crates/ocs_plugin_api"] + [dependencies] +# Stable, dependency-free add-on contract (manifest + ribbon/CadModule types). +# Plugin authors target this crate's semver, not OpenCADStudio internals. +ocs_plugin_api = { path = "crates/ocs_plugin_api" } # vtkio (pulled in transitively via truck-meshalgo) depends on xz2 → lzma-sys, # which by default links the system liblzma dynamically. On macOS that bakes a # Homebrew dylib path (/opt/homebrew/.../liblzma.5.dylib) into the binary, so diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml new file mode 100644 index 00000000..4e2ea76d --- /dev/null +++ b/crates/ocs_plugin_api/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ocs_plugin_api" +version = "0.1.0" +edition = "2021" +description = "Stable, dependency-free contract for Open CAD Studio add-on packages: plugin manifest, version handshake, and ribbon/CadModule types." +license = "GPL-3.0-only" + +[dependencies] diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs new file mode 100644 index 00000000..7e217379 --- /dev/null +++ b/crates/ocs_plugin_api/src/lib.rs @@ -0,0 +1,25 @@ +//! # Open CAD Studio plugin API +//! +//! The stable, semver-versioned contract an add-on package targets instead of +//! the `OpenCADStudio` binary internals. It is intentionally **dependency +//! free** (no `iced`, no `acadrust`) so engine crates and external tooling can +//! depend on it cheaply. +//! +//! Two pieces live here: +//! +//! - [`manifest`] — plugin identity ([`PluginManifest`]) and the host ABI +//! version handshake ([`ApiVersion`]). +//! - [`ribbon`] — the [`CadModule`] trait and the plain-data ribbon types +//! ([`RibbonGroup`], [`ToolDef`], …) a plugin uses to describe its tab. +//! +//! The runtime host surface a plugin uses at *dispatch* time (document access, +//! command line, undo) is `acadrust`-typed and therefore lives in the host +//! binary for now; see `docs/plugin-architecture.md` (phase 1b). + +pub mod manifest; +pub mod ribbon; + +pub use manifest::{ApiVersion, PluginManifest, API_VERSION}; +pub use ribbon::{ + CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef, +}; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs new file mode 100644 index 00000000..e07181bd --- /dev/null +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -0,0 +1,51 @@ +//! Plugin identity and capability declaration. + +/// Host plugin API version. Bump when the host runtime surface 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 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_matches_const() { + assert_eq!(ApiVersion::CURRENT.major, API_VERSION); + } + + #[test] + fn same_major_is_compatible() { + assert!(ApiVersion::is_compatible_with(ApiVersion::CURRENT)); + assert!(!ApiVersion::is_compatible_with(ApiVersion { + major: API_VERSION + 1, + })); + } +} + +/// 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], +} diff --git a/crates/ocs_plugin_api/src/ribbon.rs b/crates/ocs_plugin_api/src/ribbon.rs new file mode 100644 index 00000000..bb782cc2 --- /dev/null +++ b/crates/ocs_plugin_api/src/ribbon.rs @@ -0,0 +1,159 @@ +//! Ribbon description types — the plain-data vocabulary a [`CadModule`] uses to +//! declare its tab. No UI-framework dependency: the host renders these. + +// ── Events ──────────────────────────────────────────────────────────────── + +/// Events a module tool can emit to the host application. +#[derive(Debug, Clone)] +pub enum ModuleEvent { + /// Fire a named CAD command (e.g. "LINE", "CIRCLE"). + Command(String), + /// Open the OS file dialog. + OpenFileDialog, + /// Remove all loaded models from the scene. + #[allow(dead_code)] + ClearModels, + /// Toggle wireframe rendering. + SetWireframe(bool), + /// Toggle the layer manager panel. + ToggleLayers, +} + +// ── Data types ──────────────────────────────────────────────────────────── + +/// Icon source for a ribbon tool button. +#[derive(Clone, Copy)] +pub enum IconKind { + /// Unicode glyph rendered as text (fast, no file needed). + Glyph(&'static str), + /// Raw SVG bytes embedded at compile time via `include_bytes!`. + Svg(&'static [u8]), +} + +/// A single tool button shown in the ribbon. +#[derive(Clone)] +pub struct ToolDef { + /// Unique command id, e.g. "LINE". + pub id: &'static str, + /// Short label shown under the icon. + pub label: &'static str, + /// Icon — either a unicode glyph or embedded SVG bytes. + pub icon: IconKind, + /// Event emitted when the tool is clicked. + pub event: ModuleEvent, +} + +/// One item in a ribbon group — plain button or dropdown, in small (1-row) or large (3-row) size. +#[derive(Clone)] +pub enum RibbonItem { + /// 1-row button — icon only, no label. + Tool(ToolDef), + /// 3-row button — icon + label below; full ribbon height. + LargeTool(ToolDef), + /// 1-row dropdown — icon + ▾ on right, no label. + Dropdown { + id: &'static str, + icon: IconKind, + items: Vec<(&'static str, &'static str, IconKind)>, + default: &'static str, + }, + /// 3-row dropdown — icon + label + ▾ below label; full ribbon height. + LargeDropdown { + id: &'static str, + label: &'static str, + icon: IconKind, + items: Vec<(&'static str, &'static str, IconKind)>, + default: &'static str, + }, + /// Layer combo + two rows of small tools below. + /// row2: operates on the layer of a selected object (off/freeze/lock/make-current) + /// row3: all-layers operations + match (on/thaw/unlock/match) + LayerComboGroup { + row2: Vec, + row3: Vec, + }, + /// Match Properties (large button) + Color / Linetype / Lineweight combos on the right. + PropertiesGroup { match_prop: ToolDef }, + /// A style selector combobox (text / dim / mleader / table style) with + /// optional small tool rows below it. + StyleComboGroup { + /// Which style domain this combo controls. + style_key: StyleKey, + /// Unique dropdown id (must be unique across the ribbon). + combo_id: &'static str, + /// Optional command to run when the user opens the style manager. + manager_cmd: Option<&'static str>, + /// Small tool rows rendered below the combo (0–2 rows). + rows: Vec>, + }, +} + +/// Identifies which style list a `StyleComboGroup` refers to. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum StyleKey { + TextStyle, + DimStyle, + MLeaderStyle, + TableStyle, +} + +impl From for RibbonItem { + fn from(t: ToolDef) -> Self { + RibbonItem::Tool(t) + } +} + +/// A named group of tool buttons shown together in the ribbon. +pub struct RibbonGroup { + pub title: &'static str, + pub tools: Vec, +} + +// ── Trait ───────────────────────────────────────────────────────────────── + +/// A CAD module owns a set of ribbon groups shown when its tab is active. +/// Each module is a stateless unit struct — all UI state lives in Ribbon. +pub trait CadModule: Send + Sync { + #[allow(dead_code)] + fn id(&self) -> &'static str; + fn title(&self) -> &'static str; + fn ribbon_groups(&self) -> Vec; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_def_converts_into_small_tool() { + let tool = ToolDef { + id: "LINE", + label: "Line", + icon: IconKind::Glyph("/"), + event: ModuleEvent::Command("LINE".to_string()), + }; + assert!(matches!(RibbonItem::from(tool), RibbonItem::Tool(_))); + } + + #[test] + fn cad_module_is_object_safe() { + struct Demo; + impl CadModule for Demo { + fn id(&self) -> &'static str { + "demo" + } + fn title(&self) -> &'static str { + "Demo" + } + fn ribbon_groups(&self) -> Vec { + vec![RibbonGroup { + title: "Group", + tools: vec![], + }] + } + } + let m: Box = Box::new(Demo); + assert_eq!(m.title(), "Demo"); + assert_eq!(m.ribbon_groups().len(), 1); + } +} diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 192f4110..657d3aab 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -15,7 +15,7 @@ This document is the **authoritative spec** for how add-on packages integrate wi | 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. | +| **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. | @@ -69,7 +69,7 @@ This document is the **authoritative spec** for how add-on packages integrate wi |------|-----------------| | `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 | +| `iface` stable API | `ocs_plugin_api` crate (manifest + ribbon) + `HostSession` | | User folder `…/python/plugins//` | Phase 2: `%APPDATA%/OpenCADStudio/plugins//` | | Plugin repository (plugins.qgis.org) | Future: curated index; today = git + in-tree | | `qgisMinimumVersion` | `api_version` in manifest (host ABI major) | @@ -182,7 +182,14 @@ Plugins use `HostSession`, not `OpenCADStudio`: | 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. +**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`](../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 @@ -247,7 +254,7 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with - [x] Per-tab `plugin_state` - [x] Storm Sewer off `commands.rs` monolith - [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`) -- [ ] Extract `ocs_plugin_api` crate +- [~] 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) @@ -321,7 +328,7 @@ OpenCADStudio/ storm_sewer/ # add-on (has plugin.toml) crates/ stormsewer/ # Layer C engine - ocs_plugin_api/ # (phase 1b) stable host API + ocs_plugin_api/ # stable contract: manifest + ribbon (host API: phase 1b) plugins/ # (phase 2) third-party cdylibs ``` diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 3dc374d8..3c95dc30 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -13,124 +13,14 @@ // - mod.rs : module definition (ribbon groups + tool layout) // - .rs : one file per tool (ribbon def + future command logic) -// ── Events ──────────────────────────────────────────────────────────────── - -/// Events a module tool can emit to the host application. -#[derive(Debug, Clone)] -pub enum ModuleEvent { - /// Fire a named CAD command (e.g. "LINE", "CIRCLE"). - Command(String), - /// Open the OS file dialog. - OpenFileDialog, - /// Remove all loaded models from the scene. - #[allow(dead_code)] - ClearModels, - /// Toggle wireframe rendering. - SetWireframe(bool), - /// Toggle the layer manager panel. - ToggleLayers, -} - -// ── Data types ──────────────────────────────────────────────────────────── - -/// Icon source for a ribbon tool button. -#[derive(Clone, Copy)] -pub enum IconKind { - /// Unicode glyph rendered as text (fast, no file needed). - Glyph(&'static str), - /// Raw SVG bytes embedded at compile time via `include_bytes!`. - Svg(&'static [u8]), -} - -/// A single tool button shown in the ribbon. -#[derive(Clone)] -pub struct ToolDef { - /// Unique command id, e.g. "LINE". - pub id: &'static str, - /// Short label shown under the icon. - pub label: &'static str, - /// Icon — either a unicode glyph or embedded SVG bytes. - pub icon: IconKind, - /// Event emitted when the tool is clicked. - pub event: ModuleEvent, -} - -/// One item in a ribbon group — plain button or dropdown, in small (1-row) or large (3-row) size. -#[derive(Clone)] -pub enum RibbonItem { - /// 1-row button — icon only, no label. - Tool(ToolDef), - /// 3-row button — icon + label below; full ribbon height. - LargeTool(ToolDef), - /// 1-row dropdown — icon + ▾ on right, no label. - Dropdown { - id: &'static str, - icon: IconKind, - items: Vec<(&'static str, &'static str, IconKind)>, - default: &'static str, - }, - /// 3-row dropdown — icon + label + ▾ below label; full ribbon height. - LargeDropdown { - id: &'static str, - label: &'static str, - icon: IconKind, - items: Vec<(&'static str, &'static str, IconKind)>, - default: &'static str, - }, - /// Layer combo + two rows of small tools below. - /// row2: operates on the layer of a selected object (off/freeze/lock/make-current) - /// row3: all-layers operations + match (on/thaw/unlock/match) - LayerComboGroup { - row2: Vec, - row3: Vec, - }, - /// Match Properties (large button) + Color / Linetype / Lineweight combos on the right. - PropertiesGroup { match_prop: ToolDef }, - /// A style selector combobox (text / dim / mleader / table style) with - /// optional small tool rows below it. - StyleComboGroup { - /// Which style domain this combo controls. - style_key: StyleKey, - /// Unique dropdown id (must be unique across the ribbon). - combo_id: &'static str, - /// Optional command to run when the user opens the style manager. - manager_cmd: Option<&'static str>, - /// Small tool rows rendered below the combo (0–2 rows). - rows: Vec>, - }, -} - -/// Identifies which style list a `StyleComboGroup` refers to. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum StyleKey { - TextStyle, - DimStyle, - MLeaderStyle, - TableStyle, -} - -impl From for RibbonItem { - fn from(t: ToolDef) -> Self { - RibbonItem::Tool(t) - } -} - -/// A named group of tool buttons shown together in the ribbon. -pub struct RibbonGroup { - pub title: &'static str, - pub tools: Vec, -} - -// ── Trait ───────────────────────────────────────────────────────────────── - -/// A CAD module owns a set of ribbon groups shown when its tab is active. -/// Each module is a stateless unit struct — all UI state lives in Ribbon. -pub trait CadModule: Send + Sync { - #[allow(dead_code)] - fn id(&self) -> &'static str; - fn title(&self) -> &'static str; - fn ribbon_groups(&self) -> Vec; -} +// ── Ribbon vocabulary (CadModule, ToolDef, RibbonGroup, …) ───────────────── +// +// These types moved to the dependency-free `ocs_plugin_api` crate so add-ons +// can target a semver-stable contract. Re-exported here to keep the long-used +// `crate::modules::{CadModule, ToolDef, …}` paths stable across the codebase. +pub use ocs_plugin_api::ribbon::{ + CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef, +}; // ── Module declarations ─────────────────────────────────────────────────── diff --git a/src/plugin/manifest.rs b/src/plugin/manifest.rs index 58f9bbe6..54fa2338 100644 --- a/src/plugin/manifest.rs +++ b/src/plugin/manifest.rs @@ -1,32 +1,7 @@ -// Plugin identity and capability declaration. +//! Plugin identity and capability declaration. +//! +//! These types now live in the standalone, dependency-free `ocs_plugin_api` +//! crate so external add-ons can target a semver-stable contract. Re-exported +//! here to keep the `crate::plugin::manifest::*` path stable for in-tree use. -/// 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], -} \ No newline at end of file +pub use ocs_plugin_api::manifest::*;