feat(plugin): object-pick acquisition for interactive commands

Extend InteractiveCommand with needs_object_pick / on_object_pick so a
plugin tool can prompt the user to pick an existing entity (handle + point)
rather than a free point — the structure-pick path Storm Sewer's SS_PIPE
needs to connect existing structures. The host adapter delegates to the
internal entity-pick flow; over --serve the pick is fed as a hex handle.
Stays API v2 (no released plugin implements InteractiveCommand yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-18 01:43:38 +03:00
commit 707c143212
4 changed files with 114 additions and 2 deletions

View file

@ -45,6 +45,19 @@ pub trait InteractiveCommand: Send {
fn on_enter(&mut self) -> CommandStep {
CommandStep::Cancel
}
/// When `true`, the next input picks an existing **entity** (the user clicks
/// on it; over `--serve`, a handle is supplied) rather than a free point —
/// the host then calls [`on_object_pick`](Self::on_object_pick). Use this to
/// reference existing geometry (e.g. connect a pipe between two structures).
fn needs_object_pick(&self) -> bool {
false
}
/// An existing entity was picked: its `handle` and the pick point. Read the
/// entity's data (XDATA / geometry) via `HostApi`, keyed by the handle.
fn on_object_pick(&mut self, _handle: Handle, _pt: [f64; 3]) -> CommandStep {
CommandStep::Cancel
}
}
/// The outcome of an [`InteractiveCommand`] step.

View file

@ -243,6 +243,12 @@ command works by clicking in the viewport and by feeding coordinates over the
API to **v2** — the added `HostApi` method changes the contract's vtable, so v1
binaries are refused at load.
To reference **existing** geometry (e.g. connect a pipe between two structures),
set `needs_object_pick() -> true`; the host then calls
`on_object_pick(handle, pt)` with the clicked entity's handle (read its
XDATA/geometry via `HostApi`). Over `--serve` the pick is supplied as a hex
handle: `run "MY_CMD 2F 30"`.
### XDATA — domain persistence
Store domain data on entities as XDATA (under your `xdata_apps` ids), not in a

View file

@ -249,10 +249,42 @@ impl OpenCADStudio {
}
}
/// Feed one token to the active command: a coordinate becomes a point, any
/// other token an option keyword.
/// Feed one token to the active command. When the command is picking an
/// existing entity, the token is a hex handle; otherwise a coordinate point
/// or an option keyword.
fn feed_active_cmd(&mut self, token: &str) {
let i = self.active_tab;
// Object-pick step: the token is a handle (as returned by `query`).
if self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|c| c.needs_entity_pick())
{
if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) {
let handle = acadrust::Handle::new(v);
let pt = self.tabs[i]
.scene
.document
.get_entity(handle)
.map(|e| {
let bb = e.as_entity().bounding_box();
glam::Vec3::new(
((bb.min.x + bb.max.x) * 0.5) as f32,
((bb.min.y + bb.max.y) * 0.5) as f32,
0.0,
)
})
.unwrap_or(glam::Vec3::ZERO);
if let Some(r) = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_entity_pick(handle, pt))
{
let _ = self.apply_cmd_result(r);
}
}
return;
}
if let Some((mut pt, kind)) = super::helpers::parse_coord(token) {
if matches!(kind, super::helpers::CoordKind::Relative) {
if let Some(base) = self.last_point {

View file

@ -235,6 +235,15 @@ impl crate::command::CadCommand for PluginInteractiveAdapter {
fn on_enter(&mut self) -> crate::command::CmdResult {
plugin_step_to_result(self.inner.on_enter())
}
fn needs_entity_pick(&self) -> bool {
self.inner.needs_object_pick()
}
fn on_entity_pick(&mut self, handle: Handle, pt: glam::Vec3) -> crate::command::CmdResult {
plugin_step_to_result(
self.inner
.on_object_pick(handle, [pt.x as f64, pt.y as f64, pt.z as f64]),
)
}
}
fn plugin_step_to_result(
@ -340,4 +349,56 @@ mod tests {
assert_eq!(app.tabs[0].scene.document.entities().count(), 1);
assert!(app.tabs[0].active_cmd.is_none(), "command should have ended");
}
/// A plugin command that picks an existing object, then marks it.
struct PickThenMark;
impl ocs_plugin_api::host::InteractiveCommand for PickThenMark {
fn prompt(&self) -> String {
"Pick an object".into()
}
fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep {
ocs_plugin_api::host::CommandStep::Cancel
}
fn needs_object_pick(&self) -> bool {
true
}
fn on_object_pick(
&mut self,
_handle: acadrust::Handle,
pt: [f64; 3],
) -> ocs_plugin_api::host::CommandStep {
let p = acadrust::entities::Point::at(acadrust::types::Vector3::new(
pt[0], pt[1], pt[2],
));
ocs_plugin_api::host::CommandStep::CommitAndEnd(acadrust::EntityType::Point(p))
}
}
#[test]
fn plugin_object_pick_routes_to_command() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let target = {
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(acadrust::EntityType::Point(
acadrust::entities::Point::at(acadrust::types::Vector3::new(3.0, 4.0, 0.0)),
));
host.start_interactive(Box::new(PickThenMark));
h
};
// The command requested an entity pick, not a free point.
assert!(app.tabs[0]
.active_cmd
.as_ref()
.unwrap()
.needs_entity_pick());
let r = app.tabs[0]
.active_cmd
.as_mut()
.unwrap()
.on_entity_pick(target, glam::Vec3::new(3.0, 4.0, 0.0));
let _ = app.apply_cmd_result(r);
// Original point + the mark the command committed.
assert_eq!(app.tabs[0].scene.document.entities().count(), 2);
}
}