diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 115995b6..02f3ebf6 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -29,6 +29,38 @@ pub trait BuiltinPlugin: Send + Sync { fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; } +/// A point-driven interactive command a plugin starts via +/// [`HostApi::start_interactive`]. The host shows the prompt, collects points — +/// clicked in the viewport, or fed as coordinates over the `--serve` automation +/// API — and commits the entities the command yields, exactly like a built-in +/// tool. This is the plugin-facing slice of the host's command machinery; it +/// covers click-to-place placement without exposing the host's internal command +/// trait. +pub trait InteractiveCommand: Send { + /// Prompt for the next point. + fn prompt(&self) -> String; + /// A point was supplied (clicked or typed `x,y[,z]`). Returns the next step. + fn on_point(&mut self, pt: [f64; 3]) -> CommandStep; + /// Enter pressed with no point — e.g. to finish a multi-point command. + fn on_enter(&mut self) -> CommandStep { + CommandStep::Cancel + } +} + +/// The outcome of an [`InteractiveCommand`] step. +pub enum CommandStep { + /// Need another point; keep the command active. + NeedPoint, + /// Commit an entity to the document and keep collecting points. + Commit(EntityType), + /// Commit an entity and end the command. + CommitAndEnd(EntityType), + /// End the command without committing. + Done, + /// Cancel the command. + Cancel, +} + /// Export a `BuiltinPlugin` from a `cdylib` so the host can load it at runtime. /// /// Emits the two C symbols the loader looks for: `ocs_plugin_api_version` @@ -90,6 +122,10 @@ pub trait HostApi { fn push_output(&mut self, msg: &str); fn push_error(&mut self, msg: &str); + /// Start a plugin-defined interactive (click-to-place) command on the active + /// tab. The host drives it through its normal point-collection flow. + fn start_interactive(&mut self, command: Box); + // ── Per-tab plugin state (object-safe; use the typed helpers below) ────── fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>; fn plugin_state_any_mut(&mut self, plugin_id: &str) diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index e07181bd..5612fa10 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -1,8 +1,9 @@ //! Plugin identity and capability declaration. /// Host plugin API version. Bump when the host runtime surface breaks -/// compatibility. -pub const API_VERSION: u32 = 1; +/// compatibility. v2 added `HostApi::start_interactive` (the +/// `InteractiveCommand` hook) — a vtable change, so v1 binaries are refused. +pub const API_VERSION: u32 = 2; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ApiVersion { diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 79ed3777..1b5e68fe 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -203,7 +203,7 @@ version = "0.1.0" description = "…" [opencad] -api_version = 1 +api_version = 2 ribbon_order = 50 command_prefixes = ["EX_"] xdata_apps = [] @@ -225,6 +225,24 @@ tool fires `ModuleEvent::Command("EX_FOO")`, which round-trips to tool request a native file picker; on selection the host dispatches `" "` back with the path's original case preserved. +### Interactive (click-to-place) commands + +For tools that collect points — placing a structure, drawing a pipe — call +`host.start_interactive(Box::new(my_cmd))` from `dispatch`, where `my_cmd` +implements `ocs_plugin_api::host::InteractiveCommand`: + +```rust +fn on_point(&mut self, pt: [f64; 3]) -> CommandStep { + // … return NeedPoint, Commit(entity), CommitAndEnd(entity), Done, or Cancel +} +``` + +The host drives it through its normal point-collection flow, so the **same** +command works by clicking in the viewport and by feeding coordinates over the +`--serve` automation API (`run "MY_CMD 0,0 10,10"`). This is what bumped the +API to **v2** — the added `HostApi` method changes the contract's vtable, so v1 +binaries are refused at load. + ### XDATA — domain persistence Store domain data on entities as XDATA (under your `xdata_apps` ids), not in a diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml index 5961f112..904c73c6 100644 --- a/docs/plugin-template/plugin.toml +++ b/docs/plugin-template/plugin.toml @@ -7,7 +7,7 @@ author = "Your Name" license = "GPL-3.0-only" [opencad] -api_version = 1 +api_version = 2 ribbon_order = 60 command_prefixes = ["MP_"] xdata_apps = ["MYPLUGIN_RECORD"] \ No newline at end of file diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 58482dab..353519d3 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -179,6 +179,13 @@ impl HostApi for HostSession<'_> { fn push_error(&mut self, msg: &str) { self.push_error(msg) } + fn start_interactive( + &mut self, + command: Box, + ) { + self.app.tabs[self.tab].active_cmd = + Some(Box::new(PluginInteractiveAdapter { inner: command })); + } fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> { self.app.tabs[self.tab] .plugin_state @@ -207,6 +214,42 @@ impl HostApi for HostSession<'_> { } } +/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) +/// to the host's internal `CadCommand`, so a plugin tool drives the host's +/// point-collection flow (viewport clicks or `--serve` coordinates) just like a +/// built-in tool. +struct PluginInteractiveAdapter { + inner: Box, +} + +impl crate::command::CadCommand for PluginInteractiveAdapter { + fn name(&self) -> &'static str { + "PLUGIN" + } + fn prompt(&self) -> String { + self.inner.prompt() + } + fn on_point(&mut self, pt: glam::Vec3) -> crate::command::CmdResult { + plugin_step_to_result(self.inner.on_point([pt.x as f64, pt.y as f64, pt.z as f64])) + } + fn on_enter(&mut self) -> crate::command::CmdResult { + plugin_step_to_result(self.inner.on_enter()) + } +} + +fn plugin_step_to_result( + step: ocs_plugin_api::host::CommandStep, +) -> crate::command::CmdResult { + use crate::command::CmdResult; + use ocs_plugin_api::host::CommandStep; + match step { + CommandStep::NeedPoint => CmdResult::NeedPoint, + CommandStep::Commit(e) => CmdResult::CommitEntity(e), + CommandStep::CommitAndEnd(e) => CmdResult::CommitAndExit(e), + CommandStep::Done | CommandStep::Cancel => CmdResult::Cancel, + } +} + #[cfg(test)] mod tests { use super::*; @@ -258,4 +301,43 @@ mod tests { *host::plugin_state_mut::(host, "opencad.demo").unwrap() = 100; assert_eq!(*host::plugin_state::(&*host, "opencad.demo").unwrap(), 100); } + + /// A plugin command: second point commits a Point and ends. + struct PlacePoint { + got_first: bool, + } + impl ocs_plugin_api::host::InteractiveCommand for PlacePoint { + fn prompt(&self) -> String { + "Pick a point".into() + } + fn on_point(&mut self, pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep { + use ocs_plugin_api::host::CommandStep; + if self.got_first { + let p = acadrust::entities::Point::at(acadrust::types::Vector3::new( + pt[0], pt[1], pt[2], + )); + CommandStep::CommitAndEnd(acadrust::EntityType::Point(p)) + } else { + self.got_first = true; + CommandStep::NeedPoint + } + } + } + + #[test] + fn plugin_interactive_command_drives_host_flow() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + { + let mut host = HostSession::new(&mut app, 0); + host.start_interactive(Box::new(PlacePoint { got_first: false })); + } + assert!(app.tabs[0].active_cmd.is_some()); + for pt in [glam::Vec3::new(0.0, 0.0, 0.0), glam::Vec3::new(5.0, 5.0, 0.0)] { + let r = app.tabs[0].active_cmd.as_mut().unwrap().on_point(pt); + let _ = app.apply_cmd_result(r); + } + assert_eq!(app.tabs[0].scene.document.entities().count(), 1); + assert!(app.tabs[0].active_cmd.is_none(), "command should have ended"); + } } \ No newline at end of file