forked from 1b-agent/opencadstudio-upstream
feat(plugin): InteractiveCommand hook for click-to-place (API v2)
Plugins could dispatch commands but not register interactive (click-to- place) tools — the gap mf4633 flagged on #100 for Storm Sewer's SS_INLET / SS_PIPE. Add an InteractiveCommand trait + CommandStep to ocs_plugin_api and HostApi::start_interactive; a host adapter bridges it to the internal CadCommand, so a plugin tool drives the host's point-collection flow. Hybrid by construction: the same command works by clicking in the viewport AND by feeding coordinates over --serve (run "CMD x,y x,y"). Adding a HostApi method changes the contract vtable, so API_VERSION bumps to 2 — v1 plugin binaries are now refused at load. Part of the #100 extensibility epic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d3d0c30cc
commit
931eb908a9
5 files changed
+141
-4
No files matched your search
@@ -29,6 +29,38 @@ pub trait BuiltinPlugin: Send + Sync {
|
|||||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
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.
|
/// 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`
|
/// 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_output(&mut self, msg: &str);
|
||||||
fn push_error(&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<dyn InteractiveCommand>);
|
||||||
|
|
||||||
// ── Per-tab plugin state (object-safe; use the typed helpers below) ──────
|
// ── 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(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>;
|
||||||
fn plugin_state_any_mut(&mut self, plugin_id: &str)
|
fn plugin_state_any_mut(&mut self, plugin_id: &str)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
//! Plugin identity and capability declaration.
|
//! Plugin identity and capability declaration.
|
||||||
|
|
||||||
/// Host plugin API version. Bump when the host runtime surface breaks
|
/// Host plugin API version. Bump when the host runtime surface breaks
|
||||||
/// compatibility.
|
/// compatibility. v2 added `HostApi::start_interactive` (the
|
||||||
pub const API_VERSION: u32 = 1;
|
/// `InteractiveCommand` hook) — a vtable change, so v1 binaries are refused.
|
||||||
|
pub const API_VERSION: u32 = 2;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct ApiVersion {
|
pub struct ApiVersion {
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ version = "0.1.0"
|
|||||||
description = "…"
|
description = "…"
|
||||||
|
|
||||||
[opencad]
|
[opencad]
|
||||||
api_version = 1
|
api_version = 2
|
||||||
ribbon_order = 50
|
ribbon_order = 50
|
||||||
command_prefixes = ["EX_"]
|
command_prefixes = ["EX_"]
|
||||||
xdata_apps = []
|
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
|
tool request a native file picker; on selection the host dispatches
|
||||||
`"<command> <path>"` back with the path's original case preserved.
|
`"<command> <path>"` 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
|
### XDATA — domain persistence
|
||||||
|
|
||||||
Store domain data on entities as XDATA (under your `xdata_apps` ids), not in a
|
Store domain data on entities as XDATA (under your `xdata_apps` ids), not in a
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ author = "Your Name"
|
|||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
|
|
||||||
[opencad]
|
[opencad]
|
||||||
api_version = 1
|
api_version = 2
|
||||||
ribbon_order = 60
|
ribbon_order = 60
|
||||||
command_prefixes = ["MP_"]
|
command_prefixes = ["MP_"]
|
||||||
xdata_apps = ["MYPLUGIN_RECORD"]
|
xdata_apps = ["MYPLUGIN_RECORD"]
|
||||||
@@ -179,6 +179,13 @@ impl HostApi for HostSession<'_> {
|
|||||||
fn push_error(&mut self, msg: &str) {
|
fn push_error(&mut self, msg: &str) {
|
||||||
self.push_error(msg)
|
self.push_error(msg)
|
||||||
}
|
}
|
||||||
|
fn start_interactive(
|
||||||
|
&mut self,
|
||||||
|
command: Box<dyn ocs_plugin_api::host::InteractiveCommand>,
|
||||||
|
) {
|
||||||
|
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)> {
|
fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> {
|
||||||
self.app.tabs[self.tab]
|
self.app.tabs[self.tab]
|
||||||
.plugin_state
|
.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<dyn ocs_plugin_api::host::InteractiveCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -258,4 +301,43 @@ mod tests {
|
|||||||
*host::plugin_state_mut::<u32>(host, "opencad.demo").unwrap() = 100;
|
*host::plugin_state_mut::<u32>(host, "opencad.demo").unwrap() = 100;
|
||||||
assert_eq!(*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(), 100);
|
assert_eq!(*host::plugin_state::<u32>(&*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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user