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 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 17:25:05 +02:00
1 parent 795aa922b4
commit 1b1785eefb
13 files changed
+867 -1

No files matched your search

+48
View File
@@ -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)
+54
View File
@@ -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))