forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
795aa922b4
commit
1b1785eefb
13 files changed
+867
-1
No files matched your search
@@ -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:<kind>`.
|
||||
pub blocked_by: Option<String>,
|
||||
/// 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<String>,
|
||||
pub options: Vec<String>,
|
||||
/// 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<String>,
|
||||
/// The command line's error message when the step failed.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<CommandOutcome, String> {
|
||||
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<Handle, String> {
|
||||
|
||||
@@ -482,6 +482,18 @@ impl HostApi for PluginHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(&mut self, request: crate::host::CommandRequest) -> Result<crate::host::CommandOutcome, String> {
|
||||
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<Handle, String> {
|
||||
match self.client.request(PluginRequest::TableOperation { operation }) {
|
||||
Ok(PluginResponse::TableResult(result)) => {
|
||||
|
||||
@@ -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<Handle, String>),
|
||||
TableResult(Result<Handle, String>),
|
||||
CommandResult(Result<crate::host::CommandOutcome, String>),
|
||||
}
|
||||
|
||||
/// Messages sent from the host to the plugin runner.
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -796,6 +796,18 @@ impl HostApi for V4PluginHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(&mut self, request: crate::host::CommandRequest) -> Result<crate::host::CommandOutcome, String> {
|
||||
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<Handle, String> {
|
||||
match self.request(PluginRequest::TableOperation { operation }) {
|
||||
Ok(PluginResponse::TableResult(result)) => {
|
||||
|
||||
@@ -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::<PluginRequest>(&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::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::CommandResult(decoded) if decoded == result));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_operation_request_and_response_roundtrip() {
|
||||
use crate::host::{LayerConfig, TableOperation};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<PyObjectRef> {
|
||||
use ocs_plugin_api::host::CommandRequest as R;
|
||||
let options = if vm.is_none(&options) {
|
||||
vm.ctx.new_dict()
|
||||
} else {
|
||||
options.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?
|
||||
};
|
||||
let required = |key: &str| -> PyResult<PyObjectRef> {
|
||||
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::<String>(vm)? },
|
||||
"start" => R::Start { name: required("name")?.try_into_value::<String>(vm)? },
|
||||
"point" => R::Point { point: py_to_f64_array::<3>(required("point")?, vm)? },
|
||||
"text" => R::Text { text: required("text")?.try_into_value::<String>(vm)? },
|
||||
"token" => R::Token { text: required("text")?.try_into_value::<String>(vm)? },
|
||||
"entity" => R::Entity {
|
||||
handle: Handle::new(required("handle")?.try_into_value::<u64>(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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<ocs_plugin_api::host::CommandOutcome, String> {
|
||||
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<String> {
|
||||
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<String>) -> 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<String> {
|
||||
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<Handle, String> {
|
||||
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<Handle, String> {
|
||||
self.table_operation(operation)
|
||||
}
|
||||
fn run_command(&mut self, request: ocs_plugin_api::host::CommandRequest) -> Result<ocs_plugin_api::host::CommandOutcome, String> {
|
||||
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<String>) {
|
||||
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<acadrust::entities::Line> = host.document().entities().filter_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).collect();
|
||||
let circles: Vec<acadrust::entities::Circle> = host.document().entities().filter_map(|e| match e { EntityType::Circle(c) => Some(c.clone()), _ => None }).collect();
|
||||
let arcs: Vec<acadrust::entities::Arc> = 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::<Vec<_>>();
|
||||
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<ocs_plugin_api::host::CommandOutcome, String>| 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<Handle> {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user