Merge pull request #1423 from felixriestra:pr/python-host-model

The bundled `opencad.python` plugin drives OCS through plugin API v7: all
43 canvas entity kinds, the drawing tables, and OCS's own commands run
step by step. RustPython stays in the plugin process — the plugin crate is
outside the workspace with its own lock file, so the application's own
dependencies are unchanged.

Reconciled with the plugin command work that landed as #1425, which the
branch was written before:

- Both added `impl Drop for PluginProcessInteractiveAdapter`, which git
  merges silently into two impls that do not compile. One body now
  cancels the command and then drops it from the runner, in that order: a
  plugin waiting on a step has to be released before the runner forgets
  the command.
- `execute_command` (#1425) and `run_command` (#1423) are both routes from
  a script to the command line, and only the second was guarded. The
  SCRIPTCOMMANDS switch and the denylist are shared now, so neither route
  is the unguarded one.
- `run_command` dispatched with plugin dispatch still enabled, so a script
  naming a plugin command re-entered the plugin that was blocked waiting
  for it. It sets the suppression flag for the length of the dispatch, as
  `execute_command` does.

The denylist read the command as typed, while the dispatcher resolves
aliases: a user-defined `Q` for `QUIT` (ALIASEDIT writes those) walked
past it. It resolves the alias first.

Also: the active-command comparison ran on every message, a mouse move
included, allocating two strings for names that are already `&'static
str`; SCRIPTCOMMANDS' own messages go through the translation macros; and
`docs/.DS_Store` (now ignored) and an unrelated RTF note about constraint
commands are out of the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-09-22 23:26:30 +03:00
commit 41ecc0c458
72 files changed
+30614 -91

No files matched your search

+42
View File
@@ -283,6 +283,8 @@ impl OpenCADStudio {
// selection check has to run on this path too.
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_selection_changed();
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_document_changed();
res
}
@@ -767,6 +769,46 @@ impl OpenCADStudio {
#[cfg(test)]
mod tests {
#[test]
fn clayer_command_sets_layer_used_by_new_geometry() {
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LAYER NEW Annotations"}"#);
app.automation_op(r#"{"op":"run","cmd":"CLAYER Annotations"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#);
let lines = app.automation_op(r#"{"op":"query","type":"Line"}"#);
assert_eq!(lines["entities"][0]["layer"], "Annotations");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn built_in_edits_advance_plugin_document_fingerprint_once() {
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let before = app.last_plugin_document.expect("new drawing published");
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#);
let after = app.last_plugin_document.expect("line edit published");
assert_eq!(after.0, app.tabs[app.active_tab].id);
assert_ne!(after, before);
assert_eq!(after.1, app.tabs[app.active_tab].scene.geometry_epoch);
app.automation_op(r#"{"op":"query","type":"Line"}"#);
assert_eq!(app.last_plugin_document, Some(after));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn plugin_edit_publication_is_not_repeated_at_message_boundary() {
use acadrust::entities::Point;
use acadrust::EntityType;
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
let mut host = super::super::plugin_host::HostSession::new(&mut app, tab);
host.add_entity(EntityType::Point(Point::new()));
let published = app.last_plugin_document.expect("plugin write published");
app.notify_plugins_document_changed();
assert_eq!(app.last_plugin_document, Some(published));
}
use crate::app::OpenCADStudio;
#[test]
+24 -15
View File
@@ -2745,21 +2745,7 @@ impl OpenCADStudio {
self.command_line
.push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref());
} else {
if self.tabs[i].scene.document.layers.contains(name_arg) {
let handle = self.tabs[i]
.scene
.document
.layers
.get(name_arg)
.map(|l| l.handle)
.unwrap_or(acadrust::types::Handle::NULL);
self.tabs[i].scene.document.header.current_layer_name =
name_arg.to_string();
self.tabs[i].scene.document.header.current_layer_handle = handle;
self.tabs[i].active_layer = name_arg.to_string();
self.tabs[i].layers.current_layer = name_arg.to_string();
self.ribbon.active_layer = name_arg.to_string();
self.tabs[i].dirty = true;
if self.set_current_layer_name(i, name_arg).is_ok() {
self.command_line
.push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref());
} else {
@@ -2944,6 +2930,29 @@ impl OpenCADStudio {
_ => self.command_line.push_error(crate::t!("Requires 0 or 1").as_ref()),
}
}
cmd if cmd == "SCRIPTCOMMANDS" || cmd.starts_with("SCRIPTCOMMANDS ") => {
// A user setting, deliberately outside the script's reach: it is refused by
// the script command runner, so only the command line can change it.
let arg = cmd.trim_start_matches("SCRIPTCOMMANDS").trim();
match arg {
"" => {}
"1" | "ON" | "YES" => self.script_commands = true,
"0" | "OFF" | "NO" => self.script_commands = false,
_ => {
self.command_line.push_error(
crate::t!("SCRIPTCOMMANDS takes 1 (scripts may run commands) or 0 (they may not)")
.as_ref(),
);
return Some(Task::none());
}
}
if !arg.is_empty() {
self.persist_settings_if_changed();
}
let state = if self.script_commands { 1 } else { 0 };
self.command_line
.push_output(crate::tf!("SCRIPTCOMMANDS = {state}").as_ref());
}
"SAVETIME" => {
use crate::command::ValuePromptCommand;
let c = ValuePromptCommand::new(
+1 -1
View File
@@ -314,7 +314,7 @@ fn command_selection_policy(name: &str) -> &'static str {
}
}
fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
pub(super) fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
let options = command.options();
let mut accepts = Vec::new();
if command.is_selection_gathering() {
+21
View File
@@ -2,6 +2,27 @@ use super::OpenCADStudio;
use crate::ui;
impl OpenCADStudio {
/// Apply the current layer to the document header and all per-tab/UI
/// creation state. Both built-in commands and plugin settings use this.
pub(super) fn set_current_layer_name(&mut self, tab: usize, layer: &str) -> Result<(), String> {
let handle = self.tabs[tab]
.scene
.document
.layers
.get(layer)
.map(|entry| entry.handle)
.ok_or_else(|| format!("layer {layer:?} does not exist"))?;
self.tabs[tab].scene.document.header.current_layer_name = layer.to_owned();
self.tabs[tab].scene.document.header.current_layer_handle = handle;
self.tabs[tab].active_layer = layer.to_owned();
self.tabs[tab].layers.current_layer = layer.to_owned();
self.tabs[tab].dirty = true;
if tab == self.active_tab {
self.ribbon.active_layer = layer.to_owned();
self.refresh_layer_panel();
}
Ok(())
}
pub(super) fn load_layer_state_editor(&mut self, selected: Option<String>) {
let i = self.active_tab;
if let Some(name) = selected {
+11 -1
View File
@@ -35,7 +35,7 @@ mod record_api;
pub(crate) mod settings;
mod shortcuts;
mod startup;
mod style_ops;
pub(crate) mod style_ops;
mod text_inline;
mod tolerance_dialog;
mod update;
@@ -634,6 +634,8 @@ pub(super) struct OpenCADStudio {
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave.
pub savetime_min: i32,
/// SCRIPTCOMMANDS: scripts may run OCS commands (see `UserSettings`).
pub script_commands: bool,
/// Persisted default viewport background, restored from settings and applied
/// to every drawing tab (new and opened) so a chosen background survives
/// restarts (#188). `None` = the built-in dark-grey / off-white defaults.
@@ -904,6 +906,11 @@ pub(super) struct OpenCADStudio {
/// `SelectionChangedV4` fires once per real change rather than per message.
#[cfg(not(target_arch = "wasm32"))]
last_plugin_selection: Option<(u64, u64)>,
/// `(tab id, geometry epoch)` last published to the V4 document view.
/// Built-in edits bypass HostSession, so this is checked at message
/// boundaries as well as after plugin-initiated writes.
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document: Option<(u64, u64)>,
/// External add-on packages found in the plugins folder, refreshed when the
/// Plugin Manager opens.
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
@@ -4000,6 +4007,7 @@ impl OpenCADStudio {
suppress_plugin_dispatch: false,
constraint_bar_mode: 4095,
savetime_min: 10,
script_commands: true,
default_bg_color: None,
default_paper_bg_color: None,
cliprompt_lines: 3,
@@ -4101,6 +4109,8 @@ impl OpenCADStudio {
disabled_plugins: rustc_hash::FxHashSet::default(),
#[cfg(not(target_arch = "wasm32"))]
last_plugin_selection: None,
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document: None,
external_plugins: Vec::new(),
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
plugin_load_errors: rustc_hash::FxHashMap::default(),
+8336 -22
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -485,6 +485,10 @@ pub struct UserSettings {
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
/// 0 disables autosave.
pub savetime_min: i32,
/// SCRIPTCOMMANDS: whether a script (the Python plugin) may run OCS commands
/// through the host. On by default; a user turns it off with the
/// `SCRIPTCOMMANDS 0` command, and a script cannot change it.
pub script_commands: bool,
/// File type and version used when a new/unsaved drawing is first saved.
/// Existing drawings keep their own type and version.
pub default_save_format: String,
@@ -685,6 +689,7 @@ impl Default for UserSettings {
constraint_bar_display: 3,
constraint_bar_mode: 4095,
savetime_min: 10,
script_commands: true,
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
pick_add: true,
pick_drag_rect: false,
@@ -798,6 +803,19 @@ mod tests {
assert_eq!(cfg.settings.savetime_min, 42);
}
#[test]
fn script_commands_default_on_and_survive_a_round_trip() {
let missing: crate::app::config::AppConfig =
serde_json::from_str(r#"{"settings": {"pick_add": false}}"#).unwrap();
assert!(missing.settings.script_commands, "an old config keeps scripts allowed");
let off: crate::app::config::AppConfig =
serde_json::from_str(r#"{"settings": {"script_commands": false}}"#).unwrap();
assert!(!off.settings.script_commands);
let text = serde_json::to_string(&off).unwrap();
let back: crate::app::config::AppConfig = serde_json::from_str(&text).unwrap();
assert!(!back.settings.script_commands);
}
#[test]
fn isometric_drafting_never_persists_or_loads() {
// A persisted On used to bring the isoplane crosshair (vertical +
+4 -4
View File
@@ -271,7 +271,7 @@ impl OpenCADStudio {
}
}
fn style_exists(&self, kind: StyleKind, name: &str) -> bool {
pub(super) fn style_exists(&self, kind: StyleKind, name: &str) -> bool {
self.style_names(kind)
.iter()
.any(|n| n.eq_ignore_ascii_case(name))
@@ -308,7 +308,7 @@ impl OpenCADStudio {
/// Refresh anything that mirrors the style list / current style after a
/// mutation (ribbon dropdowns, geometry that depends on the style).
fn after_style_change(&mut self, kind: StyleKind) {
pub(super) fn after_style_change(&mut self, kind: StyleKind) {
if kind.in_ribbon() {
self.sync_ribbon_styles();
}
@@ -405,7 +405,7 @@ impl OpenCADStudio {
false
}
fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool {
pub(super) fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool {
let doc = &mut self.tabs[self.active_tab].scene.document;
match kind {
StyleKind::Text => doc.text_styles.remove(name).is_some(),
@@ -541,7 +541,7 @@ impl OpenCADStudio {
/// Rename `old`→`new` in the backing store, re-keying table entries and
/// rewriting name-based references + current-style pointers.
fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) {
pub(super) fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) {
let i = self.active_tab;
match kind {
StyleKind::Text => {
+2
View File
@@ -619,6 +619,7 @@ impl OpenCADStudio {
constraint_bar_display: self.constraint_bar_display,
constraint_bar_mode: self.constraint_bar_mode,
savetime_min: self.savetime_min,
script_commands: self.script_commands,
default_save_format: self.default_save_format.clone(),
pick_add: self.pick_add,
pick_drag_rect: self.pick_drag_rect,
@@ -723,6 +724,7 @@ impl OpenCADStudio {
self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3);
self.constraint_bar_mode = s.constraint_bar_mode.clamp(0, 4095);
self.savetime_min = s.savetime_min;
self.script_commands = s.script_commands;
self.default_save_format =
crate::io::canonical_save_format(&s.default_save_format).to_string();
self.pick_add = s.pick_add;
+42
View File
@@ -284,6 +284,24 @@ impl OpenCADStudio {
crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles);
}
/// Refresh the shared V4 document after built-in geometry edits. Plugin
/// writes publish through HostSession immediately; the fingerprint avoids
/// sending a duplicate notification at this message boundary.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn notify_plugins_document_changed(&mut self) {
if self.active_tab >= self.tabs.len() {
return;
}
let tab = &self.tabs[self.active_tab];
let key = (tab.id, tab.scene.geometry_epoch);
if self.last_plugin_document == Some(key) {
return;
}
self.last_plugin_document = Some(key);
crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch);
crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document);
}
pub fn update(&mut self, msg: Message) -> Task<Message> {
if let Some(tab) = self.tabs.get(self.active_tab) {
crate::entities::common::set_unit_context(
@@ -355,7 +373,29 @@ impl OpenCADStudio {
return task;
}
}
// A command's name is `&'static str`, so this runs on every message —
// a mouse move included — without allocating.
#[cfg(not(target_arch = "wasm32"))]
let active_command = |app: &Self| -> Option<(u64, Option<&'static str>)> {
app.tabs
.get(app.active_tab)
.map(|tab| (tab.id, tab.active_cmd.as_ref().map(|command| command.name())))
};
#[cfg(not(target_arch = "wasm32"))]
let command_before = active_command(self);
let task = self.update_inner(msg);
#[cfg(not(target_arch = "wasm32"))]
{
let command_after = active_command(self);
if command_before != command_after {
if let Some((tab_id, command)) = command_after {
crate::plugin::v4_support::publish_command_state_changed(
tab_id,
command.map(str::to_owned),
);
}
}
}
self.refresh_gpu_status();
self.show_next_startup_modal();
self.sync_open_command_history();
@@ -389,6 +429,8 @@ impl OpenCADStudio {
// and plugin request draining).
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_selection_changed();
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_document_changed();
// OTRACK acquires tracking points only while a command or grip drag is
// running; drop them once neither is active so the temporary tracking
// points / vectors disappear when the command ends (issue #64).