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 <noreply@anthropic.com>
This commit is contained in:
1 parent
795aa922b4
commit
1b1785eefb
13 files changed
+867
-1
No files matched your search
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user