From 1175675c5e721a0b6442d8be399b222bf33b2210 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 17 Jun 2026 16:02:16 +0300 Subject: [PATCH] docs(plugin): rewrite architecture + template for the external-only model OpenCADStudio ships no built-in plugins, so the spec and scaffold no longer describe the in-tree inventory path, demo_plugin, or the fictional Storm Sewer reference. The architecture doc now covers the external cdylib model end to end (ocs_plugin_api contract, export_plugin!, building + publishing per-platform releases, runtime loading, the marketplace + curated registry, the approach-B ABI caveat). The template becomes a standalone cdylib crate (Cargo.toml + src/lib.rs + release workflow) instead of an in-tree module. Co-Authored-By: Claude Opus 4.8 --- docs/plugin-architecture.md | 505 +++++++++--------- .../.github/workflows/release.yml | 44 ++ docs/plugin-template/Cargo.toml | 19 + docs/plugin-template/README.md | 60 ++- docs/plugin-template/dispatch.rs | 14 - docs/plugin-template/manifest.rs | 14 - docs/plugin-template/mod.rs | 34 -- docs/plugin-template/plugin.rs | 21 - docs/plugin-template/register.rs | 7 - docs/plugin-template/src/lib.rs | 68 +++ 10 files changed, 412 insertions(+), 374 deletions(-) create mode 100644 docs/plugin-template/.github/workflows/release.yml create mode 100644 docs/plugin-template/Cargo.toml delete mode 100644 docs/plugin-template/dispatch.rs delete mode 100644 docs/plugin-template/manifest.rs delete mode 100644 docs/plugin-template/mod.rs delete mode 100644 docs/plugin-template/plugin.rs delete mode 100644 docs/plugin-template/register.rs create mode 100644 docs/plugin-template/src/lib.rs diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 18cab3cc..79ed3777 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -1,12 +1,20 @@ # Open CAD Studio — Plugin Architecture -**Status:** Accepted (phase 1) -**Author:** Open CAD Studio contributors +**Status:** Accepted +**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. +This document is the **authoritative spec** for how add-on packages integrate +with Open CAD Studio. The model follows [QGIS](https://plugins.qgis.org/)-style +extensibility: a small metadata file, a single entry point, an optional separate +engine crate, and user-installable packages from a curated index. -> **Scope:** Generic host runtime only (`src/plugin/`, `src/app/plugin_host.rs`). Domain plugins (Storm Sewer, future sanitary/geotech) live under `src/modules//` and optional `crates//`. They **consume** this API; they are **not** part of the framework source. +> **Open CAD Studio ships no built-in plugins.** Every add-on is an **external +> dynamic library** (`cdylib`) the host loads at runtime from the user plugins +> folder. The host source only contains the generic plugin *runtime* +> (`src/plugin/`, `src/app/plugin_host.rs`) and the stable contract crate +> (`crates/ocs_plugin_api`). Add-ons live in their own repositories and +> **consume** that contract. --- @@ -14,357 +22,330 @@ 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 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. | +| **One package, one entry point** | Manifest, ribbon tab and commands ship together in the plugin crate; no edits to the host. | +| **Stable contract** | Authors target the semver-versioned `ocs_plugin_api` crate, not `OpenCADStudio` internals. | +| **Out-of-tree by default** | A plugin is its own repo + crate; the host never recompiles to gain one. | +| **DWG round-trip** | Domain data lives on entities as XDATA, not in an opaque side database. | +| **Engine reuse** | A headless `std`-only engine crate can run in WASM/CLI without the CAD host. | -## Non-goals (phase 1) +## Non-goals -- Sandboxed scripting (Python/Lua). -- Replacing the `acadrust` entity model. -- Full Autodesk CUI XML import. +- Sandboxing or signature verification (installing a plugin runs native code; the + user trusts the repos they install from). +- Cross-toolchain binary compatibility — see [Compatibility](#compatibility--abi). +- Sandboxed scripting (Python/Lua); replacing the `acadrust` entity model. --- -## Three layers (do not mix) +## Three layers ``` ┌────────────────────────────────────────────────────────────────────┐ -│ Layer A — Host core │ +│ Layer A — Host (OpenCADStudio) │ │ iced UI · Scene · Document · Undo · Command line │ -│ Built-in ribbon tabs: Home, Model, View, … (NOT plugins) │ +│ Core ribbon tabs: Home, Model, View, … (NOT plugins) │ +│ Generic plugin runtime: discovery, libloading, dispatch │ └───────────────────────────────┬────────────────────────────────────┘ - │ HostSession (stable adapter) + │ &mut dyn HostApi (ocs_plugin_api) ┌───────────────────────────────▼────────────────────────────────────┐ -│ Layer B — Add-on plugin package │ -│ plugin.toml · manifest.rs · register.rs · plugin.rs · dispatch.rs │ -│ Optional ribbon (CadModule) · per-tab state · XDATA schemas │ +│ Layer B — Plugin package (external repo, cdylib) │ +│ Cargo.toml · plugin.toml · src/lib.rs │ +│ PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin! │ └───────────────────────────────┬────────────────────────────────────┘ │ pure Rust API ┌───────────────────────────────▼────────────────────────────────────┐ -│ Layer C — Domain engine crate (optional) │ -│ crates/stormsewer — hydraulics, IO, no iced/acadrust dependency │ +│ Layer C — Domain engine crate (optional) │ +│ hydraulics / COGO / … — `std` only, no iced/acadrust │ └────────────────────────────────────────────────────────────────────┘ ``` -| Layer | Examples | May depend on | +| Layer | Lives in | 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) | +| **A — Host** | this repo: `src/`, `crates/ocs_plugin_api` | everything | +| **B — Plugin** | a separate repo (cdylib) | `ocs_plugin_api` + optional engine | +| **C — Engine** | the plugin's own crate or crates.io | `std` only (WASM/CLI-capable) | **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. +1. The host (`src/plugin/`) imports no plugin code — it only knows the contract. +2. Engine crates import neither `iced`, `acadrust`, nor `OpenCADStudio`. +3. A plugin never edits host source; it runs entirely from its own crate. --- -## Comparison to QGIS +## The contract crate — `ocs_plugin_api` -| 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//` | 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) | -| Processing algorithms | Headless engine crates + `SS_ANALYZE`-style commands | -| Vector layer provider | XDATA on DWG entities (`STORMSEWER_*`) | +[`crates/ocs_plugin_api`](../crates/ocs_plugin_api) is the semver-versioned API a +plugin compiles against. Two tiers: -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. +- **Dependency-free core** (default): `PluginManifest` / `ApiVersion` and the + ribbon vocabulary — `CadModule`, `ToolDef`, `RibbonGroup`, `RibbonItem`, + `IconKind`, `ModuleEvent`, `StyleKey`. Engine crates and tooling depend on this + cheaply. +- **`host` feature** (pulls `acadrust`): the runtime surface — the `HostApi` + trait, the `BuiltinPlugin` entry-point trait, and the `export_plugin!` macro. ---- - -## Add-on package layout - -Every add-on — whether in-tree or external — uses this directory shape: - -``` -/ # 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// # 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//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) +A plugin enables the `host` feature. ### `PluginManifest` ```rust pub struct PluginManifest { - pub id: &'static str, // reverse-DNS: "opencad.storm_sewer" + pub id: &'static str, // reverse-DNS: "opencad.example" pub name: &'static str, pub version: &'static str, pub description: &'static str, - pub api_version: ApiVersion, // host ABI major; must match host + pub api_version: ApiVersion, // host ABI major; must match the host pub ribbon_order: i32, // sort key among add-on tabs pub xdata_apps: &'static [&'static str], pub command_prefixes: &'static [&'static str], } ``` -### `BuiltinPlugin` +### `BuiltinPlugin` — the entry point ```rust pub trait BuiltinPlugin: Send + Sync { fn manifest(&self) -> &'static PluginManifest; - fn ribbon(&self) -> Box; + fn ribbon(&self) -> Box; // the ribbon tab fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; } ``` -### Registration (single entry point) +### `HostApi` — the plugin-facing runtime surface -```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::` 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`: +`dispatch` receives `&mut dyn HostApi`, so a plugin never touches the host's +concrete types: | Category | Methods | |----------|---------| -| Document | `document()`, `document_mut()`, `entities()`, `entities_mut()`, `add_entity()`, `bump_geometry()` | -| XDATA | `read_record(handle, app)`, `write_record(handle, record)`, `remove_record(handle, app)` — keyed by entity handle; `write_record` registers the APPID so the file stays standard | -| 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` | +| Document | `document()`, `document_mut()`, `add_entity()`, `bump_geometry()` | +| XDATA | `read_record(handle, app)`, `write_record(handle, record)`, `remove_record(handle, app)` — keyed by entity handle; `write_record` registers the APPID so data round-trips through DWG/DXF | +| Tab state | object-safe `plugin_state_any*`; use the `ocs_plugin_api::host::plugin_state` / `plugin_state_mut` / `ensure_plugin_state` helpers (keyed by `manifest.id`) | +| Command line | `push_info`, `push_output`, `push_error` | | Undo / dirty | `push_undo`, `set_dirty` | +| Tab | `tab_index()` | -**Status:** The whole contract now lives in the standalone, semver-versioned -[`crates/ocs_plugin_api`](../crates/ocs_plugin_api) crate: -- **Dependency-free core** — `PluginManifest` / `ApiVersion` (manifest) and - `CadModule` + ribbon types (`ToolDef`, `RibbonGroup`, …). Engine crates and - external tooling depend on this cheaply. -- **`host` feature** — the `acadrust`-typed `HostApi` trait (the runtime surface - in the table above). `HostSession` in the binary implements it; a plugin's - `dispatch` receives `&mut dyn HostApi`, so an out-of-tree add-on compiles - against this crate alone. Per-tab plugin state is reached through the - object-safe `plugin_state*` helpers. `set_active_command` (interactive - acquisition) stays host-side for now — see Command routing. - -The host re-exports both (`crate::plugin::manifest`, `crate::modules`, -`crate::plugin::host::HostApi`) so in-tree paths are unchanged. - -### Command routing +### `export_plugin!` — the C-ABI export ```rust -// app/commands.rs — plugins run first -if crate::plugin::try_dispatch(self, tab_index, cmd) { - return Task::none(); -} -// … legacy core commands … +ocs_plugin_api::export_plugin!(MyPlugin); ``` -Plugins own: +emits the two symbols the loader looks for: -- 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>, -} -``` - -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 | - -`HostSession` provides `read_record` / `write_record` / `remove_record` helpers (keyed by entity handle) over the `acadrust` XDATA API; `write_record` also registers the application in the APPID table so the data survives a DWG/DXF round-trip. Plugins may still use the raw `acadrust` XDATA APIs directly. +- `ocs_plugin_api_version() -> u32` — checked **first**, so an API-incompatible + build never runs its code. +- `ocs_plugin_register() -> *mut Box` — constructs the plugin + and hands ownership to the host. --- -## Core ribbon vs add-on ribbon +## Writing a plugin -| 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()` | +A plugin is a standalone crate that builds a `cdylib`: -This mirrors QGIS: the application ships core menus; plugins add tabs/tools without patching the host binary. +```toml +# Cargo.toml +[lib] +crate-type = ["cdylib"] + +[dependencies] +ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] } + +# Match the host's acadrust so the loaded library is binary-compatible. +[patch.crates-io] +acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" } +``` + +```rust +// src/lib.rs +use ocs_plugin_api::host::{BuiltinPlugin, HostApi}; +use ocs_plugin_api::manifest::{ApiVersion, PluginManifest}; +use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +static MANIFEST: PluginManifest = PluginManifest { + id: "opencad.example", name: "Example Plugin", version: "0.1.0", + description: "…", api_version: ApiVersion::CURRENT, + ribbon_order: 50, xdata_apps: &[], command_prefixes: &["EX_"], +}; + +struct ExampleModule; +impl CadModule for ExampleModule { + fn id(&self) -> &'static str { "example" } + fn title(&self) -> &'static str { "Example" } + fn ribbon_groups(&self) -> Vec { + vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef { + id: "EX_HELLO", label: "Hello", icon: IconKind::Glyph("◆"), + event: ModuleEvent::Command("EX_HELLO".to_string()), + })]}] + } +} + +struct ExamplePlugin; +impl BuiltinPlugin for ExamplePlugin { + fn manifest(&self) -> &'static PluginManifest { &MANIFEST } + fn ribbon(&self) -> Box { Box::new(ExampleModule) } + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + match cmd { "EX_HELLO" => { host.push_info("Hello"); true } _ => false } + } +} + +ocs_plugin_api::export_plugin!(ExamplePlugin); +``` + +```toml +# plugin.toml — shipped beside the binary; values mirror MANIFEST +[plugin] +id = "opencad.example" +name = "Example Plugin" +version = "0.1.0" +description = "…" + +[opencad] +api_version = 1 +ribbon_order = 50 +command_prefixes = ["EX_"] +xdata_apps = [] +``` + +The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template); +the live reference is the +[`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) +repository. + +### Commands + +A plugin owns its `command_prefixes` (e.g. `EX_`). The host's command router +calls `try_dispatch` first; a returning `true` consumes the command. A plugin +tool fires `ModuleEvent::Command("EX_FOO")`, which round-trips to +`dispatch(host, "EX_FOO")`. + +`ModuleEvent::PluginFileDialog { command, title, filter_name, extensions }` lets a +tool request a native file picker; on selection the host dispatches +`" "` back with the path's original case preserved. + +### XDATA — domain persistence + +Store domain data on entities as XDATA (under your `xdata_apps` ids), not in a +side database, so it round-trips through DWG/DXF. `write_record` also registers +the APPID. Document your schemas in the plugin's own `PLUGIN.md`. --- -## Phased rollout +## Building & distribution -### Phase 1 — Built-in add-ons (current) +Build per platform and publish to **GitHub Releases**: -- [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`) -- [x] Extract `ocs_plugin_api` crate — dependency-free manifest + ribbon/`CadModule`, plus the `acadrust`-typed `HostApi` trait behind the optional `host` feature; `dispatch` takes `&mut dyn HostApi` -- [x] Plugin manager UI (list installed, versions) — `PLUGINS` / `PLUGINMANAGER` command, or the Start-page "Plugins" button -- [x] Enable/disable plugins from the manager — a disabled plugin drops its ribbon tab and command dispatch; persisted in `settings.txt` (`disabled_plugins=`) -- [x] `ModuleEvent::PluginFileDialog` — a plugin tool requests a native file picker; the host opens it and dispatches `" "` back to the plugin with original case preserved (bypasses the command-line upper-casing) -- [x] XDATA convenience on `HostSession` — `read_record` / `write_record` / `remove_record`; `write_record` registers the APPID so plugin data round-trips through DWG/DXF +``` +cargo build --release # → target/release/lib.so | .dll | lib.dylib +``` -### Phase 2 — Dynamic loading (desktop) +A release attaches one binary per platform plus `plugin.toml`, with the platform +in the asset name so the host can pick the right one: + +``` +opencad.example-linux-x86_64.so +opencad.example-windows-x86_64.dll +opencad.example-macos-aarch64.dylib +plugin.toml +``` + +A GitHub Actions matrix workflow (see the example repo / template) cross-builds +and uploads these on a `v*` tag. + +--- + +## Loading + +On startup the host scans `/OpenCADStudio/plugins//` for a +`plugin.toml` + native library (`src/plugin/external.rs`): ``` /OpenCADStudio/plugins/ opencad.example/ plugin.toml - libocs_example_plugin.so # cdylib (.dll / .dylib per platform) + libocs_example_plugin.so # any name with the platform extension ``` -- [x] Discover packages — scan the plugins folder, read `plugin.toml`, check for - a native library, gate on `api_version`. Surfaced in the Plugin Manager with - a status pill (`src/plugin/external.rs`). -- [x] Load via `libloading` — each cdylib exports two C symbols (via the - `ocs_plugin_api::export_plugin!` macro): `ocs_plugin_api_version` (checked - first, so an incompatible build never runs) and `ocs_plugin_register` → - `*mut Box`. Loaded once at startup; the library stays - resident for the session (ribbon tabs / dispatch hold its vtables). External - plugins merge into the same ribbon + `try_dispatch` path as built-ins and - honour the enable/disable set. -- [x] `api_version` compatibility gate at load time. -- [x] Enable/disable in settings (like QGIS plugin manager) — landed in phase 1. +For each compatible package it `dlopen`s the library (`libloading`), calls +`ocs_plugin_api_version` and refuses on mismatch, then `ocs_plugin_register` to +obtain the boxed `BuiltinPlugin`. Loaded libraries stay **resident for the +session** (ribbon tabs and dispatch hold their vtables, so they are never +reloaded mid-session). External plugins merge into the same ribbon and +`try_dispatch` path the host uses and honour the enable/disable set +(`disabled_plugins` in `settings.txt`). -**ABI approach:** the plugin hands back a boxed `BuiltinPlugin` (not a `repr(C)` -vtable). This assumes the package was built against the same toolchain and -`ocs_plugin_api` version; the version symbol enforces the latter. Reference -implementation: [`crates/ocs_example_plugin`](../crates/ocs_example_plugin). - -### 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 +`` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or +`$XDG_CONFIG_HOME` / `~/.config` (Linux). --- -## Authoring a new add-on (checklist) +## Marketplace -1. Copy `docs/plugin-template/` into `src/modules//`. -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 ;` to `src/modules/mod.rs`. -7. Document XDATA in `PLUGIN.md`. -8. Optional: add `crates//` and depend from the plugin package only. -9. `cargo build` — tab appears via plugin registry; `commands.rs` untouched. +The **Plugin Manager** (`PLUGINS` / `PLUGINMANAGER`, or the Start-page button) +installs plugins from GitHub Releases: -**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//` or `plugins/`. +- **Curated registry** — [`plugins/registry.json`](../plugins/registry.json) in + this repo lists discoverable plugins. The host fetches it from `main` at + runtime and shows each entry under *Available plugins*. To list a plugin, open + a PR adding `{ "repo", "name", "description" }` (see + [`plugins/README.md`](../plugins/README.md)); merged PRs reach every user with + no app update. +- **Manual link** — *Add a repository* (`owner/repo`) for unlisted or private + dev repos; linked repos persist in `settings.txt` (`plugin_repos=`). +- **Install / upgrade / reinstall** — pick a release from the dropdown and + *Install*; the host downloads the platform asset + `plugin.toml` into the + plugins folder, checking `api_version` first. Reinstalling overwrites and + clears any stale library; picking a newer release upgrades. Changes take effect + on the next restart (the running library stays resident). +- **Uninstall** — removes the package folder (effective next restart). +- **Enable/disable** — toggles a loaded plugin's ribbon tab + dispatch without + uninstalling. --- -## Reference implementation: Storm Sewer +## Compatibility & ABI -| 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/` | +Loading uses **approach B**: the plugin returns a boxed `BuiltinPlugin` across the +`cdylib` boundary. This is sound only when the plugin was built with the **same +Rust toolchain and `ocs_plugin_api` version** as the host. The +`ocs_plugin_api_version` symbol gates the API version; it does **not** detect a +toolchain mismatch. In practice CI built with current stable Rust matches a host +built the same way. + +A future hardening step is a `#[repr(C)]` vtable (a true C ABI) so binaries built +by any toolchain interoperate — required before trusting prebuilt binaries from +arbitrary build environments. --- -## Workspace layout +## Roadmap -``` -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 -``` +Done: + +- [x] Stable `ocs_plugin_api` crate — dependency-free core + `host` feature + (`HostApi` / `BuiltinPlugin` / `export_plugin!`). +- [x] Runtime discovery + `libloading` loading with an `api_version` gate. +- [x] XDATA helpers, `ModuleEvent::PluginFileDialog`, per-tab plugin state. +- [x] Marketplace — curated registry + manual repo link, install / upgrade / + reinstall / uninstall, enable/disable. + +Next: + +- [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries. +- [ ] Trust: checksums / signatures before `dlopen`. +- [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks. +- [ ] External automation API (drive OCS headless from a process) — issue #29. --- -## Appendix: Civil 3D / SSA contrast +## Reference -| 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. \ No newline at end of file +| Piece | Location | +|-------|----------| +| Contract crate | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | +| Plugin runtime (host) | `src/plugin/`, `src/app/plugin_host.rs` | +| Marketplace + registry | `src/plugin/marketplace.rs`, [`plugins/registry.json`](../plugins/registry.json) | +| Template scaffold | [`docs/plugin-template/`](plugin-template) | +| Live example plugin | [`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) | diff --git a/docs/plugin-template/.github/workflows/release.yml b/docs/plugin-template/.github/workflows/release.yml new file mode 100644 index 00000000..2ff8de5f --- /dev/null +++ b/docs/plugin-template/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: release + +# Build the plugin cdylib for each desktop platform and attach the binaries +# (plus plugin.toml) to a GitHub Release on a v* tag. Open CAD Studio's Plugin +# Manager reads the assets, picks the one matching the user's OS/arch, and +# installs it. Rename the `asset:` files to your plugin id. +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + ext: so + asset: opencad.my_plugin-linux-x86_64.so + - os: windows-latest + ext: dll + asset: opencad.my_plugin-windows-x86_64.dll + - os: macos-latest + ext: dylib + asset: opencad.my_plugin-macos-aarch64.dylib + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --release + - name: Stage release asset + shell: bash + run: | + mkdir -p dist + cp target/release/*.${{ matrix.ext }} "dist/${{ matrix.asset }}" + cp plugin.toml dist/plugin.toml + - uses: softprops/action-gh-release@v2 + with: + files: | + dist/${{ matrix.asset }} + dist/plugin.toml diff --git a/docs/plugin-template/Cargo.toml b/docs/plugin-template/Cargo.toml new file mode 100644 index 00000000..4356b9a3 --- /dev/null +++ b/docs/plugin-template/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "my-plugin" +version = "0.1.0" +edition = "2021" +description = "An Open CAD Studio add-on." +license = "GPL-3.0-only" + +# A plugin is a C-ABI dynamic library the host loads at runtime. +[lib] +crate-type = ["cdylib"] + +[dependencies] +# The stable contract, with the runtime `host` surface (HostApi / BuiltinPlugin). +ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] } + +# Match the host's acadrust so the loaded library is binary-compatible +# (approach B assumes the same toolchain + dependency versions). +[patch.crates-io] +acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" } diff --git a/docs/plugin-template/README.md b/docs/plugin-template/README.md index 9a34345f..f6113df1 100644 --- a/docs/plugin-template/README.md +++ b/docs/plugin-template/README.md @@ -1,31 +1,47 @@ -# Add-on plugin template +# Open CAD Studio plugin template -Copy this folder to `src/modules//` and rename placeholders. +A complete scaffold for an **external** Open CAD Studio add-on. A plugin is its +own repository that builds a `cdylib`; the host loads it at runtime. Copy this +folder into a new repo and rename the placeholders. + +## Files + +| File | Purpose | +|------|---------| +| `Cargo.toml` | `cdylib` crate depending on `ocs_plugin_api` (`host` feature) | +| `src/lib.rs` | manifest + `CadModule` ribbon + `BuiltinPlugin` + `export_plugin!` | +| `plugin.toml` | metadata read by the host (mirrors the manifest) | +| `.github/workflows/release.yml` | cross-builds the cdylib and publishes a release | +| `PLUGIN.md` | your command reference + XDATA schemas | ## 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. +1. Copy this folder into a new repository. +2. Rename `my-plugin` / `My Plugin` / `opencad.my_plugin` / `my_plugin` / `MP_` + throughout (`Cargo.toml`, `src/lib.rs`, `plugin.toml`, the workflow `asset:` + names), keeping `plugin.toml` and the `MANIFEST` in sync. +3. `cargo build` to check it compiles. -## Required files +## Test locally -| 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 | +```sh +cargo build --release +mkdir -p "/OpenCADStudio/plugins/opencad.my_plugin" +cp target/release/*my_plugin*.so "/OpenCADStudio/plugins/opencad.my_plugin/" +cp plugin.toml "/OpenCADStudio/plugins/opencad.my_plugin/" +``` -## Optional +Restart Open CAD Studio: the ribbon tab appears and `MP_` commands route to your +plugin. (`` = `%APPDATA%` / `~/Library/Application Support` / +`$XDG_CONFIG_HOME`.) -| File | Purpose | -|------|---------| -| `state.rs` | Per-document tab state via `host.ensure_plugin_state(PLUGIN_ID, …)` | -| `crates/my_engine/` | Headless domain logic (no iced/acadrust) | +## Publish -See `docs/plugin-architecture.md` for the full spec. \ No newline at end of file +Push a `v*` tag — the workflow builds the cdylib on Linux/Windows/macOS and +uploads each binary plus `plugin.toml` to a GitHub Release. Users install it from +the **Plugin Manager** by linking your `owner/repo`, or — once your repo is added +to [`plugins/registry.json`](https://github.com/HakanSeven12/OpenCADStudio/blob/main/plugins/registry.json) +via PR — straight from *Available plugins*. + +> The binary must be built with the same toolchain and `ocs_plugin_api` version +> as the host (approach B). See `docs/plugin-architecture.md`. diff --git a/docs/plugin-template/dispatch.rs b/docs/plugin-template/dispatch.rs deleted file mode 100644 index 6c70a91c..00000000 --- a/docs/plugin-template/dispatch.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::plugin::host::HostApi; - -use super::manifest::PLUGIN_ID; - -pub fn handle(host: &mut dyn HostApi, cmd: &str) -> bool { - let _ = (host, PLUGIN_ID); - match cmd { - "MP_HELLO" => { - host.push_info("Hello from my_plugin"); - true - } - _ => false, - } -} \ No newline at end of file diff --git a/docs/plugin-template/manifest.rs b/docs/plugin-template/manifest.rs deleted file mode 100644 index 65cc7444..00000000 --- a/docs/plugin-template/manifest.rs +++ /dev/null @@ -1,14 +0,0 @@ -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_"], -}; \ No newline at end of file diff --git a/docs/plugin-template/mod.rs b/docs/plugin-template/mod.rs deleted file mode 100644 index 4fa749b2..00000000 --- a/docs/plugin-template/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -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 { - vec![RibbonGroup { - title: "Tools", - tools: vec![RibbonItem::LargeTool(ToolDef { - id: "MP_HELLO", - label: "Hello", - icon: IconKind::Glyph("★"), - event: ModuleEvent::Command("MP_HELLO".to_string()), - })], - }] - } -} \ No newline at end of file diff --git a/docs/plugin-template/plugin.rs b/docs/plugin-template/plugin.rs deleted file mode 100644 index 92ca34e5..00000000 --- a/docs/plugin-template/plugin.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::plugin::host::{BuiltinPlugin, HostApi}; -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 { - Box::new(super::MyPluginModule) - } - - fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { - dispatch::handle(host, cmd) - } -} \ No newline at end of file diff --git a/docs/plugin-template/register.rs b/docs/plugin-template/register.rs deleted file mode 100644 index 151eb401..00000000 --- a/docs/plugin-template/register.rs +++ /dev/null @@ -1,7 +0,0 @@ -use super::plugin::MyPlugin; - -inventory::submit! { - crate::plugin::registry::PluginRegistration { - construct: || Box::new(MyPlugin), - } -} \ No newline at end of file diff --git a/docs/plugin-template/src/lib.rs b/docs/plugin-template/src/lib.rs new file mode 100644 index 00000000..b23a25db --- /dev/null +++ b/docs/plugin-template/src/lib.rs @@ -0,0 +1,68 @@ +//! Open CAD Studio add-on template. +//! +//! Rename the crate (`Cargo.toml`), the ids/strings below, and `plugin.toml` to +//! match. Build with `cargo build --release` and ship the resulting cdylib plus +//! `plugin.toml` as GitHub Release assets (see `.github/workflows/release.yml`). + +use ocs_plugin_api::host::{BuiltinPlugin, HostApi}; +use ocs_plugin_api::manifest::{ApiVersion, PluginManifest}; +use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +// Keep these fields in sync with `plugin.toml`. +static MANIFEST: PluginManifest = PluginManifest { + id: "opencad.my_plugin", + name: "My Plugin", + version: "0.1.0", + description: "What this plugin does.", + api_version: ApiVersion::CURRENT, + ribbon_order: 50, + xdata_apps: &[], + command_prefixes: &["MP_"], +}; + +/// The ribbon tab. +struct MyModule; + +impl CadModule for MyModule { + fn id(&self) -> &'static str { + "my_plugin" + } + fn title(&self) -> &'static str { + "My Plugin" + } + fn ribbon_groups(&self) -> Vec { + vec![RibbonGroup { + title: "Tools", + tools: vec![RibbonItem::LargeTool(ToolDef { + id: "MP_HELLO", + label: "Hello", + icon: IconKind::Glyph("★"), + event: ModuleEvent::Command("MP_HELLO".to_string()), + })], + }] + } +} + +/// The plugin entry point. +struct MyPlugin; + +impl BuiltinPlugin for MyPlugin { + fn manifest(&self) -> &'static PluginManifest { + &MANIFEST + } + fn ribbon(&self) -> Box { + Box::new(MyModule) + } + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + match cmd { + "MP_HELLO" => { + host.push_info("Hello from My Plugin"); + true + } + _ => false, + } + } +} + +// Emit the C-ABI symbols the host loader looks for. +ocs_plugin_api::export_plugin!(MyPlugin);