forked from 1b-agent/opencadstudio-upstream
Merge origin/main into plugin/host-model-api
Conflicts were additive: main's AddLayer/ModifyLayer (LayerConfig, OptHandle) alongside this branch's v7 system-variable, transaction, selection and solid operations. Main's variants keep their positions in the IPC enums; the v7 additions follow them. src/io/mod.rs takes main's reworded Shape-rotation note. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
commit
6b8056cfeb
28 files changed
+1425
-97
No files matched your search
@@ -17,6 +17,10 @@ The server provides four tools:
|
||||
- `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor.
|
||||
- `ocs_capture` returns a bounded PNG of the drawing viewport or complete window.
|
||||
|
||||
Native builds also accept a second, headless entry point (`OpenCADStudio --serve`, one
|
||||
JSON request per line) for scripts and CI; the two native channels, their startup dialogs
|
||||
and their limits are described in [native.md](native.md).
|
||||
|
||||
The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `ocs_session_id` to the other tools, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape:
|
||||
|
||||
```json
|
||||
@@ -138,7 +142,27 @@ Execute responses use `response_detail: "compact"` by default and return only th
|
||||
|
||||
For unfamiliar or conditional commands, use `start`, then inspect `state.command` in every response. Its `accepts` array gives the valid MCP input kinds, `options` gives the current tokens, and `input_example` gives the next request shape. Add the current state fields and a new `request_id` to each step.
|
||||
|
||||
Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes. Geometry stays in OpenCADStudio and its geometry kernel.
|
||||
Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes.
|
||||
|
||||
A `run` reply also carries two fields that describe what happened to the line:
|
||||
|
||||
* `blocked_by` — `null` when the command finished, otherwise what *this line*
|
||||
left open and waiting: `"command"` (an interactive command), `"text_editor"`
|
||||
(the in-place editor opened by the `TEXT` content step), `"mtext_editor"`, or
|
||||
`"modal:<Kind>"` for a dialog. A surface that was already open when the line
|
||||
started (an earlier line's editor, another tab, a startup dialog) is not
|
||||
attributed to this line, so a caller polling for `completed` is never stuck
|
||||
waiting on someone else's editor. `blocked_by` is set whenever `status` is
|
||||
`waiting_input`.
|
||||
* `unconsumed` — the tokens of the line that no prompt ever asked for, in order.
|
||||
A fully consumed line reports `[]`; `CIRCLE 0,0 5 9` reports `["9"]` because
|
||||
the radius step ends the command. Over-typed input is reported instead of
|
||||
being dropped on the floor.
|
||||
|
||||
When a line ends at the `TEXT` content step (`TEXT 0,0 5 0 hello`) the text is
|
||||
typed into the in-place editor and committed, so the line behaves exactly as the
|
||||
same input typed into the GUI command line would: one `Text` entity, nothing
|
||||
unconsumed, and the command closed rather than left armed for a second line. Geometry stays in OpenCADStudio and its geometry kernel.
|
||||
|
||||
`ocs_capture` defaults to the drawing viewport and a longest edge of 1600 pixels. Set `scope` to `window` for the full interface or change `max_dimension` between 256 and 4096.
|
||||
|
||||
@@ -156,3 +180,5 @@ The repeatable live-editor evaluation draws three isolated entities in one batch
|
||||
```sh
|
||||
python3 docs/automation/mcp_eval.py target/debug/OpenCADStudio
|
||||
```
|
||||
|
||||
The web build is driven through two JavaScript functions instead of a process; see [web.md](web.md).
|
||||
@@ -0,0 +1,131 @@
|
||||
# Driving the native builds
|
||||
|
||||
Every native build has three entry points besides the editor window: the MCP
|
||||
server described in the [README](README.md) (`OpenCADStudio --mcp`), a headless
|
||||
JSON-line channel for scripts and CI (`OpenCADStudio --serve`), and a one-shot
|
||||
converter (`OpenCADStudio --export IN OUT`). The browser entry is described in
|
||||
[web.md](web.md).
|
||||
|
||||
## Choosing a channel
|
||||
|
||||
| | `--mcp` | `--serve` |
|
||||
|---|---|---|
|
||||
| Transport | MCP (JSON-RPC over stdio) with the four tools of the README | one JSON request per line on stdin, one reply per line on stdout; `--port N` listens on `127.0.0.1:N` instead |
|
||||
| Editor | attaches to a running desktop editor, or starts one when there is none (`ocs_sessions`, `launch_if_none`) | no window; a document session of its own that lives as long as the process |
|
||||
| Needs a display | yes (`xvfb-run` works on a headless machine; software rendering may raise the GPU warning dialog) | no |
|
||||
| Needs a writable `HOME` / `XDG_CONFIG_HOME` | yes — the session descriptors live in the user configuration directory; without one `ocs_sessions` answers `No user configuration directory` | no |
|
||||
| Startup dialogs | those of the editor (below) | none |
|
||||
| `capture` | `ocs_capture` | not available (`gui_required`): there is no window to capture |
|
||||
| Good for | an MCP client driving a real editor | batch jobs, CI, agents without a display |
|
||||
|
||||
## `--serve`
|
||||
|
||||
The first line written is a readiness banner, before any request is read:
|
||||
|
||||
```json
|
||||
{"ok":true,"ready":true,"version":"2026.38"}
|
||||
```
|
||||
|
||||
Over `--port N` the banner opens every connection; the listener serves one
|
||||
client at a time and the document session persists across reconnects.
|
||||
|
||||
Two request styles share the channel, told apart by the `protocol` field:
|
||||
|
||||
| Style | Shape | Operations |
|
||||
|---|---|---|
|
||||
| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` |
|
||||
| legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` |
|
||||
|
||||
The two styles differ in how a waiting command is treated. A protocol-1 `run`
|
||||
or `start` while a command is still active is refused with `command_busy`:
|
||||
continue it with `input` or drop it with `cancel`. A legacy `run` line ends the
|
||||
waiting command and starts the new one.
|
||||
|
||||
### The legacy `run` line
|
||||
|
||||
`{"op":"run","cmd":"LINE 0,0 10,10"}` feeds the tokens after the command name
|
||||
to the prompts in order and then presses Enter. The reply is
|
||||
|
||||
```json
|
||||
{"ok":true,"cmd":"CIRCLE 0,0 5 9","status":"completed","blocked_by":null,"entities":1,"added":1,"unconsumed":["9"]}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `status` | `completed`, or `waiting_input` when the line left something open |
|
||||
| `blocked_by` | what *this line* left open: `"command"`, `"text_editor"`, `"mtext_editor"`, `"modal:<Kind>"`, or `null`. A surface that was already open when the line started is not attributed to it |
|
||||
| `entities`, `added` | entity count after the line, and the difference to before |
|
||||
| `unconsumed` | tokens no prompt asked for, in order (`["9"]` above: the radius step ended the command); `[]` when everything was consumed |
|
||||
|
||||
`waiting_input` means input is still required, not that nothing happened:
|
||||
`SPLINE` and `ARC` keep accepting input after the entity exists, and
|
||||
`zoom_extents` reports it after the view has already moved.
|
||||
|
||||
`TEXT 0,0 5 0 hello world` creates one `Text` entity holding `hello world`: the
|
||||
tail of the line is typed into the in-place editor, committed, and the command
|
||||
is closed so it does not stay armed for a second line. A `TEXT` line without
|
||||
content answers `waiting_input` with `blocked_by: "text_editor"`.
|
||||
|
||||
Known gap (#1390): `MTEXT`, `HATCH` and `POLYGON` answer `completed` with
|
||||
`added: 0` on this path — they need interactive surfaces (the rich-text editor,
|
||||
a pattern choice, a boundary pick) that a single line has no tokens to fill.
|
||||
|
||||
## `--mcp`
|
||||
|
||||
The MCP server is a client of the desktop editor: `ocs_sessions` lists the live
|
||||
editor sessions found in the configuration directory and starts one when none
|
||||
is running, so several MCP connections can share one editor and its state.
|
||||
|
||||
A fresh profile opens two dialogs before any drawing exists, and both block
|
||||
document creation:
|
||||
|
||||
| Step | `ocs_sessions` reports |
|
||||
|---|---|
|
||||
| right after launch | `modal: "AssocPrompt"` |
|
||||
| after `close_modal` | `modal: "DonationPrompt"` |
|
||||
| after a second `close_modal` | `modal: null` |
|
||||
|
||||
While a dialog is open, `{"op":"new"}` answers `ok` but no tab is created.
|
||||
Close the dialogs first:
|
||||
|
||||
```python
|
||||
while (info := sessions())["modal"]:
|
||||
execute({"op": "action", "name": "close_modal", "document_id": info["document_id"]})
|
||||
```
|
||||
|
||||
The donation prompt appears once per application version per profile, so an
|
||||
automated profile meets it once per version rather than on every run.
|
||||
|
||||
`new` makes the new drawing the active document. Requests carry `document_id`;
|
||||
take it from the reply's state and pass it on the following requests, as the
|
||||
README describes.
|
||||
|
||||
## `--export`
|
||||
|
||||
`OpenCADStudio --export IN OUT` loads `IN` and writes `OUT` without a window or
|
||||
a session. The output format follows the extension: `.dxf` writes DXF, any other
|
||||
extension writes DWG at the document's own version. The exit code is 0 on
|
||||
success and 1 when the input cannot be read or the output cannot be written.
|
||||
|
||||
## Interactive steps
|
||||
|
||||
For commands whose answers cannot be written on one line, use `start` and then
|
||||
`input`. `start` replies with `state.command`, whose `accepts`, `options` and
|
||||
`input_example` say what the current step wants:
|
||||
|
||||
| Step | Request | Note |
|
||||
|---|---|---|
|
||||
| a point | `{"op":"input","kind":"point","point":[x,y,0],"space":"wcs"}` | the point is an array; `space` is `wcs` (default), `ucs` or `relative` |
|
||||
| a typed value (height, rotation) | `{"op":"input","kind":"text","text":"5"}` | digits go through `text`, not `token`; `{"op":"input","kind":"enter"}` accepts the default |
|
||||
| a keyword (`J`, `ST`, `C`) | `{"op":"input","kind":"token","text":"C"}` | the payload field is named `text` |
|
||||
| an object | `{"op":"input","kind":"entity","handle":"HANDLE","point":[x,y,0]}` | `kind: "structure"` for a sub-entity pick, `kind: "selection"` to hand over the current selection |
|
||||
| the content of `TEXT` | `{"op":"action","name":"text_input","value":"hello"}` then `{"op":"action","name":"text_commit"}` | the command has already ended; the in-place editor owns the input |
|
||||
| the content of `MTEXT` | `{"op":"action","name":"mtext_insert","value":"hello"}` then `{"op":"action","name":"mtext_commit"}` | the rich-text editor has its own actions; the `TEXT` ones answer `editor_closed` |
|
||||
|
||||
## Native-only operations
|
||||
|
||||
| Operation | `--serve` (protocol 1) | `--mcp` |
|
||||
|---|---|---|
|
||||
| measure | `{"protocol":1,"op":"measure","handles":["2A"]}` — kernel-computed length, area, bounds and mass properties; no window needed | `ocs_read` with `op: "measure"` and `parameters.handles` |
|
||||
| capture | `{"protocol":1,"op":"capture","path":"out.png"}` — needs the editor window, so it answers `gui_required` here | `ocs_capture` with `scope` (`viewport` or `window`) and `max_dimension` |
|
||||
| `set_properties` | requires `collection`; colours are the serialised enum — `{"Index": 1}` or `"ByLayer"`; a colour name such as `"Red"` is rejected with `invalid_value` | same request through `ocs_execute` |
|
||||
@@ -0,0 +1,68 @@
|
||||
# Driving the web build
|
||||
|
||||
The web build exposes the same control core as `--serve` and `--mcp`, through two
|
||||
functions on the WebAssembly module. This page covers only what is specific to the
|
||||
browser; the operations, request envelope and replies are the ones in the
|
||||
[README](README.md).
|
||||
|
||||
## Reaching the control channel
|
||||
|
||||
The Trunk loader publishes the wasm-bindgen module as `window.wasmBindings`. The
|
||||
control channel is ready once `window.wasmBindings?.ocs_control_submit` is defined
|
||||
(the [save smoke test](web_save_smoke.cjs) waits for exactly that).
|
||||
|
||||
| Function | Behaviour |
|
||||
|---|---|
|
||||
| `ocs_control_submit(request: string) → string` | Queues one request (a JSON string) and returns a ticket immediately. |
|
||||
| `ocs_control_take(ticket: string) → string \| undefined` | Returns the reply (a JSON string) once it is ready, `undefined` before that. A reply is removed when taken. |
|
||||
|
||||
Poll `ocs_control_take` until it returns a reply, as the smoke test does:
|
||||
|
||||
```js
|
||||
async function control(request) {
|
||||
const api = window.wasmBindings;
|
||||
const ticket = api.ocs_control_submit(JSON.stringify(request));
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const reply = api.ocs_control_take(ticket);
|
||||
if (reply) return JSON.parse(reply);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error('control timed out');
|
||||
}
|
||||
```
|
||||
|
||||
Limits: at most 64 requests wait in the queue (a further `submit` is answered with the
|
||||
`busy` error), and only the newest 64 replies are kept, so take a reply soon after it is
|
||||
ready. A request that is not valid JSON is answered with `op: "invalid"` and a
|
||||
`parse_error`.
|
||||
|
||||
## Opening a drawing from bytes
|
||||
|
||||
The web build has no file system, so `open` takes the file's bytes instead of a path:
|
||||
|
||||
```json
|
||||
{"op":"open","name":"plan.dwg","data_base64":"…"}
|
||||
```
|
||||
|
||||
`name` is reduced to its file name and labels the tab and the recent-files entry;
|
||||
`data_base64` must be valid base64 and non-empty (`invalid_request` otherwise). While an
|
||||
earlier drawing is still opening the request is answered with `busy`; retry it once
|
||||
`state` shows the tab. A `path` is resolved against the browser's "recent files" store
|
||||
rather than a disk, so use `data_base64` for a file the page has not seen.
|
||||
`data_base64` is rejected on native builds; use `path` there.
|
||||
|
||||
## Startup dialogs
|
||||
|
||||
A dialog can be open when the page is ready. The donation prompt appears once per
|
||||
application version in each browser profile, so a fresh or disposable profile always
|
||||
sees it. While a modal is open `state.modal` is set, and `{"op":"new"}` returns `ok`
|
||||
without creating a tab until the modal is closed. Close it first:
|
||||
|
||||
```js
|
||||
const state = await control({ op: 'state' });
|
||||
if (state.modal) await control({ op: 'action', name: 'close_modal' });
|
||||
await control({ op: 'new' });
|
||||
```
|
||||
|
||||
Native builds can show a second dialog at launch (the file-association prompt), so check
|
||||
`state.modal` again after closing one.
|
||||
Reference in new issue
Block a user