forked from 1b-agent/opencadstudio-upstream
Merge pull request #80 from mf4633/feature/plugin-host
Add plugin host (Phase 1)
This commit is contained in:
38 files changed
+1264
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# PR: Add plugin host (Phase 1)
|
||||
|
||||
**Target:** `HakanSeven12/OpenCADStudio`
|
||||
**Branch:** `feature/plugin-host` on `mf4633/OpenCADStudio`
|
||||
**Related:** Issue #78 (Storm Sewer interest check / extension architecture)
|
||||
|
||||
## Summary
|
||||
|
||||
Introduces a QGIS-style add-on architecture so discipline-specific tools (storm sewer, sanitary, geotech, …) can ship **outside** the core application. Core ribbon tabs (Home, Model, View, …) are unchanged. Add-on packages register ribbon + commands through a single `PluginRegistration` hook.
|
||||
|
||||
**This PR contains only the generic host** — no Storm Sewer tab, no `stormsewer` engine crate, no civil/hydraulics code in `src/modules/`.
|
||||
|
||||
## What's included
|
||||
|
||||
| Area | Change |
|
||||
|------|--------|
|
||||
| `src/plugin/` | Manifest, registry, `BuiltinPlugin` trait, `try_dispatch` |
|
||||
| `src/app/plugin_host.rs` | `HostSession` adapter (document, undo, tab state, command line) |
|
||||
| `src/app/document.rs` | Per-tab `plugin_state` map keyed by plugin id |
|
||||
| `src/app/commands.rs` | Plugin dispatch before legacy command match |
|
||||
| `src/command/mod.rs` | Generic `ObjectPickHit` + acquisition hooks on `CadCommand` |
|
||||
| `build.rs` | Skips dirs with `plugin.toml` (add-ons register via plugin host) |
|
||||
| `src/ui/ribbon/mod.rs` | `all_ribbon_modules()` = core tabs + plugin tabs |
|
||||
| `docs/plugin-architecture.md` | Accepted architecture spec |
|
||||
| `docs/plugin-template/` | Scaffold for new add-on authors |
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **`src/plugin/` must not import domain modules** — keeps core mergeable.
|
||||
2. **One package, one registration** — `plugin.toml` + `register.rs` + `BuiltinPlugin`.
|
||||
3. **DWG round-trip** — plugins persist domain data on entity XDATA (documented per add-on in `PLUGIN.md`).
|
||||
4. **Phase 2 ready** — same `plugin.toml` layout for future dynamic `.dll` loading.
|
||||
|
||||
## How to add an add-on (after merge)
|
||||
|
||||
Copy `docs/plugin-template/` → `src/modules/<name>/`, implement `BuiltinPlugin`, add `pub mod <name>;` to `modules/mod.rs`. No edits to `commands.rs`.
|
||||
|
||||
External repos: depend on extracted `ocs_plugin_api` (Phase 1b, follow-up PR).
|
||||
|
||||
## Follow-up (not in this PR)
|
||||
|
||||
- `ocs_plugin_api` workspace crate (semver-stable host surface)
|
||||
- Dynamic plugin loading (`%APPDATA%/OpenCADStudio/plugins/`)
|
||||
- Python/scripting bindings over host API (#29)
|
||||
- Storm Sewer add-on — separate repo/PR: `mf4633` branch `feature/storm-sewer-module`
|
||||
|
||||
## Testing
|
||||
|
||||
```powershell
|
||||
cargo build
|
||||
cargo test --lib
|
||||
```
|
||||
|
||||
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)?
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,339 @@
|
||||
# Open CAD Studio — Plugin Architecture
|
||||
|
||||
**Status:** Accepted (phase 1)
|
||||
**Author:** Open CAD Studio contributors
|
||||
**Date:** June 2026
|
||||
|
||||
This document is the **authoritative spec** for how add-on packages integrate with Open CAD Studio. It follows patterns familiar from [QGIS](https://plugins.qgis.org/) and other open-source extensibility models: a small metadata file, a single entry-point registration, optional separate engine crate, and user-installable packages in a later phase.
|
||||
|
||||
> **Scope:** Generic host runtime only (`src/plugin/`, `src/app/plugin_host.rs`). Domain plugins (Storm Sewer, future sanitary/geotech) live under `src/modules/<name>/` and optional `crates/<engine>/`. They **consume** this API; they are **not** part of the framework source.
|
||||
|
||||
---
|
||||
|
||||
## Design goals
|
||||
|
||||
| Goal | Rationale |
|
||||
|------|-----------|
|
||||
| **One package, one registration** | Ribbon tab, commands, and manifest ship together — no duplicate hooks in `build.rs` and `commands.rs`. |
|
||||
| **Stable host surface** | Plugin authors target `HostSession` / future `ocs_plugin_api` with semver, not `OpenCADStudio` internals. |
|
||||
| **Open-source add-on ergonomics** | Separate git repo + workspace crate is supported; in-tree built-ins use the same layout. |
|
||||
| **DWG round-trip** | Domain data on entities (XDATA), not opaque plugin databases. |
|
||||
| **Engine reuse** | Headless crates (`stormsewer`, …) run in WASM/CLI without the CAD host. |
|
||||
|
||||
## Non-goals (phase 1)
|
||||
|
||||
- Sandboxed scripting (Python/Lua).
|
||||
- Replacing the `acadrust` entity model.
|
||||
- Full Autodesk CUI XML import.
|
||||
|
||||
---
|
||||
|
||||
## Three layers (do not mix)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Layer A — Host core │
|
||||
│ iced UI · Scene · Document · Undo · Command line │
|
||||
│ Built-in ribbon tabs: Home, Model, View, … (NOT plugins) │
|
||||
└───────────────────────────────┬────────────────────────────────────┘
|
||||
│ HostSession (stable adapter)
|
||||
┌───────────────────────────────▼────────────────────────────────────┐
|
||||
│ Layer B — Add-on plugin package │
|
||||
│ plugin.toml · manifest.rs · register.rs · plugin.rs · dispatch.rs │
|
||||
│ Optional ribbon (CadModule) · per-tab state · XDATA schemas │
|
||||
└───────────────────────────────┬────────────────────────────────────┘
|
||||
│ pure Rust API
|
||||
┌───────────────────────────────▼────────────────────────────────────┐
|
||||
│ Layer C — Domain engine crate (optional) │
|
||||
│ crates/stormsewer — hydraulics, IO, no iced/acadrust dependency │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Layer | Examples | May depend on |
|
||||
|-------|----------|---------------|
|
||||
| **A — Host core** | `src/app/`, `src/ui/`, `src/modules/home/` | Everything in the app |
|
||||
| **B — Plugin package** | `src/modules/storm_sewer/` | Host + optional engine crate |
|
||||
| **C — Engine** | `crates/stormsewer/` | `std` only (target: WASM/CLI too) |
|
||||
|
||||
**Hard rules**
|
||||
|
||||
1. `src/plugin/` must **not** import any domain module (`storm_sewer`, …).
|
||||
2. Engine crates must **not** import `iced`, `acadrust`, or `OpenCADStudio`.
|
||||
3. Add-on plugins must **not** edit `src/app/commands.rs` for new commands.
|
||||
|
||||
---
|
||||
|
||||
## Comparison to QGIS
|
||||
|
||||
| QGIS | Open CAD Studio |
|
||||
|------|-----------------|
|
||||
| `metadata.txt` (name, version, author, …) | `plugin.toml` beside the package |
|
||||
| `classFactory(iface)` in `__init__.py` | `inventory::submit!(PluginRegistration { construct })` in `register.rs` |
|
||||
| `iface` stable API | `HostSession` → future `ocs_plugin_api` crate |
|
||||
| User folder `…/python/plugins/<id>/` | Phase 2: `%APPDATA%/OpenCADStudio/plugins/<id>/` |
|
||||
| Plugin repository (plugins.qgis.org) | Future: curated index; today = git + in-tree |
|
||||
| `qgisMinimumVersion` | `api_version` in manifest (host ABI major) |
|
||||
| Processing algorithms | Headless engine crates + `SS_ANALYZE`-style commands |
|
||||
| Vector layer provider | XDATA on DWG entities (`STORMSEWER_*`) |
|
||||
|
||||
QGIS separates **core application** from **Python plugins** loaded at runtime. Open CAD Studio phase 1 compiles add-ons **in-tree** (same ergonomics, static linking). Phase 2 adds dynamic `.dll`/`.so` with the **same** `plugin.toml` and C ABI entry point.
|
||||
|
||||
---
|
||||
|
||||
## Add-on package layout
|
||||
|
||||
Every add-on — whether in-tree or external — uses this directory shape:
|
||||
|
||||
```
|
||||
<plugin_id>/ # e.g. storm_sewer or opencad-storm-sewer repo
|
||||
plugin.toml # human metadata (mirrors manifest.rs)
|
||||
PLUGIN.md # XDATA schemas, command reference
|
||||
register.rs # ONLY inventory::submit! — no domain logic
|
||||
plugin.rs # thin BuiltinPlugin impl
|
||||
manifest.rs # static PluginManifest (compile-time truth)
|
||||
dispatch.rs # command routing for this plugin
|
||||
state.rs # per-document tab state (optional)
|
||||
mod.rs # CadModule ribbon (if the plugin has a tab)
|
||||
icons/ # SVG assets
|
||||
… # domain modules (data.rs, preview.rs, …)
|
||||
|
||||
crates/<engine>/ # optional, separate workspace member
|
||||
Cargo.toml
|
||||
src/
|
||||
```
|
||||
|
||||
### `plugin.toml` (metadata file)
|
||||
|
||||
Source of truth for **humans and phase-2 loader**. Values must match `manifest.rs`.
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "opencad.storm_sewer"
|
||||
name = "Storm Sewer"
|
||||
version = "0.2.0"
|
||||
description = "Gravity storm-drain network design and analysis"
|
||||
author = "Open CAD Studio contributors"
|
||||
license = "GPL-3.0-only"
|
||||
homepage = "https://github.com/…/storm-sewer"
|
||||
|
||||
[opencad]
|
||||
api_version = 1
|
||||
ribbon_order = 50
|
||||
command_prefixes = ["SS_"]
|
||||
xdata_apps = ["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"]
|
||||
```
|
||||
|
||||
**Discovery rule:** If `src/modules/<dir>/plugin.toml` exists, `build.rs` **excludes** that directory from the auto-generated ribbon registry. The tab is registered only via `BuiltinPlugin::ribbon()`.
|
||||
|
||||
---
|
||||
|
||||
## Host runtime API (phase 1)
|
||||
|
||||
### `PluginManifest`
|
||||
|
||||
```rust
|
||||
pub struct PluginManifest {
|
||||
pub id: &'static str, // reverse-DNS: "opencad.storm_sewer"
|
||||
pub name: &'static str,
|
||||
pub version: &'static str,
|
||||
pub description: &'static str,
|
||||
pub api_version: ApiVersion, // host ABI major; must match host
|
||||
pub ribbon_order: i32, // sort key among add-on tabs
|
||||
pub xdata_apps: &'static [&'static str],
|
||||
pub command_prefixes: &'static [&'static str],
|
||||
}
|
||||
```
|
||||
|
||||
### `BuiltinPlugin`
|
||||
|
||||
```rust
|
||||
pub trait BuiltinPlugin: Send + Sync {
|
||||
fn manifest(&self) -> &'static PluginManifest;
|
||||
fn ribbon(&self) -> Box<dyn CadModule>;
|
||||
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
### Registration (single entry point)
|
||||
|
||||
```rust
|
||||
// register.rs — keep this file free of domain logic
|
||||
inventory::submit! {
|
||||
crate::plugin::registry::PluginRegistration {
|
||||
construct: || Box::new(MyPlugin),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Host startup:
|
||||
|
||||
1. `inventory::iter::<PluginRegistration>` constructs all plugins.
|
||||
2. `try_dispatch` routes commands before the legacy `commands.rs` match.
|
||||
3. `all_ribbon_modules()` = core tabs from `build.rs` + plugin tabs sorted by `ribbon_order`.
|
||||
|
||||
### `HostSession` — plugin-facing surface
|
||||
|
||||
Plugins use `HostSession`, not `OpenCADStudio`:
|
||||
|
||||
| Category | Methods |
|
||||
|----------|---------|
|
||||
| Document | `document()`, `document_mut()`, `entities()`, `entities_mut()`, `add_entity()`, `bump_geometry()` |
|
||||
| Tab state | `plugin_state()`, `plugin_state_mut()`, `ensure_plugin_state()` keyed by `manifest.id` |
|
||||
| Command line | `push_info`, `push_output`, `push_error`, `set_active_command` |
|
||||
| Undo / dirty | `push_undo`, `set_dirty` |
|
||||
|
||||
Phase 1b: extract to `crates/ocs_plugin_api` with semver when the surface stabilizes.
|
||||
|
||||
### Command routing
|
||||
|
||||
```rust
|
||||
// app/commands.rs — plugins run first
|
||||
if crate::plugin::try_dispatch(self, tab_index, cmd) {
|
||||
return Task::none();
|
||||
}
|
||||
// … legacy core commands …
|
||||
```
|
||||
|
||||
Plugins own:
|
||||
|
||||
- One-shot commands (`SS_ANALYZE`)
|
||||
- Interactive acquisition (`SS_PIPE` → `PlacePipe`)
|
||||
- Subcommands (`SS_PARAMS RP 25`)
|
||||
|
||||
Autocomplete: each plugin submits `inventory::submit!(CommandRegistration { names: &[…] })` in `mod.rs` or `register.rs`.
|
||||
|
||||
Interactive acquisition (C3D-style orange ObjectPick) stays in the **host** via generic `CadCommand` hooks — `resolve_object_pick`, `object_pick_hover_previews`, `entity_pick_acquire_previews` — so `app/update.rs` never imports domain modules.
|
||||
|
||||
### Per-document state
|
||||
|
||||
```rust
|
||||
DocumentTab {
|
||||
plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
|
||||
}
|
||||
```
|
||||
|
||||
Store under `manifest.id` (e.g. `opencad.storm_sewer`), not ad hoc globals.
|
||||
|
||||
### XDATA contract
|
||||
|
||||
Domain persistence lives on entities. Document schemas in `PLUGIN.md`:
|
||||
|
||||
| App id | Owner | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `STORMSEWER_STRUCT` | `opencad.storm_sewer` | Inlet / junction / outfall |
|
||||
| `STORMSEWER_PIPE` | `opencad.storm_sewer` | Pipe link between structures |
|
||||
| `STORMSEWER_CATCHMENT` | `opencad.storm_sewer` | Catchment boundary + hydrology |
|
||||
|
||||
Host may add `xdata::read_record` / `write_record` helpers later; plugins use `acadrust` XDATA APIs today.
|
||||
|
||||
---
|
||||
|
||||
## Core ribbon vs add-on ribbon
|
||||
|
||||
| Kind | Location | Registration |
|
||||
|------|----------|--------------|
|
||||
| **Core tab** | `src/modules/home/`, `view/`, … | `build.rs` auto-discovers `mod.rs` (no `plugin.toml`) |
|
||||
| **Add-on tab** | `src/modules/storm_sewer/`, … | `plugin.toml` + `BuiltinPlugin::ribbon()` |
|
||||
|
||||
This mirrors QGIS: the application ships core menus; plugins add tabs/tools without patching the host binary.
|
||||
|
||||
---
|
||||
|
||||
## Phased rollout
|
||||
|
||||
### Phase 1 — Built-in add-ons (current)
|
||||
|
||||
- [x] `src/plugin/` runtime + `try_dispatch`
|
||||
- [x] Per-tab `plugin_state`
|
||||
- [x] Storm Sewer off `commands.rs` monolith
|
||||
- [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`)
|
||||
- [ ] Extract `ocs_plugin_api` crate
|
||||
- [ ] Plugin manager UI stub (list installed, versions)
|
||||
|
||||
### Phase 2 — Dynamic loading (desktop)
|
||||
|
||||
```
|
||||
%APPDATA%/OpenCADStudio/plugins/
|
||||
opencad.storm_sewer/
|
||||
plugin.toml
|
||||
opencad_storm_sewer.dll # cdylib
|
||||
```
|
||||
|
||||
- `libloading` + `#[no_mangle] extern "C" fn ocs_plugin_register() -> *const PluginVTable`
|
||||
- `api_version` compatibility gate at load time
|
||||
- Enable/disable in settings (like QGIS plugin manager)
|
||||
|
||||
### Phase 3 — Interchange & QA
|
||||
|
||||
- LandXML / SWMM export as plugins or engine features
|
||||
- Golden-file tests per plugin
|
||||
- Public plugin index (optional)
|
||||
|
||||
### Phase 4 — Live analysis & WASM
|
||||
|
||||
- `on_entity_committed` hooks
|
||||
- WASM-hosted engines on hydrocomplete.com
|
||||
|
||||
---
|
||||
|
||||
## Authoring a new add-on (checklist)
|
||||
|
||||
1. Copy `docs/plugin-template/` into `src/modules/<name>/`.
|
||||
2. Fill `plugin.toml` and `manifest.rs` (keep in sync).
|
||||
3. Implement `CadModule` in `mod.rs` (ribbon).
|
||||
4. Implement `dispatch.rs` (all commands for your prefixes).
|
||||
5. Add `plugin.rs` + `register.rs`.
|
||||
6. Add `pub mod <name>;` to `src/modules/mod.rs`.
|
||||
7. Document XDATA in `PLUGIN.md`.
|
||||
8. Optional: add `crates/<engine>/` and depend from the plugin package only.
|
||||
9. `cargo build` — tab appears via plugin registry; `commands.rs` untouched.
|
||||
|
||||
**External repo:** Publish the engine crate to crates.io; depend on `ocs_plugin_api` (when extracted) + ship a `cdylib` for phase 2. In-tree path: add as a git submodule under `src/modules/<name>/` or `plugins/`.
|
||||
|
||||
---
|
||||
|
||||
## Reference implementation: Storm Sewer
|
||||
|
||||
| Piece | Path |
|
||||
|-------|------|
|
||||
| Metadata | `storm_sewer/plugin.toml`, `manifest.rs` |
|
||||
| Registration | `storm_sewer/register.rs` |
|
||||
| Adapter | `storm_sewer/plugin.rs` |
|
||||
| Commands | `storm_sewer/dispatch.rs` |
|
||||
| Ribbon | `storm_sewer/mod.rs` |
|
||||
| Tab state | `storm_sewer/state.rs` |
|
||||
| XDATA | `storm_sewer/data.rs`, `PLUGIN.md` |
|
||||
| Engine | `crates/stormsewer/` |
|
||||
|
||||
---
|
||||
|
||||
## Workspace layout
|
||||
|
||||
```
|
||||
OpenCADStudio/
|
||||
docs/
|
||||
plugin-architecture.md # this file
|
||||
plugin-template/ # scaffold for new add-ons
|
||||
src/
|
||||
plugin/ # Layer A runtime (generic)
|
||||
modules/
|
||||
home/ # core ribbon (no plugin.toml)
|
||||
storm_sewer/ # add-on (has plugin.toml)
|
||||
crates/
|
||||
stormsewer/ # Layer C engine
|
||||
ocs_plugin_api/ # (phase 1b) stable host API
|
||||
plugins/ # (phase 2) third-party cdylibs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Civil 3D / SSA contrast
|
||||
|
||||
| SSA / Civil 3D | Open CAD Studio add-on |
|
||||
|----------------|------------------------|
|
||||
| Proprietary project DB | DWG + XDATA |
|
||||
| Vendor-only hydraulics | Pluggable `stormsewer` engine |
|
||||
| Monolithic install | QGIS-style optional packages |
|
||||
| Closed API | Documented `HostSession` + `PLUGIN.md` |
|
||||
|
||||
This positions Open CAD Studio as an **open, inspectable** civil CAD platform rather than a single-vendor clone.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Add-on plugin template
|
||||
|
||||
Copy this folder to `src/modules/<your_module>/` and rename placeholders.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Copy files into `src/modules/my_plugin/`.
|
||||
2. Replace `MY_PLUGIN`, `my_plugin`, `opencad.my_plugin`, `MP_` throughout.
|
||||
3. Add `pub mod my_plugin;` to `src/modules/mod.rs`.
|
||||
4. `cargo build` — ribbon tab and commands register automatically.
|
||||
|
||||
## Required files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `plugin.toml` | Metadata (QGIS-style); excludes dir from `build.rs` ribbon scan |
|
||||
| `manifest.rs` | Compile-time `PluginManifest` — keep in sync with `plugin.toml` |
|
||||
| `register.rs` | `inventory::submit!(PluginRegistration { … })` only |
|
||||
| `plugin.rs` | Thin `BuiltinPlugin` impl |
|
||||
| `dispatch.rs` | All command handlers |
|
||||
| `mod.rs` | `CadModule` ribbon + `CommandRegistration` for autocomplete |
|
||||
| `PLUGIN.md` | XDATA schemas and command reference |
|
||||
|
||||
## Optional
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `state.rs` | Per-document tab state via `host.ensure_plugin_state(PLUGIN_ID, …)` |
|
||||
| `crates/my_engine/` | Headless domain logic (no iced/acadrust) |
|
||||
|
||||
See `docs/plugin-architecture.md` for the full spec.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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_"],
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
pub mod dispatch;
|
||||
pub mod manifest;
|
||||
pub mod plugin;
|
||||
pub mod register;
|
||||
|
||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["MP_HELLO"]
|
||||
});
|
||||
|
||||
pub struct MyPluginModule;
|
||||
|
||||
impl CadModule for MyPluginModule {
|
||||
fn id(&self) -> &'static str {
|
||||
"my_plugin"
|
||||
}
|
||||
|
||||
fn title(&self) -> &'static str {
|
||||
"My Plugin"
|
||||
}
|
||||
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
vec![RibbonGroup {
|
||||
title: "Tools",
|
||||
tools: vec![RibbonItem::LargeTool(ToolDef {
|
||||
id: "MP_HELLO",
|
||||
label: "Hello",
|
||||
icon: IconKind::Glyph("★"),
|
||||
event: ModuleEvent::Command("MP_HELLO".to_string()),
|
||||
})],
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::plugin::host::{BuiltinPlugin, HostSession};
|
||||
use crate::plugin::manifest::PluginManifest;
|
||||
|
||||
use super::dispatch;
|
||||
use super::manifest;
|
||||
|
||||
pub struct MyPlugin;
|
||||
|
||||
impl BuiltinPlugin for MyPlugin {
|
||||
fn manifest(&self) -> &'static PluginManifest {
|
||||
&manifest::MANIFEST
|
||||
}
|
||||
|
||||
fn ribbon(&self) -> Box<dyn crate::modules::CadModule> {
|
||||
Box::new(super::MyPluginModule)
|
||||
}
|
||||
|
||||
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool {
|
||||
dispatch::handle(host, cmd)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,7 @@
|
||||
use super::plugin::MyPlugin;
|
||||
|
||||
inventory::submit! {
|
||||
crate::plugin::registry::PluginRegistration {
|
||||
construct: || Box::new(MyPlugin),
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<dyn Any + Send + Sync>>,
|
||||
}
|
||||
|
||||
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<T: Any + Send + Sync + 'static>(
|
||||
&self,
|
||||
plugin_id: &'static str,
|
||||
_type_id: TypeId,
|
||||
) -> Option<&T> {
|
||||
self.plugin_state.get(plugin_id)?.downcast_ref::<T>()
|
||||
}
|
||||
|
||||
pub(super) fn plugin_state_mut<T: Any + Send + Sync + 'static>(
|
||||
&mut self,
|
||||
plugin_id: &'static str,
|
||||
_type_id: TypeId,
|
||||
) -> Option<&mut T> {
|
||||
self.plugin_state.get_mut(plugin_id)?.downcast_mut::<T>()
|
||||
}
|
||||
|
||||
pub(super) fn ensure_plugin_state<T: Any + Send + Sync + 'static>(
|
||||
&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::<T>()
|
||||
.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.
|
||||
|
||||
@@ -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<String> {
|
||||
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<Message>) {
|
||||
|
||||
@@ -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<Item = &EntityType> {
|
||||
self.document().entities()
|
||||
}
|
||||
|
||||
pub fn entities_mut(&mut self) -> impl Iterator<Item = &mut EntityType> {
|
||||
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<dyn CadCommand>) {
|
||||
self.app.tabs[self.tab].active_cmd = Some(cmd);
|
||||
}
|
||||
|
||||
pub fn plugin_state<T: Any + Send + Sync + 'static>(
|
||||
&self,
|
||||
plugin_id: &'static str,
|
||||
) -> Option<&T> {
|
||||
self.app.tabs[self.tab].plugin_state(plugin_id, TypeId::of::<T>())
|
||||
}
|
||||
|
||||
pub fn plugin_state_mut<T: Any + Send + Sync + 'static>(
|
||||
&mut self,
|
||||
plugin_id: &'static str,
|
||||
) -> Option<&mut T> {
|
||||
self.app.tabs[self.tab].plugin_state_mut(plugin_id, TypeId::of::<T>())
|
||||
}
|
||||
|
||||
pub fn ensure_plugin_state<T: Any + Send + Sync + 'static>(
|
||||
&mut self,
|
||||
plugin_id: &'static str,
|
||||
init: impl FnOnce() -> T,
|
||||
) -> &mut T {
|
||||
self.app.tabs[self.tab].ensure_plugin_state(plugin_id, init)
|
||||
}
|
||||
}
|
||||
+115
-4
@@ -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()
|
||||
|
||||
+55
-1
@@ -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<ObjectPickHit> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Preview wires while hovering during object-point pick.
|
||||
fn object_pick_hover_previews(&self, _scene: &Scene, _cursor: Vec3) -> Vec<WireModel> {
|
||||
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<WireModel> {
|
||||
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) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ mod entities;
|
||||
mod io;
|
||||
mod linetypes;
|
||||
mod modules;
|
||||
mod plugin;
|
||||
mod patterns;
|
||||
mod scene;
|
||||
mod snap;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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_"],
|
||||
};
|
||||
@@ -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<RibbonGroup> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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<dyn crate::modules::CadModule> {
|
||||
Box::new(super::DemoPluginModule)
|
||||
}
|
||||
|
||||
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool {
|
||||
dispatch::handle(host, cmd)
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
@@ -0,0 +1,7 @@
|
||||
use super::plugin::DemoPlugin;
|
||||
|
||||
inventory::submit! {
|
||||
crate::plugin::registry::PluginRegistration {
|
||||
construct: || Box::new(DemoPlugin),
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field access).
|
||||
|
||||
pub(crate) use crate::app::plugin_host::HostSession;
|
||||
|
||||
use crate::modules::CadModule;
|
||||
|
||||
use super::manifest::PluginManifest;
|
||||
|
||||
/// Add-on package entry point (phase 1: in-tree, in-process).
|
||||
///
|
||||
/// One `PluginRegistration` per package — ribbon tab, manifest, and command
|
||||
/// dispatch are owned here. See `docs/plugin-architecture.md`.
|
||||
pub trait BuiltinPlugin: Send + Sync {
|
||||
fn manifest(&self) -> &'static PluginManifest;
|
||||
fn ribbon(&self) -> Box<dyn CadModule>;
|
||||
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool;
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Open CAD Studio plugin runtime (phase 1: built-in, in-process).
|
||||
//
|
||||
// Generic host only — no domain logic. See `docs/plugin-architecture.md`.
|
||||
// Domain plugins (e.g. storm_sewer) live under `src/modules/<name>/` and
|
||||
// register here via `inventory::submit!(PluginRegistration { … })`.
|
||||
|
||||
pub mod host;
|
||||
pub mod manifest;
|
||||
pub mod registry;
|
||||
|
||||
pub use registry::all_ribbon_modules;
|
||||
pub(crate) use host::BuiltinPlugin;
|
||||
pub(crate) use registry::try_dispatch;
|
||||
@@ -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<dyn BuiltinPlugin>,
|
||||
}
|
||||
|
||||
inventory::collect!(PluginRegistration);
|
||||
|
||||
/// Construct every registered built-in plugin (once per process).
|
||||
pub fn all_plugins() -> Vec<Box<dyn BuiltinPlugin>> {
|
||||
inventory::iter::<PluginRegistration>
|
||||
.into_iter()
|
||||
.map(|r| (r.construct)())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Core ribbon tabs plus add-on tabs (sorted by `manifest.ribbon_order`).
|
||||
pub fn all_ribbon_modules() -> Vec<Box<dyn CadModule>> {
|
||||
let mut core = core_registry::all_modules();
|
||||
let mut addons: Vec<(i32, Box<dyn CadModule>)> = all_plugins()
|
||||
.into_iter()
|
||||
.map(|p| (p.manifest().ribbon_order, p.ribbon()))
|
||||
.collect();
|
||||
addons.sort_by_key(|(order, _)| *order);
|
||||
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
|
||||
core
|
||||
}
|
||||
|
||||
/// Try each plugin until one handles `cmd`. Returns true if handled.
|
||||
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
|
||||
let mut host = HostSession::new(app, tab);
|
||||
for plugin in all_plugins() {
|
||||
if plugin.dispatch(&mut host, cmd) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct HistoryEntry {
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum EntryKind {
|
||||
Command,
|
||||
Output,
|
||||
|
||||
+27
-8
@@ -500,18 +500,37 @@ impl canvas::Program<Message> 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<Message> 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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user