forked from 1b-agent/opencadstudio-upstream
Add opt-in response_detail=summary with step and change counts, compact state, warnings, and actionable errors or prompts. Return an ocs_read batch_result request for full cached details without resuming mutations. Keep compact/full behavior and idempotent batch retries intact. Preserve active-step context and report unavailable cached results explicitly. Document cache scope and retention; update schemas and digest. Validation: 47 MCP unit tests, live stdio smoke test, rustfmt, and diff checks passed.
921 lines
51 KiB
Markdown
921 lines
51 KiB
Markdown
# OpenCADStudio MCP control
|
||
|
||
> **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).
|
||
|
||
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
|
||
```
|
||
|
||
The client must start that command over standard input/output. If it asks for the executable and arguments separately, select the installed `OpenCADStudio` executable and enter `--mcp` as its only argument. The exact registration screen or configuration location belongs to the client. Once connected, the four tools below should appear in the client's MCP tool list.
|
||
|
||
The server provides four tools:
|
||
|
||
- `ocs_sessions` finds running editor sessions and opens OpenCADStudio when none exists.
|
||
- `ocs_read` discovers capabilities and reads document state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events, and operation status.
|
||
- `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor.
|
||
- `ocs_capture` returns a bounded PNG of the drawing viewport or complete window.
|
||
|
||
### Stale sessions clean themselves up
|
||
|
||
Dead editor sessions used to leave descriptor files behind that slowed down
|
||
session discovery. Discovery now skips processes that are gone (and removes
|
||
their leftover files), and a bridge started from an older build exits on its
|
||
own once it detects a rebuild so the client starts a fresh one. No action is
|
||
needed on your side; if a client reports version errors after an upgrade,
|
||
reconnecting its MCP servers picks up the new build.
|
||
|
||
Run `python docs/automation/mcp_acceptance.py target/debug/OpenCADStudio.exe`
|
||
for a repeatable end-to-end acceptance. It creates visible geometry through
|
||
MCP, audits and verified-saves DWG 2000/2013/2018 plus DXF 2000, and records a
|
||
viewport PNG and JSON report below `target/acceptance/`.
|
||
|
||
On a Windows workstation with a reference CAD application's command-line
|
||
console, run
|
||
`docs/automation/reference_acceptance.ps1 -Directory target/acceptance/TIMESTAMP -ConsolePath <console.exe>`.
|
||
It opens the three generated DWGs read-only in isolated console profiles, runs
|
||
AUDIT without repairs, and keeps one log per version. A console that stays
|
||
alive after `QUIT` is terminated after the timeout (only the process the runner
|
||
launched), and `ForcedTermination` is recorded separately from the audit result.
|
||
|
||
Native builds also accept a second, headless entry point (`OpenCADStudio --serve`, one
|
||
JSON request per line) for scripts and CI; the two native channels, their startup dialogs
|
||
and their limits are described in [native.md](native.md).
|
||
|
||
The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `ocs_session_id` to the other tools, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape:
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"request": {
|
||
"op": "undo",
|
||
"request_id": "undo-1",
|
||
"document_id": 1,
|
||
"revision": 12
|
||
}
|
||
}
|
||
```
|
||
|
||
Call `ocs_read` with `op: "capabilities"` before unfamiliar work. It reports the command, geometry, transaction, capture, and database facilities supported by the running build. The `records.collections` list gives every available database collection, its record count, and whether it can be edited.
|
||
|
||
Before delivery, call `ocs_read` with `op: "audit"` and the intended
|
||
`target_format` / `target_version`. The report includes entity manifests,
|
||
layers, blocks, finite bounds, duplicate entity handles, DXF dangling handle
|
||
references, source hash, and `dropped_on_save`. A clean in-memory document can
|
||
still be lossy for a different format, so target selection is part of the
|
||
audit:
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"op": "audit",
|
||
"parameters": {"target_format": "dwg", "target_version": "2000"}
|
||
}
|
||
```
|
||
|
||
Use `save_verified` for the delivery write. It requires an explicit absolute
|
||
path, refuses overwrite and lossy conversion unless separately acknowledged,
|
||
writes the requested version, reopens it, checks the actual version, compares
|
||
the semantic entity manifest, audits raw DXF handle references, and returns a
|
||
SHA-256 hash. A verification failure preserves the output for diagnosis and
|
||
reports it as failed instead of silently declaring success.
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"request": {
|
||
"op": "save_verified",
|
||
"request_id": "deliver-2026-09-22-1",
|
||
"path": "C:\\drawings\\issued\\plan.dwg",
|
||
"target_format": "dwg",
|
||
"target_version": "2000",
|
||
"overwrite": false
|
||
}
|
||
}
|
||
```
|
||
|
||
Call `ocs_read` with `op: "record_schema"` before editing an unfamiliar record. With no parameters it lists the complete generated type registry. A collection returns the record types accepted by that collection even when the current drawing has no instance of a type. Supplying both `collection` and `type` returns the type's complete dependency graph, flattened property paths, JSON types, optional and sequence markers, enum variants, integer bounds, unambiguous unit annotations, identity fields, and write rules:
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"op": "record_schema",
|
||
"parameters": {
|
||
"collection": "entities",
|
||
"type": "Insert"
|
||
}
|
||
}
|
||
```
|
||
|
||
Sequence fields include an `item_path_template` such as `/attributes/{index}`. Replace `{index}` with an index returned by `records` before using the path in `set_properties`. When a matching record exists, `runtime_schema` adds its exact JSON shape and bounded scalar examples. The embedded registry is generated from all 48 entity variants, all 36 non-graphical object variants, symbol-table records, header data, summary data, and the remaining stored record roots at build time.
|
||
|
||
`op: "records"` returns every serializable property of entities, non-graphical objects, symbol-table records, the complete header, document metadata, and derived database views. Omitting `collection` returns the same collection manifest; use `collection: "all"` for a paged query across the complete database. Records have a stable collection plus a handle or name, a type, and `properties`. Entity records also include their display and file record type names.
|
||
|
||
Property filters and projections use RFC 6901 JSON Pointer paths relative to `properties`. Multiple `where` filters are combined with AND. Supported operators are `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`, `starts_with`, `ends_with`, `in`, `exists`, and `not_exists`:
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"op": "records",
|
||
"parameters": {
|
||
"collection": "entities",
|
||
"type": "Insert",
|
||
"where": [
|
||
{"path": "/common/layer", "value": "Equipment"},
|
||
{"path": "/attributes/0/tag", "value": "COMPANY"}
|
||
],
|
||
"paths": ["/block_name", "/insert_point", "/attributes"]
|
||
}
|
||
}
|
||
```
|
||
|
||
Use `set_properties` to replace one or more fields atomically. OCS verifies the record identity, JSON types, optional compare-and-set values, layer locks, document revision, and request ID before committing one undoable edit. Handles, ownership slots, table names, and other database identity fields are read-only; their dedicated editor commands preserve cross-record references.
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"request": {
|
||
"op": "set_properties",
|
||
"request_id": "attributes-1",
|
||
"document_id": 1,
|
||
"revision": 12,
|
||
"collection": "entities",
|
||
"handle": "2A",
|
||
"updates": [
|
||
{"path": "/attributes/0/value", "expected": "BPH", "value": "Updated"},
|
||
{"path": "/insert_point/z", "value": 3.0}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
The same operation edits entries in `objects`, every symbol table, `header`, and `summary_info`. Collections marked `mutable: false` are derived or storage metadata and remain queryable. Their source records must be changed so OCS can rebuild those views consistently.
|
||
|
||
To discover command syntax without reading application source, call `ocs_read` with `op: "commands"`. The unfiltered response lists commands and actions. Add `parameters: {"name": "PLINE"}` for a command's batch examples and interactive guidance.
|
||
|
||
A complete command can be sent with `run`. Prompt answers are separated by spaces, points use `x,y` or `x,y,z`, and option answers use their displayed token:
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"request": {
|
||
"op": "run",
|
||
"request_id": "polyline-1",
|
||
"document_id": 1,
|
||
"revision": 12,
|
||
"cmd": "PLINE 0,0 10,0 10,10 C"
|
||
}
|
||
}
|
||
```
|
||
|
||
Use `batch` when the steps are already known. OCS supplies each step with the state produced by the previous one and stops on the first failure. `completed_steps` and `next_step` show exactly what committed:
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"response_detail": "changed_entities",
|
||
"request": {
|
||
"op": "batch",
|
||
"request_id": "shape-1",
|
||
"steps": [
|
||
{"op": "run", "cmd": "LINE 0,0 10,0"},
|
||
{"op": "run", "cmd": "CIRCLE 5,5 2"}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed.
|
||
|
||
For large batches, use `response_detail: "summary"`. It omits the full step
|
||
results and change list, returning step counts, `failed_steps`, `change_count`,
|
||
compact final state and a `details` tool request. `change_count` counts change
|
||
records, not unique entities. Warnings, failed or waiting-input steps in
|
||
`attention`, and any `active_step` are retained so the next action remains clear.
|
||
For non-batch operations, `summary` behaves like `compact`.
|
||
|
||
To inspect the retained full result, call the tool and arguments in `details`:
|
||
`ocs_read` with the same `ocs_session_id`, `op: "batch_result"`, and
|
||
`parameters: {"request_id": "<original batch request_id>"}`. This reads a cached
|
||
snapshot without executing or resuming any step. Results are retained in the
|
||
originating MCP connection, for up to 64 batches per session; after eviction or
|
||
reconnection the read fails explicitly. Do not replay a mutation merely to
|
||
retrieve expired details. A `running` snapshot is not completion; resume work
|
||
through the existing batch retry/task workflow.
|
||
|
||
`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. `detail: "full"` adds the complete serialized entity properties. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel:
|
||
|
||
```json
|
||
{
|
||
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
|
||
"op": "query",
|
||
"parameters": {"intersections": ["2A", "31"]}
|
||
}
|
||
```
|
||
|
||
For unfamiliar or conditional commands, use `start`, then inspect `state.command` in every response. Its `accepts` array gives the valid MCP input kinds, `options` gives the current tokens, and `input_example` gives the next request shape. Add the current state fields and a new `request_id` to each step.
|
||
|
||
Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes.
|
||
|
||
A `run` reply also carries two fields that describe what happened to the line:
|
||
|
||
* `blocked_by` — `null` when the command finished, otherwise what *this line*
|
||
left open and waiting: `"command"` (an interactive command), `"text_editor"`
|
||
(the in-place editor opened by the `TEXT` content step), `"mtext_editor"`, or
|
||
`"modal:<Kind>"` for a dialog. A surface that was already open when the line
|
||
started (an earlier line's editor, another tab, a startup dialog) is not
|
||
attributed to this line, so a caller polling for `completed` is never stuck
|
||
waiting on someone else's editor. `blocked_by` is set whenever `status` is
|
||
`waiting_input`.
|
||
* `unconsumed` — the tokens of the line that no prompt ever asked for, in order.
|
||
A fully consumed line reports `[]`; `CIRCLE 0,0 5 9` reports `["9"]` because
|
||
the radius step ends the command. Over-typed input is reported instead of
|
||
being dropped on the floor.
|
||
|
||
When a line ends at the `TEXT` content step (`TEXT 0,0 5 0 hello`) the text is
|
||
typed into the in-place editor and committed, so the line behaves exactly as the
|
||
same input typed into the GUI command line would: one `Text` entity, nothing
|
||
unconsumed, and the command closed rather than left armed for a second line. Geometry stays in OpenCADStudio and its geometry kernel.
|
||
|
||
`ocs_capture` defaults to the drawing viewport and a longest edge of 1600 pixels. Set `scope` to `window` for the full interface or change `max_dimension` between 256 and 4096.
|
||
|
||
With `diff: true`, an unchanged comparison returns metadata only, including
|
||
`diff.changed: false`, the hash and resource URI; it does not resend image or
|
||
resource content blocks, regardless of `delivery`. The initial baseline (also
|
||
after `reset_diff_baseline: true`) still uses the requested image delivery.
|
||
Changed captures retain the requested diff mode, including cropped patches.
|
||
Use `diff: false` or read the returned resource URI to retrieve the full image
|
||
when an unchanged comparison omitted it.
|
||
|
||
Clients using MCP 2026-07-28 can advertise `io.modelcontextprotocol/tasks`. OCS then returns a standard task handle when an operation is still running and accepts `tasks/get`, `tasks/update`, and `tasks/cancel`. Other clients continue to receive the existing operation status and can read it with `ocs_read`.
|
||
|
||
The source-tree protocol smoke test treats the executable as a black box and uses only the Python standard library. It checks both supported protocol styles, the published schemas, structured errors, and the four-tool surface:
|
||
|
||
```sh
|
||
cargo build
|
||
python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio
|
||
```
|
||
|
||
The repeatable live-editor evaluation draws three isolated entities in one batch, verifies exact intersections, nearest geometry and kernel measurements, removes the entities, and reports call count, elapsed time and wire bytes:
|
||
|
||
```sh
|
||
python3 docs/automation/mcp_eval.py target/debug/OpenCADStudio
|
||
```
|
||
|
||
The web build is driven through two JavaScript functions instead of a process; see [web.md](web.md).
|
||
## 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.
|
||
|
||
### Text operations — search, audit and replace
|
||
|
||
Three dedicated text operations go beyond `query` for bulk text work:
|
||
|
||
**`text_search`** (read) scans all `Text`, `MText`, and `Insert` attribute values for a pattern. It supports case-insensitive, whole-word, and accent-insensitive matching, scoped to a layer, entity type list, or handle set.
|
||
|
||
```json
|
||
{"op":"text_search","find":"CUVE","match_case":false,"whole_word":true,"ignore_accents":true}
|
||
```
|
||
|
||
**`text_audit`** (read) is a non-mutating spell-check and dry-run replacement preview. It accepts three independent analysis modes, all optional, in one call:
|
||
|
||
| Parameter | Effect |
|
||
|---|---|
|
||
| `pairs` | `[{"find":"X","replace":"Y"}, …]` — simulates a batch replacement and reports what would change, without touching the document |
|
||
| `check_terms` | `["CIRTUITS","NON TRAITE"]` — flags entities that contain suspect strings |
|
||
| `dictionary` | `["EAU","POMPE","VANNE"]` — any word ≥ 3 letters not found in this list is reported as unrecognised |
|
||
| `system_spellcheck` | `true` to use the OS native spell-checker alongside or instead of the agent dictionary |
|
||
| `language` | BCP-47 tag for the system speller, e.g. `"en-US"` or `"fr-FR"` (defaults to the OS default) |
|
||
| `suggest` | `true` (default) to include spelling suggestions for unrecognised words |
|
||
|
||
The agent dictionary and system spell-checker work as a layered pipeline: a word that matches the agent dictionary is accepted immediately; only words that fail (or when no dictionary is supplied) are forwarded to the OS spell-checker when enabled.
|
||
|
||
System spell-check backends:
|
||
|
||
| Platform | Backend | Notes |
|
||
|---|---|---|
|
||
| Windows 8.1+ | Win32 `ISpellChecker` COM API | Per-language; checks available via Settings → Language. ALL-CAPS words are checked in lowercase since Windows skips all-caps by default. |
|
||
| macOS | `NSSpellChecker` | Uses the system language or the `language` parameter |
|
||
| Linux | Hunspell `.dic` files or `hunspell` CLI | Reads `/usr/share/hunspell/` dictionaries; falls back to pipe mode |
|
||
|
||
Example combining an agent dictionary with system spell-check:
|
||
|
||
```json
|
||
{
|
||
"op": "text_audit",
|
||
"dictionary": ["VANNE", "CHAUDIÈRE", "POMPE"],
|
||
"system_spellcheck": true,
|
||
"language": "fr-FR",
|
||
"suggest": true
|
||
}
|
||
```
|
||
|
||
The response includes a `system_speller` object with `enabled`, `available`, `backend`, and `language` fields, plus an `unrecognized_words` array with each word's `handle`, `position`, and optional `suggestions`.
|
||
|
||
**`text_replace`** (execute) performs the actual batch replacement with single-transaction undo. Add `"dry_run": true` to preview the result without mutating the document or pushing an undo entry.
|
||
|
||
### 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, and serves its machine-readable
|
||
description at `GET /api/v1/openapi` (OpenAPI 3, embedded from
|
||
`src/rest_openapi.json`).
|
||
|
||
Every request carries `Authorization: Bearer <token>`; anything else is
|
||
answered `401 {"code":"unauthorized"}`. Loopback is shared by every process
|
||
and user on the machine, so the port alone proves nothing. Set the token
|
||
yourself with `OCS_API_TOKEN` before launching, or read the random one the
|
||
server prints on stderr at startup. No CORS headers are sent, so a web page
|
||
cannot drive or read the session. The same token guards `--serve --port`
|
||
(as a `"token"` field in each JSON line) and the GUI-hosted channel below.
|
||
|
||
```sh
|
||
export OCS_API_TOKEN=$(openssl rand -hex 32)
|
||
OpenCADStudio --http 8090 # start the REST server (headless)
|
||
curl -H "Authorization: Bearer $OCS_API_TOKEN" 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 -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" "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 -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" "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 -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" "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 -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/sysvars \
|
||
-H "Content-Type: application/json" -d '{"set":{"ltscale":2.5}}'
|
||
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "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 -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/layouts \
|
||
-H "Content-Type: application/json" -d '{"name":"PLAN"}'
|
||
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \
|
||
-H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}'
|
||
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document
|
||
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/groups \
|
||
-H "Content-Type: application/json" -d '{"name":"FRAME","handles":["63","67"]}'
|
||
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -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 -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true"
|
||
|
||
# 13. Close a document, discarding unsaved changes.
|
||
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -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` |
|
||
| `Spline` | `control_points` (more than `degree`) or `fit_points` (≥ 2), optional `degree` (1–10, default 3), `knots` (control points + degree + 1, non-decreasing), `weights` (one positive value per control point), `closed`, `periodic`, `rational`, `planar`, `linear` |
|
||
| `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`, `style` |
|
||
| `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 and the table record). A definition anything still points at — an
|
||
INSERT, a dimension-style arrow block, a multi-leader's block content — is
|
||
refused with `block_referenced` and the shared message text; add
|
||
`"force":true` to erase the referencing INSERTs in the same undo step and get
|
||
`result.references` with the number that were overridden. `result.erased`
|
||
counts every entity the step removed. Unknown names are `block_missing`,
|
||
`*`-prefixed layout records, anonymous and xref definitions are
|
||
`block_protected`, and a block with an open BEDIT session is `block_in_edit`.
|
||
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.
|