diff --git a/build.rs b/build.rs index d6acb604a..baa65b3c4 100644 --- a/build.rs +++ b/build.rs @@ -7,6 +7,7 @@ // Rules for a directory to be picked up: // • Located directly in src/modules/ // • Contains a mod.rs file +// • Does NOT contain plugin.toml (add-ons register via BuiltinPlugin::ribbon) // • Defines a pub struct {PascalCase}Module implementing CadModule // (unit struct — no ::new() needed) // @@ -38,7 +39,8 @@ fn main() { } let name = entry.file_name().into_string().ok()?; let mod_file = mods_dir.join(&name).join("mod.rs"); - if mod_file.exists() { + let plugin_toml = mods_dir.join(&name).join("plugin.toml"); + if mod_file.exists() && !plugin_toml.exists() { Some(name) } else { None diff --git a/docs/PR-plugin-host.md b/docs/PR-plugin-host.md new file mode 100644 index 000000000..ebb6611c1 --- /dev/null +++ b/docs/PR-plugin-host.md @@ -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//`, implement `BuiltinPlugin`, add `pub mod ;` 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 +``` + +A minimal **`demo_plugin`** add-on registers at compile time for smoke tests (`DP_HELLO` command, Demo Plugin ribbon tab). Remove or gate it before release if maintainers prefer zero in-tree add-ons. + +## 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)? \ No newline at end of file diff --git a/docs/issue-78-comment.txt b/docs/issue-78-comment.txt new file mode 100644 index 000000000..0be48a3aa --- /dev/null +++ b/docs/issue-78-comment.txt @@ -0,0 +1,36 @@ +@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:** #80 — **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 (PR #80):** 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. + +### Storm Sewer (separate) + +Storm Sewer stays on a separate branch as the reference consumer — not in core: [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)? + +— Michael diff --git a/docs/issue-78-reply.md b/docs/issue-78-reply.md new file mode 100644 index 000000000..a684c4bc6 --- /dev/null +++ b/docs/issue-78-reply.md @@ -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 \ No newline at end of file diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md new file mode 100644 index 000000000..192f4110d --- /dev/null +++ b/docs/plugin-architecture.md @@ -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//` and optional `crates//`. 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//` | 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_*`) | + +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: + +``` +/ # 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) + +### `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; + 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::` 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>, +} +``` + +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//`. +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. + +**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/`. + +--- + +## 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. \ No newline at end of file diff --git a/docs/plugin-template/PLUGIN.md b/docs/plugin-template/PLUGIN.md new file mode 100644 index 000000000..f0352edd6 --- /dev/null +++ b/docs/plugin-template/PLUGIN.md @@ -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. \ No newline at end of file diff --git a/docs/plugin-template/README.md b/docs/plugin-template/README.md new file mode 100644 index 000000000..9a34345f6 --- /dev/null +++ b/docs/plugin-template/README.md @@ -0,0 +1,31 @@ +# Add-on plugin template + +Copy this folder to `src/modules//` 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. \ No newline at end of file diff --git a/docs/plugin-template/dispatch.rs b/docs/plugin-template/dispatch.rs new file mode 100644 index 000000000..174f6b94d --- /dev/null +++ b/docs/plugin-template/dispatch.rs @@ -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, + } +} \ No newline at end of file diff --git a/docs/plugin-template/manifest.rs b/docs/plugin-template/manifest.rs new file mode 100644 index 000000000..65cc74446 --- /dev/null +++ b/docs/plugin-template/manifest.rs @@ -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_"], +}; \ No newline at end of file diff --git a/docs/plugin-template/mod.rs b/docs/plugin-template/mod.rs new file mode 100644 index 000000000..4fa749b27 --- /dev/null +++ b/docs/plugin-template/mod.rs @@ -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 { + 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 new file mode 100644 index 000000000..6230b57d9 --- /dev/null +++ b/docs/plugin-template/plugin.rs @@ -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 { + Box::new(super::MyPluginModule) + } + + fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool { + dispatch::handle(host, cmd) + } +} \ No newline at end of file diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml new file mode 100644 index 000000000..5961f112c --- /dev/null +++ b/docs/plugin-template/plugin.toml @@ -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"] \ No newline at end of file diff --git a/docs/plugin-template/register.rs b/docs/plugin-template/register.rs new file mode 100644 index 000000000..151eb4016 --- /dev/null +++ b/docs/plugin-template/register.rs @@ -0,0 +1,7 @@ +use super::plugin::MyPlugin; + +inventory::submit! { + crate::plugin::registry::PluginRegistration { + construct: || Box::new(MyPlugin), + } +} \ No newline at end of file diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 32964ee82..789fd97d5 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -251,6 +251,11 @@ impl OpenCADStudio { } CmdResult::ReplaceMany(replacements, additions) => { let label = self.history_label_from_active_cmd(i, "FILLET"); + let was_catchment = self + .tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.name() == "SS_CATCHMENT"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { self.tabs[i].scene.erase_entities(&[handle]); @@ -265,6 +270,10 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; + if was_catchment { + self.command_line + .push_info("Catchment tagged successfully."); + } self.refresh_properties(); } CmdResult::ReplaceEntity(handle, new_entities) => { diff --git a/src/app/commands.rs b/src/app/commands.rs index f7ba9e32b..0b21100fe 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -28,6 +28,10 @@ impl OpenCADStudio { return Task::done(Message::OpenPathPicked(Some((path, size)))); } + if crate::plugin::try_dispatch(self, i, cmd) { + return Task::none(); + } + match cmd { "NEW" => return Task::done(Message::TabNew), "OPEN" => return Task::done(Message::OpenFile), diff --git a/src/app/document.rs b/src/app/document.rs index 8dece0e79..6f2ce0086 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -9,6 +9,8 @@ use crate::ui::{LayerPanel, PropertiesPanel}; use acadrust::tables::Ucs; use acadrust::{CadDocument, Handle}; use iced; +use std::any::{Any, TypeId}; +use std::collections::HashMap; use std::path::PathBuf; // ── Dynamic input ────────────────────────────────────────────────────────── @@ -100,6 +102,8 @@ pub(super) struct DocumentTab { /// of the model-space shader. The scene is still constructed so the /// rest of the code can treat it as a normal tab when reading. pub(super) is_start: bool, + /// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state). + pub(super) plugin_state: HashMap<&'static str, Box>, } impl DocumentTab { @@ -146,9 +150,41 @@ impl DocumentTab { active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, is_start: false, + plugin_state: HashMap::new(), } } + pub(super) fn plugin_state( + &self, + plugin_id: &'static str, + _type_id: TypeId, + ) -> Option<&T> { + self.plugin_state.get(plugin_id)?.downcast_ref::() + } + + pub(super) fn plugin_state_mut( + &mut self, + plugin_id: &'static str, + _type_id: TypeId, + ) -> Option<&mut T> { + self.plugin_state.get_mut(plugin_id)?.downcast_mut::() + } + + pub(super) fn ensure_plugin_state( + &mut self, + plugin_id: &'static str, + init: impl FnOnce() -> T, + ) -> &mut T { + if !self.plugin_state.contains_key(plugin_id) { + self.plugin_state.insert(plugin_id, Box::new(init())); + } + self.plugin_state + .get_mut(plugin_id) + .expect("just inserted") + .downcast_mut::() + .expect("plugin_id type mismatch") + } + /// Welcome / Start tab. Carries a dummy Scene so the rest of the app /// can read tab state uniformly; the viewport renderer detects /// `is_start` and shows a welcome page instead. diff --git a/src/app/mod.rs b/src/app/mod.rs index 396272732..3dc6e873c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,6 @@ mod cmd_result; mod commands; +pub mod plugin_host; mod document; mod expr_eval; mod helpers; @@ -1493,6 +1494,22 @@ impl OpenCADStudio { app } + #[cfg(test)] + pub(crate) fn new_for_test() -> Self { + Self::new() + } + + #[cfg(test)] + pub(crate) fn command_history_info(&self) -> Vec { + use crate::ui::command_line::EntryKind; + self.command_line + .history + .iter() + .filter(|e| e.kind == EntryKind::Info) + .map(|e| e.text.clone()) + .collect() + } + /// Boot function for `iced::daemon`: returns initial state plus a task that /// opens the primary application window. fn boot() -> (Self, Task) { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs new file mode 100644 index 000000000..f5df25174 --- /dev/null +++ b/src/app/plugin_host.rs @@ -0,0 +1,94 @@ +// HostSession — plugin-facing API implemented inside `app` (private field access). + +use std::any::{Any, TypeId}; + +use acadrust::{CadDocument, EntityType, Handle}; + +use super::OpenCADStudio; +use crate::command::CadCommand; + +/// Session adapter: one active document tab, command line, undo. +pub(crate) struct HostSession<'a> { + app: &'a mut OpenCADStudio, + tab: usize, +} + +impl<'a> HostSession<'a> { + pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self { + Self { app, tab } + } + + pub fn tab_index(&self) -> usize { + self.tab + } + + pub fn document(&self) -> &CadDocument { + &self.app.tabs[self.tab].scene.document + } + + pub fn document_mut(&mut self) -> &mut CadDocument { + &mut self.app.tabs[self.tab].scene.document + } + + pub fn entities(&self) -> impl Iterator { + self.document().entities() + } + + pub fn entities_mut(&mut self) -> impl Iterator { + self.document_mut().entities_mut() + } + + pub fn add_entity(&mut self, entity: EntityType) -> Handle { + self.app.tabs[self.tab].scene.add_entity(entity) + } + + pub fn bump_geometry(&mut self) { + self.app.tabs[self.tab].scene.bump_geometry(); + } + + pub fn push_undo(&mut self, label: &str) { + self.app.push_undo_snapshot(self.tab, label); + } + + pub fn set_dirty(&mut self) { + self.app.tabs[self.tab].dirty = true; + } + + pub fn push_info(&mut self, msg: &str) { + self.app.command_line.push_info(msg); + } + + pub fn push_output(&mut self, msg: &str) { + self.app.command_line.push_output(msg); + } + + pub fn push_error(&mut self, msg: &str) { + self.app.command_line.push_error(msg); + } + + pub fn set_active_command(&mut self, cmd: Box) { + self.app.tabs[self.tab].active_cmd = Some(cmd); + } + + pub fn plugin_state( + &self, + plugin_id: &'static str, + ) -> Option<&T> { + self.app.tabs[self.tab].plugin_state(plugin_id, TypeId::of::()) + } + + pub fn plugin_state_mut( + &mut self, + plugin_id: &'static str, + ) -> Option<&mut T> { + self.app.tabs[self.tab].plugin_state_mut(plugin_id, TypeId::of::()) + } + + pub fn ensure_plugin_state( + &mut self, + plugin_id: &'static str, + init: impl FnOnce() -> T, + ) -> &mut T { + self.app.tabs[self.tab].ensure_plugin_state(plugin_id, init) + } +} \ No newline at end of file diff --git a/src/app/update.rs b/src/app/update.rs index 1848e593c..f167bbbe6 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2386,6 +2386,11 @@ impl OpenCADStudio { .as_ref() .map(|c| c.needs_entity_pick()) .unwrap_or(false); + let needs_structure = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_structure_point_pick()) + .unwrap_or(false); let is_gathering = self.tabs[i] .active_cmd .as_ref() @@ -2396,7 +2401,7 @@ impl OpenCADStudio { .as_ref() .map(|c| c.needs_tangent_pick()) .unwrap_or(false); - self.tabs[i].snap_result = if needs_entity || is_gathering { + self.tabs[i].snap_result = if needs_entity || is_gathering || needs_structure { None } else if needs_tan { self.snapper.snap_tangent_only( @@ -2468,6 +2473,39 @@ impl OpenCADStudio { }; self.tabs[i].last_cursor_world = effective; self.tabs[i].last_cursor_screen = p_full; + + // C3D-style orange object snap (plugin commands implement resolve_object_pick). + if needs_structure { + use crate::snap::{SnapResult, SnapType}; + let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| { + c.resolve_object_pick( + &self.tabs[i].scene, + effective.x as f64, + effective.y as f64, + ) + }); + if let Some(pick) = pick { + let world = + glam::Vec3::new(pick.x as f32, pick.y as f32, effective.z); + let ndc = view_proj.project_point3(world); + let screen = iced::Point::new( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ); + self.tabs[i].snap_result = Some(SnapResult { + world, + screen, + snap_type: SnapType::ObjectPick, + tangent_obj: None, + }); + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.set_acquisition_hint(Some(pick.label)); + } + } else if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.set_acquisition_hint(None); + } + } + // Snap glyph is positioned in canvas space; shift the // tile-local snap screen point back to the full canvas. if let Some(s) = self.tabs[i].snap_result.as_mut() { @@ -2475,16 +2513,42 @@ impl OpenCADStudio { s.screen.y += tile_b.y; } - let mut previews = if needs_entity { + let mut previews = if needs_structure { + let mut p = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective)) + .unwrap_or_default(); + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + p.extend(cmd.on_preview_wires(effective)); + } + p + } else if needs_entity { let hover_handle = scene::hit_test::click_hit(p, &all_wires[..], view_proj, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .unwrap_or(acadrust::Handle::NULL); - self.tabs[i] + let mut p = self.tabs[i] .active_cmd .as_mut() .map(|c| c.on_hover_entity(hover_handle, effective)) - .unwrap_or_default() + .unwrap_or_default(); + if !hover_handle.is_null() { + if let Some(cmd) = self.tabs[i].active_cmd.as_ref() { + p.extend(cmd.entity_pick_acquire_previews( + &self.tabs[i].scene, + hover_handle, + )); + } + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if let Some(hint) = + cmd.entity_pick_acquire_hint(hover_handle) + { + cmd.set_acquisition_hint(Some(hint)); + } + } + } + p } else { self.tabs[i] .active_cmd @@ -2946,6 +3010,36 @@ impl OpenCADStudio { }; let result = if self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_structure_point_pick()) + .unwrap_or(false) + { + let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| { + c.resolve_object_pick( + &self.tabs[i].scene, + world_pt.x as f64, + world_pt.y as f64, + ) + }); + if let Some(pick) = pick { + let center = glam::Vec3::new(pick.x as f32, pick.y as f32, world_pt.z); + let result = self.tabs[i].active_cmd.as_mut().map(|c| { + c.on_structure_pick(pick.handle, center) + }); + self.command_line + .push_info(&format!("{} acquired.", pick.label)); + result + } else { + let msg = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.object_pick_miss_message()) + .unwrap_or("No object near click."); + self.command_line.push_error(msg); + None + } + } else if self.tabs[i] .active_cmd .as_ref() .map(|c| c.needs_entity_pick()) @@ -2956,6 +3050,23 @@ impl OpenCADStudio { let hit = scene::hit_test::click_hit(p, &all_wires2[..], vp_mat2, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { + // Some commands (e.g. SS_CATCHMENT) need the entity + // body before `on_entity_pick` can advance. + let inject_first = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.inject_before_entity_pick()) + .unwrap_or(false); + if inject_first { + if let Some(entity) = + self.tabs[i].scene.document.get_entity(handle).cloned() + { + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.inject_picked_entity(entity); + } + } + } + let result = self.tabs[i] .active_cmd .as_mut() diff --git a/src/command/mod.rs b/src/command/mod.rs index 75a5562dc..13f36002a 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -7,9 +7,19 @@ use crate::scene::hatch_model::HatchModel; use crate::scene::wire_model::WireModel; +use crate::scene::Scene; use acadrust::{EntityType, Handle}; use glam::Vec3; +/// Domain object resolved under the cursor for C3D-style ObjectPick snapping. +#[derive(Clone, Copy, Debug)] +pub struct ObjectPickHit { + pub handle: Handle, + pub x: f64, + pub y: f64, + pub label: &'static str, +} + // ── Transform ───────────────────────────────────────────────────────────── /// A geometric transformation applied to existing entities. @@ -271,6 +281,44 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Point-click pick of domain objects (wire hit-test often misses small markers). + fn needs_structure_point_pick(&self) -> bool { + false + } + + /// Resolve a domain object near `(x, y)` while `needs_structure_point_pick()` is active. + fn resolve_object_pick(&self, _scene: &Scene, _x: f64, _y: f64) -> Option { + None + } + + /// Preview wires while hovering during object-point pick. + fn object_pick_hover_previews(&self, _scene: &Scene, _cursor: Vec3) -> Vec { + vec![] + } + + /// Message when `resolve_object_pick` returns none on click. + fn object_pick_miss_message(&self) -> &'static str { + "No object near click." + } + + /// Called when `needs_structure_point_pick()` is true and a structure is found near the click. + fn on_structure_pick(&mut self, _handle: Handle, _pt: Vec3) -> CmdResult { + CmdResult::Cancel + } + + /// Extra acquisition previews during entity pick (besides `on_hover_entity`). + fn entity_pick_acquire_previews(&self, _scene: &Scene, _handle: Handle) -> Vec { + vec![] + } + + /// Acquisition hint label during entity pick hover. + fn entity_pick_acquire_hint(&self, _handle: Handle) -> Option<&'static str> { + None + } + + /// Hover label for C3D-style object acquisition (e.g. "Inlet" under cursor). + fn set_acquisition_hint(&mut self, _hint: Option<&str>) {} + /// Called after `CmdResult::ReplaceEntity` is applied to the document. /// `old` is the erased handle; `new_handles` are the handles assigned to the replacement entities. /// Commands that stay active across replaces should update their internal snapshots here. @@ -371,7 +419,13 @@ pub trait CadCommand: Send { self.on_point(hit) } - /// Called by update.rs after `on_entity_pick` to inject the cloned entity into commands + /// When true, `update.rs` injects the picked entity before calling + /// `on_entity_pick` (required when the pick handler reads injected state). + fn inject_before_entity_pick(&self) -> bool { + false + } + + /// Called by update.rs to inject the cloned entity into commands /// that need to read/modify it (e.g. DIMTEDIT, MLEADERADD, MLEADERREMOVE). /// Default: no-op. fn inject_picked_entity(&mut self, _entity: acadrust::EntityType) {} diff --git a/src/lib.rs b/src/lib.rs index f9164b31f..4f3d75c66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod entities; pub mod io; pub mod linetypes; pub mod modules; +pub mod plugin; pub mod patterns; pub mod scene; pub mod snap; diff --git a/src/main.rs b/src/main.rs index d420e6cf5..d58a6149d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod entities; mod io; mod linetypes; mod modules; +mod plugin; mod patterns; mod scene; mod snap; diff --git a/src/modules/demo_plugin/dispatch.rs b/src/modules/demo_plugin/dispatch.rs new file mode 100644 index 000000000..82e604f97 --- /dev/null +++ b/src/modules/demo_plugin/dispatch.rs @@ -0,0 +1,11 @@ +use crate::plugin::host::HostSession; + +pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { + match cmd { + "DP_HELLO" => { + host.push_info("Hello from demo_plugin (plugin host OK)."); + true + } + _ => false, + } +} \ No newline at end of file diff --git a/src/modules/demo_plugin/manifest.rs b/src/modules/demo_plugin/manifest.rs new file mode 100644 index 000000000..e8638212f --- /dev/null +++ b/src/modules/demo_plugin/manifest.rs @@ -0,0 +1,14 @@ +use crate::plugin::manifest::{ApiVersion, PluginManifest}; + +pub const PLUGIN_ID: &str = "opencad.demo_plugin"; + +pub static MANIFEST: PluginManifest = PluginManifest { + id: PLUGIN_ID, + name: "Demo Plugin", + version: "0.1.0", + description: "Minimal add-on for plugin-host integration tests", + api_version: ApiVersion::CURRENT, + ribbon_order: 99, + xdata_apps: &[], + command_prefixes: &["DP_"], +}; \ No newline at end of file diff --git a/src/modules/demo_plugin/mod.rs b/src/modules/demo_plugin/mod.rs new file mode 100644 index 000000000..7b3743265 --- /dev/null +++ b/src/modules/demo_plugin/mod.rs @@ -0,0 +1,47 @@ +// Minimal in-tree add-on — validates plugin host on `feature/plugin-host`. + +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: &["DP_HELLO"] +}); + +pub struct DemoPluginModule; + +impl CadModule for DemoPluginModule { + fn id(&self) -> &'static str { + "demo_plugin" + } + + fn title(&self) -> &'static str { + "Demo Plugin" + } + + fn ribbon_groups(&self) -> Vec { + vec![RibbonGroup { + title: "Smoke", + tools: vec![RibbonItem::LargeTool(ToolDef { + id: "DP_HELLO", + label: "Hello", + icon: IconKind::Glyph("★"), + event: ModuleEvent::Command("DP_HELLO".to_string()), + })], + }] + } +} + +#[cfg(test)] +mod tests { + use crate::plugin::all_ribbon_modules; + + #[test] + fn ribbon_tab_is_registered() { + let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect(); + assert!(titles.contains(&"Demo Plugin"), "tabs: {titles:?}"); + } +} \ No newline at end of file diff --git a/src/modules/demo_plugin/plugin.rs b/src/modules/demo_plugin/plugin.rs new file mode 100644 index 000000000..2d3e074ed --- /dev/null +++ b/src/modules/demo_plugin/plugin.rs @@ -0,0 +1,21 @@ +use crate::plugin::host::{BuiltinPlugin, HostSession}; +use crate::plugin::manifest::PluginManifest; + +use super::dispatch; +use super::manifest; + +pub struct DemoPlugin; + +impl BuiltinPlugin for DemoPlugin { + fn manifest(&self) -> &'static PluginManifest { + &manifest::MANIFEST + } + + fn ribbon(&self) -> Box { + Box::new(super::DemoPluginModule) + } + + fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool { + dispatch::handle(host, cmd) + } +} \ No newline at end of file diff --git a/src/modules/demo_plugin/plugin.toml b/src/modules/demo_plugin/plugin.toml new file mode 100644 index 000000000..16703b279 --- /dev/null +++ b/src/modules/demo_plugin/plugin.toml @@ -0,0 +1,13 @@ +[plugin] +id = "opencad.demo_plugin" +name = "Demo Plugin" +version = "0.1.0" +description = "Minimal add-on for plugin-host integration tests" +author = "Open CAD Studio contributors" +license = "GPL-3.0-only" + +[opencad] +api_version = 1 +ribbon_order = 99 +command_prefixes = ["DP_"] +xdata_apps = [] \ No newline at end of file diff --git a/src/modules/demo_plugin/register.rs b/src/modules/demo_plugin/register.rs new file mode 100644 index 000000000..e89aa2f2d --- /dev/null +++ b/src/modules/demo_plugin/register.rs @@ -0,0 +1,7 @@ +use super::plugin::DemoPlugin; + +inventory::submit! { + crate::plugin::registry::PluginRegistration { + construct: || Box::new(DemoPlugin), + } +} \ No newline at end of file diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 7dbebd138..3dc374d88 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,10 +1,13 @@ // Module system — CadModule, ToolDef, RibbonGroup. // -// To add a new module: -// 1. Create `src/modules/my_name/` directory +// To add a **core** ribbon tab (Home, View, …): +// 1. Create `src/modules/my_name/` directory (no `plugin.toml`) // 2. Add `src/modules/my_name/mod.rs` implementing `CadModule` as `MyNameModule` // 3. Add `pub mod my_name;` below -// 4. `cargo build` — module appears in the ribbon automatically +// 4. `cargo build` — tab appears via build.rs registry +// +// To add an **add-on plugin** (Storm Sewer, …): +// See `docs/plugin-architecture.md` and copy `docs/plugin-template/`. // // Each module folder contains: // - mod.rs : module definition (ribbon groups + tool layout) @@ -136,6 +139,7 @@ pub mod home; pub mod insert; pub mod model; pub mod layout; +pub mod demo_plugin; pub mod manage; pub mod view; diff --git a/src/plugin/host.rs b/src/plugin/host.rs new file mode 100644 index 000000000..da1c53adc --- /dev/null +++ b/src/plugin/host.rs @@ -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; + fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool; +} \ No newline at end of file diff --git a/src/plugin/manifest.rs b/src/plugin/manifest.rs new file mode 100644 index 000000000..58f9bbe60 --- /dev/null +++ b/src/plugin/manifest.rs @@ -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], +} \ No newline at end of file diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs new file mode 100644 index 000000000..158ed2232 --- /dev/null +++ b/src/plugin/mod.rs @@ -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//` 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; \ No newline at end of file diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs new file mode 100644 index 000000000..c7b1e9f0d --- /dev/null +++ b/src/plugin/registry.rs @@ -0,0 +1,88 @@ +// 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, +} + +inventory::collect!(PluginRegistration); + +/// Construct every registered built-in plugin (once per process). +pub fn all_plugins() -> Vec> { + inventory::iter:: + .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> { + let mut core = core_registry::all_modules(); + let mut addons: Vec<(i32, Box)> = 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 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::OpenCADStudio; + #[test] + fn discovers_registered_plugins() { + let plugins = all_plugins(); + assert!( + !plugins.is_empty(), + "expected at least one PluginRegistration (demo_plugin)" + ); + assert!( + plugins + .iter() + .any(|p| p.manifest().id == "opencad.demo_plugin"), + "demo_plugin missing; ids: {:?}", + plugins.iter().map(|p| p.manifest().id).collect::>() + ); + } + + #[test] + fn addon_ribbon_tabs_merge_after_core() { + let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect(); + assert!(titles.contains(&"Demo Plugin"), "ribbon tabs: {titles:?}"); + let core = core_registry::all_modules(); + assert_eq!(titles.len(), core.len() + all_plugins().len()); + } + + #[test] + fn try_dispatch_routes_demo_command() { + let mut app = OpenCADStudio::new_for_test(); + assert!(try_dispatch(&mut app, 0, "DP_HELLO")); + let info = app.command_history_info(); + assert!( + info.iter().any(|t| t.contains("demo_plugin") && t.contains("plugin host OK")), + "info history: {info:?}" + ); + } + + #[test] + fn unknown_plugin_command_falls_through() { + let mut app = OpenCADStudio::new_for_test(); + assert!(!try_dispatch(&mut app, 0, "DP_NOPE")); + } +} \ No newline at end of file diff --git a/src/scene/wire_model.rs b/src/scene/wire_model.rs index dbb357819..a0abd491a 100644 --- a/src/scene/wire_model.rs +++ b/src/scene/wire_model.rs @@ -79,6 +79,9 @@ impl WireModel { pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0]; pub const CYAN: [f32; 4] = [0.25, 0.85, 1.00, 1.0]; pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0]; + /// Civil 3D-style object acquisition highlight (structure / catchment hover). + pub const PICK_HIGHLIGHT: [f32; 4] = [0.95, 0.50, 0.08, 1.0]; + pub const PICK_HIGHLIGHT_DIM: [f32; 4] = [0.95, 0.50, 0.08, 0.45]; /// Sentinel AABB that never rejects any snap query. pub const UNBOUNDED_AABB: [f32; 4] = [ f32::NEG_INFINITY, diff --git a/src/snap/mod.rs b/src/snap/mod.rs index ed22bf1e6..b6e1781ee 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -30,6 +30,8 @@ pub enum SnapType { ApparentIntersection, Parallel, Grid, + /// C3D-style object acquisition (storm structure / network pick) — orange marker. + ObjectPick, } /// Ordered list used by the popup and snap engine. diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 3c1fd8e43..b69ff80cb 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -56,7 +56,7 @@ pub struct HistoryEntry { pub pinned: bool, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum EntryKind { Command, Output, diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 2d98bc91e..452616c25 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -500,18 +500,37 @@ impl canvas::Program for SelectionCanvas { // ── Snap marker ─────────────────────────────────────────────────── if let Some((sp, snap_type)) = self.snap { - let yellow = Color { - r: 1.0, - g: 0.9, - b: 0.1, - a: 1.0, + let (r, g, b) = if snap_type == SnapType::ObjectPick { + (0.95_f32, 0.50, 0.08) // C3D-style orange object snap + } else { + (1.0, 0.9, 0.1) // classic yellow OSNAP }; + let marker = Color { r, g, b, a: 1.0 }; let stroke = canvas::Stroke { - width: 1.5, - style: canvas::Style::Solid(yellow), + width: if snap_type == SnapType::ObjectPick { 2.0 } else { 1.5 }, + style: canvas::Style::Solid(marker), ..Default::default() }; match snap_type { + SnapType::ObjectPick => { + // Target box + center dot (Civil 3D acquisition glyph). + let h = 7.0_f32; + let rect = canvas::Path::rectangle( + Point::new(sp.x - h, sp.y - h), + Size::new(h * 2.0, h * 2.0), + ); + frame.stroke(&rect, stroke.clone()); + let r = 3.0_f32; + frame.fill( + &canvas::Path::circle(sp, r), + Color { + r: 0.95, + g: 0.50, + b: 0.08, + a: 0.85, + }, + ); + } SnapType::Endpoint => { let h = 5.0_f32; let rect = canvas::Path::rectangle( @@ -655,7 +674,7 @@ impl canvas::Program for SelectionCanvas { let r = 1.4_f32; for k in [-7.0_f32, 0.0, 7.0] { let dot = canvas::Path::circle(Point::new(sp.x + k, sp.y), r); - frame.fill(&dot, yellow); + frame.fill(&dot, marker); } } SnapType::Parallel => { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 9c0052caa..adbbc83f7 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -13,8 +13,8 @@ use iced::widget::{button, column, container, mouse_area, row, scrollable, svg, use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; use crate::app::Message; -use crate::modules::registry; use crate::modules::{CadModule, IconKind, RibbonItem}; +use crate::plugin::all_ribbon_modules; use crate::ui::properties::{color_picker_dropdown, lw_options, LinetypeItem}; mod widgets; @@ -67,7 +67,7 @@ pub struct LayerInfo { impl Ribbon { pub fn new() -> Self { Self { - modules: registry::all_modules(), + modules: all_ribbon_modules(), active: 0, active_tool: None, wireframe: false,