From 1b1785eefbe8004abf00cd2dcec68aa01a2e3244 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 17:25:05 +0200 Subject: [PATCH] feat(python): command runner and modify wrappers (doc.command, doc.modify) HostApi::run_command drives the real OCS command through the same primitives the automation channel uses, synchronously on the host thread: run a line, start a command and answer point/text/token/entity/selection/enter prompts, or cancel (closing anything the command opened). Refused while another command is active, for a denylist (QUIT, NEW, OPEN, SAVE*, PY_* ...) and off the active tab. Python: doc.command, doc.start_command and doc.modify (offset, trim, extend, fillet, move, copy, rotate, scale, mirror, erase). Real-IPC audit checks exact geometry for every wrapper plus guards and undo; IPC and Python unit tests; docs. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 57 +++ crates/ocs_plugin_api/src/ipc/client.rs | 12 + crates/ocs_plugin_api/src/ipc/protocol.rs | 3 + crates/ocs_plugin_api/src/ipc/server.rs | 1 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 12 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 37 ++ docs/plugin-host-model-completion-plan.md | 20 + plugins/opencad-python/PLUGIN.md | 48 +++ plugins/opencad-python/src/document_model.py | 199 +++++++++ plugins/opencad-python/src/ocs_module.rs | 54 +++ .../tests/test_document_model.py | 47 +++ src/app/control/mod.rs | 2 +- src/app/plugin_host.rs | 376 ++++++++++++++++++ 13 files changed, 867 insertions(+), 1 deletion(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 9b1ff2ee3..bcdb88cda 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -85,6 +85,56 @@ pub enum SolidBoolean { Intersect, } +/// One step of driving an OCS command from a script (API v7, additive). The +/// host runs the real command, so every tool behaves exactly as at the command +/// line, and answers with where the command stands. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CommandRequest { + /// Run a whole command line (`OFFSET 5 ...`): the tokens after the name + /// answer the prompts in order and a final Enter finishes the command. + Run { line: String }, + /// Start a command and leave it waiting for its first input. + Start { name: String }, + /// Answer the current prompt with a world-coordinate point. + Point { point: [f64; 3] }, + /// Answer with typed text (a distance, an angle, a name). + Text { text: String }, + /// Answer with a keyword option. + Token { text: String }, + /// Pick an entity at a point. + Entity { handle: Handle, point: [f64; 3] }, + /// Complete an object-selection prompt with the current selection. + Selection, + /// Press Enter. + Enter, + /// Cancel the running command and close anything it left open. + Cancel, +} + +/// Where a command stands after a [`CommandRequest`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct CommandOutcome { + /// `completed` when nothing is waiting, `waiting_input` when the command + /// (or an editor or dialog it opened) needs more. + pub status: String, + /// What is still open: `command`, `text_editor`, `mtext_editor` or `modal:`. + pub blocked_by: Option, + /// The waiting command's name and prompt (empty when none). + pub command: String, + pub prompt: String, + /// The kinds of input the prompt accepts (`point`, `entity`, `selection`, + /// `text`, `token`, `enter`) and its keyword options. + pub accepts: Vec, + pub options: Vec, + /// Entities in the drawing after the step, and the change. + pub entities: u64, + pub added: i64, + /// Typed tokens no prompt asked for. + pub unconsumed: Vec, + /// The command line's error message when the step failed. + pub error: Option, +} + /// A change to a drawing table record (API v7, additive). The host validates /// the request, records one undo step and refuses without changing anything /// when it cannot honour it; the result is the record's handle. @@ -808,6 +858,13 @@ pub trait HostApi { Err("solid operations are not supported by this host".to_owned()) } + /// Drive an OCS command from a script (API v7, additive). Refused, with + /// nothing run, when another command is already active or the request names + /// a command that could end the session or re-enter the plugin. + fn run_command(&mut self, _request: CommandRequest) -> Result { + Err("running commands is not supported by this host".to_owned()) + } + /// Create, change, rename, delete or select a drawing table record (API v7, /// additive). Returns the record's handle; a refusal changes nothing. fn table_operation(&mut self, _operation: TableOperation) -> Result { diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index c4dba1ac8..21cceff54 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -482,6 +482,18 @@ impl HostApi for PluginHostApi { } } + fn run_command(&mut self, request: crate::host::CommandRequest) -> Result { + match self.client.request(PluginRequest::RunCommand { request }) { + Ok(PluginResponse::CommandResult(result)) => { + self.document_cache = OnceCell::new(); + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected command response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result { match self.client.request(PluginRequest::TableOperation { operation }) { Ok(PluginResponse::TableResult(result)) => { diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index a69d501ca..ee6bd92ec 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -157,6 +157,8 @@ pub enum PluginRequest { SolidOperation { operation: crate::host::SolidOperation }, /// V7 (additive): drawing table record create/modify/rename/delete. TableOperation { operation: crate::host::TableOperation }, + /// V7 (additive): drive an OCS command. + RunCommand { request: crate::host::CommandRequest }, } /// Responses the host sends back for `PluginRequest`. @@ -192,6 +194,7 @@ pub enum PluginResponse { SelectionResult(Result<(), String>), SolidResult(Result), TableResult(Result), + CommandResult(Result), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 46aab7f84..a2d3c4b64 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -111,5 +111,6 @@ pub fn handle_plugin_request( SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)), SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)), TableOperation { operation } => PluginResponse::TableResult(host.table_operation(operation)), + RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 2ab6c7c66..d6da46bbe 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -796,6 +796,18 @@ impl HostApi for V4PluginHostApi { } } + fn run_command(&mut self, request: crate::host::CommandRequest) -> Result { + match self.request(PluginRequest::RunCommand { request }) { + Ok(PluginResponse::CommandResult(result)) => { + self.document_cache = OnceCell::new(); + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected command response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result { match self.request(PluginRequest::TableOperation { operation }) { Ok(PluginResponse::TableResult(result)) => { diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 967181cac..bbf969f41 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -105,6 +105,43 @@ mod tests { assert_eq!(decoded, changed); } + #[test] + fn command_request_and_outcome_roundtrip() { + use crate::host::{CommandOutcome, CommandRequest}; + for request in [ + CommandRequest::Run { line: "LINE 0,0 1,1".into() }, + CommandRequest::Start { name: "OFFSET".into() }, + CommandRequest::Point { point: [1.0, 2.0, 3.0] }, + CommandRequest::Text { text: "2".into() }, + CommandRequest::Token { text: "R".into() }, + CommandRequest::Entity { handle: acadrust::Handle::new(9), point: [0.0; 3] }, + CommandRequest::Selection, + CommandRequest::Enter, + CommandRequest::Cancel, + ] { + let bytes = bincode::serialize(&PluginRequest::RunCommand { request: request.clone() }).unwrap(); + assert!(matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::RunCommand { request: decoded } if decoded == request)); + } + let outcome = CommandOutcome { + status: "waiting_input".into(), + blocked_by: Some("command".into()), + command: "OFFSET".into(), + prompt: "Select object".into(), + accepts: vec!["entity".into()], + options: vec!["M".into()], + entities: 4, + added: -1, + unconsumed: vec!["x".into()], + error: Some("e".into()), + }; + for result in [Ok(outcome), Err("refused".to_owned())] { + let bytes = bincode::serialize(&PluginResponse::CommandResult(result.clone())).unwrap(); + assert!(matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::CommandResult(decoded) if decoded == result)); + } + } + #[test] fn table_operation_request_and_response_roundtrip() { use crate::host::{LayerConfig, TableOperation}; diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 8c870598d..e9fb5aac2 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -253,3 +253,23 @@ persistence; deleting the current layout; four-step undo restoring the layouts, round-trip and Python unit tests. Not covered: complex (text/shape) linetypes, reordering layouts, plot devices and styles, and page-setup names. Next: headless command execution. +### 2026-09-21: command runner and modify wrappers (`doc.command`, `doc.start_command`, `doc.modify`) + +`HostApi::run_command(CommandRequest) -> CommandOutcome` (additive v7; `Run`, `Start`, `Point`, `Text`, `Token`, `Entity`, +`Selection`, `Enter`, `Cancel`) drives the real command through the same primitives the automation channel uses +(`run_command_line`, `dispatch_command`, `feed_command`, `feed_active_cmd`, `CommandEscape`) and finishes each step with +`drive_headless_task`, which is synchronous on the host thread. The earlier "nested command replay can hang" concern does not +apply to this path: no step waits on anything that must arrive from outside, the exploration harness ran every command +tried without a hang, and an editor or dialog a command opens is reported in `blocked_by` and closed by `Cancel`. It refuses +while another command is active, on a denylist (`QUIT`, `EXIT`, `CLOSE*`, `NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`, +`RUNSCRIPT`, `PY_*`), and off the active tab. It does not consult the user's automation on/off switch, which governs the +external MCP/serve channel; a script is already something the user chose to run. Python: `doc.command`, `doc.start_command` +(context manager that cancels a waiting command) and `doc.modify` (offset, trim, extend, fillet, move, copy, rotate, scale, +mirror, erase). The prompt sequences were observed with `spike_command_runner_prompts` (ignored harness). Evidence: +`audit_python_modify_wrappers_over_real_ipc` checks exact geometry for every wrapper (offset distance and side, trim and +extend end points, fillet arc centre and radius, move, copy keeping the original, rotate, scale, mirror keeping the source, +erase), `doc.command` and an interactive session, nine refusals (quit, new, `PY_RUN`, unknown, incomplete line, input with no +command, second command, run while active), no command left running, and a one-step undo. IPC round-trip and Python unit tests. +Not covered: chamfer, arrays, hatch, join, explode, break and the many other commands (reachable through `doc.command` and +`start_command`); reading prompts for commands with a text editor; an on/off setting for script-driven commands. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 00b94a712..04e6b1b8c 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -258,6 +258,54 @@ layout limits. Layout operations act on the active document. Records list `current`, `tab_order`, `entity_count`, `viewport_count`, `paper_size`, `rotation` and `scale`. Reordering layouts and plot devices or styles are not available yet. +### Running OCS commands: `doc.command`, `doc.start_command`, `doc.modify` + +Scripts can drive OCS's own tools. The host runs the real command, so it behaves +exactly as at the command line (same prompts, same geometry, same undo), and does +it synchronously on the editor thread, so a step never waits on outside input. + +```python +doc = ocs.active_document +doc.command("CIRCLE 5,5 3") # a whole line: tokens answer the prompts, then Enter +with doc.start_command("OFFSET") as c: # or answer prompt by prompt + c.text(2) # a typed value + c.entity(line, at=(5, 0, 0)) # an object pick + c.point((5, 4, 0)) # a point + c.enter() + print(c.outcome["status"], c.outcome["prompt"], c.outcome["added"]) + +M = doc.modify # the common tools as plain calls +M.offset(line, 2, side=(5, 5)) # returns the new entity +M.trim(target, at=(108, 0)); M.extend(target, at=(208, 0)) +M.fillet(first, second, radius=2) # radius 0 squares the corner +M.move([a, b], (0, 0, 0), (5, 5, 0)); M.copy([a], base, target) +M.rotate([a], base, 90); M.scale([a], base, 2) +M.mirror([a], p1, p2, erase_source=False); M.erase([a]) +``` + +Each step returns an outcome dict: `status` (`completed` or `waiting_input`), +`blocked_by`, `command`, `prompt`, `accepts` (`point`, `entity`, `selection`, +`text`, `token`, `enter`), `options` (keyword tokens), `entities`, `added`, +`unconsumed` and `error`. `c.point`, `c.text`, `c.token`, `c.entity`, +`c.selection`, `c.enter` and `c.cancel` map to those prompts; an `error` raises +`RuntimeError`, and leaving the `with` block cancels a command that is still waiting, +also closing any editor or dialog it opened. `doc.command` cancels and raises when +the line leaves a prompt open. + +Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`) select +the entities you pass first, so the command skips its own selection prompt. Pick +points default to the middle of a line, arc or circle; give `at=` for anything else. +`trim` and `extend` cut or extend against every entity in the drawing. Each command is +undone as the editor would undo it. + +Guards: a command is refused while another is active (cancel it first) or when the +line names a command that could end the session or re-enter Python: `QUIT`, +`EXIT`, `CLOSE`, `CLOSEALL`, `NEW`, `QNEW`, `OPEN`, `SAVE`, `QSAVE`, `SAVEAS`, `SAVEALL`, +`RECOVER`, `SCRIPT`, `RUNSCRIPT` and any `PY_*`. Commands run on the active +document only. Commands that need an interactive surface a script cannot fill +(the rich-text editor, a file dialog) report it in `blocked_by` and are cancelled. +`ocs.command_step(kind, options)` is the raw single step behind all of this. + ### Historical command-replay experiment (not in the default build) The following records an earlier experiment. The bundled build does **not** diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 64a7f9761..703352752 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -580,6 +580,185 @@ class _Layouts(_Named): return self[name] +def _pt(value): + """A point as [x, y, z] from a tuple, list or {"x", "y", "z"} dict.""" + if isinstance(value, dict): + return [float(value["x"]), float(value["y"]), float(value.get("z", 0.0))] + coords = [float(v) for v in value] + if len(coords) == 2: + coords.append(0.0) + if len(coords) != 3: + raise ValueError("a point needs 2 or 3 coordinates") + return coords + + +def _handle(value): + return value.handle if isinstance(value, _Entity) else int(value) + + +class _Command: + """One running OCS command, driven a step at a time: + + with doc.start_command("OFFSET") as c: + c.text(2); c.entity(line, at=(5, 0, 0)); c.point((5, 4, 0)); c.enter() + + Each step returns the outcome dict (`status`, `prompt`, `accepts`, + `options`, `added`, `error`, ...) and raises `RuntimeError` when the command + reports an error. Leaving the `with` block cancels the command if it is still + waiting. The host runs the real command, refuses to start one while another + is active, and refuses commands that could end the session (QUIT, NEW, OPEN, + SAVE...) or re-enter Python (PY_*).""" + + def __init__(self, name): + self.name = str(name) + self.outcome = None + self._send("start", name=self.name) + + def _send(self, kind, **options): + outcome = ocs.command_step(kind, options) + self.outcome = outcome + if outcome["error"]: + raise RuntimeError("%s: %s" % (self.name, outcome["error"])) + return outcome + + @property + def waiting(self): + return self.outcome is not None and self.outcome["status"] == "waiting_input" + + def point(self, point): + return self._send("point", point=_pt(point)) + + def text(self, value): + return self._send("text", text=str(value)) + + def token(self, keyword): + return self._send("token", text=str(keyword)) + + def entity(self, entity, at): + return self._send("entity", handle=_handle(entity), point=_pt(at)) + + def selection(self): + """Complete an object-selection prompt with the current selection.""" + return self._send("selection") + + def enter(self): + return self._send("enter") + + def cancel(self): + return self._send("cancel") + + def __enter__(self): + return self + + def __exit__(self, *exc): + if self.waiting: + ocs.command_step("cancel", None) + return False + + +def _finished(command, what): + """Require that `command` ended by itself; cancel and raise if it did not.""" + if command.waiting: + prompt = command.outcome["prompt"] + command.cancel() + raise RuntimeError("%s did not finish: still asking %r" % (what, prompt)) + + +class _Modify: + """Modify tools as plain calls. Each drives the real OCS command, so it + behaves exactly as at the command line, and returns the entities it added. + A tool that needs a pick point takes `at=` (a point on or near the entity); + without it the middle of a line, arc or circle is used.""" + + def __init__(self, document): + self._document = document + + def _at(self, entity, at): + if at is not None: + return _pt(at) + samples = ocs.curve_samples(_handle(entity), 4) + if samples is None: + raise ValueError("pass at=(x, y, z): the middle of this kind of entity is not known") + middle = samples["vertices"][len(samples["vertices"]) // 2] + return [middle[0], middle[1], samples["elevation"]] + + def _select(self, entities): + ocs.select([_handle(e) for e in entities]) + + def _added(self, before): + before = set(before) + return [self._document.entities[h] for h in ocs.entity_handles() if h not in before] + + def _run(self, name, steps, entities=None): + before = list(ocs.entity_handles()) + if entities is not None: + self._select(entities) + with _Command(name) as command: + for step in steps: + step(command) + _finished(command, name) + return self._added(before) + + def offset(self, entity, distance, side, at=None): + """Offset one entity by `distance` towards the point `side`; returns the new entity.""" + result = self._run("OFFSET", [ + lambda c: c.text(distance), + lambda c: c.entity(entity, self._at(entity, at)), + lambda c: c.point(side), + lambda c: c.enter(), + ]) + if not result: + raise RuntimeError("OFFSET produced nothing") + return result[0] + + def trim(self, target, at): + """Trim `target` at the crossing nearest the click point `at`, against every entity in the drawing.""" + return self._run("TRIM", [lambda c: c.entity(target, at), lambda c: c.enter()]) + + def extend(self, target, at): + """Extend `target` from the end nearest `at` to the next entity in its way.""" + return self._run("EXTEND", [lambda c: c.entity(target, at), lambda c: c.enter()]) + + def fillet(self, first, second, radius=0, at1=None, at2=None): + """Round (or, with radius 0, square) the corner between two entities.""" + return self._run("FILLET", [ + lambda c: c.token("R"), + lambda c: c.text(radius), + lambda c: c.entity(first, self._at(first, at1)), + lambda c: c.entity(second, self._at(second, at2)), + lambda c: c.enter(), + ]) + + def move(self, entities, base, target): + self._run("MOVE", [lambda c: c.point(base), lambda c: c.point(target)], entities) + + def copy(self, entities, base, target): + """Copy the entities once from `base` to `target`; returns the copies.""" + return self._run("COPY", [lambda c: c.point(base), lambda c: c.point(target), lambda c: c.enter()], entities) + + def rotate(self, entities, base, angle): + """Rotate about `base` by `angle` degrees.""" + self._run("ROTATE", [lambda c: c.point(base), lambda c: c.text(angle)], entities) + + def scale(self, entities, base, factor): + self._run("SCALE", [lambda c: c.point(base), lambda c: c.text(factor)], entities) + + def mirror(self, entities, first, second, erase_source=False): + """Mirror about the line through two points; returns the mirrored copies.""" + return self._run("MIRROR", [ + lambda c: c.point(first), + lambda c: c.point(second), + lambda c: c.token("Y" if erase_source else "N"), + ], entities) + + def erase(self, entities): + before = len(list(ocs.entity_handles())) + self._select(entities) + with _Command("ERASE") as command: + _finished(command, "ERASE") + return before - len(list(ocs.entity_handles())) + + class _Document: def __init__(self): self._pending = None @@ -589,9 +768,29 @@ class _Document: self.blocks = _Blocks() self.linetypes = _Linetypes() self.layouts = _Layouts() + self.modify = _Modify(self) self.entities = _Entities(self) self.solids = _Solids(self) + def command(self, line): + """Run one whole OCS command line, e.g. `"CIRCLE 5,5 3"`; the tokens after + the name answer the prompts in order and a final Enter finishes. Returns + the outcome dict. If the line leaves the command waiting for more input the + command is cancelled and `RuntimeError` names the prompt; use + `start_command` to answer prompts one at a time.""" + outcome = ocs.command_step("run", {"line": str(line)}) + if outcome["error"]: + raise RuntimeError("%s: %s" % (line, outcome["error"])) + if outcome["status"] != "completed": + prompt = outcome["prompt"] or outcome["blocked_by"] + ocs.command_step("cancel", None) + raise RuntimeError("%r did not finish: still asking %r" % (line, prompt)) + return outcome + + def start_command(self, name): + """Start a command and answer its prompts step by step (see `_Command`).""" + return _Command(name) + def transaction(self, label): return _Transaction(self, label) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 60c70fb46..6a953f8f1 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -1606,6 +1606,60 @@ mod ocs { Ok(vm.ctx.new_list(output).into()) } + /// One step of driving a real OCS command. `kind` is `run` (`line`), + /// `start` (`name`), `point` (`point`), `text` (`text`), `token` (`text`), + /// `entity` (`handle`, `point`), `selection`, `enter` or `cancel`. Returns + /// where the command stands: `status`, `blocked_by`, `command`, `prompt`, + /// `accepts`, `options`, `entities`, `added`, `unconsumed`, `error`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn command_step(kind: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::CommandRequest as R; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let required = |key: &str| -> PyResult { + options + .get_item_opt(key, vm)? + .ok_or_else(|| vm.new_value_error(format!("ocs.command_step: {kind} needs {key}"))) + }; + let request = match kind.as_str() { + "run" => R::Run { line: required("line")?.try_into_value::(vm)? }, + "start" => R::Start { name: required("name")?.try_into_value::(vm)? }, + "point" => R::Point { point: py_to_f64_array::<3>(required("point")?, vm)? }, + "text" => R::Text { text: required("text")?.try_into_value::(vm)? }, + "token" => R::Token { text: required("text")?.try_into_value::(vm)? }, + "entity" => R::Entity { + handle: Handle::new(required("handle")?.try_into_value::(vm)?), + point: py_to_f64_array::<3>(required("point")?, vm)?, + }, + "selection" => R::Selection, + "enter" => R::Enter, + "cancel" => R::Cancel, + other => return Err(vm.new_value_error(format!("ocs.command_step: unknown step {other:?}"))), + }; + let outcome = host_ctx::with_host(|host| host.run_command(request)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .map_err(|error| vm.new_runtime_error(format!("ocs.command_step: {error}")))?; + let dict = vm.ctx.new_dict(); + let strings = |items: &[String]| -> PyObjectRef { + PyObjectRef::from(vm.ctx.new_list(items.iter().map(|v| vm.new_pyobj(v.clone())).collect())) + }; + dict.set_item("status", vm.new_pyobj(outcome.status.clone()), vm)?; + dict.set_item("blocked_by", outcome.blocked_by.clone().map_or_else(|| vm.ctx.none(), |v| vm.new_pyobj(v)), vm)?; + dict.set_item("command", vm.new_pyobj(outcome.command.clone()), vm)?; + dict.set_item("prompt", vm.new_pyobj(outcome.prompt.clone()), vm)?; + dict.set_item("accepts", strings(&outcome.accepts), vm)?; + dict.set_item("options", strings(&outcome.options), vm)?; + dict.set_item("entities", vm.new_pyobj(outcome.entities), vm)?; + dict.set_item("added", vm.new_pyobj(outcome.added), vm)?; + dict.set_item("unconsumed", strings(&outcome.unconsumed), vm)?; + dict.set_item("error", outcome.error.clone().map_or_else(|| vm.ctx.none(), |v| vm.new_pyobj(v)), vm)?; + Ok(dict.into()) + } + /// Every layer with its full properties, in table order. #[cfg(feature = "experimental-host-model")] #[pyfunction] diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 6149f617b..ada10cfe5 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -46,11 +46,58 @@ class DocumentModelTests(unittest.TestCase): linetype_operation=lambda op, name, options: self.calls.append(("linetype", op, name, options)) or 9, layout_records=lambda: [{"name": n, "current": n == "Model"} for n in ("Model", "Sheet1")], layout_operation=lambda op, name, options: self.calls.append(("layout", op, name, options)) or 9, + command_step=self._command_step, + curve_samples=lambda handle, n: {"vertices": [[0, 0], [1, 0], [2, 0]], "elevation": 5.0, "closed": False}, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) self.doc = self.ocs.active_document + def _command_step(self, kind, options): + self.calls.append(("step", kind, options)) + outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [], + "options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None} + outcome.update(self.step_outcome.get(kind, {})) + if kind == "enter": + new = max(self.records) + 1 + self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True} + return outcome + + def test_command_runner(self): + self.step_outcome = {} + out = self.doc.command("CIRCLE 0,0 5") + self.assertEqual(self.calls[-1], ("step", "run", {"line": "CIRCLE 0,0 5"})) + self.assertEqual(out["status"], "completed") + self.step_outcome = {"run": {"status": "waiting_input", "prompt": "Select object"}} + with self.assertRaises(RuntimeError): + self.doc.command("OFFSET") + self.assertEqual(self.calls[-1], ("step", "cancel", None)) + self.step_outcome = {"run": {"error": "bad"}} + with self.assertRaises(RuntimeError): + self.doc.command("X") + waiting = {"status": "waiting_input"} + self.step_outcome = {"start": waiting, "point": waiting, "entity": waiting} + with self.doc.start_command("OFFSET") as command: + command.point((1, 2)) + self.assertEqual(self.calls[-1], ("step", "point", {"point": [1.0, 2.0, 0.0]})) + command.entity(self.doc.entities[1], at=(0, 0, 0)) + self.assertEqual(self.calls[-1][2]["handle"], 1) + self.assertEqual(self.calls[-1], ("step", "cancel", None)) + + def test_modify_wrappers(self): + self.step_outcome = {} + self.doc.modify.trim(1, (3, 0, 0)) + steps = [c for c in self.calls if c[0] == "step"] + self.assertEqual([c[1] for c in steps], ["start", "entity", "enter"]) + self.calls.clear() + self.doc.modify.move([1, 2], (0, 0, 0), (5, 5, 0)) + self.assertEqual(self.calls[0], ("selection", [1, 2])) + self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "point", "point"]) + self.calls.clear() + self.doc.modify.offset(1, 2, (0, 5)) + entity_step = [c for c in self.calls if c[0] == "step" and c[1] == "entity"][0] + self.assertEqual(entity_step[2]["point"], [1, 0, 5.0]) + def test_batch_edit_and_rollback(self): line = self.doc.entities[1] self.assertEqual(line.start, (1, 2, 3)) diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 97cf725f0..40f5bb591 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -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() { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 2c6358e67..0ee70ef4c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1273,6 +1273,178 @@ impl<'a> HostSession<'a> { } } + /// Drive a real OCS command one step at a time (see + /// `ocs_plugin_api::host::CommandRequest`). The same primitives the + /// automation channel uses, run synchronously on this thread, so a step + /// never waits on anything that has to arrive from outside. + pub fn run_command( + &mut self, + request: ocs_plugin_api::host::CommandRequest, + ) -> Result { + use crate::command::StepInput; + use ocs_plugin_api::host::CommandRequest as R; + if self.tab != self.app.active_tab { + return Err("commands act on the active document; switch to it first".to_owned()); + } + let tab = self.tab; + let refuse = |line: &str| -> Option { + let first = line.split_whitespace().next()?.trim_start_matches(['\'', '_']).to_uppercase(); + const DENIED: &[&str] = &[ + "QUIT", "EXIT", "CLOSE", "CLOSEALL", "NEW", "QNEW", "OPEN", "SAVE", "QSAVE", "SAVEAS", + "SAVEALL", "RECOVER", "SCRIPT", "RUNSCRIPT", + ]; + if DENIED.contains(&first.as_str()) || first.starts_with("PY_") { + Some(format!("the command {first} cannot be run from a script")) + } else { + None + } + }; + let active = self.app.tabs[tab].active_cmd.is_some(); + let before = self.document().entities().count() as i64; + let error_revision = self.app.command_line.error_revision; + let editor_before = self.app.text_inline.is_some(); + let mtext_before = self.app.mtext_editor.is_some(); + let modal_before = self.app.active_modal.is_some(); + self.app.command_line.unconsumed.clear(); + let task = match request { + R::Run { line } => { + if line.trim().is_empty() { + return Err("a command line is empty".to_owned()); + } + if let Some(reason) = refuse(&line) { + return Err(reason); + } + if active { + return Err("a command is already active; cancel it first".to_owned()); + } + self.app.run_command_line(&line) + } + R::Start { name } => { + if name.trim().is_empty() || name.split_whitespace().count() != 1 { + return Err("a command name is one word".to_owned()); + } + if let Some(reason) = refuse(&name) { + return Err(reason); + } + if active { + return Err("a command is already active; cancel it first".to_owned()); + } + self.app.dispatch_command(name.trim()) + } + R::Cancel => { + let task = self.app.update(crate::app::Message::CommandEscape); + self.app.drive_headless_task(task)?; + // Close what the command opened: an editor or a dialog would leave + // the interface waiting on a script that has moved on. + if !editor_before { + self.app.text_inline = None; + } + if !mtext_before { + self.app.mtext_editor = None; + } + if !modal_before { + self.app.active_modal = None; + } + return Ok(self.command_outcome(before, None)); + } + other => { + if !active { + return Err("no command is waiting for input".to_owned()); + } + let input = match other { + R::Point { point } => { + if point.iter().any(|v| !v.is_finite()) { + return Err("a point must be finite".to_owned()); + } + StepInput::Point(glam::DVec3::new(point[0], point[1], point[2])) + } + R::Text { text } => StepInput::Text(text), + R::Token { text } => { + let task = self.app.feed_active_cmd(&text); + self.app.drive_headless_task(task)?; + return Ok(self.finish_command_step(before, error_revision, editor_before, mtext_before, modal_before)); + } + R::Entity { handle, point } => { + if point.iter().any(|v| !v.is_finite()) { + return Err("a point must be finite".to_owned()); + } + if self.document().get_entity(handle).is_none() { + return Err(format!("entity {} does not exist", handle.value())); + } + StepInput::EntityPick(handle, glam::DVec3::new(point[0], point[1], point[2])) + } + R::Selection => StepInput::SelectionComplete(self.app.tabs[tab].scene.selected_handles_in_order()), + R::Enter => StepInput::Enter, + R::Run { .. } | R::Start { .. } | R::Cancel => unreachable!(), + }; + self.app.feed_command(input) + } + }; + self.app.drive_headless_task(task)?; + Ok(self.finish_command_step(before, error_revision, editor_before, mtext_before, modal_before)) + } + + fn finish_command_step( + &mut self, + before: i64, + error_revision: u64, + editor_before: bool, + mtext_before: bool, + modal_before: bool, + ) -> ocs_plugin_api::host::CommandOutcome { + let error = (self.app.command_line.error_revision != error_revision) + .then(|| self.app.command_line.last_error.clone().unwrap_or_default()); + let blocked = if self.app.tabs[self.tab].active_cmd.is_some() { + Some("command".to_owned()) + } else if let (false, Some(modal)) = (modal_before, self.app.active_modal.as_ref()) { + Some(format!("modal:{modal:?}")) + } else if !editor_before && self.app.text_inline.is_some() { + Some("text_editor".to_owned()) + } else if !mtext_before && self.app.mtext_editor.is_some() { + Some("mtext_editor".to_owned()) + } else { + None + }; + self.set_dirty(); + self.publish_document_view(); + let mut outcome = self.command_outcome(before, blocked); + outcome.error = error; + outcome + } + + fn command_outcome(&self, before: i64, blocked_by: Option) -> ocs_plugin_api::host::CommandOutcome { + let now = self.document().entities().count() as i64; + let metadata = self.app.tabs[self.tab] + .active_cmd + .as_deref() + .map(crate::app::control::active_command_metadata); + let text = |key: &str| metadata.as_ref().and_then(|m| m[key].as_str()).unwrap_or("").to_owned(); + let list = |key: &str| -> Vec { + metadata + .as_ref() + .and_then(|m| m[key].as_array()) + .map(|items| items.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default() + }; + let options = metadata + .as_ref() + .and_then(|m| m["options"].as_array()) + .map(|items| items.iter().filter_map(|o| o["keyword"].as_str().filter(|k| !k.is_empty()).map(str::to_owned)).collect()) + .unwrap_or_default(); + ocs_plugin_api::host::CommandOutcome { + status: if blocked_by.is_some() { "waiting_input" } else { "completed" }.to_owned(), + blocked_by, + command: text("name"), + prompt: text("prompt"), + accepts: list("accepts"), + options, + entities: now as u64, + added: now - before, + unconsumed: self.app.command_line.unconsumed.clone(), + error: None, + } + } + /// Simple linetype create/modify/rename/delete. fn linetype_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { use ocs_plugin_api::host::TableOperation; @@ -2367,6 +2539,9 @@ impl HostApi for HostSession<'_> { fn table_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { self.table_operation(operation) } + fn run_command(&mut self, request: ocs_plugin_api::host::CommandRequest) -> Result { + self.run_command(request) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -7830,6 +8005,207 @@ LT.delete('Temp') assert!(document.entities().any(|e| matches!(e, EntityType::Circle(_))), "undo restored the sheet's circle"); } + /// The modify wrappers through Python over the real runner: each drives + /// the real OCS command and the resulting geometry is checked exactly. + #[test] + fn audit_python_modify_wrappers_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_modify_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z=0.0): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nM = doc.modify\nL = doc.layers\n", + "refused, failed = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n failed.append('ACCEPTED_' + tag)\n", + "def step(tag, fn):\n", + " try:\n return fn()\n except Exception as error:\n", + " failed.append(tag + '_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:70])\n", + "{body}\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(failed))\n", + ), body = body)).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, failed) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), failed.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +def line(a, b): return doc.create_entity('Line', start=P(*a), end=P(*b)) +def circle(c, r): return doc.create_entity('Circle', center=P(*c), radius=r) +a = line((0, 0), (10, 0)) +step('offset', lambda: M.offset(a, 2, (5, 5))) +h = line((100, 0), (110, 0)); v = line((105, -5), (105, 5)) +step('trim', lambda: M.trim(h, (108, 0))) +h2 = line((200, 0), (209, 0)); w = line((215, -5), (215, 5)) +step('extend', lambda: M.extend(h2, (208, 0))) +f1 = line((300, 0), (310, 0)); f2 = line((300, 0), (300, 10)) +step('fillet', lambda: M.fillet(f1, f2, 2, (308, 0), (300, 8))) +m = circle((400, 0), 1) +step('move', lambda: M.move([m], (0, 0, 0), (5, 5, 0))) +c = circle((500, 0), 1) +step('copy', lambda: M.copy([c], (0, 0, 0), (0, 10, 0))) +r = line((600, 0), (610, 0)) +step('rotate', lambda: M.rotate([r], (600, 0, 0), 90)) +sc = line((700, 0), (710, 0)) +step('scale', lambda: M.scale([sc], (700, 0, 0), 2)) +mi = line((800, 0), (810, 0)) +step('mirror', lambda: M.mirror([mi], (0, 5, 0), (1, 5, 0))) +e = line((900, 0), (910, 0)) +step('erase', lambda: M.erase([e])) +step('run_line', lambda: doc.command('LINE 1100,0 1110,10')) +def interactive(): + with doc.start_command('CIRCLE') as c: + c.point((1200, 0, 0)) + c.text(3) + if c.waiting: + raise RuntimeError('circle still waiting') +step('interactive', interactive) +check('quit', lambda: doc.start_command('QUIT')) +check('newdoc', lambda: doc.command('NEW')) +check('py', lambda: doc.command('PY_RUN nothing')) +check('unknown', lambda: doc.start_command('NOSUCHCOMMAND')) +check('incomplete', lambda: doc.command('OFFSET')) +check('input_without_command', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) +open_command = doc.start_command('LINE') +check('second_command', lambda: doc.start_command('CIRCLE')) +check('run_while_active', lambda: doc.command('POINT 1,1')) +open_command.cancel() +check('bad_point', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) +"#); + let (refused, failed) = report(host.document()); + assert!(failed.is_empty(), "wrapper failures: {failed:?}"); + assert_eq!(refused, 9); + + let lines: Vec = host.document().entities().filter_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).collect(); + let circles: Vec = host.document().entities().filter_map(|e| match e { EntityType::Circle(c) => Some(c.clone()), _ => None }).collect(); + let arcs: Vec = host.document().entities().filter_map(|e| match e { EntityType::Arc(a) => Some(a.clone()), _ => None }).collect(); + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + let has_line = |x1: f64, y1: f64, x2: f64, y2: f64| lines.iter().any(|l| { + (near(l.start.x, x1) && near(l.start.y, y1) && near(l.end.x, x2) && near(l.end.y, y2)) + || (near(l.start.x, x2) && near(l.start.y, y2) && near(l.end.x, x1) && near(l.end.y, y1)) + }); + let describe = || lines.iter().map(|l| format!("({},{})-({},{})", l.start.x, l.start.y, l.end.x, l.end.y)).collect::>(); + assert!(has_line(0.0, 0.0, 10.0, 0.0) && has_line(0.0, 2.0, 10.0, 2.0), "offset: {:?}", describe()); + assert!(has_line(100.0, 0.0, 105.0, 0.0), "trim: {:?}", describe()); + assert!(has_line(200.0, 0.0, 215.0, 0.0), "extend: {:?}", describe()); + assert!(arcs.iter().any(|a| near(a.radius, 2.0) && near(a.center.x, 302.0) && near(a.center.y, 2.0)), "fillet arc: {arcs:?}"); + assert!(circles.iter().any(|c| near(c.center.x, 405.0) && near(c.center.y, 5.0)), "move: {circles:?}"); + assert!(!circles.iter().any(|c| near(c.center.x, 400.0)), "move left the original: {circles:?}"); + assert!(circles.iter().any(|c| near(c.center.x, 500.0) && near(c.center.y, 0.0)) && circles.iter().any(|c| near(c.center.x, 500.0) && near(c.center.y, 10.0)), "copy: {circles:?}"); + assert!(has_line(600.0, 0.0, 600.0, 10.0), "rotate: {:?}", describe()); + assert!(has_line(700.0, 0.0, 720.0, 0.0), "scale: {:?}", describe()); + assert!(has_line(800.0, 0.0, 810.0, 0.0) && has_line(800.0, 10.0, 810.0, 10.0), "mirror keeps the source and adds the reflection: {:?}", describe()); + assert!(!has_line(900.0, 0.0, 910.0, 0.0), "erase: {:?}", describe()); + assert!(has_line(1100.0, 0.0, 1110.0, 10.0), "doc.command run: {:?}", describe()); + assert!(circles.iter().any(|c| near(c.center.x, 1200.0) && near(c.radius, 3.0)), "start_command session: {circles:?}"); + assert!(host.app.tabs[0].active_cmd.is_none(), "no command is left running"); + + // U: a wrapper's change is one undoable step (the report layer is the other). + run(&mut host, "undo", r#" +u = doc.create_entity('Circle', center=P(1000, 0), radius=1) +step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) +"#); + assert!(report(host.document()).1.is_empty()); + let moved = |document: &CadDocument, x: f64| document.entities().any(|e| matches!(e, EntityType::Circle(c) if near(c.center.x, x) && near(c.center.y, 0.0))); + assert!(moved(host.document(), 1007.0)); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(2); + assert!(moved(&app.tabs[0].scene.document, 1000.0), "undo put the circle back"); + assert!(!moved(&app.tabs[0].scene.document, 1007.0)); + } + + /// Exploration harness (not a gate): prints how the real commands answer + /// each step so the wrappers are written against observed prompts. + #[test] + #[ignore] + #[allow(unused_must_use)] + fn spike_command_runner_prompts() { + use ocs_plugin_api::host::CommandRequest as R; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let show = |label: &str, r: &Result| match r { + Ok(o) => eprintln!("SPIKE {label}: {} cmd={} prompt={:?} accepts={:?} opts={:?} added={} unconsumed={:?} err={:?}", + o.status, o.command, o.prompt, o.accepts, o.options, o.added, o.unconsumed, o.error), + Err(e) => eprintln!("SPIKE {label}: ERR {e}"), + }; + let run = |host: &mut HostSession<'_>, label: &str, r: R| { let out = host.run_command(r); show(label, &out); out }; + let mk = |host: &mut HostSession<'_>, lines: &[&str]| -> Vec { + for l in lines { host.run_command(R::Run { line: (*l).into() }).unwrap(); } + host.document().entities().map(|e| e.common().handle).collect() + }; + let cancel = |host: &mut HostSession<'_>| { let _ = host.run_command(R::Cancel); }; + let hs = mk(&mut host, &["LINE 0,0 10,0", "LINE 5,-5 5,5", "LINE 20,0 30,0", "LINE 0,10 10,10", "LINE 0,20 10,20"]); + // EXTEND: extend line 3 toward line 2? use boundary line 2 at x=5; extend line hs[0]'s end at x=10? Extend hs[2] (20..30) leftwards to x=5 no; use hs[0] end to the wall at x=15 + let _ = mk(&mut host, &["LINE 15,-5 15,5"]); + let wall = *host.document().entities().last().map(|e| &e.common().handle).unwrap(); + run(&mut host, "EXTEND start", R::Start { name: "EXTEND".into() }); + run(&mut host, "EXTEND pick", R::Entity { handle: hs[0], point: [9.0, 0.0, 0.0] }); + run(&mut host, "EXTEND enter", R::Enter); + cancel(&mut host); + // FILLET + run(&mut host, "FILLET start", R::Start { name: "FILLET".into() }); + run(&mut host, "FILLET R", R::Token { text: "R".into() }); + run(&mut host, "FILLET radius", R::Text { text: "2".into() }); + run(&mut host, "FILLET first", R::Entity { handle: hs[3], point: [2.0, 10.0, 0.0] }); + run(&mut host, "FILLET second", R::Entity { handle: hs[4], point: [2.0, 20.0, 0.0] }); + cancel(&mut host); + let _ = wall; + // CHAMFER + run(&mut host, "CHAMFER start", R::Start { name: "CHAMFER".into() }); + cancel(&mut host); + // selection commands + host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); + run(&mut host, "COPY run", R::Run { line: "COPY 0,0 0,3".into() }); + run(&mut host, "COPY leftover", R::Enter); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); + run(&mut host, "ROTATE run", R::Run { line: "ROTATE 0,0 90".into() }); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + run(&mut host, "SCALE run", R::Run { line: "SCALE 0,0 2".into() }); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + run(&mut host, "MIRROR start", R::Start { name: "MIRROR".into() }); + run(&mut host, "MIRROR sel", R::Selection); + run(&mut host, "MIRROR p1", R::Point { point: [0.0, 0.0, 0.0] }); + run(&mut host, "MIRROR p2", R::Point { point: [1.0, 0.0, 0.0] }); + run(&mut host, "MIRROR erase?", R::Token { text: "N".into() }); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + run(&mut host, "ARRAYRECT start", R::Start { name: "ARRAYRECT".into() }); + run(&mut host, "ARRAYRECT sel", R::Selection); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + run(&mut host, "ARRAYPOLAR start", R::Start { name: "ARRAYPOLAR".into() }); + run(&mut host, "ARRAYPOLAR sel", R::Selection); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[2]]); + run(&mut host, "ERASE run", R::Run { line: "ERASE".into() }); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[0]]); + run(&mut host, "EXPLODE run", R::Run { line: "EXPLODE".into() }); + cancel(&mut host); + eprintln!("SPIKE final entity count {}", host.document().entities().count()); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType;