forked from 1b-agent/opencadstudio-upstream
feat(automation): entity, block, publishing and REST operations from the fork
The operations docs/automation/API-SPEC.md (PR #1383) describes, brought over from huaninstratech/OpenCADStudio so the specification no longer runs ahead of the code: - entity CRUD: entities_create, entities_delete, entities_transform, entities_copy_to, and `where` filters on query - blocks and export: block_define, block_delete, a normalised wblock - publishing: layout_create, page_setup_set, plot (per_page included) - metadata: sysvar, xdata_set / xdata_get, file_identity - collections: group_create, selection_set_save / load - control plane: batch, events, view_focus, close - the REST transport: `--http <port>`, the /api/v1 routes and their OpenAPI 3 description - person-in-the-loop picks: user_select and getpoint, hosted by the GUI over the same REST routes, with on-screen pending signals Taken from the fork's commits b7a7ed80, 69396b42, 64f1cf6a, c3aafc8e, bb5a7bfa, 52317a5c, 50d293ba, b90e194f, 517c6397, 53a5c4c9, a49ac045, 37de5a46, a78b1822, 6d9aed2a, 33f522da, 5b5cafe2, 02a7e78d, 94df40dc, fd5bc14d, 485fac61 and 2c5fc994. Left out on purpose: 37de5a46 also removed the donation surface (the Patreon fetch, the donation prompt, the Start-page supporters rail, the DONATE command and its locale strings) and replaced the About dialog's call to action; that is the fork's own product decision and stays out. The fork's version bump and its unrelated IFC/BIM work are not part of this. Adapted while porting: the REST server and the GUI bridge are desktop-only and a template start is refused in the web build, so the wasm target still compiles; the two plot tests read the PDF through the shared stream helper, because exported streams are Flate-compressed; the benches set the new crosshair field; comments and docs describe each operation in its own terms rather than by another product's API names. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
531ba44faa
commit
279a87418e
37 files changed
+8795
-82
No files matched your search
@@ -0,0 +1,615 @@
|
||||
# OpenCADStudio Automation API — Specification
|
||||
|
||||
Specification version **1.1** · API protocol **1** · applies to fork builds
|
||||
`2026.41` and later (`2026.40` for everything except §3.5 and §6.12).
|
||||
|
||||
This document is the complete, self-contained reference for the OpenCADStudio
|
||||
automation API. Everything a client needs — conventions, transports, the
|
||||
operation catalogue, validation rules, error codes and worked examples — lives
|
||||
here. The API is transport-neutral: one operation dispatcher serves every
|
||||
client, so a request has identical semantics whether it arrives over a local
|
||||
HTTP port, a stdio pipe, a TCP socket or an AI-tool (MCP) session.
|
||||
|
||||
MCP session set-up and a hands-on REST walkthrough live in
|
||||
[`docs/automation/README.md`](README.md); this file links to it rather than
|
||||
repeating either.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
| Concept | Meaning |
|
||||
|---|---|
|
||||
| **Session** | One running editor process. Owns a session id, an event cursor and 1…N open documents. |
|
||||
| **Document** | One drawing (a tab). Identified by a stable `document_id`. Exactly one document is **active** at a time. |
|
||||
| **Handle** | Identity of every database object — a hexadecimal string (e.g. `"2A"`). Handles are stable across save/open and are the key for all read, edit, erase and clone operations. |
|
||||
| **Operation** | One atomic, undoable step. Validated in full before anything commits; a rejected request changes nothing. |
|
||||
| **Envelope** | The mutation wrapper: `"protocol":1`, a caller-generated `request_id`, and the `document_id` under edit. |
|
||||
| **Pick** | A human-in-the-loop operation (`user_select`, `getpoint`, §6.12): the operation stays pending while a *person* answers at the screen. |
|
||||
|
||||
Design rules, in priority order:
|
||||
|
||||
1. **Idempotency** — a mutation replayed with the *same* `request_id` returns
|
||||
the cached result instead of executing twice. Generate one fresh
|
||||
`request_id` per logical operation (≤ 128 bytes) and reuse it only to
|
||||
retry after a timeout.
|
||||
2. **Optimistic state** — read `state` first, pass `document_id` (and
|
||||
`revision`/`geometry_revision` when you hold them) back on mutations. A
|
||||
refusal whose code is in the retryable set (§5.2) means "your snapshot is
|
||||
behind": refresh state and retry once with a *new* `request_id`.
|
||||
3. **Atomicity** — every operation is all-or-nothing and produces exactly one
|
||||
undo step. Multi-step transactions are the client's `batch` op or a
|
||||
sequence of ops against a held `revision`.
|
||||
4. **Geometry stays in the engine** — measure, containment, intersection and
|
||||
ranking are computed by the built-in geometry kernel; clients never
|
||||
reimplement them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Conventions
|
||||
|
||||
| Convention | Value |
|
||||
|---|---|
|
||||
| Angles | **degrees** on the wire, counterclockwise positive (`rotation_deg`, `angle_deg`, `start_angle_deg`, `end_angle_deg`) |
|
||||
| Points | `[x, y]` (z = 0) or `[x, y, z]`, world coordinates (WCS). Interactive input additionally accepts `"space":"ucs"` / `"relative"`. |
|
||||
| Distances, sizes | drawing units (millimetres only where a field is explicitly paper-space, e.g. `paper_width`) |
|
||||
| Colors | integer index `1–255` (`1` red … `7` white), `256` by-layer, `0` by-block; true color accepted where noted |
|
||||
| Handles | hexadecimal strings, case-insensitive, no `0x` prefix required |
|
||||
| Names | layer / block / style names are case-insensitively unique; `*`-prefixed names are reserved |
|
||||
| Booleans | JSON `true`/`false` |
|
||||
| Templates | a `*.dwt` file is written and read as DWG bytes — same container, template extension |
|
||||
| Paper sizes | catalog names, e.g. `"ISO_A4_(210.00_x_297.00_MM)"` (canonical name is echoed back in results) |
|
||||
| Scales | `"fit"` or `"paper:drawing"` ratio like `"1:100"` (both parts > 0) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Transports
|
||||
|
||||
All transports speak to the same dispatcher; pick one per client. REST and
|
||||
MCP advertise the whole surface; feature detection is `capabilities` (§4).
|
||||
|
||||
### 3.1 HTTP REST — headless (`--http <port>`)
|
||||
|
||||
`OpenCADStudio --http 8090` runs a private, window-less session behind a
|
||||
resource-oriented REST surface at `http://127.0.0.1:<port>/api/v1` (loopback
|
||||
bind only, permissive CORS, JSON bodies, one request per connection). A
|
||||
machine-readable OpenAPI 3 description of the whole surface is served at
|
||||
`GET /api/v1/openapi`.
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `200` | Success (read or update) |
|
||||
| `201` | A resource was created (document, entities, block, group, selection set, layout) |
|
||||
| `400` | Validation failure — the body keeps the symbolic `code` and message |
|
||||
| `404` | Unknown route or unknown handle (`code:"entity_absent"`) |
|
||||
| `409` | State conflict (`stale_state`, `document_not_active`) or GUI required (`gui_required`) |
|
||||
| `503` | Dispatcher busy |
|
||||
|
||||
The REST layer generates `request_id`s and caches the active `document_id`
|
||||
server-side, retrying once on retryable refusals — plain HTTP clients do not
|
||||
need envelope bookkeeping. Power clients may instead POST any operation name
|
||||
to `/api/v1/{op}` with an explicit body — including `operation` (poll a
|
||||
pending op) and `cancel`.
|
||||
|
||||
Connections are served one thread each: a slow op (`plot`, opening a large
|
||||
drawing) or a client that connects and stalls holds only its own connection,
|
||||
never the other callers. The ops themselves still execute one at a time on
|
||||
the private session (the same one-dispatcher pipeline as every transport), so
|
||||
the concurrency buys responsiveness, not parallel mutation. A connection that
|
||||
sends nothing for **15 s** is dropped.
|
||||
|
||||
### 3.2 HTTP REST — GUI-hosted (`--http <port>` **with a drawing file**)
|
||||
|
||||
`OpenCADStudio.exe "<file>" --http 8090` boots the **normal editor** and hosts
|
||||
the same REST surface on this very process, aimed at the drawing the person at
|
||||
the screen is working on: identical routes, identical semantics, plus the
|
||||
human-in-the-loop picks of §6.12 (which need the window this process owns).
|
||||
Differences worth knowing:
|
||||
|
||||
- `GET /state` primes a server-side `document_id` cache; later mutations
|
||||
address the live drawing without the caller repeating the id. A stale cache
|
||||
(the person switched documents) costs one state refresh and one retry, not
|
||||
an error.
|
||||
- `session_id` is not needed (the channel has no descriptor handshake; a
|
||||
guessed value is stripped).
|
||||
- One thread per connection: a parked pick never blocks other calls. A pick
|
||||
may park up to **30 minutes**; any other op answers within **300 s** or the
|
||||
bridge replies `response_timeout`.
|
||||
- A parked pick watches its own connection: the moment the client hangs up
|
||||
(its own timeout, a crash, a killed probe), the pending pick is retracted
|
||||
with `cancel` and the channel is free again — the next pick starts clean
|
||||
instead of dying on `interactive_pending` until the person happens to press
|
||||
Esc. The same retraction fires if the caller waits out the full 30 minutes.
|
||||
- Every pick logs its lifecycle to stderr as `[pick] <op> <request_id>` lines
|
||||
(start, cancelled-by-disconnect, settled), one per state, so a client-side
|
||||
log can be cross-referenced line for line.
|
||||
- Unknown POST paths are treated as op attempts and forwarded (any op of the
|
||||
surface works — including `operation` to poll a pending pick and `cancel`
|
||||
to retract it); truly unrouted paths answer `404 {"code":"unknown_route"}`.
|
||||
- `OPTIONS *` answers **204** (CORS preflight).
|
||||
|
||||
### 3.3 stdio JSONL (`--serve`)
|
||||
|
||||
One JSON value per line over stdin/stdout. The **first** stdout line is the
|
||||
ready greeting: `{"ok":true,"ready":true,"version":"…","session_id":"…"}`.
|
||||
Every subsequent line is one request/response pair (legacy reads need no
|
||||
envelope; mutations use the envelope of §1).
|
||||
|
||||
### 3.4 TCP (`--serve --port N`)
|
||||
|
||||
The same JSONL protocol as §3.3 over `127.0.0.1:<N>`, one client at a time.
|
||||
|
||||
### 3.5 AI-tool session (`--mcp`)
|
||||
|
||||
Exposes four tools — `ocs_sessions`, `ocs_read`, `ocs_execute`, `ocs_capture`
|
||||
— with JSON-Schema validated arguments (`ocs_execute` alone accepts all 36
|
||||
mutation operations, §6). Session discovery and capability advertisement
|
||||
follow the same protocol-1 semantics. Set-up instructions: see the MCP section
|
||||
of [`README.md`](README.md).
|
||||
|
||||
---
|
||||
|
||||
## 4. Session and state
|
||||
|
||||
`{"protocol":1,"op":"state"}` (or `GET /api/v1/state`) returns everything a
|
||||
client needs to act:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `session_id` | Identity of the running session; required by the MCP transport, accepted-and-ignored elsewhere (only a *wrong* value is refused) |
|
||||
| `document_id` | The active document |
|
||||
| `revision`, `geometry_revision` | Optimistic-concurrency tokens for data and geometry |
|
||||
| `hand_seed` | The drawing's handle seed, hex — a monotonically advancing per-file object counter |
|
||||
| `documents[]` | Every open document: `id`, `title`, `path`, `dirty`, `revision` |
|
||||
| `selection[]` | Handles currently selected, in pick order |
|
||||
| `command` | An interactive command's state, when one is running: `accepts[]`, `options[]`, `input_example` |
|
||||
| `operation` | The `request_id` of the operation currently pending, when one is |
|
||||
| `capabilities` | Feature list (also available as the dedicated `capabilities` read) |
|
||||
|
||||
`hello` is an alias of `state`. `file_identity` (§6.1) additionally gives
|
||||
every drawing a stable GUID.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error model
|
||||
|
||||
### 5.1 Response envelope
|
||||
|
||||
Every settled response carries:
|
||||
|
||||
```json
|
||||
{"ok":true,"status":"completed","request_id":"…","result":{…},"changes":[…],"state":{…}}
|
||||
```
|
||||
|
||||
`status` is one of `completed`, `accepted`, `running` (async work — poll with
|
||||
`{"protocol":1,"op":"operation","request_id":…}`), `waiting_input` (an
|
||||
interactive command wants more input), `cancelled`, `failed`. `changes[]`
|
||||
lists affected handles (bounded to 1000) so clients can refresh cheaply.
|
||||
|
||||
### 5.2 Error codes
|
||||
|
||||
Failures are `{"ok":false,"code":"…","error":"…"}`. The `code` is symbolic
|
||||
and stable; branch on it, not on message text.
|
||||
|
||||
| Code | Meaning | Retryable |
|
||||
|---|---|---|
|
||||
| `stale_state`, `session_changed` | The caller's snapshot is behind | yes — refresh `state`, retry once with a new `request_id` |
|
||||
| `document_required`, `document_not_active`, `document_closed` | Envelope addressing problem | yes (refresh state) |
|
||||
| `document_dirty` | Closing would lose unsaved changes | no — resend with `"discard":true` to confirm |
|
||||
| `entity_absent` | A listed handle does not exist (the message names them) | refresh and re-read |
|
||||
| `command_busy` | An interactive command is active | finish or `cancel` first |
|
||||
| `gui_required` | Operation needs the editor window (`view_focus`, and the picks of §6.12 over headless transports) | headless sessions only |
|
||||
| `busy` | Async work still running | poll `operation` |
|
||||
| `interactive_pending` | Another person-pick (§6.12) is already waiting for the user | wait for it, or retract it with `cancel` |
|
||||
| `user_input_busy` | The person is mid-command or in a dialog; their keystrokes own Enter/Esc | wait, then retry |
|
||||
| `invalid_detail` | `user_select` `detail` outside `summary`/`geometry`/`full` | no |
|
||||
| `selection_changed` | Precondition `selection[]` no longer matches | re-read and confirm |
|
||||
| `name_required`, `entities_required`, `selection_required`, `app_required`, `path_required`, `handles_required`, `block_required` | Missing mandatory field | no |
|
||||
| `unknown_entity_type`, `invalid_radius`, `invalid_vertices`, `invalid_point`, `invalid_text`, `invalid_scale`, `invalid_paper`, `invalid_handle`, `invalid_xdata`, `invalid_sysvar_value` | Validation failures — nothing commits | no |
|
||||
| `unknown_sysvar` | Variable outside the registry (§6.7) | no |
|
||||
| `layout_exists`, `layout_missing`, `layer_failed`, `block_failed`, `block_missing`, `block_protected`, `selection_set_missing`, `template_missing`, `template_failed`, `save_failed` | Domain refusals, self-explanatory | no |
|
||||
|
||||
Unknown routes answer `code:"unknown_route"` (HTTP 404). A timed-out parked
|
||||
request answers `code:"response_timeout"`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Operations reference
|
||||
|
||||
Every mutation below is an envelope op (§1). Reads (`query`, `records`,
|
||||
`layers`, `header`, `xdata_get`, `capabilities`, …) need no envelope. All 36
|
||||
mutations are listed in the `capabilities` advertisement and in the MCP
|
||||
schema; the REST passthrough accepts any of them at `POST /api/v1/{op}`.
|
||||
|
||||
### 6.1 Documents, session, lifecycle
|
||||
|
||||
| Op | Request (beyond envelope) | Result highlights |
|
||||
|---|---|---|
|
||||
| `new` | optional `"template":"<.dwt/.dwg/.dxf>"` — start an untitled drawing whose tables/styles/entities come from the template; the document keeps no source path | `total` (entities imported) |
|
||||
| `open` | `"path"` — opens a drawing as the active document | entity summary |
|
||||
| `activate` | `"document_id"` | — |
|
||||
| `close` | optional `"discard":true` — refused with `document_dirty` while unsaved changes exist; closing the last tab leaves a fresh empty drawing | `closed` |
|
||||
| `save` | optional `"path"` (required while untitled). `*.dwg`/`*.dxf` follow the extension; a `*.dwt` path writes a template (DWG bytes) | `saved` |
|
||||
| `file_identity` | optional `"renew":true` — stable RFC 4122 v4 GUID, minted on first call, persisted inside the drawing; survives save/open and clone | `identity`, `created` |
|
||||
| `undo` / `redo` | — | — |
|
||||
|
||||
REST: `POST /documents` (`{}` → fresh document, `{"template":…}`, `{"path":…}`),
|
||||
`DELETE /documents/{id}?discard=true`, `POST /save`.
|
||||
|
||||
### 6.2 Entity creation — `entities_create`
|
||||
|
||||
`{"entities":[{…},…]}` — a batch of typed definitions, validated in full
|
||||
before anything commits; one undo step; missing layers are auto-created and
|
||||
reported in `result.layers_created`. Every definition also accepts `layer`
|
||||
and `color` (index).
|
||||
|
||||
| Type | Fields |
|
||||
|---|---|
|
||||
| `Line` | `start`, `end`, optional `thickness` |
|
||||
| `Circle` | `center`, `radius` (≥ 0) |
|
||||
| `Arc` | `center`, `radius` (> 0), `start_angle_deg`, `end_angle_deg` |
|
||||
| `LwPolyline` | `vertices` (≥ 2), `closed`, optional `constant_width` |
|
||||
| `Point` | `location` |
|
||||
| `Text` | `value`, `position`, optional `height` (default 2.5), `rotation_deg`, `style` |
|
||||
| `MText` | `value`, `position`, optional `height`, `width`, `rotation_deg` |
|
||||
| `Insert` | `block` (must exist), `position`, optional `scale` (number or `[x,y,z]`, nonzero), `rotation_deg` |
|
||||
| `Solid` | `corners` (3 or 4 `[x,y,z]`) |
|
||||
| `Hatch` | `boundary` (≥ 3 points, closed), `solid`, or `pattern` (catalog name, default `ANSI31`), `pattern_scale`, `pattern_angle_deg` |
|
||||
|
||||
Result: `{"handles":[…],"created":N,"layers_created":[…]}` → HTTP 201.
|
||||
|
||||
### 6.3 Entity modification
|
||||
|
||||
| Op | Fields | Notes |
|
||||
|---|---|---|
|
||||
| `entities_delete` | `handles` | Every handle must exist; nothing is erased otherwise (`entity_absent` names them) |
|
||||
| `entities_transform` | `handles`, `action`, per-action params | `move`/`copy`: `vector:[dx,dy(,dz)]` (copy returns new handles in `result.created`); `rotate`: `center`, `angle_deg`; `scale`: `center`, `factor` (≠ 0); `mirror`: `axis:[[x1,y1],[x2,y2]]`, optional `copy:true`; `array`: `rows`, `columns`, `row_spacing`, `column_spacing` |
|
||||
| `entities_copy_to` | `handles`, `document_id` (target) | Cross-document clone; referenced layer definitions travel along; result `created[]` + `count` |
|
||||
|
||||
### 6.4 Query and spatial filters — `query`
|
||||
|
||||
Parameters (all optional, combine freely — AND semantics):
|
||||
|
||||
| Parameter | Meaning |
|
||||
|---|---|
|
||||
| `type`, `layer` | Entity type name / layer name |
|
||||
| `handles` | Exact handle list |
|
||||
| `detail` | `summary` (identity + common), `geometry` (+ shape fields), `full` (+ complete serialized properties incl. bounds) |
|
||||
| `offset`, `limit` | Paging (limit ≤ 10000) |
|
||||
| `fields` | Projection (property paths) |
|
||||
| `bounds` | `[x0,y0,x1,y1]` world-window filter (entities intersecting the box) — for a "fully inside" test, compare each entity's own `bounds` client-side |
|
||||
| `near` | `[x,y(,z)]` — rank by kernel-computed distance |
|
||||
| `contains_point` | `[x,y]` — closed curves containing the point (kernel ray test) |
|
||||
| `intersections` | `[h1,h2]` — exact intersection points of two planar curves (kernel) |
|
||||
| `where` | Property filter list over serialized entities (below) |
|
||||
|
||||
`where` filters use JSON Pointer paths into the serialized entity
|
||||
(`"/radius"`, `"/common/layer"`) with the operators `eq`, `ne`, `lt`, `lte`,
|
||||
`gt`, `gte`, `contains`, `starts_with`, `ends_with`, `in`, `exists`,
|
||||
`not_exists`. Filters are validated up front; a bad pointer or unknown
|
||||
operator fails the query before any entity is inspected. On REST the same
|
||||
filter rides `GET /entities?where=<json-encoded>`.
|
||||
|
||||
Text entities return the raw string in `value` plus a formatting-free
|
||||
rendering in `text`; degenerate-width text bounds are widened with a
|
||||
documented estimate (height × 0.8 × character count) so region filters stay
|
||||
usable.
|
||||
|
||||
Companion reads: `entities` (alias), `layers`, `header`, `records`
|
||||
(paged, filterable database records), `record_schema` (generated type
|
||||
registry with write rules), `properties`, `measure` (length/area/bounds),
|
||||
`xdata_get`, `get_selection` (§6.12).
|
||||
|
||||
### 6.5 Blocks
|
||||
|
||||
| Op | Fields | Semantics |
|
||||
|---|---|---|
|
||||
| `block_define` | `name`, `base:[x,y,z]`, `handles`, optional `insert_at`, optional `"replace":true` | The sources move into the new definition (flattened at `base` = block origin) and one `Insert` is placed at `insert_at` (default `base`), so the drawing looks unchanged. `"replace":true` drops a same-named definition — children, markers and inserts — inside the same undo step before creating the new one (idempotent re-import). Result `{"block":name,"insert":"<handle>"}`. Names are unique, `*` is reserved. |
|
||||
| `block_delete` | `name` | Removes the definition completely: child entities, block markers, every `Insert` referencing it and the table record. One undo step. Result `erased`. Unknown → `block_missing`; `*`-records → `block_protected`. |
|
||||
| `wblock` | `path`, `handles` *or* `block`; optional `"template":"<file>"` (the export inherits that file's tables/styles), optional `"normalize":true` (the export is shifted so its overall bounds minimum lands on the origin) | Writes a standalone DWG/DXF (extension decides). The source document is untouched. Result `{path, entities, normalized}`. |
|
||||
|
||||
REST: `POST /blocks`, `DELETE /blocks/{name}`, `POST /wblock`.
|
||||
|
||||
### 6.6 Layouts and plotting
|
||||
|
||||
| Op | Fields |
|
||||
|---|---|
|
||||
| `layout_create` | `name` — adds a layout with the default page setup and a sheet viewport; duplicates refused (`layout_exists`) |
|
||||
| `page_setup_set` | `layout` plus any of `paper` (catalog name), `orientation` (`portrait`/`landscape`), `fit:true` or `scale:"1:100"`, `center`, `window:[x0,y0,x1,y1]`, `plot_style`. Explicit fields win; the rest keeps the stored setup. The stored setup is what `plot` consumes by default. |
|
||||
| `plot` | `path`, `layout` (`"Model"` default, a name, or `"all"`), `area` (`"extents"` default, `"display"`, `"limits"`, `"window"` + `window:[…]`, `"layout"`), `paper`, `orientation`, `fit`/`scale`, `center`, `offset_x/offset_y`, `upside_down`, `plot_style` (CTB/STB), output toggles `transparency`, `lineweights`, `merge_lines`, `stamp`, and `"per_page":true` (with `layout:"all"` writes **one PDF per layout**, returning `result.files`) |
|
||||
|
||||
Plotting writes PDF only, synchronously, with no device dialogs; result
|
||||
reports `{path, pages, page_sizes}` (millimetres).
|
||||
|
||||
### 6.7 Metadata and variables
|
||||
|
||||
**Extended data (`xdata_set` / `xdata_get`)** — typed key-value records per
|
||||
application name on any entity; the application table entry is registered
|
||||
implicitly on write. An empty (or absent) `data` list removes the record.
|
||||
|
||||
```json
|
||||
{"op":"xdata_set","handles":["65"],"app":"SPEC","data":[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":3}]}
|
||||
```
|
||||
|
||||
| Code | Value type |
|
||||
|---|---|
|
||||
| `1000` | string |
|
||||
| `1003` | layer name |
|
||||
| `1004` | hex bytes |
|
||||
| `1005` | hex handle |
|
||||
| `1010`–`1013` | `[x,y,z]` point / position / displacement / direction |
|
||||
| `1040` / `1041` / `1042` | real |
|
||||
| `1070` / `1071` | 16-bit / 32-bit integer |
|
||||
|
||||
**System variables (`sysvar`)** — atomic get/set over a fixed registry:
|
||||
|
||||
| Var | Meaning | Writable |
|
||||
|---|---|---|
|
||||
| `ltscale`, `celtscale` | global / current-entity linetype scale | ✓ |
|
||||
| `clayer`, `ctextstyle` | current layer / text style (must exist) | ✓ |
|
||||
| `textsize`, `filletrad`, `pdmode`, `pdsize` | defaults for new content | ✓ |
|
||||
| `mirrtext` (`0`/`1`), `insunits`, `osmode` | behaviour flags | ✓ |
|
||||
| `extmin`, `extmax` | model-space extents `[x,y,z]` | read-only |
|
||||
|
||||
`{"op":"sysvar","set":{"ltscale":2.5}}` applies the whole object atomically
|
||||
(`unknown_sysvar` / `invalid_sysvar_value` refuse it all);
|
||||
`{"op":"sysvar","get":["ltscale"]}` → `result.values`. Omitted `get` returns
|
||||
every readable name.
|
||||
|
||||
**Record editing (`set_properties`)** — typed, validated property writes on
|
||||
any database record via JSON Pointer paths, with compare-and-set support;
|
||||
`records`/`record_schema` describe every writable field.
|
||||
|
||||
### 6.8 Groups and selection sets
|
||||
|
||||
| Op | Fields | Notes |
|
||||
|---|---|---|
|
||||
| `group_create` | `name`, `handles` | Named group over existing entities; result reports the group's own object `handle` |
|
||||
| `selection_set_save` | `name`, `handles` | Stores a named handle set for the session |
|
||||
| `selection_set_load` | `name`, optional `"select":true` (default) | Recalls the set; with `select` it also becomes the current selection (`result.selected`) |
|
||||
|
||||
REST: `POST /groups`, `POST /selection-sets`, `GET /selection-sets/{name}[?select=true]`.
|
||||
|
||||
### 6.9 Images — `embed_image`
|
||||
|
||||
`{"path":"<png/jpg>","at":[x,y],"width":<drawing units>,…}`
|
||||
|
||||
| Mode | Behaviour |
|
||||
|---|---|
|
||||
| **Embedded (default)** | The bytes are stored inside the drawing as a self-contained frame entity. There is no external path, so the image cannot break when the source file is moved or deleted, and it travels through block definitions, clones and exports like any entity. Trade-offs: the DWG grows by the image size, and the picture never updates when the source file changes. Use this for barcodes/QR and any image that is part of the deliverable. |
|
||||
| `"linked":true` | Stores a path-referenced image. The file must travel with the drawing and *can* break; the mode exists for images that must be swappable without touching the drawing. |
|
||||
|
||||
Repairing a broken linked image from a drawing authored elsewhere — no
|
||||
dedicated op needed:
|
||||
|
||||
1. `query` with `type:"RasterImage"`, `detail:"full"` — the entity exposes
|
||||
its `file_path` and world `bounds`.
|
||||
2. `entities_delete` the broken reference by handle.
|
||||
3. `embed_image` a replacement at the same `at`/`width` derived from the old
|
||||
bounds — embedded, so it cannot break again.
|
||||
|
||||
### 6.10 Commands, batches, events
|
||||
|
||||
| Op | Purpose |
|
||||
|---|---|
|
||||
| `run` | Execute one command line headlessly (`"cmd":"LINE 0,0 10,10"` — command name + space-separated prompt answers; points `x,y[,z]`, options by displayed token). Response `status` is `completed` or `waiting_input`. Requires `document_id`. |
|
||||
| `start` / `input` | Interactive dialog: `start` opens a command, `state.command.accepts` lists the valid input kinds (`text`, `token`, `point`, `entity`, `structure`, `selection`, `enter`), `input` feeds one. |
|
||||
| `cancel` | Aborts the running command — or retracts this client's own pending pick (§6.12). |
|
||||
| `batch` | `steps[]` — sequential ops (≤ 64) where each step sees the previous step's state; stops at the first failure and reports `completed_steps` / `next_step`. |
|
||||
| `select` | Change the current selection (`handles`, `clear`, filters). |
|
||||
| `events` | Cursor-paged event stream (command lifecycle, document and selection changes, 128-event ring, `resync` flag). Polling by design. |
|
||||
| `history` | Command-line history (also the person's view of what clients did — see §6.12). |
|
||||
| `capture` | PNG snapshot of the viewport or full window (`scope`, `max_dimension`). |
|
||||
| `view_focus` | Zoom-to-fit + highlight the listed handles (GUI sessions; headless answers `gui_required`). |
|
||||
| `action` | The 20 UI toggles (grid, ortho, …) by name. |
|
||||
|
||||
### 6.11 Complete REST endpoint table
|
||||
|
||||
| Method & path | Purpose |
|
||||
|---|---|
|
||||
| `GET /ready` · `/state` · `/capabilities` · `/openapi` | Readiness · full state · feature advertisement · OpenAPI 3 document |
|
||||
| `GET /documents` · `POST /documents` · `DELETE /documents/{id}?discard=` | List · open/template/new · close |
|
||||
| `GET /sysvars?names=` · `POST /sysvars` | Read · set |
|
||||
| `POST /layouts` · `PUT /layouts/{name}/page-setup` | Create · write plot configuration |
|
||||
| `GET /entities` · `GET /entities/{h}` · `POST /entities` · `DELETE /entities?handles=` | Query (incl. `where`) · one entity · create · erase |
|
||||
| `POST /entities/transform` · `POST /entities/copy-to` | Modify · cross-document copy |
|
||||
| `GET/PUT/DELETE /entities/{h}/xdata[/{app}]` | Extended data |
|
||||
| `POST /blocks` · `DELETE /blocks/{name}` | Define (+`replace`) · delete |
|
||||
| `POST /groups` · `POST /selection-sets` · `GET /selection-sets/{name}` | Groups · selection sets |
|
||||
| `POST /getpoint` · `POST /user_select` | Person-in-the-loop picks (§6.12; the connection parks) |
|
||||
| `POST /plot` · `/wblock` · `/images` · `/commands` · `/undo` · `/redo` · `/save` | Publishing, export, command line, lifecycle |
|
||||
| `GET /layers` · `/header` · `/records` | Database reads |
|
||||
| `POST /file-identity` | Stable drawing GUID |
|
||||
| `POST /{op}` | Passthrough for any envelope op, including `cancel` and `history` |
|
||||
|
||||
### 6.12 Human-in-the-loop picks — `user_select` & `getpoint`
|
||||
|
||||
These two operations hand the screen to the person at the desk. They are
|
||||
answerable only where a person exists: on the GUI-hosted channel (§3.2) or
|
||||
MCP sessions attached to a desktop build, a headless session answers
|
||||
`gui_required`. Both stay pending — pollers see `running` — until that person
|
||||
answers, and both can be retracted programmatically with
|
||||
`{"op":"cancel","request_id":"<a fresh id>"}`.
|
||||
|
||||
**`user_select`** — interactive selection sampling. The person picks entities with the
|
||||
normal gestures (click, window, crossing), **Enter** confirms, **Escape**
|
||||
cancels.
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"user_select","request_id":"sample-1","document_id":1,
|
||||
"type":"INSERT","layer":"TITLE","prompt":"Chọn các block cần lấy props",
|
||||
"detail":"full","clear":true}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `type`, `layer` | optional filters for what counts as picked; matching is case-insensitive against the display name (`"Block Reference"`) and the DXF name (`"INSERT"`) |
|
||||
| `prompt` | optional; replaces the default hint on the command line |
|
||||
| `detail` | `summary` / `geometry` / `full` (default) — `full` returns each entity's complete serialized properties |
|
||||
| `clear` | default `true`: the request starts a fresh selection; `false` keeps whatever is selected |
|
||||
|
||||
Confirm resolves with:
|
||||
|
||||
```json
|
||||
{"ok":true,"status":"completed","result":{
|
||||
"cancelled":false,"count":1,"ignored":1,
|
||||
"handles":["6F"],
|
||||
"entities":[{"handle":"6F","type":"Block Reference","block":"ANCHOR_A",
|
||||
"position":[0,0,0],"properties":{…}}]}}
|
||||
```
|
||||
|
||||
`ignored` counts picks the `type`/`layer` filter deselected at confirm time
|
||||
(the person's gestures are never blocked; only the answer is filtered).
|
||||
Escape — or a `cancel` retraction, or the document closing — resolves with
|
||||
`status:"cancelled"` and `{"cancelled":true,"count":0}`. Starting a pick
|
||||
while a command or dialog is active is refused (`user_input_busy`); a second
|
||||
concurrent pick is refused (`interactive_pending`).
|
||||
|
||||
**`getpoint`** — one snapped world point from the next left-click.
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"getpoint","request_id":"qr-spot","document_id":1,
|
||||
"prompt":"Chọn vị trí đặt QR code"}
|
||||
→ {"ok":true,"status":"completed","result":{"point":[125.5,64.25,0.0]}}
|
||||
```
|
||||
|
||||
Escape cancels with `{"cancelled":true}`. `detail` does not apply.
|
||||
|
||||
**What the person sees while a pick is pending** (so integrators know the
|
||||
request is visible even before anyone clicks):
|
||||
|
||||
- the command line pins a labeled, non-fading request line:
|
||||
`[user_select · sample-1] Chọn các block cần lấy props (Enter confirms, Esc cancels)`;
|
||||
- the automation pill on the command line turns blue — *"MCP is waiting for
|
||||
you to pick — Enter confirms, Esc cancels"*;
|
||||
- the viewport cursor trades its crosshair arms for a blue pickbox;
|
||||
- the answer is printed back into the message list with the same identity
|
||||
(`user_select sample-1: handed 1 object(s) to the client.`), and the full
|
||||
trail of every client operation — including failures — stays reviewable in
|
||||
the command-line history.
|
||||
|
||||
On the GUI-hosted HTTP channel (§3.2) the whole exchange rides one parked
|
||||
connection: the request holds until the person answers (up to 30 minutes,
|
||||
then `response_timeout`), so no polling loop is needed. Callers that prefer
|
||||
polling may treat an `accepted` reply as the answer and follow the operation
|
||||
with `{"op":"operation","request_id":…}` reads; a parked pick that loses its
|
||||
connection (or its caller) is retracted automatically, and the server-side
|
||||
trail of every state change — start, answer, cancel — lands on stderr as
|
||||
`[pick] <op> <request_id>: …` lines for cross-referencing against the
|
||||
client's own log.
|
||||
|
||||
---
|
||||
|
||||
## 7. Worked examples
|
||||
|
||||
### 7.1 Draw, verify, publish (REST)
|
||||
|
||||
```sh
|
||||
# Create (missing layers auto-created; the batch is atomic).
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{
|
||||
"entities":[
|
||||
{"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"},
|
||||
{"type":"Text","value":"PAGE-01","position":[10,50],"height":3.0}
|
||||
]}'
|
||||
# → 201 {"result":{"handles":["63","64"],"created":2,"layers_created":["FRAME"]}}
|
||||
|
||||
# Filter by property.
|
||||
curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D"
|
||||
|
||||
# Mark, wrap into a reusable block, plot every sheet, save as a template.
|
||||
curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \
|
||||
-H "Content-Type: application/json" -d '[{"code":1000,"value":"PAGE-01"}]'
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \
|
||||
-H "Content-Type: application/json" -d '{"name":"MARK","base":[0,0,0],"handles":["63","64"]}'
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/plot \
|
||||
-H "Content-Type: application/json" -d '{"path":"C:/out/pages.pdf","layout":"all","per_page":true}'
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/save \
|
||||
-H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}'
|
||||
```
|
||||
|
||||
### 7.2 Sample entities with the person at the screen (GUI-hosted)
|
||||
|
||||
```sh
|
||||
OpenCADStudio.exe "C:/drawings/plan.dwg" --http 8090 # the editor stays interactive
|
||||
|
||||
# Ask the person to pick block references; the connection parks until they
|
||||
# press Enter (or Esc). No polling loop needed.
|
||||
curl -s -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"request_id":"sample-1","type":"INSERT","detail":"full","prompt":"Pick the title blocks"}'
|
||||
# → {"ok":true,"status":"completed","result":{"cancelled":false,"count":2,…}}
|
||||
|
||||
# Read the same entities back, then continue driving the live drawing.
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}'
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"handles":["6F"],"action":"move","vector":[5,0]}'
|
||||
```
|
||||
|
||||
### 7.3 Clone a region into a new, normalized drawing
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"wblock","request_id":"clone-1","document_id":1,
|
||||
"handles":["63","64"],
|
||||
"template":"C:/tpl/base.dwt",
|
||||
"normalize":true,
|
||||
"path":"C:/out/page-01.dwg"}
|
||||
```
|
||||
|
||||
The export inherits the template's tables/styles and is shifted so its
|
||||
minimum lands on the origin. Open it afterwards with `{"op":"open","path":…}`
|
||||
and verify with `query`.
|
||||
|
||||
### 7.4 Stable identity + idempotent retry
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"file_identity","request_id":"fid-1","document_id":1}
|
||||
→ {"ok":true,"result":{"identity":"5db78b90-ca0b-4a4f-ad68-8c860483a41f","created":true}}
|
||||
{"protocol":1,"op":"file_identity","request_id":"fid-1","document_id":1}
|
||||
→ same response served from the idempotency cache — never executed twice
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Surface status and upstreaming map
|
||||
|
||||
This spec documents the fork's surface, which is a strict superset of
|
||||
upstream `main`. Everything in the **core** column exists upstream today and
|
||||
is documented here unchanged; each **piece** row is a self-contained,
|
||||
independently reviewable addition — the matching section of this spec is
|
||||
meant to travel with its piece's PR, so no section ever runs ahead of the
|
||||
code it describes.
|
||||
|
||||
| Scope | Contents | Spec sections |
|
||||
|---|---|---|
|
||||
| **Core (upstream `main`)** | Transports `--serve` (stdio JSONL), `--serve --port` (TCP), `--mcp`; ops `new`, `open`, `save`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `capture`, `undo`, `redo`; reads `state`, `hello`, `capabilities`, `query`/`entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`; envelope conventions and the core error codes | §1–§5, §6.1, §6.4, §6.10 |
|
||||
| Piece A — entity CRUD | `entities_create`, `entities_delete`, `entities_transform`, `entities_copy_to` | §6.2, §6.3 |
|
||||
| Piece B — blocks & export | `block_define`, `block_delete`, `wblock` | §6.5 |
|
||||
| Piece C — publishing | `layout_create`, `page_setup_set`, `plot` (incl. `per_page`) | §6.6 |
|
||||
| Piece D — metadata | `sysvar`, `xdata_set`/`xdata_get`, `file_identity` | §6.7 |
|
||||
| Piece E — collections | `group_create`, `selection_set_save`/`load` | §6.8 |
|
||||
| Piece F — control plane | `batch`, `events`, `view_focus`, `close`, `where` filters | §6.4 (`where`), §6.10 |
|
||||
| Piece G — REST transport | `--http` headless server, `/api/v1` routes, OpenAPI 3 document | §3.1, §6.11, §7.1 |
|
||||
| Piece H — person-in-the-loop | GUI-hosted `--http` channel, `user_select`, `getpoint`, the on-screen pending signals | §3.2, §6.12, §7.2 |
|
||||
|
||||
Sections §3.2/§6.12 (piece H) additionally require the desktop build; over a
|
||||
headless session every documented request still answers truthfully
|
||||
(`gui_required`), so a client written against this spec behaves consistently
|
||||
on both.
|
||||
|
||||
---
|
||||
|
||||
## 9. Versioning and compatibility
|
||||
|
||||
- The wire protocol version is **1**; it is declared in every envelope and in
|
||||
the greeting line. Additions (new ops, new optional fields, new error
|
||||
codes) are non-breaking; clients should ignore unknown fields and treat
|
||||
unknown op names in `capabilities` as "not available in this build".
|
||||
- Feature detection: read `capabilities` before unfamiliar work — every op in
|
||||
this document is listed there when the running build supports it.
|
||||
- Drawing files: DWG and DXF readers/writers are version-tolerant; templates
|
||||
(`*.dwt`) are byte-compatible DWG files.
|
||||
|
||||
*Companion documents: the automation walkthrough and MCP set-up
|
||||
([`README.md`](README.md)) and the OpenAPI 3 description served at
|
||||
`GET /api/v1/openapi`.*
|
||||
+592
-2
@@ -1,8 +1,11 @@
|
||||
# OpenCADStudio MCP control
|
||||
|
||||
Every native OpenCADStudio build contains the same MCP server as the editor. `OpenCADStudio --mcp` starts it over stdio, opens the desktop editor when needed, and exposes the live document without Python, a package manager, a sidecar service, or client-specific code.
|
||||
> **Client developers start here:** [`API-SPEC.md`](API-SPEC.md) is the
|
||||
> consolidated, transport-neutral API specification — conventions, envelope,
|
||||
> error codes and the full operation catalogue in one document. This README
|
||||
> remains the operator-facing guide (setup, smoke tests, walkthroughs).
|
||||
|
||||
MCP lets an AI client inspect the open drawing, execute editor commands, and verify the result through a shared protocol. Install OpenCADStudio, then add a local MCP server in the client and set its command to:
|
||||
Every native OpenCADStudio build contains the same MCP server as the editor. `OpenCADStudio --mcp` starts it over stdio, opens the desktop editor when needed, and exposes the live document without Python, a package manager, a sidecar service, or client-specific code.MCP lets an AI client inspect the open drawing, execute editor commands, and verify the result through a shared protocol. Install OpenCADStudio, then add a local MCP server in the client and set its command to:
|
||||
|
||||
```sh
|
||||
OpenCADStudio --mcp
|
||||
@@ -182,3 +185,590 @@ 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).
|
||||
## Headless automation server (`--serve`)
|
||||
|
||||
Application clients that do not speak MCP spawn `OpenCADStudio --serve` and exchange one JSON object per line over stdin/stdout (`--port N` switches to `127.0.0.1:<N>`, one client at a time). The greeting line carries the build version and the session identity:
|
||||
|
||||
```json
|
||||
{"ok":true,"ready":true,"version":"2026.38","session_id":"…"}
|
||||
```
|
||||
|
||||
Legacy ops (`new`, `open`, `run`, `entities`, `query`, `records`, `layers`, `header`, `save`, …) act on the active document and need no envelope. Mutation ops of the shared control protocol — `wblock`, `plot`, `embed_image`, `set_properties`, `undo`, … — are addressed with an envelope: `"protocol":1`, a caller-generated `request_id` (≤128 bytes), and the `document_id` from `{"protocol":1,"op":"state"}`. An absent `session_id` is accepted; only a wrong one is refused. Settled responses carry `{ok, status, request_id, result, changes, state}`; `status` stays `accepted`/`running` for asynchronous work, polled with `{"protocol":1,"op":"operation","request_id":…}`.
|
||||
|
||||
### `wblock` — export entities or a block to a new DWG/DXF
|
||||
|
||||
Either a handle list or a block name, never both. The open document is not modified; the output format follows the path extension (`.dwg` or `.dxf`).
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"wblock","request_id":"clone-1","document_id":1,"path":"C:/out/page-01.dwg","handles":["2A","31"]}
|
||||
{"protocol":1,"op":"wblock","request_id":"clone-2","document_id":1,"path":"C:/out/cover.dwg","block":"A_CPT"}
|
||||
```
|
||||
|
||||
The result reports `{"path":…, "entities":<count>}`. Referenced layer definitions travel with the entities; block definitions export flattened into model space.
|
||||
|
||||
### `plot` — render to PDF
|
||||
|
||||
Plots the active document without any dialog. `layout` selects `"Model"` (default), one layout name, or `"all"`. `area` is `"extents"` (default), `"display"`, `"limits"`, `"window"` (needs `window:[x0,y0,x1,y1]`), or `"layout"` (the laid-out sheet, layouts only). `paper` names a catalog sheet, `orientation` is `"Portrait"`/`"Landscape"`, sizing is `"fit":true` (default) or `"scale":"1:100"`, plus `center`, `offset_x/offset_y`, `upside_down`, `plot_style` (CTB path or discovered name) and the output toggles `transparency`, `lineweights`, `merge_lines`, `stamp`:
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"plot","request_id":"plot-1","document_id":1,"path":"C:/out/pages.pdf","layout":"all","paper":"ISO_A4_(210.00_x_297.00_MM)","fit":true,"center":true}
|
||||
```
|
||||
|
||||
Explicitly supplied fields override a layout's stored page setup; unspecified fields keep it. The result reports `{"path":…, "pages":<n>, "page_sizes":[[w,h],…]}` in millimetres.
|
||||
|
||||
### `embed_image` — attach a picture
|
||||
|
||||
Places the picture at `at:[x,y]` with `width` in drawing units (default ≈ pixels/100). The default embeds the raster into the drawing as a self-contained OLE2FRAME (`"kind":"Ole2Frame"`). `"linked":true` stores a `RasterImage` + `ImageDefinition` referencing the file path instead (`"kind":"RasterImage"`) — the image file must then travel with the drawing.
|
||||
|
||||
#### Embedded vs linked — when images can "break"
|
||||
|
||||
The embedded default carries the image bytes **inside** the DWG. There is no
|
||||
path to lose, so the classic broken-image failure (PNG moved, renamed or
|
||||
deleted after placing) cannot happen: the drawing renders the same on any
|
||||
machine, and embedded images travel normally through `block_define`,
|
||||
`wblock` and `entities_copy_to` like any other entity. Barcode/QR placement
|
||||
(the SPM flow: attach → wrap into a block → plot → clone) should stay on
|
||||
this default. Two deliberate trade-offs: the DWG grows by the image's file
|
||||
size, and the picture never updates when the source PNG changes — which is
|
||||
exactly what self-contained means.
|
||||
|
||||
`"linked":true` keeps the path and therefore keeps the failure mode. It
|
||||
exists for images that must be swappable without touching the drawing.
|
||||
Legacy SPM.ACAD files arrive with linked QR images, so a broken one is
|
||||
still possible there; repair is a three-step composition, no dedicated op
|
||||
needed:
|
||||
|
||||
1. Find it: `query` with `type:"RasterImage"` (or the `records` op) —
|
||||
`detail:"full"` exposes the definition path and the entity `bounds`.
|
||||
2. Erase the broken reference by handle (`entities_delete`).
|
||||
3. Re-place a fresh `embed_image` (embedded, so it cannot break again) at
|
||||
the same `at`/`width` read from the old `bounds`.
|
||||
|
||||
An image inside a block definition is reached the same way after opening
|
||||
the block's content — SPM's `FindAndChangeRasterImagePath` (Catalog §5.3)
|
||||
maps onto these three steps rather than a native op.
|
||||
|
||||
### Text content and bounds
|
||||
|
||||
`query` entities of type `TEXT`/`MTEXT` return the raw stored string in `value` plus a formatting-free rendering in `text` (MTEXT inline codes such as `\A1;` or `\P` are resolved; `%%d`-style TEXT codes become their glyphs). With `detail:"full"`, degenerate-width text bounds are widened with a documented estimate (height × 0.8 × character count) so `bounds`-based region filters stay usable.
|
||||
|
||||
### Smoke test
|
||||
|
||||
The stdio path is covered black-box by a Python script that draws two entities, exports them with `wblock`, plots them with `plot`, and checks the capability advertisement:
|
||||
|
||||
```sh
|
||||
python3 docs/automation/serve_smoke.py target/debug/OpenCADStudio
|
||||
```
|
||||
|
||||
## Capability areas
|
||||
|
||||
The automation surface is organised around the capability areas a CAD
|
||||
automation client needs — application and documents, database objects, the
|
||||
editor, events and plotting.
|
||||
|
||||
## REST API (`--http`) — for ordinary HTTP clients
|
||||
|
||||
`OpenCADStudio --http 8090` runs the same headless session behind a
|
||||
resource-oriented REST surface on `http://127.0.0.1:<port>/api/v1`. Any
|
||||
HTTP client works — curl, Python, C# `HttpClient`, JS `fetch`; there is no
|
||||
SDK and nothing AI-specific. The server keeps one drawing session alive
|
||||
across requests, binds loopback only, sends permissive CORS headers, and
|
||||
serves its machine-readable description at `GET /api/v1/openapi`
|
||||
(OpenAPI 3, embedded from `src/rest_openapi.json`).
|
||||
|
||||
```sh
|
||||
OpenCADStudio --http 8090 # start the REST server (headless)
|
||||
curl http://127.0.0.1:8090/api/v1/ready
|
||||
```
|
||||
|
||||
### GUI-hosted REST channel (`--http` + file)
|
||||
|
||||
`OpenCADStudio.exe "<file>" --http 8090` boots the **normal editor** and
|
||||
hosts a loopback REST channel on this very process, bound to
|
||||
`http://127.0.0.1:8090/api/v1`: the **full REST API aimed at the live
|
||||
session** — control the drawing (create, move, plot, query, records, …)
|
||||
while the person keeps working, plus two person-in-the-loop picks only this
|
||||
process can answer. Routes resolve through the same table as the headless
|
||||
server, with the same ergonomics.
|
||||
|
||||
`GET /api/v1/state` caches the active `document_id` and later mutations
|
||||
address the drawing without the caller repeating it; a stale cache (the
|
||||
person switched documents) costs one state refresh and a retry, not an
|
||||
error. `session_id` is not needed (the channel has no descriptor handshake;
|
||||
a guessed value is stripped). Unknown POST paths are treated as automation
|
||||
ops and forwarded — MCP-style, any op works; truly unrouted paths answer
|
||||
`404 {"code":"unknown_route"}`.
|
||||
|
||||
On top of the shared surface, two **person-in-the-loop** picks exist only
|
||||
on this channel — they need the GUI and the person at the screen:
|
||||
|
||||
| Call | Purpose |
|
||||
|---|---|
|
||||
| `POST /api/v1/getpoint` | Ask the person to pick one point; the connection stays open until they click (or Esc). |
|
||||
| `POST /api/v1/user_select` | Ask the person to pick a sample set; the connection stays open until they press Enter (or Esc). |
|
||||
|
||||
Requests run one thread per connection, so a parked `getpoint`/`user_select`
|
||||
never blocks other calls; a pick may park up to 30 minutes before the bridge
|
||||
answers `response_timeout`.
|
||||
|
||||
**`getpoint`** — `{"prompt"}` is optional (shown on the command line):
|
||||
|
||||
```json
|
||||
{"ok":true,"status":"completed","result":{"point":[125.5,64.25,0.0]}}
|
||||
```
|
||||
|
||||
The next left-click in the drawing answers with the same snapped world
|
||||
point a command would receive; **Escape** (or `{"op":"cancel"}`) answers
|
||||
`{"ok":true,"status":"cancelled","result":{"cancelled":true}}`. While the
|
||||
pick is pending, other mutations wait (`busy`), so `status:"running"` on a
|
||||
poll means the person has not clicked yet.
|
||||
|
||||
**`user_select`** — interactive selection sampling: the person picks entities with the
|
||||
normal gestures, **Enter** confirms, **Escape** cancels. The request body is
|
||||
the `user_select` op verbatim (`type`/`layer` filter what counts as picked,
|
||||
`prompt` replaces the command-line hint, `detail` is `summary`/`geometry`/
|
||||
`full`, `clear` starts a fresh selection — all optional):
|
||||
|
||||
```json
|
||||
POST /api/v1/user_select
|
||||
{"request_id":"sample-1","type":"LINE","prompt":"Chọn đối tượng mẫu","detail":"full","clear":true}
|
||||
```
|
||||
|
||||
The connection stays open while the person picks and answers with the same
|
||||
payload the MCP/native callers see:
|
||||
|
||||
```json
|
||||
{"ok":true,"status":"completed","result":{"cancelled":false,"count":1,"ignored":0,
|
||||
"handles":["2A"],"entities":[{"handle":"2A","type":"Line","layer":"0","start":[0,0,0],"end":[10,10,0],…}]}}
|
||||
```
|
||||
|
||||
Escape (or `{"op":"cancel"}`) answers `status:"cancelled"` with
|
||||
`{"cancelled":true,"count":0}`. Picks outside the `type`/`layer` filter are
|
||||
deselected and reported in `ignored`.
|
||||
|
||||
**`get_selection` response** — `handle`/`type`/`layer`/`bounds` match the
|
||||
`query` output exactly so clients share one parser; `text` is the
|
||||
MTEXT-stripped string and `value` the raw one (both `null` for non-text
|
||||
entities), and `block`/`position` identify an INSERT's block definition and
|
||||
insertion point (`null` otherwise):
|
||||
|
||||
```json
|
||||
{"ok":true,"status":"completed","result":{"count":2,"entities":[
|
||||
{"handle":"2A","type":"LwPolyline","layer":"CUT","bounds":{"min":[0,0,0],"max":[100,50,0]},"text":null,"value":null,"block":null,"position":null},
|
||||
{"handle":"31","type":"Block Reference","layer":"TITLE","bounds":{"min":[0,0,0],"max":[420,297,0]},
|
||||
"text":null,"value":null,"block":"A3","position":[0,0,0]}]}}
|
||||
```
|
||||
|
||||
The read is passive — it never changes the selection, and the person can
|
||||
keep working while the client polls.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method & path | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/v1/ready` | Version, session id, active document id. |
|
||||
| `GET /api/v1/state` | Full editor state (documents, selection, command, camera). |
|
||||
| `GET /api/v1/capabilities` | Feature/collection advertisement incl. `operations`. |
|
||||
| `GET /api/v1/openapi` | OpenAPI 3 description of this whole surface. |
|
||||
| `GET /api/v1/documents` · `POST /api/v1/documents` | List; open `{"path":…}`, start from a template `{"template":…}` (a `.dwt`/`.dwg` file; the active drawing is replaced), or `Add()` a fresh untitled document (empty body) → **201**. |
|
||||
| `DELETE /api/v1/documents/{id}?discard=true` | Close a document; `discard=true` drops unsaved changes, otherwise a dirty document is refused (`document_dirty`). |
|
||||
| `GET /api/v1/entities` | Query — `type`, `layer`, `detail`, `offset`, `limit`, `fields`, `handles`, `bounds`, `near`, `contains_point`, `intersections` (comma-separated) and `where` (a JSON-encoded property filter array, e.g. `[{"path":"/radius","op":"gt","value":2}]`). |
|
||||
| `GET /api/v1/entities/{handle}` | One entity, `detail:"full"`. |
|
||||
| `POST /api/v1/entities` | Create a typed batch → **201** with `handles`. |
|
||||
| `DELETE /api/v1/entities?handles=a,b` | Erase by handle (body `{"handles":[…]}` also accepted). |
|
||||
| `POST /api/v1/entities/transform` | move/copy/rotate/scale/mirror/array. |
|
||||
| `POST /api/v1/entities/copy-to` | Copy entities into another open document: `{"handles":[…],"document_id":target}` → **201** with the new handles. |
|
||||
| `GET /api/v1/entities/{h}/xdata?app=` · `PUT …/xdata/{app}` · `DELETE …/xdata/{app}` | Read / replace / remove extended data. |
|
||||
| `POST /api/v1/blocks` | Define a block from entities (+ Insert); `"replace":true` drops a same-named definition first. |
|
||||
| `DELETE /api/v1/blocks/{name}` | Delete a block definition with its children, markers and every Insert referencing it. |
|
||||
| `POST /api/v1/file-identity` | Stable per-document GUID (`{"renew":true}` mints a fresh one). |
|
||||
| `POST /api/v1/groups` | Create a named group from handles → **201**. |
|
||||
| `POST /api/v1/selection-sets` · `GET /api/v1/selection-sets/{name}` | Save a named selection set → **201**; recall it (`?select=true` also makes it the current selection). |
|
||||
| `POST /api/v1/get_selection` | Read-only snapshot of the current selection (see [the GUI-hosted channel](#gui-hosted-rest-channel---http--file) below). |
|
||||
| `GET /api/v1/sysvars?names=a,b` · `POST /api/v1/sysvars` | Read sysvars (`{"get":[…]}` in the body also works) / set them (`{"set":{"ltscale":2.5}}`). |
|
||||
| `POST /api/v1/layouts` | Create a layout with default page setup → **201**. |
|
||||
| `PUT /api/v1/layouts/{name}/page-setup` | Write a layout's plot configuration (paper, orientation, fit/scale, center, window, plot style). |
|
||||
| `POST /api/v1/plot` · `/api/v1/wblock` · `/api/v1/images` | PDF export (`"layout":"all","per_page":true` writes one PDF per layout) · DWG/DXF export (`"template"` bases the new database on a file, `"normalize":true` shifts the export to the origin) · attach picture. |
|
||||
| `POST /api/v1/commands` | Run one command line (`{"cmd":"LINE 0,0 10,10"}`). |
|
||||
| `POST /api/v1/undo` · `/redo` · `/save` | Lifecycle (`/save` to a `*.dwt` path writes the template — DWG bytes). |
|
||||
| `GET /api/v1/layers` · `/header` · `/records?collection=…` | Database reads. |
|
||||
| `POST /api/v1/{op}` | Generic passthrough for any automation op. |
|
||||
|
||||
Status codes: `200`/`201` on success; `400` validation; `404` unknown route
|
||||
or handle (`code:"entity_absent"`); `409` stale state or GUI required;
|
||||
`503` busy. Every failure body keeps the symbolic `code` and message, so
|
||||
clients can branch on either. The server-generated `request_id` and the
|
||||
retry-on-stale-state dance happen inside the REST layer — callers just do
|
||||
HTTP.
|
||||
|
||||
### Walkthrough with curl
|
||||
|
||||
```sh
|
||||
# 1. Create entities (the missing layer "FRAME" is created automatically;
|
||||
# the whole batch commits as one undoable step or not at all).
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/entities \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"entities":[
|
||||
{"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"},
|
||||
{"type":"LwPolyline","vertices":[[0,0],[100,0],[100,60],[0,60]],"closed":true},
|
||||
{"type":"Text","value":"PAGE-01","position":[10,50],"height":3.0},
|
||||
{"type":"Circle","center":[80,30],"radius":5}
|
||||
]}'
|
||||
# → 201 {"ok":true,"status":"completed","result":{"handles":["63","64","65","66"],…}}
|
||||
|
||||
# 2. Verify: query it back.
|
||||
curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full"
|
||||
# → {"ok":true,"entities":[{"handle":"63","start":[0,0,0],"end":[100,0,0],…},…]
|
||||
|
||||
# 3. Move the circle 10 units right.
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"handles":["66"],"action":"move","vector":[10,0]}'
|
||||
|
||||
# 4. Mark the text with extended data (RegApp "SPM" registered implicitly).
|
||||
curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":3}]'
|
||||
curl -s "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM"
|
||||
|
||||
# 5. Turn the two frame lines into a block definition + Insert.
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"FRAME-MARK","base":[0,0,0],"handles":["63","67"]}'
|
||||
# → 201 {"result":{"block":"FRAME-MARK","insert":"6B"}}
|
||||
|
||||
# 6. Save and prove persistence.
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/save \
|
||||
-H "Content-Type: application/json" -d '{"path":"C:/out/session.dwg"}'
|
||||
|
||||
# 7. Filter entities by any property (RFC 6901 pointers, SQL-ish operators).
|
||||
curl -s "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D"
|
||||
|
||||
# 8. Set and read back drawing sysvars.
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/sysvars \
|
||||
-H "Content-Type: application/json" -d '{"set":{"ltscale":2.5}}'
|
||||
curl -s "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext"
|
||||
|
||||
# 9. Provision a sheet: create the layout, write its page setup, then
|
||||
# plot every layout to its own PDF (one file per entry in result.files).
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/layouts \
|
||||
-H "Content-Type: application/json" -d '{"name":"PLAN"}'
|
||||
curl -s -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}'
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/plot \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path":"C:/out/plan.pdf","layout":"all","per_page":true}'
|
||||
|
||||
# 10. Save the drawing as a template (a .dwt is DWG bytes — same writer,
|
||||
# no lock held on the file) and start a fresh drawing from it.
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/save \
|
||||
-H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}'
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/documents \
|
||||
-H "Content-Type: application/json" -d '{"template":"C:/out/session.dwt"}'
|
||||
|
||||
# 11. Second document, then copy entities across documents.
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"handles":["63","67"],"document_id":2}'
|
||||
|
||||
# 12. Group the copies, save them as a named selection set, recall it.
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/groups \
|
||||
-H "Content-Type: application/json" -d '{"name":"FRAME","handles":["63","67"]}'
|
||||
curl -s -X POST http://127.0.0.1:8090/api/v1/selection-sets \
|
||||
-H "Content-Type: application/json" -d '{"name":"frame-set","handles":["63","67"]}'
|
||||
curl -s "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true"
|
||||
|
||||
# 13. Close a document, discarding unsaved changes.
|
||||
curl -s -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true"
|
||||
```
|
||||
|
||||
The same lifecycle — create → verify → transform → xdata → block →
|
||||
save → reopen → verify persisted → erase → undo, then where filters,
|
||||
sysvars, layout + page setup + per-page plotting, a `.dwt` template →
|
||||
new-from-template round trip, a cross-document copy, groups and selection
|
||||
sets, and a close-with-discard — runs as an automated black-box check:
|
||||
|
||||
```sh
|
||||
python3 docs/automation/rest_smoke.py target/debug/OpenCADStudio
|
||||
```
|
||||
|
||||
## Entity operations reference (protocol ops)
|
||||
|
||||
These are the underlying operations the REST routes call; every native
|
||||
transport (stdio `--serve`, TCP, MCP, wasm) exposes them under the same
|
||||
names and semantics. Angles are **degrees** on the wire; points accept
|
||||
`[x,y]` (z=0) or `[x,y,z]`; handles are hexadecimal strings.
|
||||
|
||||
### `entities_create` — add entities
|
||||
|
||||
Batch of typed definitions, validated in full before anything commits; one
|
||||
undo step; missing `layer`s are created and reported in `layers_created`.
|
||||
|
||||
| type | fields |
|
||||
|---|---|
|
||||
| `Line` | `start`, `end`, optional `thickness` |
|
||||
| `Circle` | `center`, `radius` (≥ 0) |
|
||||
| `Arc` | `center`, `radius` (> 0), `start_angle_deg`, `end_angle_deg` |
|
||||
| `LwPolyline` | `vertices` (≥ 2), `closed`, optional `constant_width` |
|
||||
| `Point` | `location` |
|
||||
| `Text` | `value`, `position`, optional `height` (default 2.5), `rotation_deg`, `style` |
|
||||
| `MText` | `value`, `position`, optional `height`, `width`, `rotation_deg` |
|
||||
| `Insert` | `block` (must exist), `position`, optional `scale` (number or `[x,y,z]`, nonzero), `rotation_deg` |
|
||||
| `Solid` | `corners` (3 or 4 `[x,y,z]`) |
|
||||
| `Hatch` | `boundary` (≥ 3 points, closed), `solid`, or `pattern` (catalog name, default `ANSI31`), `pattern_scale`, `pattern_angle_deg` |
|
||||
|
||||
Every definition also accepts `layer` and `color` (ACI index). Response:
|
||||
`{"handles":[…], "created":N, "layers_created":[…]}`. An invalid definition
|
||||
(`unknown_entity_type`, `invalid_radius`, `invalid_vertices`, …) aborts the
|
||||
whole batch with nothing committed.
|
||||
|
||||
### `entities_delete` — erase entities
|
||||
|
||||
`{"op":"entities_delete","handles":[…]}` — every handle must exist
|
||||
(`entity_absent` lists the missing ones and nothing is erased). One undo
|
||||
step; `result.erased` counts.
|
||||
|
||||
### `entities_transform` — move, rotate, scale, mirror
|
||||
|
||||
`{"op":"entities_transform","handles":[…],"action":…}` with, per action:
|
||||
|
||||
- `move` / `copy` — `vector:[dx,dy(,dz)]`; `copy` keeps the originals and
|
||||
returns the new handles in `result.created`.
|
||||
- `rotate` — `center:[x,y]`, `angle_deg` (CCW positive).
|
||||
- `scale` — `center`, `factor` (nonzero).
|
||||
- `mirror` — `axis:[[x1,y1],[x2,y2]]` (points must differ); optional
|
||||
`"copy":true` to keep the originals.
|
||||
- `array` — `rows`, `columns`, `row_spacing`, `column_spacing`; creates
|
||||
rows×columns−1 copies (the original counts as cell 0,0).
|
||||
|
||||
`result.affected` counts the addressed entities; `result.created` lists new
|
||||
handles for copy/mirror-copy/array.
|
||||
|
||||
### `xdata_set` / `xdata_get` — extended data
|
||||
|
||||
`{"op":"xdata_set","handles":[…],"app":"SPM","data":[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":7}]}` —
|
||||
replaces that application's record on every handle and registers the APPID
|
||||
table entry implicitly. Supported codes: `1000` string, `1003` layer name,
|
||||
`1004` hex bytes, `1005` hex handle, `1010`–`1013` `[x,y,z]` (point,
|
||||
position, displacement, direction), `1040`/`1041`/`1042` real, `1070` int16,
|
||||
`1071` int32. An empty (or absent) `data` list removes the record — that is
|
||||
the clear operation.
|
||||
|
||||
`xdata_get` is a **read**: `{"op":"xdata_get","handles":[…],"app":"SPM"}` →
|
||||
`{"ok":true,"items":[{"handle":"65","xdata":{"SPM":["PAGE-01",7]}}]}` — no
|
||||
`request_id`, no `document_id` needed.
|
||||
|
||||
### `block_define` / `block_delete` — block definition lifecycle
|
||||
|
||||
```json
|
||||
{"op":"block_define","name":"MARK","base":[0,0,0],"handles":["63","67"],"insert_at":[0,0,0]}
|
||||
{"op":"block_define","name":"MARK","base":[0,0,0],"handles":["6C"],"replace":true}
|
||||
{"op":"block_delete","name":"MARK"}
|
||||
```
|
||||
|
||||
BLOCK semantics: the sources move into the definition (flattened at
|
||||
`base` = block origin) and one `Insert` is placed at `insert_at` (default
|
||||
`base`), so the drawing looks unchanged while the entities became one
|
||||
reusable block. Response: `{"block":"MARK","insert":"6B"}`. Names must be
|
||||
unique and may not start with `*`. `"replace":true` drops a same-named
|
||||
definition — its children, markers and inserts — inside the same undo step
|
||||
before creating the new one: that is SPM's barcode re-import flow, so
|
||||
`block_define` never fails with "already exists" on a re-import.
|
||||
`block_delete` removes a definition completely (children, Block/BlockEnd
|
||||
markers, every Insert referencing it and the table record) and reports
|
||||
`result.erased`; unknown names are `block_missing`, `*`-prefixed layout
|
||||
records are `block_protected`. Definitions survive save/open round trips
|
||||
and are consumable by `wblock` (by block name) and `INSERT` creation via
|
||||
`entities_create`.
|
||||
|
||||
### `file_identity` — per-document GUID
|
||||
|
||||
```json
|
||||
{"op":"file_identity"} → {"identity":"<guid>","created":true}
|
||||
{"op":"file_identity"} → {"identity":"<same guid>","created":false}
|
||||
{"op":"file_identity","renew":true} → {"identity":"<new guid>","created":true}
|
||||
```
|
||||
|
||||
A stable RFC 4122 v4 GUID per drawing, minted on first call and rewritten
|
||||
only with `"renew":true`. The identity lives on a single invisible marker
|
||||
text (height 0.0001) at the origin carrying the XData application
|
||||
`SPMDOCUNIQUE` — exactly how SPM.ACAD marks files — so it survives
|
||||
save/open round trips and travels with clones that include the marker.
|
||||
`GET /api/v1/state` also reports the document's `hand_seed` (the DWG handle
|
||||
seed, hex).
|
||||
|
||||
### `view_focus` — GUI sessions
|
||||
|
||||
```json
|
||||
{"op":"view_focus","handles":["65"],"highlight":true}
|
||||
```
|
||||
|
||||
Fits the entities into the view and selects them. Headless servers refuse
|
||||
with `code:"gui_required"`; REST clients get HTTP 409.
|
||||
|
||||
## Document, sheet and organization operations (protocol ops)
|
||||
|
||||
Same envelope conventions as the entity operations: `protocol:1`,
|
||||
caller-generated `request_id`, `document_id` addressing, one undo step.
|
||||
Angles stay **degrees** on the wire everywhere below.
|
||||
|
||||
### `close` — close a document
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"close","request_id":"cl-1","document_id":1,"discard":true}
|
||||
```
|
||||
|
||||
Closes the addressed document. A document with unsaved changes is refused
|
||||
with `code:"document_dirty"` unless `"discard":true`; an unknown document id
|
||||
is `document_closed`. The last tab is replaced by a fresh empty drawing, so
|
||||
the session always keeps one document. `result.closed` reports the outcome.
|
||||
`activate`, `close` and `entities_copy_to` are exempt from the
|
||||
active-document guard — they may address any open document.
|
||||
|
||||
### `sysvar` — read and write system variables
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"sysvar","request_id":"sv-1","document_id":1,"set":{"ltscale":2.5,"clayer":"Walls"}}
|
||||
{"protocol":1,"op":"sysvar","request_id":"sv-2","document_id":1,"get":["ltscale","extmax"]}
|
||||
```
|
||||
|
||||
`set` applies a JSON object of name → value pairs atomically (unknown names
|
||||
→ `unknown_sysvar`, wrong values → `invalid_sysvar_value`, nothing partial).
|
||||
`get` (also with an omitted list = every readable name) returns
|
||||
`result.values`. The registry:
|
||||
|
||||
| var | meaning | writable |
|
||||
|---|---|---|
|
||||
| `ltscale` | global linetype scale (positive) | ✓ |
|
||||
| `celtscale` | current-entity linetype scale | ✓ |
|
||||
| `clayer` | current layer (must exist) | ✓ |
|
||||
| `ctextstyle` | current text style (must exist) | ✓ |
|
||||
| `textsize` | default text height | ✓ |
|
||||
| `filletrad` | fillet radius (≥ 0) | ✓ |
|
||||
| `mirrtext` | mirror-text flag (`0`/`1`) | ✓ |
|
||||
| `insunits` | insertion units (integer) | ✓ |
|
||||
| `osmode` | object-snap bitmask (integer) | ✓ |
|
||||
| `pdmode` / `pdsize` | point display mode / size | ✓ |
|
||||
| `extmin` / `extmax` | model-space extents `[x,y,z]` | read-only |
|
||||
|
||||
### `layout_create` / `page_setup_set` — layouts and page setups
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"layout_create","request_id":"lc-1","document_id":1,"name":"PLAN"}
|
||||
{"protocol":1,"op":"page_setup_set","request_id":"ps-1","document_id":1,"layout":"PLAN",
|
||||
"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true,
|
||||
"scale":"1:100","window":[0,0,210,297],"plot_style":"monochrome.ctb"}
|
||||
```
|
||||
|
||||
`layout_create` adds a layout with the default page setup and a sheet
|
||||
viewport; duplicates are refused (`layout_exists`). `page_setup_set` writes
|
||||
the named layout's plot configuration — explicit fields win, the rest keeps
|
||||
the stored setup. `paper` resolves through the paper catalog
|
||||
(`invalid_paper` lists nothing — use a canonical catalog name);
|
||||
`orientation` is `portrait`/`landscape` (degrees of sheet rotation);
|
||||
sizing is `"fit":true` or `"scale":"paper:drawing"` like `"1:100"` (both
|
||||
parts positive — `nonsense` is refused with `invalid_scale`, it does not
|
||||
silently mean 1:1); `center` centers the plot; `window` sets a plot window;
|
||||
`plot_style` names a CTB/STB. Unknown layouts are `layout_missing`. The
|
||||
stored setup is what `plot` consumes when the op's own fields are absent.
|
||||
|
||||
### Templates — start from and save as `.dwt`
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"new","request_id":"nt-1","template":"C:/tpl/base.dwt"}
|
||||
{"protocol":1,"op":"wblock","request_id":"wb-1","document_id":1,"path":"C:/out/x.dxf","handles":["2A"],"template":"C:/tpl/base.dwg"}
|
||||
```
|
||||
|
||||
`new` with `"template"` starts an untitled drawing from a `.dwg`/`.dxf`/
|
||||
`.dwt` file: tables, styles and entities come from the template and the
|
||||
document keeps no source path (`result.total` counts the imported
|
||||
entities). A `.dwt` file is **DWG bytes** — the same binary DWG writer
|
||||
produces it through a hidden scratch file that is renamed into place, so
|
||||
no lock is held on the target and a save can never leave a half-written
|
||||
template. `wblock` with `"template"` bases the exported database on the
|
||||
template's tables/styles instead of the source drawing's.
|
||||
|
||||
### `entities_copy_to` — copy into another document
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"entities_copy_to","request_id":"cp-1","document_id":2,"handles":["2A","31"]}
|
||||
```
|
||||
|
||||
Copies the entities from the active document into the open document
|
||||
addressed by `document_id`. Every handle must exist in the source
|
||||
(`entity_absent` otherwise); the target must be open (`document_closed`).
|
||||
Referenced (missing) layer definitions travel along. `result.created` lists
|
||||
the new handles in the target, `result.count` their number. Combined with
|
||||
an empty `POST /documents` (REST) this is the multi-document assembly
|
||||
workflow.
|
||||
|
||||
### `group_create` — named groups
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"group_create","request_id":"g-1","document_id":1,"name":"FRAME","handles":["2A","31"]}
|
||||
```
|
||||
|
||||
Creates an anonymous-group-record-backed named group over existing handles
|
||||
(`entity_absent` if any is missing). `result` reports the `group` name and
|
||||
its object `handle`.
|
||||
|
||||
### `selection_set_save` / `selection_set_load` — SelectionSet reuse
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"selection_set_save","request_id":"s-1","document_id":1,"name":"frame-set","handles":["2A","31"]}
|
||||
{"protocol":1,"op":"selection_set_load","request_id":"s-2","document_id":1,"name":"frame-set","select":false}
|
||||
```
|
||||
|
||||
Named handle sets are **session-scoped** by design (like a held
|
||||
`SelectionSet` object id, they live while the session lives). `save` stores
|
||||
and returns the handle list; `load` recalls it and, with `"select":true`
|
||||
(the default), also makes the entities the current selection
|
||||
(`result.selected` counts). Unknown names are `selection_set_missing`.
|
||||
|
||||
### `user_select` — ask the person at the screen to pick entities
|
||||
|
||||
```json
|
||||
{"protocol":1,"op":"user_select","request_id":"us-1","document_id":1,"type":"CIRCLE","layer":"Equipment","prompt":"Pick the equipment circles","detail":"full","clear":true}
|
||||
```
|
||||
|
||||
Hands the screen to the person operating the GUI: they pick entities with the normal gestures and **Enter confirms,
|
||||
Escape cancels**. The operation resolves only after that answer — pollers
|
||||
see `status:"running"` the whole time, and every other mutation waits, so
|
||||
treat `running` as "the person is still picking", not as a hang. When they
|
||||
confirm, `result` carries `cancelled:false`, the picked `handles` and the
|
||||
matching `entities` with their attributes (`detail`: `summary`, `geometry`
|
||||
or `full` — default `full`, the whole entity object). Escape answers
|
||||
`cancelled:true` with empty lists; an immediate Enter with nothing picked
|
||||
is an empty answer.
|
||||
|
||||
Optional filters: `type` / `layer` gate the *answer*, not the gestures —
|
||||
picks outside the filter are deselected at confirm time and counted in
|
||||
`result.ignored`. `prompt` replaces the on-screen instruction, `clear`
|
||||
(default `true`) starts from an empty selection, and `detail` controls the
|
||||
attribute depth. Requires the desktop GUI (headless `--http`/`--serve`
|
||||
sessions answer `gui_required` — there is nobody at a screen to ask).
|
||||
|
||||
### `plot` per-page publishing
|
||||
|
||||
`{"op":"plot", …, "layout":"all","per_page":true}` writes **one PDF per
|
||||
layout** instead of one multi-page document, returning `result.files`
|
||||
(`[{path,…},…]`) — one entry per layout, ready for sheet-by-sheet delivery.
|
||||
Without `per_page` the result stays `{path, pages, page_sizes}`.
|
||||
|
||||
### `query` `where` — property filters
|
||||
|
||||
```json
|
||||
{"op":"query","type":"Circle","detail":"geometry",
|
||||
"where":[{"path":"/radius","op":"gt","value":2},{"path":"/common/layer","op":"eq","value":"Walls"}]}
|
||||
```
|
||||
|
||||
`where` filters query results on serialized entity properties using RFC
|
||||
6901 JSON Pointer paths (`/radius`, `/common/layer`, …) and the operators
|
||||
`eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`, `starts_with`,
|
||||
`ends_with`, `in`, `exists`, `not_exists`. Filters combine with
|
||||
AND and are validated up front (a bad pointer or unknown operator fails the
|
||||
query before any entity is inspected). The same filter shape works over the
|
||||
REST `GET /entities?where=<json-encoded>` parameter. Hatch queries expose
|
||||
their loop definitions under `properties` so boundaries can be read back.
|
||||
@@ -52,7 +52,16 @@ def legacy(server: Path) -> None:
|
||||
names = set(definitions)
|
||||
assert names == TOOLS, names
|
||||
execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"]
|
||||
assert len(execute_request["oneOf"]) == 16
|
||||
assert len(execute_request["oneOf"]) == 35
|
||||
op_enum = execute_request["properties"]["op"]["enum"]
|
||||
for shipped in ("entities_create", "entities_delete", "entities_transform",
|
||||
"block_define", "block_delete", "file_identity", "xdata_set",
|
||||
"view_focus", "wblock", "plot",
|
||||
"entities_copy_to", "group_create", "selection_set_save",
|
||||
"selection_set_load", "close", "sysvar", "layout_create",
|
||||
"page_setup_set"):
|
||||
assert shipped in op_enum, shipped
|
||||
assert "xdata_get" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"]
|
||||
assert execute_request["properties"]["steps"]["maxItems"] == 64
|
||||
assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10"
|
||||
assert "set_properties" in execute_request["properties"]["op"]["enum"]
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Black-box smoke test for the REST transport (`--http <port>`).
|
||||
|
||||
Drives the whole entity lifecycle over plain HTTP — the same API surface any
|
||||
language can call — against a real drawing session: create → verify by query
|
||||
→ transform → mark with xdata → define a block → save → reopen → verify
|
||||
everything persisted → erase → undo. Then exercises the P1 surface the same
|
||||
way, always through real files: where filters, sysvars, layouts + page setups
|
||||
+ per-page plotting to real PDFs, a .dwt template → new-from-template round
|
||||
trip, a cross-document copy between two open documents, groups and selection
|
||||
sets, and a close-with-discard. Uses only the Python standard library.
|
||||
|
||||
python3 docs/automation/rest_smoke.py target/debug/OpenCADStudio
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def call(port: int, method: str, path: str, body=None):
|
||||
"""One HTTP call; returns (status, parsed-json-or-None)."""
|
||||
data = None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/api/v1{path}", data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
raw = response.read()
|
||||
return response.status, (json.loads(raw) if raw else None)
|
||||
except urllib.error.HTTPError as error:
|
||||
raw = error.read()
|
||||
return error.code, (json.loads(raw) if raw else None)
|
||||
|
||||
|
||||
def wait_ready(process, port: int, timeout: float = 60.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise SystemExit(f"server exited early: {process.returncode}")
|
||||
try:
|
||||
status, body = call(port, "GET", "/ready")
|
||||
if status == 200 and body.get("ok"):
|
||||
return
|
||||
except (urllib.error.URLError, ConnectionError, OSError):
|
||||
time.sleep(0.25)
|
||||
raise SystemExit("server never became ready")
|
||||
|
||||
|
||||
def expect(condition, message):
|
||||
if not condition:
|
||||
raise SystemExit(f"FAIL: {message}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
exe = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve()
|
||||
port = 8091
|
||||
out_dir = Path(tempfile.mkdtemp(prefix="ocs_rest_smoke_"))
|
||||
process = subprocess.Popen(
|
||||
[str(exe), "--http", str(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
wait_ready(process, port)
|
||||
|
||||
# 0. Readiness + capability discovery.
|
||||
status, ready = call(port, "GET", "/ready")
|
||||
expect(status == 200 and ready["session_id"], f"ready: {ready}")
|
||||
status, capabilities = call(port, "GET", "/capabilities")
|
||||
expect(capabilities["operations"]["entities_create"] is True, "capabilities")
|
||||
|
||||
# 1. Create a batch of typed entities; the missing layer is created.
|
||||
status, created = call(port, "POST", "/entities", {
|
||||
"entities": [
|
||||
{"type": "Line", "start": [0, 0], "end": [100, 0], "layer": "FRAME"},
|
||||
{"type": "LwPolyline", "vertices": [[0, 0], [100, 0], [100, 60], [0, 60]], "closed": True},
|
||||
{"type": "Text", "value": "PAGE-01", "position": [10, 50], "height": 3.0},
|
||||
{"type": "Circle", "center": [80, 30], "radius": 5},
|
||||
]
|
||||
})
|
||||
expect(status == 201 and created["result"]["created"] == 4, f"create: {created}")
|
||||
handles = created["result"]["handles"]
|
||||
expect(created["result"]["layers_created"] == ["FRAME"], "layer auto-created")
|
||||
|
||||
# 2. Verify by query — the geometry really landed.
|
||||
status, query = call(port, "GET", "/entities?type=Line&detail=full")
|
||||
expect(query["entities"][0]["end"] == [100.0, 0.0, 0.0], f"line geometry: {query}")
|
||||
status, query = call(port, "GET", f"/entities/{handles[2]}")
|
||||
expect(query["entities"][0]["value"] == "PAGE-01", "text content by handle")
|
||||
|
||||
# 3. Transform: move the circle and verify, then array the frame.
|
||||
status, moved = call(port, "POST", "/entities/transform", {
|
||||
"handles": [handles[3]], "action": "move", "vector": [10, 0],
|
||||
})
|
||||
expect(moved["result"]["affected"] == 1, f"move: {moved}")
|
||||
status, query = call(port, "GET", f"/entities/{handles[3]}")
|
||||
expect(query["entities"][0]["center"][0] == 90.0, "circle moved")
|
||||
|
||||
status, copied = call(port, "POST", "/entities/transform", {
|
||||
"handles": [handles[0]], "action": "copy", "vector": [0, -10],
|
||||
})
|
||||
expect(len(copied["result"]["created"]) == 1, f"copy: {copied}")
|
||||
|
||||
# 4. Mark the text with extended data (RegApp registered implicitly).
|
||||
status, marked = call(
|
||||
port, "PUT", f"/entities/{handles[2]}/xdata/SPM",
|
||||
[{"code": 1000, "value": "PAGE-01"}, {"code": 1070, "value": 3}],
|
||||
)
|
||||
expect(marked["result"]["updated"] == 1, f"xdata put: {marked}")
|
||||
status, xdata = call(port, "GET", f"/entities/{handles[2]}/xdata?app=SPM")
|
||||
expect(xdata["items"][0]["xdata"]["SPM"][0] == "PAGE-01", "xdata read")
|
||||
|
||||
# 5. Define a block from the two frame lines, BLOCK-command style.
|
||||
status, block = call(port, "POST", "/blocks", {
|
||||
"name": "FRAME-MARK", "base": [0, 0, 0], "handles": [handles[0], copied["result"]["created"][0]],
|
||||
})
|
||||
expect(status == 201 and block["result"]["block"] == "FRAME-MARK", f"block: {block}")
|
||||
|
||||
# 6. Save → reopen: everything survives the file round trip.
|
||||
saved = out_dir / "rest_smoke.dwg"
|
||||
status, saved_response = call(port, "POST", "/save", {"path": str(saved)})
|
||||
expect(saved_response["ok"], f"save: {saved_response}")
|
||||
status, opened = call(port, "POST", "/documents", {"path": str(saved)})
|
||||
expect(opened["ok"], f"reopen: {opened}")
|
||||
|
||||
status, query = call(port, "GET", "/entities?type=Insert&detail=full")
|
||||
expect(query["count"] == 1 and query["entities"][0]["block"] == "FRAME-MARK", "insert persisted")
|
||||
status, query = call(port, "GET", "/entities?type=Text")
|
||||
text_handle = query["entities"][0]["handle"]
|
||||
status, xdata = call(port, "GET", f"/entities/{text_handle}/xdata?app=SPM")
|
||||
expect(xdata["items"][0]["xdata"]["SPM"][1] == 3, "xdata persisted")
|
||||
|
||||
# 7. Erase + undo — the standard lifecycle close.
|
||||
status, erased = call(port, "DELETE", f"/entities?handles={text_handle}")
|
||||
expect(erased["result"]["erased"] == 1, f"erase: {erased}")
|
||||
status, undone = call(port, "POST", "/undo")
|
||||
expect(undone["ok"], f"undo: {undone}")
|
||||
status, query = call(port, "GET", "/entities?type=Text")
|
||||
expect(query["count"] == 1, "undo restored the text")
|
||||
|
||||
# 8. Where filters over entity properties (RFC 6901 pointers).
|
||||
status, circles = call(port, "POST", "/entities", {
|
||||
"entities": [
|
||||
{"type": "Circle", "center": [0, -40], "radius": 2},
|
||||
{"type": "Circle", "center": [30, -40], "radius": 8},
|
||||
]
|
||||
})
|
||||
expect(status == 201, f"filter circles: {circles}")
|
||||
where = urllib.parse.quote(json.dumps([{"path": "/radius", "op": "gt", "value": 6}]))
|
||||
status, query = call(port, "GET", f"/entities?type=Circle&detail=geometry&where={where}")
|
||||
expect(query["count"] == 1 and query["entities"][0]["radius"] == 8.0, f"where filter: {query}")
|
||||
status, query = call(port, "GET", f"/entities?where={urllib.parse.quote('not-json')}")
|
||||
expect(status == 400 and query["code"] == "invalid_where", f"bad where: {query}")
|
||||
|
||||
# 9. Sysvars: set over POST, read back over GET, unknown refused.
|
||||
status, set_response = call(port, "POST", "/sysvars", {"set": {"ltscale": 3.5, "mirrtext": 1}})
|
||||
expect(status == 200 and set_response["ok"], f"sysvar set: {set_response}")
|
||||
status, read_back = call(port, "GET", "/sysvars?names=ltscale,mirrtext")
|
||||
expect(read_back["result"]["values"]["ltscale"] == 3.5, f"sysvar read: {read_back}")
|
||||
expect(read_back["result"]["values"]["mirrtext"] == 1, "mirrtext read back")
|
||||
status, refused = call(port, "POST", "/sysvars", {"set": {"not_a_sysvar": 1}})
|
||||
expect(status == 400 and refused["code"] == "unknown_sysvar", f"unknown sysvar: {refused}")
|
||||
|
||||
# 10. Sheets: a layout, its page setup, then one PDF per layout.
|
||||
status, layout = call(port, "POST", "/layouts", {"name": "PLAN"})
|
||||
expect(status == 201 and "PLAN" in layout["result"]["layouts"], f"layout: {layout}")
|
||||
status, setup = call(port, "PUT", "/layouts/PLAN/page-setup", {
|
||||
"paper": "ISO_A4_(210.00_x_297.00_MM)", "orientation": "landscape",
|
||||
"fit": True, "center": True,
|
||||
})
|
||||
expect(status == 200 and "ISO_A4" in setup["result"]["paper"], f"page setup: {setup}")
|
||||
plot_base = out_dir / "smoke_plot.pdf"
|
||||
status, plotted = call(port, "POST", "/plot", {
|
||||
"path": str(plot_base), "layout": "all", "per_page": True,
|
||||
})
|
||||
expect(plotted["ok"] and len(plotted["result"]["files"]) >= 2, f"per-page plot: {plotted}")
|
||||
for entry in plotted["result"]["files"]:
|
||||
pdf = Path(entry["path"])
|
||||
expect(pdf.read_bytes().startswith(b"%PDF"), f"not a PDF: {pdf}")
|
||||
pdf.unlink()
|
||||
|
||||
# 11. Template round trip: save as .dwt, then start the drawing from
|
||||
# the template — the entities and their xdata survive.
|
||||
status, query = call(port, "GET", "/entities")
|
||||
count_before = query["count"]
|
||||
template = out_dir / "rest_smoke.dwt"
|
||||
status, saved_dwt = call(port, "POST", "/save", {"path": str(template)})
|
||||
expect(saved_dwt["ok"], f"dwt save: {saved_dwt}")
|
||||
expect(template.stat().st_size > 64, ".dwt written (DWG bytes)")
|
||||
status, templated = call(port, "POST", "/documents", {"template": str(template)})
|
||||
expect(templated["ok"] and templated["result"]["total"] == count_before,
|
||||
f"new from template: {templated}")
|
||||
status, query = call(port, "GET", "/entities?type=Text")
|
||||
expect(query["count"] == 1, "text survived the template")
|
||||
text_handle = query["entities"][0]["handle"]
|
||||
status, xdata = call(port, "GET", f"/entities/{text_handle}/xdata?app=SPM")
|
||||
expect(xdata["items"][0]["xdata"]["SPM"][0] == "PAGE-01", "xdata survived the template")
|
||||
|
||||
# 12. Cross-document copy: an empty POST /documents is
|
||||
# A fresh second document in its own tab.
|
||||
status, state = call(port, "GET", "/state")
|
||||
source_doc = state["document_id"]
|
||||
status, fresh = call(port, "POST", "/documents", {})
|
||||
expect(status == 201 and fresh["ok"], f"second document: {fresh}")
|
||||
status, state = call(port, "GET", "/state")
|
||||
target_doc = state["document_id"]
|
||||
expect(target_doc != source_doc, "two open documents")
|
||||
status, switched = call(port, "POST", "/activate", {"document_id": source_doc})
|
||||
expect(switched["ok"], f"activate source: {switched}")
|
||||
status, query = call(port, "GET", "/entities?type=Line")
|
||||
line_handles = [entity["handle"] for entity in query["entities"]]
|
||||
expect(len(line_handles) == 2, f"source lines: {query}")
|
||||
status, copy_op = call(port, "POST", "/entities/copy-to", {
|
||||
"handles": line_handles, "document_id": target_doc,
|
||||
})
|
||||
expect(status == 201 and copy_op["result"]["count"] == 2, f"copy-to: {copy_op}")
|
||||
status, switched = call(port, "POST", "/activate", {"document_id": target_doc})
|
||||
expect(switched["ok"], f"activate target: {switched}")
|
||||
status, query = call(port, "GET", "/entities?type=Line")
|
||||
expect(query["count"] == 2, f"lines copied into the second document: {query}")
|
||||
|
||||
# 13. Groups, selection sets, block replace/delete and a stable file
|
||||
# identity over the second document.
|
||||
all_lines = [entity["handle"] for entity in query["entities"]]
|
||||
status, group = call(port, "POST", "/groups", {
|
||||
"name": "SMOKE-GROUP", "handles": all_lines,
|
||||
})
|
||||
expect(status == 201 and group["ok"], f"group: {group}")
|
||||
status, selection = call(port, "POST", "/selection-sets", {
|
||||
"name": "smoke-set", "handles": all_lines,
|
||||
})
|
||||
expect(status == 201 and selection["ok"], f"selection set save: {selection}")
|
||||
status, recalled = call(port, "GET", "/selection-sets/smoke-set")
|
||||
expect(len(recalled["result"]["handles"]) == 2, f"selection set load: {recalled}")
|
||||
status, selected = call(port, "GET", "/selection-sets/smoke-set?select=true")
|
||||
expect(selected["result"]["selected"] == 2, f"selection set select: {selected}")
|
||||
status, identity = call(port, "POST", "/file-identity", {})
|
||||
expect(status == 200 and len(identity["result"]["identity"]) == 36,
|
||||
f"file identity: {identity}")
|
||||
status, again = call(port, "POST", "/file-identity", {})
|
||||
expect(again["result"]["identity"] == identity["result"]["identity"],
|
||||
"file identity stable")
|
||||
status, block = call(port, "POST", "/blocks", {
|
||||
"name": "MARK", "base": [0, 0, 0], "handles": all_lines,
|
||||
})
|
||||
expect(status == 201, f"block define: {block}")
|
||||
# The define consumed the source lines; replace wraps fresh content
|
||||
# (SPM re-import draws new content and blocks it under the barcode).
|
||||
status, fresh = call(port, "POST", "/entities", {
|
||||
"entities": [{"type": "Point", "location": [9, 9]}],
|
||||
})
|
||||
expect(status == 201, f"fresh point: {fresh}")
|
||||
status, replaced = call(port, "POST", "/blocks", {
|
||||
"name": "MARK", "base": [0, 0, 0],
|
||||
"handles": fresh["result"]["handles"], "replace": True,
|
||||
})
|
||||
expect(status == 201, f"block replace: {replaced}")
|
||||
status, deleted = call(port, "DELETE", "/blocks/MARK")
|
||||
expect(status == 200 and deleted["result"]["erased"] >= 1, f"block delete: {deleted}")
|
||||
|
||||
# 14. Close the dirty second document with discard.
|
||||
status, closed = call(port, "DELETE", f"/documents/{target_doc}?discard=true")
|
||||
expect(status == 200 and closed["result"]["closed"] is True, f"close: {closed}")
|
||||
status, state = call(port, "GET", "/state")
|
||||
expect(state["document_id"] == source_doc, "back on the first document")
|
||||
|
||||
print("rest smoke: OK")
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait(timeout=30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Black-box smoke test for the headless automation server (`--serve`).
|
||||
|
||||
Exercises the ops an external client needs: state discovery, the wblock
|
||||
export, the plot-to-PDF export, and the capability advertisement. Uses only
|
||||
the Python standard library; treats the executable as a black box.
|
||||
|
||||
python3 docs/automation/serve_smoke.py target/debug/OpenCADStudio
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Serve:
|
||||
def __init__(self, exe: Path):
|
||||
self.process = subprocess.Popen(
|
||||
[str(exe), "--serve"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.ready = json.loads(self.process.stdout.readline())
|
||||
assert self.ready["ok"] is True, self.ready
|
||||
assert self.ready["session_id"], self.ready
|
||||
|
||||
def send(self, request: dict) -> dict:
|
||||
self.process.stdin.write(json.dumps(request) + "\n")
|
||||
self.process.stdin.flush()
|
||||
return json.loads(self.process.stdout.readline())
|
||||
|
||||
def close(self) -> None:
|
||||
self.process.stdin.close()
|
||||
self.process.wait(timeout=30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
exe = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve()
|
||||
out_dir = Path(tempfile.mkdtemp(prefix="ocs_serve_smoke_"))
|
||||
server = Serve(exe)
|
||||
try:
|
||||
state = server.send({"protocol": 1, "op": "state"})
|
||||
document_id = state["document_id"]
|
||||
|
||||
# Draw something to export and plot.
|
||||
server.send({"op": "new"})
|
||||
for request_id, cmd in [("run-1", "LINE 0,0 100,80"), ("run-2", "CIRCLE 50,40 20")]:
|
||||
response = server.send({"op": "run", "cmd": cmd})
|
||||
assert response["ok"] is True, response
|
||||
|
||||
# Read-only ops stay on the legacy (non-envelope) shape.
|
||||
summary = server.send({"op": "entities"})
|
||||
assert summary["total"] == 2, summary
|
||||
|
||||
# wblock: export the two entities to a standalone DXF.
|
||||
export = out_dir / "wblock.dxf"
|
||||
response = server.send({
|
||||
"protocol": 1,
|
||||
"op": "wblock",
|
||||
"request_id": "smoke-wblock-1",
|
||||
"document_id": document_id,
|
||||
"path": str(export),
|
||||
"handles": [
|
||||
entity["handle"]
|
||||
for entity in server.send({"op": "query", "detail": "summary"})["entities"]
|
||||
],
|
||||
})
|
||||
assert response["ok"] is True, response
|
||||
assert response["status"] == "completed", response
|
||||
assert response["result"]["entities"] == 2, response
|
||||
assert export.exists()
|
||||
|
||||
# plot: render model space to a one-page PDF.
|
||||
pdf = out_dir / "plot.pdf"
|
||||
response = server.send({
|
||||
"protocol": 1,
|
||||
"op": "plot",
|
||||
"request_id": "smoke-plot-1",
|
||||
"document_id": document_id,
|
||||
"path": str(pdf),
|
||||
})
|
||||
assert response["ok"] is True, response
|
||||
assert response["result"]["pages"] == 1, response
|
||||
assert pdf.read_bytes()[:4] == b"%PDF"
|
||||
|
||||
# The file operations are discoverable through capabilities.
|
||||
capabilities = server.send({"op": "capabilities"})
|
||||
operations = capabilities["operations"]
|
||||
assert operations["wblock"] is True, operations
|
||||
assert operations["plot"] is True, operations
|
||||
assert operations["embed_image"] is True, operations
|
||||
print("serve smoke: OK")
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user