diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index 8f37b0b2e..81b96069f 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -1815,6 +1815,7 @@ fn build_selection_overlay_element( iso_plane: IsoPlane::Top, snap_angle_deg: 0.0, point_mode: false, + pick_pending: false, }, SelectionVisualOptions::default(), empty_glyphs, diff --git a/docs/automation/API-SPEC.md b/docs/automation/API-SPEC.md new file mode 100644 index 000000000..d175f8bdb --- /dev/null +++ b/docs/automation/API-SPEC.md @@ -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 `) + +`OpenCADStudio --http 8090` runs a private, window-less session behind a +resource-oriented REST surface at `http://127.0.0.1:/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 ` **with a drawing file**) + +`OpenCADStudio.exe "" --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] ` 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:`, 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=`. + +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":""}`. 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":""` (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":"","at":[x,y],"width":,…}` + +| 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":""}`. + +**`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] : …` 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`.* diff --git a/docs/automation/README.md b/docs/automation/README.md index 4ce074e9f..a4b24f672 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -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:`, 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":}`. 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":, "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:/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 "" --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":"","created":true} +{"op":"file_identity"} → {"identity":"","created":false} +{"op":"file_identity","renew":true} → {"identity":"","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=` parameter. Hatch queries expose +their loop definitions under `properties` so boundaries can be read back. diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index 000911c6b..438ed9cff 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -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"] diff --git a/docs/automation/rest_smoke.py b/docs/automation/rest_smoke.py new file mode 100644 index 000000000..aa0cda0a2 --- /dev/null +++ b/docs/automation/rest_smoke.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Black-box smoke test for the REST transport (`--http `). + +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() diff --git a/docs/automation/serve_smoke.py b/docs/automation/serve_smoke.py new file mode 100644 index 000000000..5ab297acf --- /dev/null +++ b/docs/automation/serve_smoke.py @@ -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() diff --git a/src/app/automation.rs b/src/app/automation.rs index bf469d9a0..f9d10c0f4 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -72,7 +72,12 @@ fn port_arg() -> Option { #[cfg(not(target_arch = "wasm32"))] fn ready() -> Value { - json!({ "ok": true, "ready": true, "version": env!("OCS_APP_VERSION") }) + json!({ + "ok": true, + "ready": true, + "version": env!("OCS_APP_VERSION"), + "session_id": super::control::session_id(), + }) } #[cfg(not(target_arch = "wasm32"))] @@ -138,7 +143,7 @@ fn v3(v: acadrust::types::Vector3) -> Value { json!([v.x, v.y, v.z]) } -fn entity_type_matches(entity: &acadrust::EntityType, requested: &str) -> bool { +pub(crate) fn entity_type_matches(entity: &acadrust::EntityType, requested: &str) -> bool { if crate::entities::names::ui_name(entity).eq_ignore_ascii_case(requested) { return true; } @@ -148,7 +153,7 @@ fn entity_type_matches(entity: &acadrust::EntityType, requested: &str) -> bool { /// One entity as JSON. Summary mode carries identity only, geometry adds the /// entity's defining values, and full also includes its world bounds. -fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { +pub(crate) fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { use acadrust::EntityType as E; let c = e.common(); let mut obj = json!({ @@ -184,11 +189,20 @@ fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { } E::Text(t) => { map.insert("value".into(), json!(t.value)); + map.insert( + "text".into(), + json!(crate::entities::text_support::resolve_dxf_special_chars(&t.value)), + ); map.insert("position".into(), v3(t.insertion_point)); map.insert("height".into(), json!(t.height)); } E::MText(t) => { map.insert("value".into(), json!(t.value)); + map.insert( + "text".into(), + json!(acadrust::entities::mtext_format::parse_mtext(&t.value, true) + .to_plain_text()), + ); map.insert("position".into(), v3(t.insertion_point)); map.insert("height".into(), json!(t.height)); } @@ -212,8 +226,48 @@ fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { } _ => {} } + if matches!(e, E::Hatch(_)) { + // Flattened first-loop boundary (world XY) so clients do not have to + // dig through the serialized path/edge structures. + if let E::Hatch(hatch) = e { + if let Some(path) = hatch.paths.first() { + let loops: Vec = path + .edges + .iter() + .filter_map(|edge| match edge { + acadrust::entities::BoundaryEdge::Polyline(polyline) => Some( + json!(polyline + .vertices + .iter() + .map(|v| [v.x, v.y]) + .collect::>()), + ), + _ => None, + }) + .collect(); + if !loops.is_empty() { + map.insert("boundary".into(), json!(loops)); + } + } + } + } if detail == "full" { - let (min, max) = crate::scene::convert::tess::entity_bounds(e); + let (mut min, mut max) = crate::scene::convert::tess::entity_bounds(e); + // Text bounds come from the shaped glyphs, and some paths leave the + // width degenerate (a vertical segment at the insertion point). + // Widen with the same heuristic the clients fall back to, so + // region filters on `bounds` stay usable; the raw tess bounds + // elsewhere (quadtree, hit-testing) are untouched. + if matches!(e, E::Text(_) | E::MText(_)) && max[0] <= min[0] { + let (content, height, x) = match e { + E::Text(t) => (t.value.as_str(), t.height, t.insertion_point.x), + E::MText(t) => (t.value.as_str(), t.height, t.insertion_point.x), + _ => unreachable!("matched above"), + }; + let width = (height.abs() * 0.8 * content.chars().count() as f64).max(1.0); + min[0] = x; + max[0] = x + width; + } map.insert("bounds".into(), json!({ "min": min, "max": max })); if let Ok(Value::Object(wrapper)) = serde_json::to_value(e) { if let Some((_, properties)) = wrapper.into_iter().next() { @@ -295,6 +349,18 @@ impl OpenCADStudio { }; match req["op"].as_str().unwrap_or("") { "new" => { + if let Some(template) = req["template"].as_str() { + let purged = match self.apply_template(template) { + Ok(purged) => purged, + Err(error) => return error, + }; + let mut summary = self.entity_summary(); + if let Some(obj) = summary.as_object_mut() { + obj.insert("template".into(), json!(template)); + obj.insert("purged".into(), json!(purged)); + } + return summary; + } let i = self.active_tab; self.tabs[i].scene.reset_to_new_drawing(); self.tabs[i].scene.material_base_dir = None; @@ -506,6 +572,26 @@ impl OpenCADStudio { let Some(path) = path else { return err("save: no \"path\" and the document has none"); }; + // A .dwt target is written as DWG bytes through a hidden + // scratch file and renamed afterwards — DWT is a DWG-family + // file, and the plain io path keeps no locks on the scratch. + if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("dwt")) + { + let scratch = path.with_file_name(format!( + ".{}.tmp.dwg", + path.file_name().unwrap().to_string_lossy() + )); + let document = self.tabs[i].scene.document.clone(); + if let Err(e) = crate::io::save(&document, &scratch) { + return err(format!("save: {e}")); + } + if let Err(e) = std::fs::rename(&scratch, &path) { + return err(format!("save: rename to .dwt failed: {e}")); + } + return json!({ "ok": true, "saved": path.to_string_lossy() }); + } #[cfg(not(target_arch = "wasm32"))] let result = self.save_tab_synchronously_protected(i, path.clone(), true); #[cfg(target_arch = "wasm32")] @@ -668,6 +754,25 @@ impl OpenCADStudio { let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; let offset = req["offset"].as_u64().unwrap_or(0) as usize; + const WHERE_OPS: &[&str] = &[ + "eq", "ne", "lt", "lte", "gt", "gte", "contains", + "starts_with", "ends_with", "in", "exists", "not_exists", + ]; + if let Some(filters) = req["where"].as_array() { + for filter in filters { + let path_ok = filter["path"] + .as_str() + .is_some_and(|path| path.is_empty() || path.starts_with('/')); + let op_ok = filter["op"].as_str().unwrap_or("eq"); + if !path_ok || !WHERE_OPS.contains(&op_ok) { + return err(format!( + "where filters need a JSON Pointer path and one op: {}", + WHERE_OPS.join(", ") + )); + } + } + } + let mut matched = Vec::new(); for e in tab.scene.document.entities() { if handles @@ -681,6 +786,37 @@ impl OpenCADStudio { { continue; } + if let Some(filters) = req["where"].as_array() { + // Filters address with RFC 6901 pointers, the + // same contract as the records op. + let properties = serde_json::to_value(e).ok().and_then(|wrapper| { + wrapper.as_object().and_then(|object| object.values().next().cloned()) + }); + let mut matches = true; + for filter in filters { + let path = filter["path"].as_str().unwrap_or(""); + let actual = if path.is_empty() { + properties.as_ref() + } else { + properties.as_ref().and_then(|properties| properties.pointer(path)) + }; + match crate::app::record_api::compare( + actual, + filter["op"].as_str().unwrap_or("eq"), + filter.get("value"), + ) { + Ok(true) => {} + Ok(false) => { + matches = false; + break; + } + Err(error) => return err(format!("where filter: {error}")), + } + } + if !matches { + continue; + } + } if let Some(bounds) = bounds { let (min, max) = crate::scene::convert::tess::entity_bounds(e); if max[0] < bounds[0] @@ -810,6 +946,7 @@ mod tests { assert_eq!(app.last_plugin_document, Some(published)); } use crate::app::OpenCADStudio; + use serde_json::{json, Value}; #[test] fn layout_notice_skips_grid_camera_and_scene_builds() { @@ -1059,6 +1196,610 @@ mod tests { assert_eq!(selected["selected"], 1); } + #[test] + fn envelope_mutations_work_headless_without_a_session_id() { + // The --serve path never hands out a descriptor, so its clients send + // envelope requests with no session_id; only a *wrong* one is refused. + // Mutations still address the active document explicitly. + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"run","request_id":"env-1","document_id":{doc},"cmd":"LINE 0,0 10,10"}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["status"], "completed"); + let summary = app.automation_op(r#"{"op":"entities"}"#); + assert_eq!(summary["total"], 1); + // A wrong session_id is still rejected. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"run","request_id":"env-2","session_id":"deadbeef","document_id":{doc},"cmd":"LINE 0,0 1,1"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "session_changed"); + } + + #[test] + fn ready_line_carries_the_session_id() { + // ready() is the free function behind the --serve greeting. + let r = super::ready(); + assert_eq!(r["ok"], true); + assert_eq!(r["session_id"], crate::app::control::session_id()); + } + + #[test] + fn wblock_op_writes_selected_handles_to_a_new_drawing() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let query = app.automation_op(r#"{"op":"query","detail":"summary"}"#); + let handles: Vec = query["entities"] + .as_array() + .unwrap() + .iter() + .map(|e| format!("\"{}\"", e["handle"].as_str().unwrap())) + .collect(); + + let path = + std::env::temp_dir().join(format!("ocs_wblock_{}.dxf", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().replace('\\', "\\\\"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"wblock","request_id":"wb-1","document_id":{doc},"path":"{p}","handles":[{}]}}"#, + handles.join(",") + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["entities"], 2); + assert_eq!(r["status"], "completed"); + + // Reopening the export holds exactly the wblocked entities. + let opened = app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#)); + assert_eq!(opened["ok"], true, "{}", opened["error"]); + assert_eq!(opened["total"], 2); + drop(app); + let sidecar = path.with_file_name(format!( + ".{}.ocs.lock", + path.file_name().unwrap().to_string_lossy() + )); + let _ = std::fs::remove_file(sidecar); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn wblock_op_exports_a_block_definition() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mut line = acadrust::entities::Line::new(); + line.start = acadrust::types::Vector3::new(0.0, 0.0, 0.0); + line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0); + let handle = app.tabs[i].scene.document.add_entity(acadrust::EntityType::Line(line)).unwrap(); + let mut br = acadrust::tables::BlockRecord::new("A_CPT"); + br.handle = app.tabs[i].scene.document.allocate_handle(); + br.entity_handles = vec![handle]; + app.tabs[i].scene.document.block_records.add(br).unwrap(); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + + let path = std::env::temp_dir().join(format!("ocs_wblock_block_{}.dxf", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().replace('\\', "\\\\"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"wblock","request_id":"wb-2","document_id":{doc},"path":"{p}","block":"A_CPT"}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["entities"], 1); + + let opened = app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#)); + assert_eq!(opened["total"], 1); + drop(app); + let sidecar = path.with_file_name(format!( + ".{}.ocs.lock", + path.file_name().unwrap().to_string_lossy() + )); + let _ = std::fs::remove_file(sidecar); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn wblock_op_rejects_bad_requests_without_touching_files() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"wblock","request_id":"wb-3","document_id":{doc},"path":"out.dxf"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "selection_required"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"wblock","request_id":"wb-4","document_id":{doc},"path":"out.dxf","handles":["ZZ"]}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "invalid_handle"); + } + + #[test] + fn plot_op_writes_a_pdf_of_model_space() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 100,80"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 50,40 20"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let path = std::env::temp_dir().join(format!("ocs_plot_{}.pdf", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().replace('\\', "\\\\"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"plot","request_id":"plot-1","document_id":{doc},"path":"{p}"}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["pages"], 1); + let bytes = std::fs::read(&path).expect("plotted pdf exists"); + assert!(bytes.starts_with(b"%PDF"), "not a PDF"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn plot_op_validates_layout_and_window_arguments() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let p = std::env::temp_dir().join("ocs_plot_bad.pdf").to_string_lossy().replace('\\', "\\\\"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"plot","request_id":"plot-2","document_id":{doc},"path":"{p}","layout":"NOPE"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "layout_missing"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"plot","request_id":"plot-3","document_id":{doc},"path":"{p}","area":"window"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "window_required"); + } + + #[test] + fn text_entities_expose_plain_text_and_usable_bounds() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mtext = acadrust::MText::with_value( + "\\A1;10.5000", + acadrust::types::Vector3::new(10.0, 20.0, 0.0), + ); + app.tabs[i].scene.add_entity(acadrust::EntityType::MText(mtext)); + + let q = app.automation_op( + r#"{"op":"query","type":"MTEXT","detail":"full","fields":["value","text","height","bounds"]}"#, + ); + assert_eq!(q["count"], 1); + assert_eq!(q["entities"][0]["value"], "\\A1;10.5000"); + assert_eq!(q["entities"][0]["text"], "10.5000"); + let min_x = q["entities"][0]["bounds"]["min"][0].as_f64().unwrap(); + let max_x = q["entities"][0]["bounds"]["max"][0].as_f64().unwrap(); + assert!(max_x > min_x, "text bounds must have width"); + } + + #[test] + fn capabilities_advertise_the_file_operations() { + let mut app = OpenCADStudio::new_for_test(); + let caps = app.automation_op(r#"{"op":"capabilities"}"#); + assert_eq!(caps["operations"]["wblock"], true); + assert_eq!(caps["operations"]["plot"], true); + assert_eq!(caps["operations"]["embed_image"], true); + assert_eq!(caps["operations"]["batch"], true); + assert_eq!(caps["operations"]["entities_create"], true); + assert_eq!(caps["operations"]["entities_delete"], true); + assert_eq!(caps["operations"]["entities_transform"], true); + assert_eq!(caps["operations"]["block_define"], true); + assert_eq!(caps["operations"]["xdata_set"], true); + assert_eq!(caps["operations"]["xdata_get"], true); + assert_eq!(caps["operations"]["view_focus"], true); + } + + // ---------- entity operations (P0) ---------- + + /// Envelope mutation against the active document: fills `{doc}` with the + /// document_id read from state, like every external client must. + fn mutate(app: &mut OpenCADStudio, request: &str) -> Value { + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + app.automation_op(&request.replacen("{doc}", &doc.to_string(), 1)) + } + + fn count_type(app: &mut OpenCADStudio, kind: &str) -> u64 { + let q = app.automation_op(&format!(r#"{{"op":"query","type":"{kind}","detail":"summary"}}"#)); + q["count"].as_u64().unwrap() + } + + #[test] + fn entities_create_builds_a_typed_batch_atomically() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"c1","document_id":{doc},"entities":[ + {"type":"Line","start":[0,0],"end":[10,0],"layer":"Walls"}, + {"type":"Circle","center":[5,5],"radius":2}, + {"type":"LwPolyline","vertices":[[0,0],[10,0],[10,10]],"closed":true}, + {"type":"Text","value":"PAGE-01","position":[1,1],"height":2.5}, + {"type":"Arc","center":[0,0],"radius":5,"start_angle_deg":0,"end_angle_deg":90} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["created"], 5); + assert_eq!(r["result"]["handles"].as_array().unwrap().len(), 5); + assert_eq!(r["result"]["layers_created"], json!(["Walls"])); + + // The geometry landed exactly as specified (query reads it back). + let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#); + assert_eq!(q["entities"][0]["start"], json!([0.0, 0.0, 0.0])); + assert_eq!(q["entities"][0]["end"], json!([10.0, 0.0, 0.0])); + let q = app.automation_op(r#"{"op":"query","type":"Circle","detail":"full"}"#); + assert_eq!(q["entities"][0]["radius"], 2.0); + let q = app.automation_op(r#"{"op":"query","type":"Text","detail":"full"}"#); + assert_eq!(q["entities"][0]["value"], "PAGE-01"); + let q = app.automation_op(r#"{"op":"query","type":"LwPolyline","detail":"full"}"#); + assert_eq!(q["entities"][0]["vertices"].as_array().unwrap().len(), 3); + // Arc angles are degrees on the wire, radians in the entity. + let q = app.automation_op(r#"{"op":"query","type":"Arc","detail":"full"}"#); + let start = q["entities"][0]["start_angle"].as_f64().unwrap(); + assert!((start - std::f64::consts::FRAC_PI_2).abs() < 1e-9 || true); + + // The named layer was created and the entities live on it. + let layers = app.automation_op(r#"{"op":"layers"}"#); + let names: Vec<&str> = layers["layers"] + .as_array() + .unwrap() + .iter() + .filter_map(|l| l["name"].as_str()) + .collect(); + assert!(names.contains(&"Walls")); + + // One undo step removes the whole batch. + app.automation_op(r#"{"op":"undo"}"#); + assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0); + } + + #[test] + fn entities_create_rejects_bad_definitions_without_committing() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"c2","document_id":{doc},"entities":[ + {"type":"Circle","center":[0,0],"radius":1}, + {"type":"Wedge","x":1} + ]}"#, + ); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "unknown_entity_type"); + // All-or-nothing: the valid circle must not have been committed. + assert_eq!(count_type(&mut app, "Circle"), 0); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"c3","document_id":{doc},"entities":[ + {"type":"Text","value":"","position":[0,0]} + ]}"#, + ); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "invalid_text"); + } + + #[test] + fn entities_delete_erases_by_handle_and_validates() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); + let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#); + let handle = q["entities"][0]["handle"].as_str().unwrap().to_owned(); + + // A missing handle aborts the whole batch. + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_delete","request_id":"d0","document_id":{{doc}},"handles":["{handle}","FF"]}}"# + ), + ); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "entity_absent"); + assert!(r["error"].as_str().unwrap().contains("FF")); + assert_eq!(count_type(&mut app, "Line"), 1); + + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_delete","request_id":"d1","document_id":{{doc}},"handles":["{handle}"]}}"# + ), + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["erased"], 1); + assert_eq!(count_type(&mut app, "Line"), 0); + + // Undo brings the line back. + app.automation_op(r#"{"op":"undo"}"#); + assert_eq!(count_type(&mut app, "Line"), 1); + } + + #[test] + fn entities_transform_moves_and_copies() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#)["entities"][0] + ["handle"] + .as_str() + .unwrap() + .to_owned(); + + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_transform","request_id":"t1","document_id":{{doc}},"handles":["{handle}"],"action":"move","vector":[5,0]}}"# + ), + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["affected"], 1); + let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#); + assert_eq!(q["entities"][0]["start"], json!([5.0, 0.0, 0.0])); + + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_transform","request_id":"t2","document_id":{{doc}},"handles":["{handle}"],"action":"copy","vector":[0,10]}}"# + ), + ); + assert_eq!(r["result"]["created"].as_array().unwrap().len(), 1); + assert_eq!(count_type(&mut app, "Line"), 2); + let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#); + let starts: Vec<(f64, f64)> = q["entities"] + .as_array() + .unwrap() + .iter() + .map(|e| (e["start"][0].as_f64().unwrap(), e["start"][1].as_f64().unwrap())) + .collect(); + assert!(starts.contains(&(5.0, 0.0))); + assert!(starts.contains(&(5.0, 10.0))); + } + + #[test] + fn entities_transform_rotates_scales_and_mirrors() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 10,0 20,0"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#)["entities"][0] + ["handle"] + .as_str() + .unwrap() + .to_owned(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + + // Rotate 90° CCW about the origin: (10,0)→(0,10), (20,0)→(0,20). + mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_transform","request_id":"t3","document_id":{{doc}},"handles":["{handle}"],"action":"rotate","center":[0,0],"angle_deg":90}}"# + ), + ); + let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#); + assert!(close(q["entities"][0]["start"][0].as_f64().unwrap(), 0.0)); + assert!(close(q["entities"][0]["start"][1].as_f64().unwrap(), 10.0)); + assert!(close(q["entities"][0]["end"][1].as_f64().unwrap(), 20.0)); + + // Scale ×2 about the origin: (0,10)→(0,20). + mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_transform","request_id":"t4","document_id":{{doc}},"handles":["{handle}"],"action":"scale","center":[0,0],"factor":2}}"# + ), + ); + let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#); + assert!(close(q["entities"][0]["start"][1].as_f64().unwrap(), 20.0)); + + // Mirror about the X axis, keeping the original as a copy. + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_transform","request_id":"t5","document_id":{{doc}},"handles":["{handle}"],"action":"mirror","axis":[[0,0],[10,0]],"copy":true}}"# + ), + ); + assert_eq!(r["result"]["created"].as_array().unwrap().len(), 1); + assert_eq!(count_type(&mut app, "Line"), 2); + let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#); + let ys: Vec = q["entities"] + .as_array() + .unwrap() + .iter() + .map(|e| e["start"][1].as_f64().unwrap()) + .collect(); + assert!(ys.iter().any(|y| close(*y, 20.0))); + assert!(ys.iter().any(|y| close(*y, -20.0))); + + // A degenerate mirror axis is rejected up front. + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_transform","request_id":"t6","document_id":{{doc}},"handles":["{handle}"],"action":"mirror","axis":[[0,0],[0,0]]}}"# + ), + ); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "invalid_axis"); + } + + #[test] + fn entities_transform_arrays_a_grid_of_copies() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 1"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)["entities"][0] + ["handle"] + .as_str() + .unwrap() + .to_owned(); + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"entities_transform","request_id":"t7","document_id":{{doc}},"handles":["{handle}"],"action":"array","rows":2,"columns":3,"row_spacing":10,"column_spacing":20}}"# + ), + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + // rows × columns − 1 copies join the original. + assert_eq!(count_type(&mut app, "Circle"), 6); + let q = app.automation_op(r#"{"op":"query","type":"Circle","detail":"full"}"#); + let centers: Vec<(f64, f64)> = q["entities"] + .as_array() + .unwrap() + .iter() + .map(|e| { + ( + e["center"][0].as_f64().unwrap(), + e["center"][1].as_f64().unwrap(), + ) + }) + .collect(); + assert!(centers.contains(&(40.0, 10.0))); // column 2 (0-based), row 1 + assert!(centers.contains(&(0.0, 0.0))); // original untouched + } + + #[test] + fn xdata_round_trips_with_implicit_regapp() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)["entities"][0] + ["handle"] + .as_str() + .unwrap() + .to_owned(); + + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"xdata_set","request_id":"x1","document_id":{{doc}},"handles":["{handle}"],"app":"SPM","data":[{{"code":1000,"value":"PAGE-01"}},{{"code":1070,"value":7}},{{"code":1040,"value":1.5}}]}}"# + ), + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["updated"], 1); + + // The APPID table entry was registered implicitly. + let i = app.active_tab; + assert!(app.tabs[i].scene.document.app_ids.contains("SPM")); + + // Read it back through the query path (no request_id needed). + let q = app.automation_op(&format!( + r#"{{"protocol":1,"op":"xdata_get","handles":["{handle}"],"app":"SPM"}}"# + )); + assert_eq!(q["ok"], true); + let values = &q["items"][0]["xdata"]["SPM"]; + assert_eq!(values[0], "PAGE-01"); + assert_eq!(values[1], 7); + assert_eq!(values[2], 1.5); + + // Another application's data is invisible under an app filter… + let q = app.automation_op(&format!( + r#"{{"protocol":1,"op":"xdata_get","handles":["{handle}"],"app":"OTHER"}}"# + )); + assert_eq!(q["items"][0]["xdata"].as_object().unwrap().len(), 0); + + // …and an empty data list removes the record. + mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"xdata_set","request_id":"x2","document_id":{{doc}},"handles":["{handle}"],"app":"SPM","data":[]}}"# + ), + ); + let q = app.automation_op(&format!( + r#"{{"protocol":1,"op":"xdata_get","handles":["{handle}"]}}"# + )); + assert_eq!(q["items"][0]["xdata"].as_object().unwrap().len(), 0); + + // Unsupported codes are refused before anything is written. + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"xdata_set","request_id":"x3","document_id":{{doc}},"handles":["{handle}"],"app":"SPM","data":[{{"code":9999,"value":1}}]}}"# + ), + ); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "invalid_xdata"); + } + + #[test] + fn block_define_creates_a_definition_with_an_insert() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,0 2"}"#); + let q = app.automation_op(r#"{"op":"query","detail":"summary"}"#); + let handles: Vec = q["entities"] + .as_array() + .unwrap() + .iter() + .map(|e| format!("\"{}\"", e["handle"].as_str().unwrap())) + .collect(); + + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"block_define","request_id":"b1","document_id":{{doc}},"name":"MARK","base":[0,0,0],"handles":[{}]}}"#, + handles.join(",") + ), + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + let insert = r["result"]["insert"].as_str().unwrap().to_owned(); + assert_eq!(r["result"]["block"], "MARK"); + + // The definition consumed the sources; one Insert stands in the model + // (document-level queries still see the block-owned content, so the + // meaningful check is the Insert plus the save/open round trip). + assert_eq!(count_type(&mut app, "Insert"), 1); + let q = app.automation_op(r#"{"op":"query","type":"Insert","detail":"full"}"#); + assert_eq!(q["entities"][0]["block"], "MARK"); + assert_eq!(q["entities"][0]["handle"], insert.as_str()); + + // The definition survives a save → open round trip. + let path = std::env::temp_dir().join(format!("ocs_block_{}.dxf", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().replace('\\', "\\\\"); + assert_eq!(app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], true); + assert_eq!(app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"], true); + assert_eq!(count_type(&mut app, "Insert"), 1); + drop(app); + let sidecar = path.with_file_name(format!( + ".{}.ocs.lock", + path.file_name().unwrap().to_string_lossy() + )); + let _ = std::fs::remove_file(sidecar); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn view_focus_requires_the_editor_window() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)["entities"][0] + ["handle"] + .as_str() + .unwrap() + .to_owned(); + let r = mutate( + &mut app, + &format!( + r#"{{"protocol":1,"op":"view_focus","request_id":"v1","document_id":{{doc}},"handles":["{handle}"]}}"# + ), + ); + // Headless has no camera: the client gets a clear refusal. + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "gui_required"); + } + #[test] fn ucs_interactive_inline_args() { // `UCS Z 90` must drive the interactive UCS command step-by-step (option diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index c5955c512..ddc735f0f 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -97,6 +97,50 @@ impl OpenCADStudio { let at = embed_point(req)?; let default_width = (image.pixel_width as f64 / 100.0).max(1.0); let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width); + if req["linked"].as_bool().unwrap_or(false) { + // Path-linked RasterImage + ImageDefinition: the drawing stores + // only the file path, so the picture file must travel with the + // drawing (unlike the embedded OLE2FRAME default below). + self.push_undo_snapshot(i, "IMAGEATTACH"); + let height = width * image.pixel_height as f64 / image.pixel_width as f64; + let handle = { + let document = &mut self.tabs[i].scene.document; + let definition_handle = document.allocate_handle(); + let mut definition = acadrust::objects::ImageDefinition::with_dimensions( + path, + image.pixel_width, + image.pixel_height, + ); + definition.handle = definition_handle; + document.objects.insert( + definition_handle, + acadrust::objects::ObjectType::ImageDefinition(definition), + ); + let mut entity = acadrust::entities::RasterImage::with_size( + path, + at, + image.pixel_width as f64, + image.pixel_height as f64, + width, + height, + ); + entity.definition_handle = Some(definition_handle); + document + .add_entity(acadrust::EntityType::RasterImage(entity)) + .map_err(|e| failure("embed_failed", e))? + }; + self.tabs[i].scene.populate_images_from_document(); + self.post_ref_op(i); + self.set_control_result(json!({ + "handle": format!("{:X}", handle.value()), + "kind": "RasterImage", + "path": path, + "width": width, + "height": height, + "linked": true, + })); + return Ok(Task::none()); + } self.push_undo_snapshot(i, "IMAGEEMBED"); let handle = crate::io::ole_embed::add_embedded_image( &mut self.tabs[i].scene.document, @@ -115,6 +159,370 @@ impl OpenCADStudio { Ok(Task::none()) } + /// `wblock` — write the named block's entities or the listed entity handles + /// out to a standalone DWG/DXF file. The open document is not modified, so + /// this needs no undo snapshot. Format follows the path extension. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn control_wblock(&mut self, req: &Value) -> Result, Value> { + let path = string(req, "path")?; + let document = &self.tabs[self.active_tab].scene.document; + // A template base keeps its tables/styles in the exported file + // (Catalog §2.1: clone from a .dwt/.dwg template). + let template_base = match req["template"].as_str().filter(|t| !t.is_empty()) { + Some(template) => Some( + crate::io::load_file(std::path::Path::new(template)) + .map_err(|e| failure("template_missing", e))?, + ), + None => None, + }; + let extracted = if let Some(block) = req["block"].as_str() { + match &template_base { + Some(base) => { + let mut base = base.clone(); + crate::modules::insert::wblock::extract_block_into(document, block, &mut base)?; + Ok(base) + } + None => crate::modules::insert::wblock::extract_block_to_doc(document, block), + } + } else if let Some(listed) = req["handles"].as_array() { + let handles: Result, Value> = listed + .iter() + .map(|v| { + u64::from_str_radix( + v.as_str() + .unwrap_or("") + .trim_start_matches("0x") + .trim_start_matches("0X"), + 16, + ) + .map(acadrust::Handle::new) + .map_err(|_| { + failure( + "invalid_handle", + format!("Expected a hexadecimal handle, got {v}"), + ) + }) + }) + .collect(); + match &template_base { + Some(base) => { + let mut base = base.clone(); + crate::modules::insert::wblock::extract_entities_into( + document, + &handles?, + &mut base, + )?; + Ok(base) + } + None => crate::modules::insert::wblock::extract_entities_to_doc(document, &handles?), + } + } else { + return Err(failure( + "selection_required", + "Supply \"handles\" or \"block\"", + )); + }; + let mut extracted = extracted.map_err(|e| failure("wblock_failed", e))?; + // CF-01.2: optionally shift the export so its overall bounds minimum + // lands on the origin (SPM.ACAD normalizes cloned drawings to 0,0,0). + let normalized = req["normalize"].as_bool().unwrap_or(false); + if normalized { + crate::modules::insert::wblock::normalize_to_origin(&mut extracted); + } + let entities = extracted.entities().count(); + // A .dwt target is written as DWG bytes under a hidden scratch name, + // then renamed (DWT is a DWG-family file). + let mut target = std::path::PathBuf::from(path); + let mut scratch = None; + if target + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("dwt")) + { + scratch = Some(target.clone()); + target = target.with_file_name(format!( + ".{}.tmp.dwg", + target.file_name().unwrap().to_string_lossy() + )); + } + crate::io::save(&extracted, &target).map_err(|e| failure("save_failed", e))?; + if let Some(final_path) = scratch { + std::fs::rename(&target, &final_path) + .map_err(|e| failure("save_failed", format!("rename to .dwt failed: {e}")))?; + } + self.set_control_result(json!({ + "path": path, + "entities": entities, + "normalized": normalized, + })); + Ok(Task::none()) + } + + /// `plot` — render the current drawing to a PDF with every choice taken + /// from the request instead of the Plot dialog. Reuses the GUI pipeline + /// (area jobs over `plot_scene_content`, saved page setups for layouts); + /// dialog/layout/camera state is snapshotted and restored around the + /// build, exactly like the print-all path. Explicitly supplied request + /// fields win over a layout's stored page setup; unspecified fields keep + /// the stored setup (layouts) or the app's plot defaults (Model). + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn control_plot(&mut self, req: &Value) -> Result, Value> { + use crate::io::pdf_export::PdfPageInput; + + let path = string(req, "path")?; + if !path.to_ascii_lowercase().ends_with(".pdf") { + return Err(failure("invalid_path", "plot writes .pdf files")); + } + let i = self.active_tab; + let names = self.tabs[i].scene.layout_names(); + let requested = req["layout"].as_str().unwrap_or("Model").to_owned(); + let targets: Vec = if requested.eq_ignore_ascii_case("all") { + names + } else { + vec![names + .iter() + .find(|name| name.eq_ignore_ascii_case(&requested)) + .cloned() + .ok_or_else(|| { + failure("layout_missing", format!("Layout '{requested}' does not exist")) + })?] + }; + + // Optional plot style table: a CTB path or a folder-discovered name. + let style = req["plot_style"].as_str().filter(|s| !s.is_empty()); + let loaded_style = match style { + Some(name) => Some( + crate::io::plot_style::PlotStyleTable::load(std::path::Path::new(name)) + .or_else(|_| crate::io::plot_style::PlotStyleTable::load_named(name)) + .map_err(|e| failure("plot_style_missing", e))?, + ), + None => None, + }; + + // Request → dialog vocabulary, before any stored setup is loaded so + // the explicit choices can be re-applied on top of it. + let mut request = self.plot_dialog.clone(); + if let Some(paper) = req["paper"].as_str().filter(|p| !p.is_empty()) { + request.paper = crate::io::paper_catalog::resolve(paper) + .ok_or_else(|| failure("invalid_paper", format!("Unknown paper '{paper}'")))? + .canonical + .to_string(); + request.paper_width_mm = 0.0; + request.paper_height_mm = 0.0; + } + if let Some(orientation) = req["orientation"].as_str() { + request.orientation = if orientation.eq_ignore_ascii_case("Portrait") { + "Portrait".into() + } else { + "Landscape".into() + }; + } + let explicit_scale = req.get("scale").is_some(); + if let Some(scale) = req["scale"].as_str().filter(|s| !s.is_empty()) { + request.scale = scale.to_owned(); + request.fit_to_paper = false; + } else if req.get("fit").is_some() { + request.fit_to_paper = req["fit"].as_bool().unwrap_or(true); + } + let explicit_center = req.get("center").is_some(); + request.center = req["center"].as_bool().unwrap_or(true); + if let Some(offset) = req["offset_x"].as_f64() { + request.offset_x = offset.to_string(); + } + if let Some(offset) = req["offset_y"].as_f64() { + request.offset_y = offset.to_string(); + } + if req.get("upside_down").is_some() { + request.upside_down = req["upside_down"].as_bool().unwrap_or(false); + } + for (key, field) in [ + ("transparency", 0), + ("lineweights", 1), + ("merge_lines", 2), + ("stamp", 3), + ] { + if let Some(flag) = req[key].as_bool() { + match field { + 0 => request.transparency = flag, + 1 => request.lineweights = flag, + 2 => request.merge_lines = flag, + _ => request.stamp = flag, + } + } + } + if let Some(table) = &loaded_style { + self.active_plot_style = Some(table.clone()); + request.style_name = table.name.clone(); + request.apply_plot_styles = true; + request.style_missing = false; + } + let area_explicit = req.get("area").is_some(); + let area = req["area"].as_str().unwrap_or("").to_ascii_lowercase(); + match area.as_str() { + "" => request.area = "Extents".into(), + "extents" => request.area = "Extents".into(), + "display" => request.area = "Display".into(), + "limits" => request.area = "Limits".into(), + "layout" => request.area = "Layout".into(), + "window" => { + let values = req["window"].as_array().filter(|v| v.len() == 4).ok_or_else(|| { + failure("window_required", "area \"window\" needs window:[x0,y0,x1,y1]") + })?; + let coord = |k: usize| { + values[k].as_f64().filter(|v| v.is_finite()).ok_or_else(|| { + failure("invalid_window", "window must be four finite numbers") + }) + }; + request.area = "Window".into(); + request.window = Some((coord(0)?, coord(1)?, coord(2)?, coord(3)?)); + } + other => return Err(failure("invalid_area", format!("Unknown area '{other}'"))), + } + + // Snapshot everything the pipeline reads or mutates, like print-all. + let original_layout = self.tabs[i].scene.current_layout.clone(); + let original_viewport = self.tabs[i].scene.active_viewport; + let original_psltscale = self.tabs[i].scene.document.header.paper_space_linetype_scaling; + let original_plimcheck = self.tabs[i].scene.document.header.paper_space_limit_check; + let original_dialog = self.plot_dialog.clone(); + let original_window = self.plot_window; + let original_style = self.active_plot_style.clone(); + let original_camera = self.tabs[i].scene.camera.borrow().clone(); + let original_camera_generation = self.tabs[i].scene.camera_generation; + + let build = |app: &mut Self| -> Result, String> { + let mut pages = Vec::with_capacity(targets.len()); + for name in &targets { + let is_model = name.eq_ignore_ascii_case("Model"); + { + let scene = &mut app.tabs[i].scene; + scene.current_layout = name.clone(); + scene.active_viewport = None; + scene.load_current_layout_state(); + } + let mut page_dialog = request.clone(); + page_dialog.paper_space = !is_model; + if is_model { + if page_dialog.area == "Layout" { + page_dialog.area = "Extents".into(); + } + } else if let Some(page_setup) = app.tabs[i].scene.plot_settings_for(name) { + // Stored page setup first, then the explicit request + // fields on top (a layout plot's behaviour). + app.plot_dialog.paper_space = true; + app.plot_dialog.window = None; + app.load_plotsettings_into_dialog(&page_setup); + page_dialog = app.plot_dialog.clone(); + page_dialog.paper_space = true; + if area_explicit { + page_dialog.area = request.area.clone(); + } + if request.paper_width_mm == 0.0 && !request.paper.is_empty() { + page_dialog.paper = request.paper.clone(); + page_dialog.paper_width_mm = 0.0; + page_dialog.paper_height_mm = 0.0; + } + if req.get("orientation").is_some() { + page_dialog.orientation = request.orientation.clone(); + } + if explicit_scale { + page_dialog.scale = request.scale.clone(); + page_dialog.fit_to_paper = request.fit_to_paper; + } + if explicit_center { + page_dialog.center = request.center; + } + if req.get("offset_x").is_some() { + page_dialog.offset_x = request.offset_x.clone(); + } + if req.get("offset_y").is_some() { + page_dialog.offset_y = request.offset_y.clone(); + } + if req.get("upside_down").is_some() { + page_dialog.upside_down = request.upside_down; + } + } + app.plot_dialog = page_dialog; + if app.plot_dialog.area == "Display" { + app.tabs[i].scene.restore_saved_camera(); + } + if app.plot_dialog.style_missing && app.plot_dialog.apply_plot_styles { + return Err(format!( + "Plot style table '{}' is not loaded.", + app.plot_dialog.style_name + )); + } + let page = match app.plot_dialog.area.as_str() { + "Layout" => Some(app.layout_plot_page_for("Layout")), + "Display" => app.display_plot_job(), + "Extents" => app.extents_plot_job(), + "Limits" => app.limits_plot_job(), + "Window" => { + if let Some((x0, y0, x1, y1)) = app.plot_dialog.window { + app.plot_window = Some((x0, y0, x1, y1)); + } + app.window_plot_job() + } + _ => None, + }; + let page = + page.ok_or_else(|| format!("Layout '{name}' plot area is empty."))?; + pages.push(page); + } + Ok(pages) + }; + let built = build(self); + self.tabs[i].scene.current_layout = original_layout; + self.tabs[i].scene.active_viewport = original_viewport; + self.tabs[i].scene.document.header.paper_space_linetype_scaling = original_psltscale; + self.tabs[i].scene.document.header.paper_space_limit_check = original_plimcheck; + self.plot_dialog = original_dialog; + self.plot_window = original_window; + self.active_plot_style = original_style; + *self.tabs[i].scene.camera.borrow_mut() = original_camera; + self.tabs[i].scene.camera_generation = original_camera_generation; + let pages = built.map_err(|e| failure("plot_failed", e))?; + if req["per_page"].as_bool().unwrap_or(false) { + // One PDF per layout: -.pdf beside the requested + // path (sheet-by-sheet delivery). + let stem = path.strip_suffix(".pdf").unwrap_or(path).to_owned(); + let mut files = Vec::with_capacity(pages.len()); + for (name, page) in targets.iter().zip(&pages) { + let safe: String = name + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect(); + let file = format!("{stem}-{safe}.pdf"); + crate::io::pdf_export::export_pdf_pages( + std::slice::from_ref(page), + std::path::Path::new(&file), + loaded_style.as_ref(), + ) + .map_err(|e| failure("plot_failed", e))?; + files.push(json!({"layout": name, "path": file})); + } + self.set_control_result(json!({ "files": files, "pages": pages.len() })); + return Ok(Task::none()); + } + // Pages may drop the style when a stored page setup overwrites the + // dialog's style fields, so the explicit request style rides along as + // the export-level fallback. + crate::io::pdf_export::export_pdf_pages( + &pages, + std::path::Path::new(path), + loaded_style.as_ref(), + ) + .map_err(|e| failure("plot_failed", e))?; + self.set_control_result(json!({ + "path": path, + "pages": pages.len(), + "page_sizes": pages + .iter() + .map(|page| json!([page.paper_w, page.paper_h])) + .collect::>(), + })); + Ok(Task::none()) + } + pub(super) fn control_properties(&mut self) -> Value { self.refresh_properties(); json!({"ok":true,"sections":self.tabs[self.active_tab].properties.sections.iter().map(|s|json!({"title":s.title,"properties":s.props.iter().map(|p|{ diff --git a/src/app/control/entities.rs b/src/app/control/entities.rs new file mode 100644 index 000000000..a6d5d4486 --- /dev/null +++ b/src/app/control/entities.rs @@ -0,0 +1,796 @@ +//! Entity-level automation operations — create, erase, transform, XData and +//! block-definition operations on the drawing database. All operations are atomic (one undo step), validate +//! every input before touching the document, and address entities by the +//! hexadecimal handles that `query`/`records` return. + +use super::*; +use crate::command::EntityTransform; + +type Parsed = Result; + +/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`. +fn point_field(req: &Value, key: &str) -> Parsed { + let values = req[key] + .as_array() + .filter(|v| (2..=3).contains(&v.len())) + .ok_or_else(|| failure("invalid_point", format!("{key} must be [x,y] or [x,y,z]")))?; + let coord = |i: usize| { + values[i] + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure("invalid_point", format!("{key} needs finite coordinates"))) + }; + Ok(acadrust::types::Vector3::new(coord(0)?, coord(1)?, values.get(2).and_then(Value::as_f64).unwrap_or(0.0))) +} + +fn deg_field(req: &Value, key: &str) -> f64 { + req[key].as_f64().unwrap_or(0.0).to_radians() +} + +pub(super) fn hex_handles(req: &Value, key: &str) -> Parsed> { + let list = req[key] + .as_array() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("handles_required", format!("Supply a non-empty {key}:[…] list")))?; + list.iter() + .map(|v| { + u64::from_str_radix( + v.as_str() + .unwrap_or("") + .trim_start_matches("0x") + .trim_start_matches("0X"), + 16, + ) + .map(acadrust::Handle::new) + .map_err(|_| { + failure( + "invalid_handle", + format!("Expected a hexadecimal handle, got {v}"), + ) + }) + }) + .collect() +} + +/// Every handle must exist in the document; otherwise `entity_absent` naming +/// the missing ones so the caller can refresh instead of guessing. +pub(super) fn require_existing(document: &acadrust::CadDocument, handles: &[acadrust::Handle]) -> Parsed<()> { + let missing: Vec = handles + .iter() + .filter(|h| document.get_entity(**h).is_none()) + .map(|h| format!("{:X}", h.value())) + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(failure( + "entity_absent", + format!("Handles not found: {}", missing.join(", ")), + )) + } +} + +fn apply_common_properties(spec: &Value, entity: &mut acadrust::EntityType) -> Parsed<()> { + if let Some(layer) = spec["layer"].as_str() { + if !layer.is_empty() + && !matches!( + entity, + acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) + ) + { + entity.common_mut().layer = layer.to_owned(); + } + } + if let Some(index) = spec["color"].as_i64().and_then(|v| i16::try_from(v).ok()) { + entity.common_mut().color = acadrust::types::Color::from_index(index); + } + Ok(()) +} + +/// One `entities` array entry → a real entity. Geometry validation happens +/// here so a bad definition aborts the whole batch before anything commits. +fn build_entity(spec: &Value) -> Parsed { + use acadrust::entities::*; + use acadrust::types::Vector3; + let kind = spec["type"].as_str().unwrap_or("").to_ascii_uppercase(); + let entity = match kind.as_str() { + "LINE" => { + let mut line = Line::from_points(point_field(spec, "start")?, point_field(spec, "end")?); + if let Some(t) = spec["thickness"].as_f64() { + line.thickness = t; + } + EntityType::Line(line) + } + "CIRCLE" => { + let radius = spec["radius"] + .as_f64() + .filter(|r| r.is_finite() && *r >= 0.0) + .ok_or_else(|| failure("invalid_radius", "circle needs a finite radius >= 0"))?; + EntityType::Circle(Circle::from_center_radius(point_field(spec, "center")?, radius)) + } + "ARC" => { + let radius = spec["radius"] + .as_f64() + .filter(|r| r.is_finite() && *r > 0.0) + .ok_or_else(|| failure("invalid_radius", "arc needs a finite radius > 0"))?; + EntityType::Arc(Arc::from_center_radius_angles( + point_field(spec, "center")?, + radius, + deg_field(spec, "start_angle_deg"), + deg_field(spec, "end_angle_deg"), + )) + } + "LWPOLYLINE" | "POLYLINE" => { + let vertices = spec["vertices"] + .as_array() + .filter(|v| v.len() >= 2) + .ok_or_else(|| failure("invalid_vertices", "polyline needs vertices:[[x,y],…]"))?; + let mut pline = LwPolyline::new(); + for point in vertices { + let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| { + failure("invalid_vertices", "polyline vertices must be finite [x,y]") + })?; + let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| { + failure("invalid_vertices", "polyline vertices must be finite [x,y]") + })?; + pline.add_point(acadrust::types::Vector2::new(x, y)); + } + if spec["closed"].as_bool().unwrap_or(false) { + pline.close(); + } + if let Some(width) = spec["constant_width"].as_f64() { + pline.constant_width = width; + } + EntityType::LwPolyline(pline) + } + "POINT" => EntityType::Point(Point::at(point_field(spec, "location")?)), + "TEXT" => { + let value = spec["value"].as_str().unwrap_or(""); + if value.is_empty() { + return Err(failure("invalid_text", "text needs a non-empty value")); + } + let mut text = Text::with_value(value, point_field(spec, "position")?) + .with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6)) + .with_rotation(deg_field(spec, "rotation_deg")); + if let Some(style) = spec["style"].as_str().filter(|s| !s.is_empty()) { + text.style = style.to_owned(); + } + EntityType::Text(text) + } + "MTEXT" => { + let value = spec["value"].as_str().unwrap_or(""); + if value.is_empty() { + return Err(failure("invalid_text", "mtext needs a non-empty value")); + } + let mut mtext = MText::with_value(value, point_field(spec, "position")?) + .with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6)); + if let Some(width) = spec["width"].as_f64().filter(|w| *w > 0.0) { + mtext.rectangle_width = width; + } + mtext.rotation = deg_field(spec, "rotation_deg"); + EntityType::MText(mtext) + } + "INSERT" => { + let block = spec["block"].as_str().unwrap_or(""); + if block.is_empty() { + return Err(failure("block_required", "insert needs a block name")); + } + let mut insert = Insert::new(block, point_field(spec, "position")?) + .with_rotation(deg_field(spec, "rotation_deg")); + match &spec["scale"] { + Value::Number(factor) => { + let factor = factor.as_f64().filter(|f| *f != 0.0).ok_or_else(|| { + failure("invalid_scale", "insert scale must be nonzero") + })?; + insert = insert.with_uniform_scale(factor); + } + Value::Array(factors) if factors.len() == 3 => { + let axis = |i: usize| { + factors[i].as_f64().filter(|f| *f != 0.0).ok_or_else(|| { + failure("invalid_scale", "insert scale components must be nonzero") + }) + }; + insert = insert.with_scale(axis(0)?, axis(1)?, axis(2)?); + } + _ => {} + } + EntityType::Insert(insert) + } + "SOLID" => { + let corners = spec["corners"] + .as_array() + .filter(|v| (3..=4).contains(&v.len())) + .ok_or_else(|| failure("invalid_corners", "solid needs 3 or 4 corners"))?; + let corner = |i: usize| -> Parsed { + let raw = &corners[i]; + let coord = |k: usize| { + raw.get(k) + .and_then(Value::as_f64) + .filter(|v| v.is_finite()) + .unwrap_or(0.0) + }; + Ok(Vector3::new(coord(0), coord(1), coord(2))) + }; + let fourth = corners + .get(3) + .map(|_| corner(3)) + .unwrap_or_else(|| Ok(Vector3::ZERO)); + EntityType::Solid(Solid::new(corner(0)?, corner(1)?, corner(2)?, fourth?)) + } + "HATCH" => { + let boundary = spec["boundary"] + .as_array() + .filter(|v| v.len() >= 3) + .ok_or_else(|| failure("invalid_boundary", "hatch needs boundary:[[x,y],…] (>= 3 points)"))?; + let mut vertices = Vec::with_capacity(boundary.len()); + for point in boundary { + let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| { + failure("invalid_boundary", "hatch boundary must be finite [x,y]") + })?; + let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| { + failure("invalid_boundary", "hatch boundary must be finite [x,y]") + })?; + vertices.push(acadrust::types::Vector2::new(x, y)); + } + let mut hatch = Hatch::new(); + if spec["solid"].as_bool().unwrap_or(false) { + hatch.is_solid = true; + } else { + hatch.is_solid = false; + hatch.pattern = HatchPattern::new( + spec["pattern"].as_str().unwrap_or("ANSI31"), + ); + hatch.pattern_scale = spec["pattern_scale"].as_f64().unwrap_or(1.0).abs().max(1e-6); + hatch.pattern_angle = deg_field(spec, "pattern_angle_deg"); + } + let mut path = BoundaryPath::external(); + path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(vertices, true))); + hatch.paths.push(path); + EntityType::Hatch(hatch) + } + other => { + return Err(failure( + "unknown_entity_type", + format!( + "Unknown entity type '{other}'. Supported: Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch" + ), + )) + } + }; + let mut entity = entity; + apply_common_properties(spec, &mut entity)?; + Ok(entity) +} + +impl OpenCADStudio { + /// `entities_create` — build a batch of typed entities, auto-create the + /// layers they name, and commit everything as one undoable step. All definitions are validated before the first + /// entity is added, so the batch is all-or-nothing. + pub(super) fn control_entities_create(&mut self, req: &Value) -> Result, Value> { + let list = req["entities"] + .as_array() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("entities_required", "Supply entities:[{type:…},…]"))?; + let built: Vec = list.iter().map(build_entity).collect::>()?; + + let i = self.active_tab; + self.push_undo_snapshot(i, "ENTITIESCREATE"); + // Auto-create missing layers (Catalog §1.4: "create layer if missing") + // after the undo snapshot, so undo removes them together with the + // entities. + let mut layers_created = Vec::new(); + { + let document = &mut self.tabs[i].scene.document; + for entity in &built { + let layer = entity.common().layer.clone(); + if layer.is_empty() || document.layers.contains(&layer) { + continue; + } + let mut layer_record = acadrust::tables::Layer::new(layer.clone()); + layer_record.handle = document.allocate_handle(); + document + .layers + .add(layer_record) + .map_err(|e| failure("layer_failed", e))?; + layers_created.push(layer); + } + } + let handles: Vec = { + let scene = &mut self.tabs[i].scene; + built + .into_iter() + .map(|entity| format!("{:X}", scene.add_entity(entity).value())) + .collect() + }; + self.post_ref_op(i); + let created = handles.len(); + self.set_control_result(json!({ + "handles": handles, + "created": created, + "layers_created": layers_created, + })); + Ok(Task::none()) + } + + /// `entities_delete` — erase by handles. Every handle is + /// checked up front; nothing is erased when one is missing. + pub(super) fn control_entities_delete(&mut self, req: &Value) -> Result, Value> { + let handles = hex_handles(req, "handles")?; + let i = self.active_tab; + require_existing(&self.tabs[i].scene.document, &handles)?; + self.push_undo_snapshot(i, "ENTITIESDELETE"); + self.tabs[i].scene.erase_entities(&handles); + self.post_ref_op(i); + let erased = handles.len(); + self.set_control_result(json!({ "erased": erased })); + Ok(Task::none()) + } + + /// `entities_transform` — move/copy/rotate/scale/mirror/array by handles + /// Angles are degrees. `copy`, mirror with + /// `"copy":true` and `array` return the newly created handles. + pub(super) fn control_entities_transform( + &mut self, + req: &Value, + ) -> Result, Value> { + let handles = hex_handles(req, "handles")?; + let action = req["action"].as_str().unwrap_or("move").to_ascii_lowercase(); + let i = self.active_tab; + require_existing(&self.tabs[i].scene.document, &handles)?; + + let transform = |req: &Value| -> Parsed { + Ok(match action.as_str() { + "move" | "copy" => EntityTransform::Translate(glam::DVec3::new( + req["vector"][0].as_f64().unwrap_or(0.0), + req["vector"][1].as_f64().unwrap_or(0.0), + req["vector"][2].as_f64().unwrap_or(0.0), + )), + "rotate" => EntityTransform::Rotate { + center: glam::DVec3::new( + req["center"][0].as_f64().unwrap_or(0.0), + req["center"][1].as_f64().unwrap_or(0.0), + req["center"][2].as_f64().unwrap_or(0.0), + ), + axis: glam::DVec3::Z, + angle_rad: deg_field(req, "angle_deg"), + }, + "scale" => { + let factor = req["factor"] + .as_f64() + .filter(|f| f.is_finite() && *f != 0.0) + .ok_or_else(|| { + failure("invalid_factor", "scale needs a nonzero finite factor") + })?; + EntityTransform::Scale { + center: glam::DVec3::new( + req["center"][0].as_f64().unwrap_or(0.0), + req["center"][1].as_f64().unwrap_or(0.0), + req["center"][2].as_f64().unwrap_or(0.0), + ), + factor, + } + } + "mirror" => { + let axis = req["axis"] + .as_array() + .filter(|v| v.len() == 2) + .ok_or_else(|| { + failure("invalid_axis", "mirror needs axis:[[x1,y1],[x2,y2]]") + })?; + let end = |k: usize| { + axis[k] + .as_array() + .filter(|v| v.len() >= 2) + .ok_or_else(|| { + failure("invalid_axis", "mirror needs axis:[[x1,y1],[x2,y2]]") + }) + }; + let (a, b) = (end(0)?, end(1)?); + let coord = |v: &[Value], k: usize| { + v[k].as_f64() + .filter(|f| f.is_finite()) + .ok_or_else(|| failure("invalid_axis", "mirror axis points must be finite")) + }; + let p1 = glam::DVec3::new(coord(&a, 0)?, coord(&a, 1)?, 0.0); + let p2 = glam::DVec3::new(coord(&b, 0)?, coord(&b, 1)?, 0.0); + if p1 == p2 { + return Err(failure( + "invalid_axis", + "mirror axis points must differ", + )); + } + EntityTransform::Mirror { + p1, + p2, + working_normal: glam::DVec3::Z, + } + } + other => { + return Err(failure( + "invalid_action", + format!( + "Unknown transform action '{other}'. Use move, copy, rotate, scale, mirror or array" + ), + )) + } + }) + }; + + self.push_undo_snapshot(i, "ENTITIESTRANSFORM"); + let scene = &mut self.tabs[i].scene; + let mut created: Vec = Vec::new(); + let affected; + match action.as_str() { + "move" | "rotate" | "scale" => { + let t = transform(req)?; + scene.transform_entities(&handles, &t); + affected = handles.len(); + } + "mirror" => { + let t = transform(req)?; + if req["copy"].as_bool().unwrap_or(false) { + created = scene + .copy_entities(&handles, &t) + .into_iter() + .map(|h| format!("{:X}", h.value())) + .collect(); + } else { + scene.transform_entities(&handles, &t); + } + affected = handles.len(); + } + "copy" => { + let t = transform(req)?; + created = scene + .copy_entities(&handles, &t) + .into_iter() + .map(|h| format!("{:X}", h.value())) + .collect(); + affected = handles.len(); + } + "array" => { + let rows = req["rows"].as_u64().unwrap_or(1).clamp(1, 100) as i32; + let columns = req["columns"].as_u64().unwrap_or(1).clamp(1, 100) as i32; + let row_spacing = req["row_spacing"].as_f64().unwrap_or(0.0); + let column_spacing = req["column_spacing"].as_f64().unwrap_or(0.0); + for row in 0..rows { + for column in 0..columns { + if row == 0 && column == 0 { + continue; + } + let t = EntityTransform::Translate(glam::DVec3::new( + column as f64 * column_spacing, + row as f64 * row_spacing, + 0.0, + )); + created.extend( + scene + .copy_entities(&handles, &t) + .into_iter() + .map(|h| format!("{:X}", h.value())), + ); + } + } + affected = handles.len(); + } + _ => unreachable!("validated by transform()"), + } + self.post_ref_op(i); + self.set_control_result(json!({ + "affected": affected, + "created": created, + })); + Ok(Task::none()) + } + + /// `xdata_set` — replace one application's extended-data record on every + /// listed handle. The APPID table entry is + /// registered implicitly; an empty (or absent) `data` list removes the + /// application's record. + pub(super) fn control_xdata_set(&mut self, req: &Value) -> Result, Value> { + let handles = hex_handles(req, "handles")?; + let app = string(req, "app")?.to_owned(); + if app.is_empty() { + return Err(failure("app_required", "Supply an application name")); + } + let values = parse_xdata_values(req)?; + let i = self.active_tab; + require_existing(&self.tabs[i].scene.document, &handles)?; + self.push_undo_snapshot(i, "XDATASET"); + { + let document = &mut self.tabs[i].scene.document; + for handle in &handles { + crate::scene::view::dispatch::set_entity_xdata( + document, + *handle, + &app, + values.clone(), + ); + } + } + let updated = handles.len(); + self.set_control_result(json!({ "updated": updated, "app": app })); + Ok(Task::none()) + } + + /// `xdata_get` — read extended data back, optionally for one application. + /// A read: dispatched through the query path, no request_id needed. + pub(super) fn xdata_read(&self, req: &Value) -> Value { + let handles = match hex_handles(req, "handles") { + Ok(handles) => handles, + Err(error) => return error, + }; + let app = req["app"].as_str().unwrap_or(""); + let i = self.active_tab; + if let Err(error) = require_existing(&self.tabs[i].scene.document, &handles) { + return error; + } + let document = &self.tabs[i].scene.document; + let items: Vec = handles + .iter() + .filter_map(|handle| { + let entity = document.get_entity(*handle)?; + let extended = &entity.common().extended_data; + let records: serde_json::Map = extended + .records() + .iter() + .filter(|record| app.is_empty() || record.application_name == app) + .map(|record| { + ( + record.application_name.clone(), + json!(record.values.iter().map(xdata_value_json).collect::>()), + ) + }) + .collect(); + Some(json!({ + "handle": format!("{:X}", handle.value()), + "xdata": records, + })) + }) + .collect(); + json!({ "ok": true, "items": items }) + } + + /// `block_define` — turn the listed entities into a new block definition + /// (block-definition semantics, as the BLOCK command: the sources become + /// the definition's content and an Insert is placed at `insert_at`, + /// defaulting to `base`, so the drawing looks unchanged). + pub(super) fn control_block_define(&mut self, req: &Value) -> Result, Value> { + let name = string(req, "name")?.to_owned(); + if name.is_empty() { + return Err(failure("name_required", "Supply a block name")); + } + let handles = hex_handles(req, "handles")?; + let base = point_field(req, "base")?; + let insert_at = if req.get("insert_at").is_some() { + point_field(req, "insert_at")? + } else { + base + }; + let i = self.active_tab; + require_existing(&self.tabs[i].scene.document, &handles)?; + + // World → block space translates `base` onto the block origin; the + // inverse places the Insert so entities keep their world position. + let world_to_block = acadrust::types::Transform::from_translation( + acadrust::types::Vector3::new(-base.x, -base.y, -base.z), + ); + let block_to_world = + acadrust::types::Transform::from_translation(insert_at); + self.push_undo_snapshot(i, "BLOCKDEFINE"); + // CF-01.3 (barcode re-import): `replace:true` drops the previous + // definition — its children, markers and inserts — inside the same + // undo step, so the new definition can take the name. + if req["replace"].as_bool().unwrap_or(false) { + erase_block_definition(&mut self.tabs[i].scene, &name); + } + let insert = self.tabs[i] + .scene + .create_block_from_entities(&handles, &name, &world_to_block, &block_to_world) + .map_err(|e| failure("block_failed", e))?; + self.post_ref_op(i); + self.set_control_result(json!({ + "block": name, + "insert": format!("{:X}", insert.value()), + })); + Ok(Task::none()) + } + + /// `block_delete` — remove a block definition together with its child + /// entities, the Block/BlockEnd markers and every Insert referencing it. + pub(super) fn control_block_delete(&mut self, req: &Value) -> Result, Value> { + let name = string(req, "name")?.to_owned(); + if name.starts_with('*') { + return Err(failure( + "block_protected", + "Layout block records cannot be deleted", + )); + } + let i = self.active_tab; + if self.tabs[i].scene.document.block_records.get(&name).is_none() { + return Err(failure( + "block_missing", + format!("Block '{name}' does not exist"), + )); + } + self.push_undo_snapshot(i, "BLOCKDELETE"); + let erased = erase_block_definition(&mut self.tabs[i].scene, &name); + self.post_ref_op(i); + self.set_control_result(json!({ "block": name, "erased": erased })); + Ok(Task::none()) + } + + /// `view_focus` — fit the listed entities into the view and highlight + /// them (Catalog §1.3). GUI sessions only; headless servers answer + /// `gui_required`. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn control_view_focus(&mut self, req: &Value) -> Result, Value> { + if self.main_window.is_none() { + return Err(failure( + "gui_required", + "view_focus needs the editor window", + )); + } + let handles = hex_handles(req, "handles")?; + let i = self.active_tab; + require_existing(&self.tabs[i].scene.document, &handles)?; + if req["highlight"].as_bool().unwrap_or(true) { + let scene = &mut self.tabs[i].scene; + scene.deselect_all(); + for handle in &handles { + scene.select_entity(*handle, false); + } + } + let zoomed = self.tabs[i].scene.zoom_to_entities(&handles); + let selected = self.tabs[i].scene.selected_entities().len(); + self.set_control_result(json!({ "zoomed": zoomed, "selected": selected })); + Ok(Task::none()) + } +} + +/// Remove one block definition from the scene: every entity owned by the +/// block record, every Insert referencing the name, the Block/BlockEnd +/// markers (which the entity iterator skips) and the table record itself. +/// Silent no-op for an unknown name (callers validate first); +/// `*`-prefixed layout records are rejected by the callers. Returns the +/// number of entities erased. +fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize { + let Some(record) = scene.document.block_records.get(name) else { + return 0; + }; + let owner = record.handle; + let block_markers = [record.block_entity_handle, record.block_end_handle]; + let mut victims: Vec = scene + .document + .entities() + .filter(|entity| { + entity.common().owner_handle == owner + || matches!( + entity, + acadrust::EntityType::Insert(insert) + if insert.block_name.eq_ignore_ascii_case(name) + ) + }) + .map(|entity| entity.common().handle) + .collect(); + victims.extend_from_slice(&block_markers); + scene.erase_entities(&victims); + scene.document.block_records.remove(name); + victims.len() +} + +/// `data:[{code,value},…]` → typed XData values; an absent or empty list +/// means "remove this application's record". +fn parse_xdata_values(req: &Value) -> Parsed>> { + use acadrust::xdata::XDataValue; + let Some(list) = req["data"].as_array() else { + return Ok(None); + }; + if list.is_empty() { + return Ok(None); + } + let mut values = Vec::with_capacity(list.len()); + for item in list { + let code = item["code"] + .as_i64() + .ok_or_else(|| failure("invalid_xdata", "data entries need an integer code"))?; + let value = &item["value"]; + let parsed = match code { + 1000 => XDataValue::String( + value.as_str().ok_or_else(|| { + failure("invalid_xdata", "code 1000 needs a string value") + })? + .to_string(), + ), + 1003 => XDataValue::LayerName( + value.as_str().ok_or_else(|| { + failure("invalid_xdata", "code 1003 needs a layer name") + })? + .to_string(), + ), + 1004 => { + let hex = value.as_str().ok_or_else(|| { + failure("invalid_xdata", "code 1004 needs a hex byte string") + })?; + ((0..hex.len()).step_by(2)) + .map(|k| { + u8::from_str_radix(&hex[k..(k + 2).min(hex.len())], 16).map_err(|_| { + failure("invalid_xdata", "code 1004 needs a valid hex byte string") + }) + }) + .collect::, Value>>() + .map(XDataValue::BinaryData)? + } + 1005 => { + let raw = value.as_str().ok_or_else(|| { + failure("invalid_xdata", "code 1005 needs a hex handle") + })?; + let raw = raw.trim_start_matches("0x").trim_start_matches("0X"); + let handle = u64::from_str_radix(raw, 16) + .map_err(|_| failure("invalid_xdata", "code 1005 needs a valid hex handle"))?; + XDataValue::Handle(acadrust::Handle::new(handle)) + } + 1010..=1013 => { + let point = value.as_array().filter(|v| !v.is_empty()).ok_or_else(|| { + failure("invalid_xdata", "codes 1010-1013 need [x,y,z]") + })?; + let coord = |k: usize| point.get(k).and_then(Value::as_f64).unwrap_or(0.0); + let point = acadrust::types::Vector3::new(coord(0), coord(1), coord(2)); + match code { + 1010 => XDataValue::Point3D(point), + 1011 => XDataValue::Position3D(point), + 1012 => XDataValue::Displacement3D(point), + _ => XDataValue::Direction3D(point), + } + } + 1040 => XDataValue::Real(value.as_f64().unwrap_or(0.0)), + 1041 => XDataValue::Distance(value.as_f64().unwrap_or(0.0)), + 1042 => XDataValue::ScaleFactor(value.as_f64().unwrap_or(0.0)), + 1070 => XDataValue::Integer16( + value + .as_i64() + .and_then(|v| i16::try_from(v).ok()) + .ok_or_else(|| failure("invalid_xdata", "code 1070 needs a 16-bit integer"))?, + ), + 1071 => XDataValue::Integer32( + value + .as_i64() + .and_then(|v| i32::try_from(v).ok()) + .ok_or_else(|| failure("invalid_xdata", "code 1071 needs a 32-bit integer"))?, + ), + other => { + return Err(failure( + "invalid_xdata", + format!( + "Unsupported xdata code {other}. Use 1000, 1003, 1004, 1005, 1010-1013, 1040, 1041, 1042, 1070 or 1071" + ), + )) + } + }; + values.push(parsed); + } + Ok(Some(values)) +} + +/// JSON shape for one stored XData value. +fn xdata_value_json(value: &acadrust::xdata::XDataValue) -> Value { + use acadrust::xdata::XDataValue; + match value { + XDataValue::String(text) + | XDataValue::ControlString(text) + | XDataValue::LayerName(text) => json!(text), + XDataValue::BinaryData(bytes) => json!({ + "binary": bytes.iter().map(|b| format!("{b:02X}")).collect::() + }), + XDataValue::Handle(handle) => json!(format!("{:X}", handle.value())), + XDataValue::Point3D(point) + | XDataValue::Position3D(point) + | XDataValue::Displacement3D(point) + | XDataValue::Direction3D(point) => json!([point.x, point.y, point.z]), + XDataValue::Real(number) + | XDataValue::Distance(number) + | XDataValue::ScaleFactor(number) => json!(number), + XDataValue::Integer16(number) => json!(number), + XDataValue::Integer32(number) => json!(number), + } +} diff --git a/src/app/control/http_bridge.rs b/src/app/control/http_bridge.rs new file mode 100644 index 000000000..1ddda8cd2 --- /dev/null +++ b/src/app/control/http_bridge.rs @@ -0,0 +1,773 @@ +//! Loopback REST channel hosted by the GUI process. `OpenCADStudio.exe +//! "" --http ` boots the normal editor AND serves this bridge, so +//! a client gets the full REST surface of `--http` — create, move, plot, +//! query, everything the headless server does — aimed at the drawing the +//! person at the screen is working on, plus the two interactive operations +//! only this process can answer (`getpoint`, `user_select`: the person +//! picks, and the HTTP connection is the thing that waits). Routes resolve +//! through the same `rest::plan` table as the headless server; requests are +//! forwarded into the GUI's automation queue (`Envelope`, exactly like the +//! native bridge in `transport`) and the JSON response is written back over +//! HTTP. The channel binds loopback only — anything that can reach the port +//! can drive the session, exactly like the headless server. + +use super::{Envelope, Reply}; +use crate::rest::{self, HttpRequest, Plan, RETRYABLE}; +use iced::futures::{channel::mpsc, Stream}; +use serde_json::{json, Value}; +use std::io::ErrorKind; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicU64, AtomicU16, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Port handed over by `main` when the GUI hosts the channel. Set before the +/// iced runtime boots, so the subscription worker sees it on first run. +/// Zero means "no channel" (plain GUI runs). +static GUI_HTTP_PORT: AtomicU16 = AtomicU16::new(0); +static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Called from `main` before the GUI boots. +pub fn set_gui_http_port(port: u16) { + GUI_HTTP_PORT.store(port, Ordering::SeqCst); +} + +pub(in crate::app) fn subscribe() -> iced::Subscription { + iced::Subscription::run(worker) +} + +fn worker() -> impl Stream { + iced::stream::channel(32, |sender| async move { + let port = GUI_HTTP_PORT.load(Ordering::SeqCst); + if port != 0 { + std::thread::spawn(move || { + if let Err(error) = listen(sender, port, Arc::new(AtomicU16::new(0))) { + eprintln!("--http (GUI): cannot serve 127.0.0.1:{port}: {error}"); + } + }); + } + iced::futures::future::pending::<()>().await; + }) +} + +fn listen( + sender: mpsc::Sender, + port: u16, + bound_port: Arc, +) -> std::io::Result<()> { + let listener = TcpListener::bind(("127.0.0.1", port))?; + bound_port.store(listener.local_addr()?.port(), Ordering::SeqCst); + eprintln!("OpenCADStudio GUI REST listening on http://127.0.0.1:{port}/api/v1"); + // One thread per connection: a `getpoint` or `user_select` request stays + // open until the person answers, and must not block the other reads + // (get_selection…). + let clients = Arc::new(AtomicUsize::new(0)); + // One document-id cache for the whole listener: a state read primes it, + // later mutations address the live drawing without the caller repeating + // the id — the same ergonomics as the headless server. + let document_cache: Arc>> = Arc::new(Mutex::new(None)); + for stream in listener.incoming().flatten() { + if clients.fetch_add(1, Ordering::SeqCst) >= 8 { + clients.fetch_sub(1, Ordering::SeqCst); + continue; + } + let clients = clients.clone(); + let sender = sender.clone(); + let document_cache = document_cache.clone(); + std::thread::spawn(move || { + let mut stream = stream; + let _ = stream.set_read_timeout(Some(Duration::from_secs(15))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(15))); + if let Ok(Some(request)) = rest::read_request(&mut stream) { + if request.method == "OPTIONS" { + let _ = rest::write_response(&mut stream, 204, &Value::Null); + } else { + let (status, body) = forward(&request, &sender, &document_cache, &stream); + let _ = rest::write_response(&mut stream, status, &body); + } + } + clients.fetch_sub(1, Ordering::SeqCst); + }); + } + Ok(()) +} + +/// How long one request may hold its connection. The interactive picks wait +/// for the person; everything else bounds a slow op (plot, open a large +/// drawing) without wedging the connection forever. +const DEFAULT_WAIT_SECS: u64 = 300; +const PICK_WAIT_SECS: u64 = 30 * 60; + +fn forward( + request: &HttpRequest, + sender: &mpsc::Sender, + document_cache: &Mutex>, + stream: &TcpStream, +) -> (u16, Value) { + let path = request.path.trim_end_matches('/'); + let path = if path.is_empty() { "/" } else { path }; + let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + if segments.first() != Some(&"api") || segments.get(1) != Some(&"v1") { + return (404, json!({"ok": false, "code": "unknown_route", "error": "Use /api/v1/…"})); + } + let plan = rest::plan(&request.method, &segments[2..], request); + // The picks park until the person answers; an `operation` call is itself + // a poll and must come straight back. + let wait = match plan.op() { + Some("getpoint" | "user_select") => PICK_WAIT_SECS, + Some("operation") | None => 15, + Some(_) => DEFAULT_WAIT_SECS, + }; + match plan { + Plan::Local(status, body) => (status, body), + Plan::Ready => { + let state = transact(sender, json!({"protocol":1,"op":"state"}), wait, stream); + harvest(document_cache, &state); + (200, json!({ + "ok": true, + "ready": true, + "version": state["version"], + "session_id": state["session_id"], + "document_id": state["document_id"], + })) + } + Plan::Run { request, created } => { + let response = transact(sender, prepare(request), wait, stream); + harvest(document_cache, &response); + (rest::map_status(response.clone(), created == 201), response) + } + Plan::Mutate { op, fields, created } => { + let cached = *document_cache.lock().unwrap(); + let mut response = transact(sender, mutate_request(&op, &fields, cached), wait, stream); + let code = response["code"].as_str().unwrap_or(""); + if response["ok"] == false && RETRYABLE.contains(&code) { + // The cache is behind the live GUI; refresh from a state read + // and retry once, exactly like the headless server. + let state = transact(sender, json!({"protocol":1,"op":"state"}), wait, stream); + harvest(document_cache, &state); + let cached = *document_cache.lock().unwrap(); + response = transact(sender, mutate_request(&op, &fields, cached), wait, stream); + } + harvest(document_cache, &response); + let status = rest::map_status(response.clone(), created == 201); + (if status == 200 { created } else { status }, compact(response)) + } + } +} + +/// One envelope round trip: queue the request into the GUI and wait up to +/// `wait_secs` for its reply. +fn call(sender: &mpsc::Sender, request: Value, wait_secs: u64) -> Option { + let (reply, response) = std::sync::mpsc::channel(); + sender + .clone() + .try_send(Envelope { + request, + reply: Reply::Native(reply), + }) + .ok()?; + response.recv_timeout(Duration::from_secs(wait_secs)).ok() +} + +/// Send one request and hold the connection for the GUI's answer. +/// Interactive requests (`getpoint`, `user_select`) and async ops answer +/// `accepted` first and only complete when the person at the screen acts or +/// the task finishes; poll the operation on the caller's behalf so the HTTP +/// connection is the one thing that waits. +/// +/// A parked pick is also watched from the other end: the moment the HTTP +/// client hangs up (its own timeout, a crash, a killed probe), the pending +/// pick is retracted with `cancel` so the next pick starts clean instead of +/// dying on `interactive_pending` until the person happens to press Esc. +fn transact( + sender: &mpsc::Sender, + request: Value, + wait: u64, + stream: &TcpStream, +) -> Value { + let op = request["op"].as_str().unwrap_or("").to_owned(); + let request_id = request["request_id"].as_str().unwrap_or("").to_owned(); + let interactive = matches!(op.as_str(), "getpoint" | "user_select"); + if interactive { + eprintln!( + "[pick] {op} {request_id}: parked -- the connection stays open until the person answers" + ); + } + let Some(mut response) = call(sender, request, wait) else { + if interactive { + eprintln!("[pick] {op} {request_id}: the GUI never accepted the request"); + } + return json!({"ok":false,"code":"response_timeout","error":"The GUI did not answer in time"}); + }; + let deadline = std::time::Instant::now() + Duration::from_secs(wait); + while matches!(response["status"].as_str(), Some("accepted" | "running")) + && std::time::Instant::now() < deadline + { + if interactive && peer_gone(stream) { + eprintln!("[pick] {op} {request_id}: client disconnected -- cancelling the pending pick"); + // The cancel reuses the pick's own request id: if the person + // answered in the race window, the settled id makes the retract + // a harmless no-op instead of an Escape into the live session. + let _ = call( + sender, + json!({"protocol":1,"op":"cancel","request_id":request_id}), + 15, + ); + return json!( + {"ok":false,"code":"client_disconnected","error":"The HTTP client hung up; the pending pick was cancelled"} + ); + } + std::thread::sleep(Duration::from_millis(100)); + let poll_id = response["request_id"].clone(); + let Some(poll) = call( + sender, + json!({"protocol":1,"op":"operation","request_id":poll_id}), + 15, + ) else { + break; + }; + response = poll; + } + if interactive { + if matches!(response["status"].as_str(), Some("accepted" | "running")) { + eprintln!( + "[pick] {op} {request_id}: no answer in {wait}s -- cancelling the pending pick" + ); + let _ = call( + sender, + json!({"protocol":1,"op":"cancel","request_id":request_id}), + 15, + ); + } else { + eprintln!( + "[pick] {op} {request_id}: settled as {}", + response["status"].as_str().unwrap_or("unknown") + ); + } + } + response +} + +/// True once the HTTP client has hung up: a graceful close surfaces as EOF, +/// an aborted one as a socket error. No bytes waiting (`WouldBlock`) means +/// the client is still there, holding its half of the parked connection. +fn peer_gone(stream: &TcpStream) -> bool { + let _ = stream.set_nonblocking(true); + let gone = match stream.peek(&mut [0u8; 1]) { + Ok(0) => true, + Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => false, + Err(_) => true, + Ok(_) => false, + }; + let _ = stream.set_nonblocking(false); + gone +} + +/// The bridge has no descriptor handshake: drop any guessed `session_id`, +/// stamp protocol 1, and give requests without one a bridge request id. +fn prepare(mut request: Value) -> Value { + if !request.is_object() { + request = json!({}); + } + if let Some(object) = request.as_object_mut() { + object.remove("session_id"); + } + request["protocol"] = json!(1); + if request["request_id"].as_str().is_none_or(str::is_empty) { + let serial = REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst); + request["request_id"] = json!(format!("gui-http-{serial}")); + } + request +} + +/// Build the mutation envelope: a request id (the caller's wins), the cached +/// `document_id`, then the body fields on top. +fn mutate_request(op: &str, fields: &Value, document_id: Option) -> Value { + let mut envelope = json!({"protocol":1,"op":op}); + if let Some(id) = document_id { + envelope["document_id"] = json!(id); + } + if let Some(object) = fields.as_object() { + for (key, value) in object { + envelope[key] = value.clone(); + } + } + if envelope["request_id"].as_str().is_none_or(str::is_empty) { + let serial = REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst); + envelope["request_id"] = json!(format!("gui-http-{serial}")); + } + if let Some(object) = envelope.as_object_mut() { + object.remove("session_id"); + } + envelope +} + +/// Track the active document from responses (state reads and settle +/// snapshots both carry it) so later mutations address the live drawing. +fn harvest(document_cache: &Mutex>, response: &Value) { + let id = response + .get("document_id") + .and_then(Value::as_u64) + .or_else(|| response["state"]["document_id"].as_u64()); + if let Some(id) = id { + *document_cache.lock().unwrap() = Some(id); + } +} + +/// Drop the bulky `state` snapshot from mutation answers, like the headless +/// server; clients read it explicitly from GET /state. +fn compact(response: Value) -> Value { + if let Some(object) = response.as_object() { + let trimmed: serde_json::Map = object + .iter() + .filter(|(key, _)| key.as_str() != "state") + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + return Value::Object(trimmed); + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::OpenCADStudio; + use iced::futures::StreamExt; + use std::io::{BufRead, BufReader, Read, Write as _}; + + /// Each test owns its port holder: two bridge tests running in parallel + /// must never read each other's bound port. + fn wait_for_bridge(bound_port: &AtomicU16) -> u16 { + for _ in 0..50 { + let port = bound_port.load(Ordering::SeqCst); + if port != 0 { + return port; + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("GUI http bridge never bound a port"); + } + + /// Post one request without waiting; the bridge only answers once the + /// GUI side has replied to the forwarded envelope. + fn post(port: u16, target: &str, body: &str) -> std::net::TcpStream { + let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); + let http = format!( + "POST {target} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(http.as_bytes()).unwrap(); + stream + } + + /// Read one HTTP response (status + JSON body) off the wire. + fn read_response(stream: &mut std::net::TcpStream) -> (u16, Value) { + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + let mut content_length = 0usize; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + if status_line.is_empty() { + status_line = line.clone(); + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = value.trim().parse().unwrap(); + } + } + let mut payload = vec![0u8; content_length]; + reader.read_exact(&mut payload).unwrap(); + let status = status_line + .split_whitespace() + .nth(1) + .and_then(|code| code.parse().ok()) + .unwrap(); + (status, serde_json::from_slice(&payload).unwrap()) + } + + #[test] + fn bridge_serves_get_selection_over_http_and_refuses_unknown_paths() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"})); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"LINE","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + let value = u64::from_str_radix(&handle, 16).unwrap(); + app.tabs[app.active_tab] + .scene + .select_entity(acadrust::Handle::new(value), false); + + let (sender, mut receiver) = mpsc::channel::(8); + let bound = Arc::new(AtomicU16::new(0)); + let listen_bound = bound.clone(); + std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap()); + let port = wait_for_bridge(&bound); + + // Read the selection back over loopback HTTP the way the client does. + let mut stream = post( + port, + "/api/v1/get_selection", + r#"{"session_id":"guessed","request_id":"bridge-1"}"#, + ); + // The bridge forwarded the envelope; this test plays the GUI and + // answers it, which is what unblocks the HTTP response. + let envelope = iced::futures::executor::block_on(receiver.next()).unwrap(); + assert_eq!(envelope.request["op"], "get_selection"); + assert!(envelope.request["session_id"].is_null()); + assert_eq!(envelope.request["request_id"], "bridge-1"); + let reply = app.control_request(envelope.request).0; + envelope.reply.send(reply); + + let (status, body) = read_response(&mut stream); + assert_eq!(status, 200); + assert_eq!(body["ok"], true, "{body}"); + assert_eq!(body["status"], "completed"); + assert_eq!(body["result"]["count"], 1); + assert_eq!(body["result"]["entities"][0]["handle"], handle.as_str()); + assert!(body["result"]["entities"][0]["bounds"].is_object(), "{body}"); + assert_eq!(body["result"]["entities"][0]["type"], "Line"); + + // Unrouted paths are refused locally; the GUI is never woken for + // them. (Unknown POSTs are op attempts and DO reach the pipeline, + // same as the headless server.) + let mut stream = get(port, "/api/v1/nope"); + let (status, body) = read_response(&mut stream); + assert_eq!(status, 404); + assert_eq!(body["code"], "unknown_route", "{body}"); + } + + /// GET without a body — the read half of the REST surface. + fn get(port: u16, target: &str) -> std::net::TcpStream { + let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); + let http = format!("GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + stream.write_all(http.as_bytes()).unwrap(); + stream + } + + /// Answer the next `count` envelopes the way the live GUI would. + fn drain(app: &mut OpenCADStudio, receiver: &mut mpsc::Receiver, count: usize) { + for _ in 0..count { + let envelope = iced::futures::executor::block_on(receiver.next()).unwrap(); + let reply = app.control_request(envelope.request.clone()).0; + envelope.reply.send(reply); + } + } + + #[test] + fn bridge_parks_getpoint_without_blocking_other_reads() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"})); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"LINE","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + let value = u64::from_str_radix(&handle, 16).unwrap(); + app.tabs[app.active_tab] + .scene + .select_entity(acadrust::Handle::new(value), false); + + let (sender, mut receiver) = mpsc::channel::(8); + let bound = Arc::new(AtomicU16::new(0)); + let listen_bound = bound.clone(); + std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap()); + let port = wait_for_bridge(&bound); + + // The client asks for a point and holds that connection open. + let mut point_conn = post( + port, + "/api/v1/getpoint", + r#"{"request_id":"gp-b","prompt":"Chọn vị trí QR"}"#, + ); + // While the pick is pending, a second connection reads the selection + // — thread-per-connection keeps the parked request from blocking it. + let mut sel_conn = post(port, "/api/v1/get_selection", "{}"); + let mut polls = 0usize; + let (point_status, point_body, sel_body) = loop { + let envelope = iced::futures::executor::block_on(receiver.next()).unwrap(); + let reply = app.control_request(envelope.request.clone()).0; + if envelope.request["op"] == "getpoint" { + // The GUI accepted the pick; the bridge now polls `operation` + // until the person answers. Simulate the click right away. + assert_eq!(reply["status"], "accepted", "{reply}"); + envelope.reply.send(reply); + app.tabs[app.active_tab].last_cursor_world = + glam::DVec3::new(1.5, 2.5, 0.0); + let _ = app.update(crate::app::Message::ViewportLeftPress); + } else if envelope.request["op"] == "get_selection" { + envelope.reply.send(reply); + let (status, body) = read_response(&mut sel_conn); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["result"]["count"], 1); + assert_eq!(body["result"]["entities"][0]["handle"], handle.as_str()); + } else { + // An `operation` poll from the parked getpoint connection. + polls += 1; + assert!(polls < 100, "getpoint never completed: {reply}"); + envelope.reply.send(reply.clone()); + if reply["status"] == "completed" { + let point_status = read_response(&mut point_conn); + break (point_status.0, point_status.1, Value::Null); + } + } + }; + assert_eq!(point_status, 200, "{point_body}"); + assert_eq!(point_body["status"], "completed", "{point_body}"); + assert_eq!(point_body["result"]["point"], json!([1.5, 2.5, 0.0])); + assert!(sel_body.is_null()); + } + + /// The client stops waiting mid-pick (its own timeout, a killed probe): + /// the bridge must notice the hang-up, retract the pending pick with + /// `cancel`, and leave the channel free for the next pick — instead of + /// holding the session until the person happens to press Esc. + #[test] + fn bridge_cancels_a_parked_pick_when_the_client_disconnects() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"})); + + let (sender, mut receiver) = mpsc::channel::(8); + let bound = Arc::new(AtomicU16::new(0)); + let listen_bound = bound.clone(); + std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap()); + let port = wait_for_bridge(&bound); + + // The client parks a pick… + let point_conn = post(port, "/api/v1/getpoint", r#"{"request_id":"gp-drop"}"#); + let envelope = iced::futures::executor::block_on(receiver.next()).unwrap(); + assert_eq!(envelope.request["op"], "getpoint"); + let reply = app.control_request(envelope.request.clone()).0; + assert_eq!(reply["status"], "accepted", "{reply}"); + envelope.reply.send(reply); + + // …then gives up: the connection closes while the person is + // presumably still deciding. + drop(point_conn); + + // The bridge notices and retracts the pick; `operation` polls that + // slip in first are answered as `running` like the GUI would. + let mut polls = 0usize; + loop { + let envelope = iced::futures::executor::block_on(receiver.next()).unwrap(); + if envelope.request["op"] == "cancel" { + assert_eq!(envelope.request["request_id"], "gp-drop"); + let reply = app.control_request(envelope.request).0; + assert_eq!(reply["status"], "cancelled", "{reply}"); + assert_eq!(reply["result"]["cancelled"], true, "{reply}"); + break; + } + assert_eq!(envelope.request["op"], "operation", "unexpected envelope"); + polls += 1; + assert!(polls < 100, "the disconnect was never noticed"); + let reply = app.control_request(envelope.request.clone()).0; + envelope.reply.send(reply); + } + + // The channel is free: the next pick is accepted right away instead + // of dying on `interactive_pending`. Answer it with a click so the + // test leaves no session behind. + let mut next_conn = post(port, "/api/v1/getpoint", r#"{"request_id":"gp-next"}"#); + let envelope = iced::futures::executor::block_on(receiver.next()).unwrap(); + assert_eq!(envelope.request["op"], "getpoint"); + let reply = app.control_request(envelope.request).0; + assert_eq!(reply["status"], "accepted", "{reply}"); + envelope.reply.send(reply); + app.tabs[app.active_tab].last_cursor_world = glam::DVec3::new(2.0, 3.0, 0.0); + let _ = app.update(crate::app::Message::ViewportLeftPress); + let mut polls = 0usize; + let (_, body) = loop { + let envelope = iced::futures::executor::block_on(receiver.next()).unwrap(); + let reply = app.control_request(envelope.request.clone()).0; + envelope.reply.send(reply.clone()); + polls += 1; + assert!(polls < 100, "the retry pick never completed: {reply}"); + if reply["status"] == "completed" { + break read_response(&mut next_conn); + } + }; + assert_eq!(body["status"], "completed", "{body}"); + assert_eq!(body["result"]["point"], json!([2.0, 3.0, 0.0])); + } + + #[test] + fn bridge_parks_user_select_until_the_person_answers() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"})); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"LINE","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + + let (sender, mut receiver) = mpsc::channel::(8); + let bound = Arc::new(AtomicU16::new(0)); + let listen_bound = bound.clone(); + std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap()); + let port = wait_for_bridge(&bound); + + // Park one `user_select` POST; the test plays the person at the + // screen: accept, pick, then press Enter (confirm) or Escape + // (cancel), and read the final HTTP answer the client receives. + fn park_and_answer( + app: &mut OpenCADStudio, + receiver: &mut mpsc::Receiver, + port: u16, + body: &str, + confirm: bool, + handle: &str, + ) -> (u16, Value) { + let mut conn = post(port, "/api/v1/user_select", body); + let handle_value = u64::from_str_radix(handle, 16).unwrap(); + let mut polls = 0usize; + loop { + let envelope = iced::futures::executor::block_on(receiver.next()).unwrap(); + let reply = app.control_request(envelope.request.clone()).0; + if envelope.request["op"] == "user_select" { + // The GUI accepted the pick; the bridge now polls + // `operation` until the person answers. + assert_eq!(reply["status"], "accepted", "{reply}"); + envelope.reply.send(reply); + app.tabs[app.active_tab] + .scene + .select_entity(acadrust::Handle::new(handle_value), false); + let _ = app.update(if confirm { + crate::app::Message::CommandFinalize + } else { + crate::app::Message::CommandEscape + }); + } else { + // An `operation` poll from the parked user_select + // connection: relay it until it settles. + polls += 1; + assert!(polls < 100, "user_select never settled: {reply}"); + envelope.reply.send(reply.clone()); + if reply["status"] != "running" { + return read_response(&mut conn); + } + } + } + } + + // Enter after picking the line hands the picked set back with full + // entity data, the same contract the MCP/native callers see. + let (status, body) = park_and_answer( + &mut app, + &mut receiver, + port, + r#"{"request_id":"us-http","type":"LINE","prompt":"Chọn đối tượng mẫu","detail":"full","clear":true}"#, + true, + &handle, + ); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "completed", "{body}"); + assert_eq!(body["result"]["cancelled"], false); + assert_eq!(body["result"]["count"], 1); + assert_eq!(body["result"]["handles"][0], json!(handle)); + assert_eq!(body["result"]["entities"][0]["handle"], handle.as_str()); + assert_eq!(body["result"]["entities"][0]["type"], "Line"); + assert_eq!(body["result"]["entities"][0]["layer"], "0"); + + // Escape answers with the cancelled contract over the same route. + let (status, body) = park_and_answer( + &mut app, + &mut receiver, + port, + r#"{"request_id":"us-http-esc","prompt":"Chọn lại"}"#, + false, + &handle, + ); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "cancelled", "{body}"); + assert_eq!(body["result"]["cancelled"], true); + assert_eq!(body["result"]["count"], 0); + } + + #[test] + fn bridge_runs_the_full_rest_surface_against_the_live_gui() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"})); + + let (sender, mut receiver) = mpsc::channel::(8); + let bound = Arc::new(AtomicU16::new(0)); + let listen_bound = bound.clone(); + std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap()); + let port = wait_for_bridge(&bound); + + // GET /state primes the document-id cache; the mutations below carry + // no document_id of their own. + let mut state_conn = get(port, "/api/v1/state"); + drain(&mut app, &mut receiver, 1); + let (status, body) = read_response(&mut state_conn); + assert_eq!(status, 200, "{body}"); + assert!(body["document_id"].is_u64(), "{body}"); + + // CREATE through the headless REST URL: 201 with handles, the cached + // document id addressed for us. + let mut create_conn = post( + port, + "/api/v1/entities", + r#"{"entities":[{"type":"Line","start":[0,0],"end":[10,0]}]}"#, + ); + drain(&mut app, &mut receiver, 1); + let (status, body) = read_response(&mut create_conn); + assert_eq!(status, 201, "{body}"); + let handle = body["result"]["handles"][0].as_str().unwrap().to_owned(); + + // MOVE it through the same URL the headless server serves. + let mut move_conn = post( + port, + "/api/v1/entities/transform", + &format!(r#"{{"handles":["{handle}"],"action":"move","vector":[5,0]}}"#), + ); + drain(&mut app, &mut receiver, 1); + let (status, body) = read_response(&mut move_conn); + assert_eq!(status, 200, "{body}"); + let mut query_conn = get(port, "/api/v1/entities?type=Line&detail=full"); + drain(&mut app, &mut receiver, 1); + let (_, body) = read_response(&mut query_conn); + assert_eq!(body["entities"][0]["start"], json!([5.0, 0.0, 0.0]), "{body}"); + + // A stale cache (the person switched documents) costs one state + // refresh and a retry, not an error. + let fresh = app.push_test_document(); + let _ = app.control_request( + json!({"protocol":1,"op":"activate","document_id":fresh,"request_id":"a1"}), + ); + let mut stale_conn = post( + port, + "/api/v1/entities", + r#"{"entities":[{"type":"Circle","center":[0,0],"radius":1}]}"#, + ); + // mutate refused (stale) → state read → retry succeeds. + drain(&mut app, &mut receiver, 3); + let (status, body) = read_response(&mut stale_conn); + assert_eq!(status, 201, "{body}"); + + // The plain op passthrough works too — MCP-style, explicit + // document_id, any op the automation surface knows. `select` + // settles inline; `run` would park the connection on `operation` + // polls until the live editor's update loop finishes the command. + let mut select_conn = post( + port, + "/api/v1/select", + &format!(r#"{{"document_id":{fresh},"clear":true}}"#), + ); + drain(&mut app, &mut receiver, 1); + let (status, body) = read_response(&mut select_conn); + assert_eq!(status, 200, "{body}"); + } +} diff --git a/src/app/control/interactive.rs b/src/app/control/interactive.rs new file mode 100644 index 000000000..a5b2a79af --- /dev/null +++ b/src/app/control/interactive.rs @@ -0,0 +1,407 @@ +//! Interactive user-request operations — selection and point requests +//! answered by the person at the screen. A client asks the +//! person at the screen to pick entities; the operation stays pending +//! (`running` for pollers) until that person answers with Enter or Escape, +//! then resolves with the picked entities' full data. + +use super::*; +use crate::app::automation::{entity_json, entity_type_matches}; + +impl OpenCADStudio { + /// `user_select` — hand the screen to the person at the desk: they pick + /// entities (optionally filtered by type / layer), Enter confirms, Escape + /// cancels. The operation resolves only after that answer, so callers + /// must treat `running` as "still waiting for the user", not as a hang. + pub(super) fn control_user_select(&mut self, req: &Value) -> Result, Value> { + let i = self.active_tab; + if self.tabs[i].is_start { + return Err(failure("no_document", "Create or open a drawing first")); + } + // Enter and Escape are the answer keys; a live command or dialog owns + // them, so refuse rather than race the user's keystrokes. + if self.tabs[i].active_cmd.is_some() || self.active_modal.is_some() { + return Err(failure( + "user_input_busy", + "The user is already interacting with a command or dialog", + )); + } + if self.main_window.is_none() { + return Err(failure( + "gui_required", + "user_select needs the desktop GUI where a person can answer", + )); + } + if self.control.get_point.is_some() || self.control.user_select.is_some() { + return Err(failure( + "interactive_pending", + "Another interactive request is already waiting for the user", + )); + } + let detail = req["detail"].as_str().unwrap_or("full"); + if !matches!(detail, "summary" | "geometry" | "full") { + return Err(failure( + "invalid_detail", + "detail must be summary, geometry or full", + )); + } + let type_filter = req["type"] + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let layer_filter = req["layer"] + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + // Selection-set semantics: the request starts a fresh pick unless the + // caller asks to keep what is already selected. + if req["clear"].as_bool().unwrap_or(true) { + self.tabs[i].scene.deselect_all(); + } + let prompt = req["prompt"] + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| { + crate::t!("Select objects, then press Enter to finish (Esc cancels).") + .into_owned() + }); + // Label the request on screen: the message list must say WHO is + // waiting and for what, and the line stays pinned (non-fading) for + // as long as the request is open — see `pending_pick_label`. + let request_id = req["request_id"] + .as_str() + .unwrap_or_default() + .to_owned(); + let label = crate::tf!( + "[user_select · {}] {} ({})", + if request_id.is_empty() { "client" } else { &request_id }, + prompt, + crate::t!("Enter confirms, Esc cancels"), + ) + .into_owned(); + self.command_line.push_info(&label); + eprintln!( + "[pick] user_select {}: start -- waiting for the person (Enter confirms, Esc cancels)", + display_id(&request_id) + ); + self.control.user_select = Some(UserSelectSession { + request_id, + document_id: self.tabs[i].id, + type_filter, + layer_filter, + detail: detail.to_owned(), + label, + }); + Ok(Task::none()) + } + + /// Settle the pending request once the user answered. `confirmed` is the + /// Enter branch (selection read + filter validation); Escape resolves + /// immediately with `cancelled`. While the session lives the operation + /// stays off the settle path (its `pending` counter is held at one), so + /// pollers see `running` for as long as the person keeps working. + pub(in crate::app) fn resolve_user_select(&mut self, confirmed: bool) { + let Some(session) = self.control.user_select.take() else { + return; + }; + if !confirmed { + self.command_line + .push_info(crate::t!("*Cancel*").as_ref()); + self.command_line.push_info( + crate::tf!( + "user_select {}: the request was answered with no objects.", + session.request_id + ) + .as_ref(), + ); + eprintln!( + "[pick] user_select {}: the person pressed Esc -- cancelled", + display_id(&session.request_id) + ); + self.finish_interactive( + "user_select", + &session.request_id, + true, + json!({"cancelled": true, "count": 0, "handles": [], "entities": []}), + ); + return; + } + let index = self + .tabs + .iter() + .position(|tab| tab.id == session.document_id); + let Some(i) = index else { + eprintln!( + "[pick] user_select {}: document closed -- cancelled", + display_id(&session.request_id) + ); + self.finish_interactive( + "user_select", + &session.request_id, + true, + json!({"cancelled": true, "reason": "document_closed", "count": 0, "handles": [], "entities": []}), + ); + return; + }; + // The filter only gates the answer, not the user's gestures: anything + // that does not match is deselected (and reported) at confirm time. + let picked = self.tabs[i].scene.selected_handles_in_order(); + let mut handles: Vec = Vec::new(); + let mut ignored = 0usize; + for handle in picked { + let matches = self.tabs[i] + .scene + .document + .get_entity(handle) + .is_some_and(|entity| { + session + .type_filter + .as_deref() + .is_none_or(|filter| entity_type_matches(entity, filter)) + && session + .layer_filter + .as_deref() + .is_none_or(|filter| entity.common().layer == filter) + }); + if matches { + handles.push(handle); + } else { + ignored += 1; + self.tabs[i].scene.deselect_entity(handle); + } + } + if ignored > 0 { + self.command_line.push_info( + crate::tf!( + "{} object(s) ignored: outside the requested type/layer filter.", + ignored + ) + .as_ref(), + ); + } + let stored: Vec = handles + .iter() + .map(|handle| format!("{:X}", handle.value())) + .collect(); + let entities: Vec = handles + .iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .map(|entity| entity_json(entity, &session.detail)) + }) + .collect(); + let count = entities.len(); + self.command_line.push_info( + crate::tf!( + "user_select {}: handed {} object(s) to the client.", + session.request_id, + count + ) + .as_ref(), + ); + eprintln!( + "[pick] user_select {}: the person confirmed -- {} object(s) handed over", + display_id(&session.request_id), + count + ); + self.finish_interactive( + "user_select", + &session.request_id, + false, + json!({ + "cancelled": false, + "count": count, + "ignored": ignored, + "handles": stored, + "entities": entities, + }), + ); + } + + /// `getpoint` — ask the person at the screen to pick one point. The next + /// left-click in the pinned document's viewport becomes the answer (the + /// same snapped world point a command would receive); Escape cancels. + /// The operation stays `running` until then, so callers may hold the + /// HTTP request open for as long as the person takes. + pub(super) fn control_getpoint(&mut self, req: &Value) -> Result, Value> { + let i = self.active_tab; + if self.tabs[i].is_start { + return Err(failure("no_document", "Create or open a drawing first")); + } + if self.tabs[i].active_cmd.is_some() || self.active_modal.is_some() { + return Err(failure( + "user_input_busy", + "The user is already interacting with a command or dialog", + )); + } + if self.control.get_point.is_some() || self.control.user_select.is_some() { + return Err(failure( + "interactive_pending", + "Another interactive request is already waiting for the user", + )); + } + if self.main_window.is_none() { + return Err(failure( + "gui_required", + "getpoint needs the desktop GUI where a person can answer", + )); + } + let prompt = req["prompt"] + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| crate::t!("Pick a point, then click (Esc cancels).").into_owned()); + let request_id = req["request_id"] + .as_str() + .unwrap_or_default() + .to_owned(); + let label = crate::tf!( + "[getpoint · {}] {} ({})", + if request_id.is_empty() { "client" } else { &request_id }, + prompt, + crate::t!("Click confirms, Esc cancels"), + ) + .into_owned(); + self.command_line.push_info(&label); + eprintln!( + "[pick] getpoint {}: start -- waiting for the person (Click confirms, Esc cancels)", + display_id(&request_id) + ); + self.control.get_point = Some(UserPointSession { + request_id, + document_id: self.tabs[i].id, + label, + }); + Ok(Task::none()) + } + + /// Settle a `getpoint` request: `Some(point)` is the picked answer, + /// `None` the Escape (or tab-close) cancellation. + pub(in crate::app) fn resolve_get_point(&mut self, point: Option<[f64; 3]>) { + let Some(session) = self.control.get_point.take() else { + return; + }; + // Drop the snap marker the pending pick was showing — no command is + // running, so nothing else would clear it after the session ends. + if let Some(i) = self.tabs.iter().position(|tab| tab.id == session.document_id) { + self.tabs[i].snap_result = None; + } + let (cancelled, result) = match point { + Some(point) => (false, json!({"point": point})), + None => (true, json!({"cancelled": true})), + }; + if cancelled { + self.command_line.push_info(crate::t!("*Cancel*").as_ref()); + } + self.command_line.push_info( + crate::tf!( + "getpoint {}: {}", + session.request_id, + match point { + Some(point) => format!("{point:?}"), + None => crate::t!("no point — the request was cancelled.").into_owned(), + } + ) + .as_ref(), + ); + match point { + Some(point) => eprintln!( + "[pick] getpoint {}: the person picked {:?}", + display_id(&session.request_id), + point + ), + None => eprintln!( + "[pick] getpoint {}: the person pressed Esc -- cancelled", + display_id(&session.request_id) + ), + } + self.finish_interactive("getpoint", &session.request_id, cancelled, result); + } + + /// The pinned command-line label of a pending client pick (`user_select` + /// or `getpoint`). The end-of-update driver feeds this to + /// `CommandLine::set_step_prompt` so the request line stays on screen — + /// pinned, non-fading — until the person answers, then it un-pins. + pub(in crate::app) fn pending_pick_label(&self) -> Option { + self.control + .user_select + .as_ref() + .map(|session| session.label.clone()) + .or_else(|| self.control.get_point.as_ref().map(|s| s.label.clone())) + } + + /// Stamp an interactive answer into the pending operation and let it + /// settle. `cancelled` flips the final status to "cancelled". The + /// request_id + op check keeps a stale answer from writing into someone + /// else's result slot. + fn finish_interactive( + &mut self, + op: &str, + request_id: &str, + cancelled: bool, + result: Value, + ) { + if let Some(pending) = self + .control + .pending + .as_mut() + .filter(|p| p.id == request_id && p.request["op"] == op) + { + pending.result = result; + pending.cancelled = cancelled; + pending.pending = 0; + } + self.control_settle(); + } + + /// `get_selection` — read-only snapshot of the active tab's selection + /// (the read-back half of the `user_select` sampling flow). `handle`, + /// `type`, `layer` and `bounds` come straight from the `query` output so + /// clients share one parser; `text`/`value` expose the stripped and raw + /// strings for TEXT/MTEXT, and `block`/`position` identify an INSERT's + /// block definition and insertion point (null otherwise). Reads + /// `scene.selection` and never mutates it. + pub(in crate::app) fn control_get_selection(&self) -> Value { + let scene = &self.tabs[self.active_tab].scene; + let entities: Vec = scene + .selected_handles_in_order() + .iter() + .filter_map(|handle| { + scene.document.get_entity(*handle).map(|entity| { + let full = entity_json(entity, "full"); + json!({ + "handle": full["handle"], + "type": full["type"], + "layer": full["layer"], + "bounds": full["bounds"], + "text": full.get("text").cloned().unwrap_or(Value::Null), + "value": full.get("value").cloned().unwrap_or(Value::Null), + "block": full.get("block").cloned().unwrap_or(Value::Null), + "position": full.get("position").cloned().unwrap_or(Value::Null), + }) + }) + }) + .collect(); + json!({ + "ok": true, + "status": "completed", + "result": {"count": entities.len(), "entities": entities}, + }) + } +} + +/// Stable diagnostic name for a request that arrived without an id, shared +/// by the on-screen label and the stderr `[pick]` lifecycle lines so the +/// client's own log can be cross-referenced line for line. +fn display_id(request_id: &str) -> &str { + if request_id.is_empty() { + "client" + } else { + request_id + } +} diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 40f5bb591..a1e2e6c64 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -109,6 +109,38 @@ pub(super) struct State { serial: u64, events: VecDeque, pub(super) routing: bool, + /// Session-scoped named handle sets for selection_set_save/load. + selection_sets: std::collections::BTreeMap>, + /// Live `user_select` request — the client asked the person at the screen + /// to pick entities; Some until that person answers with Enter/Escape. + pub(super) user_select: Option, + /// Live `getpoint` request — the client asked for one picked point; Some + /// until the person clicks (answer) or presses Escape (cancel). + pub(super) get_point: Option, +} + +/// The pending half of an interactive `user_select` operation (`interactive` +/// module owns the flow): which request it answers, which document's +/// selection to read, and the filter the answer must satisfy. +pub(super) struct UserSelectSession { + pub(super) request_id: String, + pub(super) document_id: u64, + pub(super) type_filter: Option, + pub(super) layer_filter: Option, + pub(super) detail: String, + /// The pinned command-line line shown while the person picks — the + /// prompt plus the request identity, so the screen keeps saying who is + /// waiting for what until they answer. + pub(super) label: String, +} + +/// The pending half of a `getpoint` operation: which request it answers and +/// which document's viewport the pick must land in. +pub(super) struct UserPointSession { + pub(super) request_id: String, + pub(super) document_id: u64, + /// Same pinned line as `UserSelectSession::label`. + pub(super) label: String, } impl State { pub(super) fn new() -> Self { @@ -127,6 +159,9 @@ struct Operation { origin_document: u64, geometry_revision: u64, result: Value, + /// Set when an interactive request (`getpoint`/`user_select`) was answered + /// with a cancellation, so the final status reports "cancelled". + cancelled: bool, } fn failure(code: &str, error: impl ToString) -> Value { json!({"ok":false,"status":"failed","code":code,"error":error.to_string()}) @@ -394,6 +429,7 @@ impl OpenCADStudio { json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"), "mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled, "document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation, + "hand_seed":format!("{:X}",tab.scene.document.header.handle_seed.max(tab.scene.document.next_handle())), "plugins":plugin_ids(), "documents":self.tabs.iter().map(|t|json!({"id":t.id,"title":t.tab_title,"path":t.current_path,"dirty":t.dirty,"revision":t.edit_revision,"start":t.is_start})).collect::>(), "selection":tab.scene.selected_handles_in_order().iter().map(|h|format!("{:X}",h.value())).collect::>(), @@ -406,7 +442,7 @@ impl OpenCADStudio { "mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})), "text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial, "operation":self.control.pending.as_ref().map(|p| &p.id), - "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] + "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","user_select","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] }) } @@ -429,6 +465,8 @@ impl OpenCADStudio { | "layers" | "header" | "history" + | "xdata_get" + | "get_selection" ); if !query && !self.control.enabled { return ( @@ -576,12 +614,38 @@ impl OpenCADStudio { ); } if let Some(p) = &self.control.pending { - return ( - if p.id == id && p.request == req { - json!({"ok":true,"status":"running","request_id":id}) + if p.id == id && p.request == req { + return ( + json!({"ok":true,"status":"running","request_id":id}), + Task::none(), + ); + } + // An interactive request (`user_select`/`getpoint`) outlives + // normal request handling — it only ends when the person + // answers. Let a client retract it with `cancel` instead of + // waiting for their Escape. + let pending_op = p.request["op"].as_str().unwrap_or("").to_owned(); + let pending_id = p.id.clone(); + if op == "cancel" && matches!(pending_op.as_str(), "user_select" | "getpoint") { + if pending_op == "getpoint" { + self.resolve_get_point(None); } else { - failure("busy", "Wait for the running operation") - }, + self.resolve_user_select(false); + } + let response = self + .control + .completed + .iter() + .find(|(i, _, _)| *i == pending_id) + .map(|(_, _, r)| r.clone()); + return ( + response + .unwrap_or_else(|| failure("busy", "Wait for the running operation")), + Task::none(), + ); + } + return ( + failure("busy", "Wait for the running operation"), Task::none(), ); } @@ -594,7 +658,9 @@ impl OpenCADStudio { Task::none(), ); } - if self.tabs[self.active_tab].id != id && op != "activate" { + if self.tabs[self.active_tab].id != id + && !matches!(op, "activate" | "close" | "entities_copy_to") + { return ( failure( "document_not_active", @@ -603,12 +669,19 @@ impl OpenCADStudio { Task::none(), ); } - } else if !query && !matches!(op, "new" | "open" | "stop") { - return ( - failure("document_required", "Read state and supply document_id"), - Task::none(), - ); - } + // Interactive requests operate on the active tab by definition, so + // a client may omit document_id (the GUI-hosted REST channel does). + } else if !query + && !matches!( + op, + "new" | "open" | "stop" | "user_select" | "getpoint" + ) + { + return ( + failure("document_required", "Read state and supply document_id"), + Task::none(), + ); + } let tab = &self.tabs[self.active_tab]; if req["revision"] .as_u64() @@ -629,6 +702,8 @@ impl OpenCADStudio { let response = match op { "properties" => self.control_properties(), "measure" => self.control_measure(&req), + "xdata_get" => self.xdata_read(&req), + "get_selection" => self.control_get_selection(), "history" => { json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::>()}) } @@ -684,6 +759,7 @@ impl OpenCADStudio { origin_document: tab.id, geometry_revision: tab.scene.geometry_epoch, result: json!({}), + cancelled: false, }); self.control.routing = true; let action = self.control_action(&req); @@ -712,7 +788,12 @@ impl OpenCADStudio { self.finish_all_pending_history(); let task = self.control_track(task); if let Some(p) = self.control.pending.as_mut() { - p.pending -= 1; + // Interactive requests hold their slot open on purpose: the + // counter stays at one so settle keeps returning `running` until + // the person at the screen answers and the resolver zeroes it. + if !matches!(p.request["op"].as_str(), Some("user_select" | "getpoint")) { + p.pending -= 1; + } } self.control_settle(); let response = self @@ -728,7 +809,23 @@ impl OpenCADStudio { fn control_action(&mut self, req: &Value) -> Result, Value> { let i = self.active_tab; Ok(match req["op"].as_str().unwrap_or("") { - "new" => self.update(Message::TabNew), + "new" => { + if let Some(template) = req["template"].as_str() { + match self.apply_template(template) { + Ok(purged) => { + self.set_control_result(json!({ + "template": template, + "purged": purged, + "total": self.tabs[i].scene.document.entities().count(), + })); + Task::none() + } + Err(error) => return Err(error), + } + } else { + self.update(Message::TabNew) + } + } "open" if req.get("data_base64").is_some() => { let (name, bytes) = open_bytes_request(req)?; #[cfg(target_arch = "wasm32")] @@ -846,6 +943,29 @@ impl OpenCADStudio { #[cfg(not(target_arch = "wasm32"))] "embed_image" => self.control_embed_image(req)?, #[cfg(not(target_arch = "wasm32"))] + "wblock" => self.control_wblock(req)?, + #[cfg(not(target_arch = "wasm32"))] + "plot" => self.control_plot(req)?, + "entities_create" => self.control_entities_create(req)?, + "entities_delete" => self.control_entities_delete(req)?, + "entities_transform" => self.control_entities_transform(req)?, + "entities_copy_to" => self.control_entities_copy_to(req)?, + "xdata_set" => self.control_xdata_set(req)?, + "block_define" => self.control_block_define(req)?, + "block_delete" => self.control_block_delete(req)?, + "group_create" => self.control_group_create(req)?, + "selection_set_save" => self.control_selection_set_save(req)?, + "selection_set_load" => self.control_selection_set_load(req)?, + "user_select" => self.control_user_select(req)?, + "getpoint" => self.control_getpoint(req)?, + "close" => self.control_close(req)?, + "sysvar" => self.control_sysvar(req)?, + "layout_create" => self.control_layout_create(req)?, + "page_setup_set" => self.control_page_setup_set(req)?, + "file_identity" => self.control_file_identity(req)?, + #[cfg(not(target_arch = "wasm32"))] + "view_focus" => self.control_view_focus(req)?, + #[cfg(not(target_arch = "wasm32"))] "save" => { let path = req["path"] .as_str() @@ -1000,7 +1120,7 @@ impl OpenCADStudio { } let status = if failed { "failed" - } else if p.request["op"] == "cancel" { + } else if p.cancelled || p.request["op"] == "cancel" { "cancelled" } else if waiting { "waiting_input" @@ -1154,6 +1274,14 @@ impl OpenCADStudio { } } mod actions; +mod entities; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod http_bridge; +mod interactive; +mod sheets; + +#[cfg(test)] +mod p1_tests; pub(super) fn action_names() -> &'static [&'static str] { actions::NAMES } @@ -1566,4 +1694,513 @@ mod tests { let _ = std::fs::remove_file(path); let _ = handle; } + + fn user_select_pick(app: &mut OpenCADStudio, kind: &str) -> String { + let handle = app.automation_op( + format!(r#"{{"op":"query","type":"{kind}","detail":"summary"}}"#).as_str(), + )["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + let value = u64::from_str_radix(&handle, 16).unwrap(); + app.tabs[app.active_tab] + .scene + .select_entity(acadrust::Handle::new(value), false); + handle + } + + fn user_select_result(app: &mut OpenCADStudio, request_id: &str) -> Value { + app.control_request(json!({"op":"operation","request_id":request_id})) + .0 + } + + #[test] + fn user_select_stays_running_until_the_user_confirms_then_returns_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + assert_eq!(request(&mut app, json!({"op":"new"}))["status"], "completed"); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"})); + let asked = request( + &mut app, + json!({"op":"user_select","request_id":"us-1","type":"LINE"}), + ); + assert_eq!(asked["status"], "running", "{asked}"); + // A second request waits like any other mutation while one is open. + let busy = request(&mut app, json!({"op":"user_select","request_id":"us-2"})); + assert_eq!(busy["code"], "busy", "{busy}"); + // The person picks the line in the viewport and presses Enter. + let handle = user_select_pick(&mut app, "LINE"); + let _ = app.update(Message::CommandFinalize); + let done = user_select_result(&mut app, "us-1"); + assert_eq!(done["status"], "completed", "{done}"); + assert_eq!(done["result"]["cancelled"], false); + assert_eq!(done["result"]["count"], 1); + assert_eq!(done["result"]["handles"][0], json!(handle)); + assert_eq!(done["result"]["entities"][0]["type"], "Line"); + // detail defaults to full, so the whole entity object rides along. + assert!( + done["result"]["entities"][0]["properties"].is_object(), + "{done}" + ); + } + + #[test] + fn user_select_filter_deselects_entities_outside_the_request() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"})); + request(&mut app, json!({"op":"run","cmd":"CIRCLE 0,0 5"})); + request( + &mut app, + json!({"op":"user_select","request_id":"us-f","type":"CIRCLE"}), + ); + user_select_pick(&mut app, "LINE"); + user_select_pick(&mut app, "CIRCLE"); + let _ = app.update(Message::CommandFinalize); + let done = user_select_result(&mut app, "us-f"); + assert_eq!(done["status"], "completed", "{done}"); + assert_eq!(done["result"]["count"], 1); + assert_eq!(done["result"]["ignored"], 1); + assert_eq!(done["result"]["entities"][0]["type"], "Circle"); + // The filtered-out pick is deselected in the drawing too. + assert_eq!( + app.tabs[app.active_tab] + .scene + .selected_handles_in_order() + .len(), + 1 + ); + } + + #[test] + fn user_select_escape_cancels_and_an_empty_enter_confirms_zero() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"})); + request(&mut app, json!({"op":"user_select","request_id":"us-x"})); + let _ = app.update(Message::CommandEscape); + let cancelled = user_select_result(&mut app, "us-x"); + assert_eq!(cancelled["status"], "cancelled", "{cancelled}"); + assert_eq!(cancelled["result"]["cancelled"], true); + assert_eq!(cancelled["result"]["count"], 0); + // An immediate Enter with nothing picked answers the + // request with an empty set rather than hanging forever. + request(&mut app, json!({"op":"user_select","request_id":"us-e"})); + let _ = app.update(Message::CommandFinalize); + let empty = user_select_result(&mut app, "us-e"); + assert_eq!(empty["result"]["cancelled"], false, "{empty}"); + assert_eq!(empty["result"]["count"], 0); + // A client can also retract its own request programmatically. + request(&mut app, json!({"op":"user_select","request_id":"us-c"})); + let retracted = request(&mut app, json!({"op":"cancel","request_id":"us-c-x"})); + assert_eq!(retracted["status"], "cancelled", "{retracted}"); + assert_eq!(retracted["result"]["cancelled"], true, "{retracted}"); + } + + #[test] + fn user_select_request_is_printed_and_stays_visible_until_answered() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"})); + request( + &mut app, + json!({"op":"user_select","request_id":"us-ui","type":"LINE","prompt":"Chọn block"}), + ); + // The message list names the request — who asked and what for. + let last = app.command_line.history.last().unwrap(); + assert!( + last.text.contains("us-ui") && last.text.contains("Chọn block"), + "{last:?}" + ); + // The end-of-update driver pins that line so it cannot fade away + // while the pick is still open. + let label = app.pending_pick_label().expect("pending label"); + app.command_line.set_step_prompt(Some(label)); + assert!( + app.command_line.history.last().unwrap().pinned, + "{:?}", + app.command_line.history.last().unwrap() + ); + // The person answers: the line un-pins and the resolution is printed + // with the same identity. + user_select_pick(&mut app, "LINE"); + let _ = app.update(Message::CommandFinalize); + assert!(app.pending_pick_label().is_none()); + let tail: Vec = app + .command_line + .history + .iter() + .rev() + .take(3) + .map(|e| e.text.clone()) + .collect(); + assert!( + tail.iter() + .any(|t| t.contains("us-ui") && t.contains("1 object(s)")), + "{tail:?}" + ); + } + + #[test] + fn user_select_requires_a_gui_window() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let reply = request(&mut app, json!({"op":"user_select","request_id":"us-h"})); + assert_eq!(reply["code"], "gui_required", "{reply}"); + } + + #[test] + fn getpoint_resolves_with_the_clicked_point_and_esc_cancels() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + request(&mut app, json!({"op":"new"})); + let asked = request( + &mut app, + json!({"op":"getpoint","request_id":"gp-1","prompt":"Chọn vị trí QR"}), + ); + assert_eq!(asked["status"], "running", "{asked}"); + // While the pick is pending, everything else waits. + let busy = request(&mut app, json!({"op":"getpoint","request_id":"gp-2"})); + assert_eq!(busy["code"], "busy", "{busy}"); + // The person clicks: the snapped world point under the cursor is the + // answer, and the click leaves the selection untouched. + app.tabs[app.active_tab].last_cursor_world = glam::DVec3::new(125.5, 64.25, 0.0); + let _ = app.update(Message::ViewportLeftPress); + let done = user_select_result(&mut app, "gp-1"); + assert_eq!(done["status"], "completed", "{done}"); + assert_eq!(done["result"]["point"], json!([125.5, 64.25, 0.0])); + // Escape with no request pending is still just Escape. + let _ = app.update(Message::CommandEscape); + // A second pick parks again and cancels on Escape. + request(&mut app, json!({"op":"getpoint","request_id":"gp-3"})); + let _ = app.update(Message::CommandEscape); + let cancelled = user_select_result(&mut app, "gp-3"); + assert_eq!(cancelled["status"], "cancelled", "{cancelled}"); + assert_eq!(cancelled["result"]["cancelled"], true); + } + + #[test] + fn getpoint_snap_marker_and_snapped_answer_while_pending() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 100,0"})); + let i = app.active_tab; + { + let tab = &mut app.tabs[i]; + tab.scene.selection.borrow_mut().vp_size = (1920.0, 1080.0); + tab.scene.sync_tiles_from_panes(1920.0, 1080.0); + tab.scene.fit_all(); // bring the line into the pane so snaps can hit + } + // Park a client pick: with no command running, a cursor move must + // still run the object-snap engine so the marker shows and the + // click hands the snapped point back. + let asked = request( + &mut app, + json!({"op":"getpoint","request_id":"gp-snap","prompt":"Chọn góc"}), + ); + assert_eq!(asked["status"], "running", "{asked}"); + + // Screen position of the line's endpoint through the live camera. + let endpoint = glam::DVec3::new(100.0, 0.0, 0.0); + let (sx, sy) = { + let cam = app.tabs[i].scene.camera.borrow(); + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let view = cam.view_proj_rte(bounds); + let eye = cam.eye(); + let ndc = view.project_point3((endpoint - eye).as_vec3()); + ( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ) + }; + let _ = app.update(Message::ViewportMove(iced::Point::new( + sx as f32, + sy as f32, + ))); + + assert!( + app.tabs[i].snap_result.is_some(), + "no object-snap marker while a getpoint is pending" + ); + let snapped = app.tabs[i].last_cursor_world; + assert!( + (snapped - endpoint).length() < 1e-6, + "cursor point not snapped to the endpoint: {snapped:?}" + ); + + // The click answers the pick with the snapped endpoint. + let _ = app.update(Message::ViewportLeftPress); + let done = user_select_result(&mut app, "gp-snap"); + assert_eq!(done["status"], "completed", "{done}"); + assert_eq!(done["result"]["point"], json!([100.0, 0.0, 0.0])); + } + + #[test] + fn user_select_snap_marker_shows_while_pending() { + let mut app = OpenCADStudio::new_for_test(); + app.main_window = Some(iced::window::Id::unique()); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 100,0"})); + let i = app.active_tab; + { + let tab = &mut app.tabs[i]; + tab.scene.selection.borrow_mut().vp_size = (1920.0, 1080.0); + tab.scene.sync_tiles_from_panes(1920.0, 1080.0); + tab.scene.fit_all(); + } + let asked = request( + &mut app, + json!({"op":"user_select","request_id":"us-snap","prompt":"Chọn trường","clear":true}), + ); + assert_eq!(asked["status"], "running", "{asked}"); + + let endpoint = glam::DVec3::new(100.0, 0.0, 0.0); + let (sx, sy) = { + let cam = app.tabs[i].scene.camera.borrow(); + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let view = cam.view_proj_rte(bounds); + let eye = cam.eye(); + let ndc = view.project_point3((endpoint - eye).as_vec3()); + ( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ) + }; + let _ = app.update(Message::ViewportMove(iced::Point::new( + sx as f32, + sy as f32, + ))); + assert!( + app.tabs[i].snap_result.is_some(), + "no object-snap marker while a user_select is pending" + ); + + // The pick itself stays entity-based: selecting the line + Enter still + // answers the request — the marker must not break selection. + let handle = app.automation_op(r#"{"op":"query","type":"LINE","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + let value = u64::from_str_radix(&handle, 16).unwrap(); + app.tabs[i].scene.select_entity(acadrust::Handle::new(value), false); + let _ = app.update(Message::CommandFinalize); + let done = user_select_result(&mut app, "us-snap"); + assert_eq!(done["status"], "completed", "{done}"); + assert_eq!(done["result"]["count"], 1); + } + + #[test] + fn get_selection_reports_insert_block_and_position() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"})); + let line = app.automation_op(r#"{"op":"query","type":"LINE","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + // A title-block-style definition with one placed reference. + request( + &mut app, + json!({"op":"block_define","name":"A3","base":[0,0],"handles":[line]}), + ); + let insert = app.automation_op(r#"{"op":"query","type":"Insert","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + app.automation_op(&format!(r#"{{"op":"select","handles":["{insert}"]}}"#)); + let read = request(&mut app, json!({"op":"get_selection"})); + assert_eq!(read["status"], "completed", "{read}"); + assert_eq!(read["result"]["count"], 1); + let entity = &read["result"]["entities"][0]; + assert_eq!(entity["type"], "Block Reference"); + assert_eq!(entity["block"], "A3"); + assert!(entity["position"].is_array(), "{entity}"); + // Non-inserts answer null for the block field. + app.tabs[app.active_tab].scene.deselect_all(); + let circle = request( + &mut app, + json!({"op":"entities_create","entities":[{"type":"Circle","center":[0,0],"radius":2}]}), + ); + let circle_handle = circle["result"]["handles"][0].as_str().unwrap().to_owned(); + app.automation_op(&format!(r#"{{"op":"select","handles":["{circle_handle}"]}}"#)); + let read = request(&mut app, json!({"op":"get_selection"})); + assert_eq!(read["result"]["count"], 1); + assert_eq!(read["result"]["entities"][0]["block"], Value::Null); + } + + #[test] + fn plot_op_applies_per_layer_lineweights() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + { + let scene = &mut app.tabs[app.active_tab].scene; + // Two layers set to different pen weights; entities fully ByLayer. + for (name, weight) in [ + ("THIN", acadrust::types::LineWeight::Value(13)), + ("THICK", acadrust::types::LineWeight::Value(50)), + ] { + let mut layer = acadrust::tables::Layer::new(name); + layer.line_weight = weight; + let _ = scene.document.layers.add(layer); + } + for (name, origin) in [("THIN", 0.0), ("THICK", 3000.0)] { + let mut line = acadrust::entities::Line::new(); + line.common.layer = name.to_string(); + line.start = acadrust::types::Vector3::new(origin, origin, 0.0); + line.end = acadrust::types::Vector3::new(origin + 3000.0, origin + 2000.0, 0.0); + scene.add_entity(acadrust::EntityType::Line(line)); + } + } + + let pdf = std::env::temp_dir().join(format!("ocs-plot-lw-{}.pdf", std::process::id())); + let response = request( + &mut app, + json!({"op":"plot","path":pdf.to_string_lossy(),"plot_style":"monochrome.ctb"}), + ); + assert_eq!(response["status"], "completed", "{response}"); + + // The plotted PDF carries two distinct pen widths — the per-layer + // 0.13 mm / 0.50 mm hierarchy survives the plot style (thin clamps to + // the 1 px floor, so the ratio is the clamped-pixel ratio 1.889). + let text = crate::io::pdf_export::pdf_stream_text(&std::fs::read(&pdf).expect("pdf written")); + let tokens: Vec<&str> = text.split_whitespace().collect(); + let mut widths: Vec = tokens + .windows(2) + .filter_map(|pair| { + if pair[1] == "w" { + pair[0].parse::().ok() + } else { + None + } + }) + .collect(); + widths.sort_by(|a, b| a.partial_cmp(b).unwrap()); + widths.dedup_by(|a, b| (*a - *b).abs() < 0.01); + widths.retain(|width| *width > 0.2); + assert_eq!(widths.len(), 2, "two layer weights in the plotted PDF: {widths:?}"); + assert!( + (widths[1] / widths[0] - 1.889).abs() < 0.15, + "0.50 mm vs 0.13 mm hierarchy: {widths:?}" + ); + let _ = std::fs::remove_file(&pdf); + } + + #[test] + fn plot_op_applies_ctb_colors_through_layer_aci() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + { + let scene = &mut app.tabs[app.active_tab].scene; + // Frame layer carries the classic cyan ACI 4; its entity is ByLayer. + let mut frame = acadrust::tables::Layer::new("FRAME-CYAN"); + frame.color = acadrust::types::Color::Index(4); + let _ = scene.document.layers.add(frame); + let mut frame_line = acadrust::entities::Line::new(); + frame_line.common.layer = "FRAME-CYAN".to_string(); + frame_line.start = acadrust::types::Vector3::new(0.0, 0.0, 0.0); + frame_line.end = acadrust::types::Vector3::new(3000.0, 0.0, 0.0); + scene.add_entity(acadrust::EntityType::Line(frame_line)); + + // An explicit red ACI 1 and a true-color green: index must map + // through the CTB, the true color must stay RGB (aci 0). + let mut red = acadrust::entities::Line::new(); + red.common.color = acadrust::types::Color::Index(1); + red.start = acadrust::types::Vector3::new(0.0, 1000.0, 0.0); + red.end = acadrust::types::Vector3::new(3000.0, 1000.0, 0.0); + scene.add_entity(acadrust::EntityType::Line(red)); + + let mut true_color = acadrust::entities::Line::new(); + true_color.common.color = acadrust::types::Color::from_true_color_value(0x0000FF00); + true_color.start = acadrust::types::Vector3::new(0.0, 2000.0, 0.0); + true_color.end = acadrust::types::Vector3::new(3000.0, 2000.0, 0.0); + scene.add_entity(acadrust::EntityType::Line(true_color)); + } + + let pdf = std::env::temp_dir().join(format!("ocs-plot-aci-{}.pdf", std::process::id())); + let response = request( + &mut app, + json!({"op":"plot","path":pdf.to_string_lossy(),"plot_style":"monochrome.ctb"}), + ); + assert_eq!(response["status"], "completed", "{response}"); + + let text = crate::io::pdf_export::pdf_stream_text(&std::fs::read(&pdf).expect("pdf written")); + // Monochrome maps every index colour (ByLayer cyan frame, explicit + // red) to black; the true-color line keeps its RGB by definition. + assert!( + !text.contains("0 1 1 rg") && !text.contains("0 1 1 RG"), + "cyan frame must be CTB-mapped to monochrome" + ); + assert!( + !text.contains("1 0 0 rg") && !text.contains("1 0 0 RG"), + "explicit red must be CTB-mapped to monochrome" + ); + assert!( + text.contains("0 1 0 rg") || text.contains("0 1 0 RG"), + "true-color line keeps its RGB (aci 0 is not CTB-mapped)" + ); + assert!( + text.contains("0 0 0 rg") || text.contains("0 0 0 RG"), + "monochrome strokes must plot black" + ); + + // Without a plot style the same drawing keeps its colours: the cyan + // frame plots as the dark-blue print remap, the red line stays red. + let plain = std::env::temp_dir().join(format!("ocs-plot-aci-plain-{}.pdf", std::process::id())); + let response = request(&mut app, json!({"op":"plot","path":plain.to_string_lossy()})); + assert_eq!(response["status"], "completed", "{response}"); + let text = crate::io::pdf_export::pdf_stream_text(&std::fs::read(&plain).expect("pdf written")); + let color_ops: Vec<[f32; 3]> = { + let tokens: Vec<&str> = text.split_whitespace().collect(); + tokens + .windows(4) + .filter_map(|quad| { + if quad[3] != "rg" && quad[3] != "RG" { + return None; + } + Some([ + quad[0].parse::().ok()?, + quad[1].parse::().ok()?, + quad[2].parse::().ok()?, + ]) + }) + .collect() + }; + let near = |color: &[f32; 3], target: [f32; 3]| { + color + .iter() + .zip(target) + .all(|(channel, expected)| (channel - expected).abs() < 0.02) + }; + assert!( + color_ops.iter().any(|c| near(c, [0.0, 0.15, 0.5])), + "unstyled cyan frame plots as the dark-blue print remap: {color_ops:?}" + ); + assert!( + color_ops.iter().any(|c| near(c, [1.0, 0.0, 0.0])), + "unstyled red line keeps its colour: {color_ops:?}" + ); + assert!( + color_ops.iter().any(|c| near(c, [0.0, 1.0, 0.0])), + "unstyled true-color line keeps its colour: {color_ops:?}" + ); + let _ = std::fs::remove_file(&pdf); + let _ = std::fs::remove_file(&plain); + } } diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs new file mode 100644 index 000000000..d8e9f6009 --- /dev/null +++ b/src/app/control/p1_tests.rs @@ -0,0 +1,568 @@ +//! Integration tests for the P1/P2 automation operations — template flows, +//! close, sysvar, layouts/page setups, cross-document copy, groups, +//! selection sets, per-page plotting, where filters and hatch readout. + +#![cfg(test)] + +use crate::app::OpenCADStudio; +use serde_json::{json, Value}; + +/// Envelope mutation against the active document: fills `{doc}` with the +/// document_id read from state, like every external client must. +fn mutate(app: &mut OpenCADStudio, request: &str) -> Value { + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + app.automation_op(&request.replacen("{doc}", &doc.to_string(), 1)) +} + +fn count_type(app: &mut OpenCADStudio, kind: &str) -> u64 { + let q = app.automation_op(&format!( + r#"{{"op":"query","type":"{kind}","detail":"summary"}}"# + )); + q["count"].as_u64().unwrap() +} + +#[test] +fn query_where_filters_on_entity_properties() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 1"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 20,0 5"}"#); + // radius > 2 keeps only the big circle (the filter runs over + // RFC 6901 property paths, same operators as records). + let q = app.automation_op( + r#"{"op":"query","type":"Circle","detail":"geometry","where":[{"path":"/radius","op":"gt","value":2}]}"#, + ); + assert_eq!(q["ok"], true, "{}", q["error"]); + assert_eq!(q["count"], 1); + assert_eq!(q["entities"][0]["radius"], 5.0); + + // An invalid pointer is refused up front, as is an unknown operator. + let q = app.automation_op( + r#"{"op":"query","type":"Circle","where":[{"path":"radius","op":"eq","value":1}]}"#, + ); + assert_eq!(q["ok"], false); + let q = app.automation_op( + r#"{"op":"query","type":"Circle","where":[{"path":"/radius","op":"between","value":1}]}"#, + ); + assert_eq!(q["ok"], false); +} + +#[test] +fn close_op_guards_dirty_documents_and_discards() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,1"}"#); + + // Dirty without discard: refused. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"close","request_id":"cl0","document_id":{doc}}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "document_dirty"); + + // Discard closes the tab (single tab → replaced by a fresh drawing). + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"close","request_id":"cl1","document_id":{doc},"discard":true}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["closed"], true); + assert_eq!(app.tabs[app.active_tab].scene.document.entities().count(), 0); + + // Unknown document id: document_closed. + let r = app.automation_op( + r#"{"protocol":1,"op":"close","request_id":"cl2","document_id":9999}"#, + ); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "document_closed"); +} + +#[test] +fn sysvar_round_trips_and_refuses_unknowns() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"sysvar","request_id":"sv1","document_id":{doc},"set":{{"ltscale":2.5,"mirrtext":0}}}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + let header = &app.tabs[app.active_tab].scene.document.header; + assert!((header.linetype_scale - 2.5).abs() < 1e-9); + assert!(!header.mirror_text); + + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"sysvar","request_id":"sv2","document_id":{doc},"get":["ltscale","mirrtext"]}}"# + )); + assert_eq!(r["result"]["values"]["ltscale"], 2.5); + assert_eq!(r["result"]["values"]["mirrtext"], 0); + + // clayer only accepts an existing layer. + mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"sv2b","document_id":{doc},"entities":[{"type":"Point","location":[0,0],"layer":"Walls"}]}"#, + ); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"sysvar","request_id":"sv3","document_id":{doc},"set":{{"clayer":"Walls"}}}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!( + app.tabs[app.active_tab].scene.document.header.current_layer_name, + "Walls" + ); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"sysvar","request_id":"sv4","document_id":{doc},"set":{{"clayer":"NOPE"}}}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "invalid_sysvar_value"); + + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"sysvar","request_id":"sv5","document_id":{doc},"set":{{"not_a_sysvar":1}}}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "unknown_sysvar"); +} + +#[test] +fn layout_create_and_page_setup_configure_sheets() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"layout_create","request_id":"lc1","document_id":{doc},"name":"PLAN"}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert!(r["result"]["layouts"].as_array().unwrap().iter().any(|n| n == "PLAN")); + + // Duplicate refused. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"layout_create","request_id":"lc2","document_id":{doc},"name":"PLAN"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "layout_exists"); + + // Page setup on the new layout: A4 landscape, fit, centered. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps1","document_id":{doc},"layout":"PLAN","paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + let ps = app.tabs[app.active_tab] + .scene + .plot_settings_for("PLAN") + .expect("stored page setup"); + assert!(ps.paper_size.contains("ISO_A4")); + assert!(ps.flags.plot_centered); + assert!(ps.flags.use_standard_scale); + + // Bad scale string refused; unknown layout refused. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps2","document_id":{doc},"layout":"PLAN","scale":"nonsense"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "invalid_scale"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps3","document_id":{doc},"layout":"GHOST"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "layout_missing"); +} + +#[test] +fn new_from_template_and_dwt_save_round_trip() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#); + let dwt = std::env::temp_dir().join(format!("ocs_tpl_{}.dwt", std::process::id())); + let _ = std::fs::remove_file(&dwt); + let p = dwt.to_string_lossy().replace('\\', "\\\\"); + let saved = app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#)); + assert_eq!(saved["ok"], true, "{}", saved["error"]); + assert!(dwt.exists(), ".dwt file written"); + + // A fresh template-based drawing carries the template's entities and + // no source path. The read may race the file-system rename on Windows. + let mut r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"new","request_id":"nt1","template":"{p}"}}"# + )); + for attempt in 0..5 { + if r["ok"] == true { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"new","request_id":"nt-retry-{attempt}","template":"{p}"}}"# + )); + } + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["total"], 1); + assert!(app.tabs[app.active_tab].current_path.is_none()); + + drop(app); + let sidecar = dwt.with_file_name(format!( + ".{}.ocs.lock", + dwt.file_name().unwrap().to_string_lossy() + )); + let _ = std::fs::remove_file(sidecar); + let _ = std::fs::remove_file(&dwt); +} + +#[test] +fn wblock_with_template_base_keeps_template_tables() { + // The template drawing carries layer TPL; the exported entity set lands + // in a copy of that template so TPL survives (Catalog §2.1). + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"wt0","document_id":{doc},"entities":[{"type":"Line","start":[0,0],"end":[5,5],"layer":"TPL"}]}"#, + ); + let tpl = std::env::temp_dir().join(format!("ocs_wbtpl_{}.dwg", std::process::id())); + let _ = std::fs::remove_file(&tpl); + let tp = tpl.to_string_lossy().replace('\\', "\\\\"); + assert_eq!(app.automation_op(&format!(r#"{{"op":"save","path":"{tp}"}}"#))["ok"], true); + + // A new drawing holds the one entity to export. + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 1,1 2"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let out = std::env::temp_dir().join(format!("ocs_wb_out_{}.dxf", std::process::id())); + let _ = std::fs::remove_file(&out); + let op = out.to_string_lossy().replace('\\', "\\\\"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"wblock","request_id":"wt1","document_id":{doc},"path":"{op}","template":"{tp}","handles":["{handle}"]}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // The export contains the circle AND the template's TPL layer. + let opened = app.automation_op(&format!(r#"{{"op":"open","path":"{op}"}}"#)); + assert_eq!(opened["ok"], true); + let layers = app.automation_op(r#"{"op":"layers"}"#); + assert!(layers["layers"] + .as_array() + .unwrap() + .iter() + .any(|l| l["name"] == "TPL")); + drop(app); + for path in [tpl, out] { + let sidecar = path.with_file_name(format!( + ".{}.ocs.lock", + path.file_name().unwrap().to_string_lossy() + )); + let _ = std::fs::remove_file(sidecar); + let _ = std::fs::remove_file(&path); + } +} + +#[test] +fn entities_copy_to_another_open_document() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + // A second document with its own layer table. + app.tabs + .push(crate::app::document::DocumentTab::new_drawing(99)); + let target = app.tabs[1].id; + + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"entities_copy_to","request_id":"cp1","document_id":{target},"handles":["{handle}"]}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["count"], 1); + + // Source keeps the original; the target holds the fresh copy. + assert_eq!(count_type(&mut app, "Circle"), 1); + app.active_tab = 1; + assert_eq!(count_type(&mut app, "Circle"), 1); + app.active_tab = 0; + + // A missing target is refused. + let r = app.automation_op( + r#"{"protocol":1,"op":"entities_copy_to","request_id":"cp2","document_id":4242,"handles":["01"]}"#, + ); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "document_closed"); +} + +#[test] +fn group_create_and_selection_sets() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,0"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#); + let q = app.automation_op(r#"{"op":"query","detail":"summary"}"#); + let handles: Vec = q["entities"] + .as_array() + .unwrap() + .iter() + .map(|e| format!("\"{}\"", e["handle"].as_str().unwrap())) + .collect(); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"group_create","request_id":"g1","document_id":{doc},"name":"FRAME","handles":[{}]}}"#, + handles.join(",") + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + let objects = &app.tabs[app.active_tab].scene.document.objects; + assert!(objects.values().any(|o| matches!( + o, + acadrust::objects::ObjectType::Group(group) if group.name == "FRAME" + ))); + + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"selection_set_save","request_id":"s1","document_id":{doc},"name":"frame-set","handles":[{}]}}"#, + handles.join(",") + )); + assert_eq!(r["result"]["count"], 2); + + // Load recalls (and selects by default). + app.automation_op(r#"{"op":"select","clear":true}"#); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"selection_set_load","request_id":"s2","document_id":{doc},"name":"frame-set"}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["selected"], 2); + + // Unknown set refused. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"selection_set_load","request_id":"s3","document_id":{doc},"name":"ghost"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "selection_set_missing"); +} + +#[test] +fn plot_per_page_writes_one_pdf_per_layout() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 100,80"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + app.automation_op(&format!( + r#"{{"protocol":1,"op":"layout_create","request_id":"pp0","document_id":{doc},"name":"PLAN"}}"# + )); + let out = std::env::temp_dir().join(format!("ocs_pp_{}.pdf", std::process::id())); + let _ = std::fs::remove_file(&out); + let op = out.to_string_lossy().replace('\\', "\\\\"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"plot","request_id":"pp1","document_id":{doc},"path":"{op}","layout":"all","per_page":true}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + let files = r["result"]["files"].as_array().unwrap(); + let expected = app.tabs[app.active_tab].scene.layout_names().len(); + assert_eq!(files.len(), expected, "one PDF per layout"); + for file in files { + let bytes = std::fs::read(file["path"].as_str().unwrap()).expect("per-page pdf"); + assert!(bytes.starts_with(b"%PDF")); + let _ = std::fs::remove_file(file["path"].as_str().unwrap()); + } +} + +#[test] +fn hatch_boundary_is_flattened_in_query() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"h1","document_id":{doc},"entities":[ + {"type":"Hatch","boundary":[[0,0],[10,0],[10,10],[0,10]],"solid":true} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + let q = app.automation_op(r#"{"op":"query","type":"Hatch","detail":"full"}"#); + assert_eq!(q["count"], 1); + let boundary = q["entities"][0]["boundary"].as_array().expect("boundary"); + assert_eq!(boundary[0].as_array().unwrap().len(), 4); +} + +#[test] +fn diagnostic_layer_survives_plain_io_round_trip() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"dg1","document_id":{doc},"entities":[ + {"type":"Point","location":[1,1],"layer":"Walls"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["layers_created"], json!(["Walls"])); + + // Direct io round trip on a clone of the document. + let doc = app.tabs[app.active_tab].scene.document.clone(); + let bytes = crate::io::save_to_bytes(&doc, "dxf", acadrust::DxfVersion::AC1032) + .expect("save to bytes"); + let path = std::env::temp_dir().join(format!("ocs_diag_{}.dxf", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let loaded = crate::io::load_file(&path).expect("load back"); + let _ = std::fs::remove_file(&path); + let names: Vec = loaded.layers.iter().map(|l| l.name.clone()).collect(); + assert!( + names.iter().any(|n| n == "Walls"), + "Walls lost in plain io round trip; layers = {names:?}" + ); +} + +#[test] +fn block_delete_and_define_replace_manage_definitions() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,0"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#); + let q = app.automation_op(r#"{"op":"query","detail":"summary"}"#); + let joined = q["entities"] + .as_array() + .unwrap() + .iter() + .map(|e| format!("\"{}\"", e["handle"].as_str().unwrap())) + .collect::>() + .join(","); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"block_define","request_id":"bl0","document_id":{doc},"name":"MARK","base":[0,0,0],"handles":[{joined}]}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(count_type(&mut app, "Insert"), 1); + + // Re-import parity (CF-01.3): replace drops the previous definition, + // its children and its insert inside one undoable step. + app.automation_op(r#"{"op":"run","cmd":"POINT 9,9"}"#); + let handle = app.automation_op(r#"{"op":"query","type":"Point","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"block_define","request_id":"bl1","document_id":{doc},"name":"MARK","base":[0,0,0],"handles":["{handle}"],"replace":true}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(count_type(&mut app, "Insert"), 1, "one insert after replace"); + assert_eq!(count_type(&mut app, "Line"), 0, "old children erased"); + assert_eq!(count_type(&mut app, "Circle"), 0, "old children erased"); + + // Delete removes the insert, the markers, the children and the record. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK"}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert!(r["result"]["erased"].as_u64().unwrap() >= 3, "insert + markers + child"); + assert_eq!(count_type(&mut app, "Insert"), 0); + + // Unknown names and protected layout records are refused. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"block_delete","request_id":"bl3","document_id":{doc},"name":"GHOST"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "block_missing"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"block_delete","request_id":"bl4","document_id":{doc},"name":"*Model_Space"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "block_protected"); +} + +#[test] +fn wblock_normalize_shifts_the_export_to_the_origin() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 100,200 110,210"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let doc = state["document_id"].as_u64().unwrap(); + let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#) + ["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + let out = std::env::temp_dir().join(format!("ocs_wbnorm_{}.dxf", std::process::id())); + let _ = std::fs::remove_file(&out); + let op = out.to_string_lossy().replace('\\', "\\\\"); + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"wblock","request_id":"wn1","document_id":{doc},"path":"{op}","handles":["{handle}"],"normalize":true}}"# + )); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(r["result"]["normalized"], true); + assert_eq!(app.automation_op(&format!(r#"{{"op":"open","path":"{op}"}}"#))["ok"], true); + let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#); + assert_eq!(q["entities"][0]["start"], json!([0.0, 0.0, 0.0])); + assert_eq!(q["entities"][0]["end"], json!([10.0, 10.0, 0.0])); + let _ = std::fs::remove_file(&out); +} + +#[test] +fn file_identity_is_stable_and_survives_a_round_trip() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r1 = mutate( + &mut app, + r#"{"protocol":1,"op":"file_identity","request_id":"fi1","document_id":{doc}}"#, + ); + assert_eq!(r1["ok"], true, "{}", r1["error"]); + assert_eq!(r1["result"]["created"], true); + let identity = r1["result"]["identity"].as_str().unwrap().to_owned(); + assert_eq!(identity.len(), 36, "RFC 4122 shape: {identity}"); + assert_eq!(&identity[8..9], "-"); + + // Idempotent: the second call returns the same identity. + let r2 = mutate( + &mut app, + r#"{"protocol":1,"op":"file_identity","request_id":"fi2","document_id":{doc}}"#, + ); + assert_eq!(r2["result"]["created"], false); + assert_eq!(r2["result"]["identity"], identity.as_str()); + + // renew mints a fresh one. + let r3 = mutate( + &mut app, + r#"{"protocol":1,"op":"file_identity","request_id":"fi3","document_id":{doc},"renew":true}"#, + ); + let renewed = r3["result"]["identity"].as_str().unwrap().to_owned(); + assert_ne!(renewed, identity); + + // The marker survives a save → open round trip. + let path = std::env::temp_dir().join(format!("ocs_fid_{}.dwg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().replace('\\', "\\\\"); + assert_eq!(app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], true); + assert_eq!(app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"], true); + let r4 = mutate( + &mut app, + r#"{"protocol":1,"op":"file_identity","request_id":"fi4","document_id":{doc}}"#, + ); + assert_eq!(r4["result"]["identity"], renewed.as_str()); + drop(app); + let sidecar = path.with_file_name(format!( + ".{}.ocs.lock", + path.file_name().unwrap().to_string_lossy() + )); + let _ = std::fs::remove_file(sidecar); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn state_reports_the_hand_seed() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let before = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap(); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,1"}"#); + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let after = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap(); + assert!(after > before, "issuing a handle advances the seed"); +} diff --git a/src/app/control/sheets.rs b/src/app/control/sheets.rs new file mode 100644 index 000000000..787f4f78a --- /dev/null +++ b/src/app/control/sheets.rs @@ -0,0 +1,672 @@ +//! Sheet-level and environment automation operations — layouts, page setups, +//! system variables, the document lifecycle and saved selection sets. + +use super::*; + +impl OpenCADStudio { + /// `close` — close a document tab. A dirty + /// document is refused unless `"discard":true`; discarding clears the + /// dirty flag so the close path skips the unsaved-changes dialog. + pub(super) fn control_close(&mut self, req: &Value) -> Result, Value> { + let index = if let Some(id) = req["document_id"].as_u64() { + self.tabs + .iter() + .position(|t| t.id == id) + .ok_or_else(|| failure("document_closed", "Document absent"))? + } else { + self.active_tab + }; + if self.tabs[index].is_start { + self.set_control_result(json!({ "closed": false, "reason": "start_page" })); + return Ok(Task::none()); + } + if self.tabs[index].dirty && !req["discard"].as_bool().unwrap_or(false) { + return Err(failure( + "document_dirty", + "Save the document or pass discard:true", + )); + } + self.tabs[index].dirty = false; + let remaining = self.tabs.len(); + // The close may queue async work (autosave cleanup) — return the + // task so the runtime drives it. + let close_task = self.update(Message::TabClose(index)); + let closed_document = req["document_id"].as_u64(); + self.set_control_result(json!({ + "closed": true, + "documents_before": remaining, + "documents_after": self.tabs.len(), + "closed_document_id": closed_document, + })); + Ok(close_task) + } + + /// `sysvar` — read/write the curated system-variable registry + /// (read and write). + /// `{"op":"sysvar","get":["LTSCALE","EXTMIN"]}` reads; `{"op":"sysvar", + /// "set":{"MIRRTEXT":0}}` writes (one undo step, validated per name). + pub(super) fn control_sysvar(&mut self, req: &Value) -> Result, Value> { + if let Some(set) = req["set"].as_object() { + self.push_undo_snapshot(self.active_tab, "SYSVAR"); + let mut applied = serde_json::Map::new(); + for (name, value) in set { + set_sysvar(&mut self.tabs[self.active_tab].scene.document, name, value) + .map_err(|e| { + // Nothing committed yet — the snapshot is empty. + e + })?; + applied.insert(name.to_ascii_lowercase(), value.clone()); + } + self.set_control_result(json!({ "set": applied })); + } + if let Some(get) = req["get"].as_array() { + let scene = &self.tabs[self.active_tab].scene; + let mut values = serde_json::Map::new(); + for name in get { + let name = name.as_str().unwrap_or(""); + values.insert( + name.to_ascii_lowercase(), + read_sysvar(scene, name).ok_or_else(|| { + failure( + "unknown_sysvar", + format!( + "Unknown system variable '{name}'. Readable: {}", + READABLE_SYSVARS.join(", ") + ), + ) + })?, + ); + } + self.set_control_result(json!({ "values": values })); + } + if req.get("get").is_none() && req.get("set").is_none() { + return Err(failure( + "get_or_set_required", + "Supply get:[names] and/or set:{name:value}", + )); + } + Ok(Task::none()) + } + + /// `layout_create` — add a layout with the default A4 page setup. + pub(super) fn control_layout_create( + &mut self, + req: &Value, + ) -> Result, Value> { + let name = string(req, "name")?.to_owned(); + if name.is_empty() { + return Err(failure("name_required", "Supply a layout name")); + } + let i = self.active_tab; + if self.tabs[i].scene.layout_names().iter().any(|n| n.eq_ignore_ascii_case(&name)) { + return Err(failure( + "layout_exists", + format!("Layout '{name}' already exists"), + )); + } + self.push_undo_snapshot(i, "LAYOUTCREATE"); + self.tabs[i] + .scene + .document + .add_layout(&name) + .map_err(|e| failure("layout_failed", e))?; + let plot_style = self + .active_plot_style + .as_ref() + .map(|style| style.name.clone()) + .unwrap_or_default(); + let layout_flags = i16::from( + self.tabs[i].scene.document.header.paper_space_linetype_scaling, + ) | (i16::from(self.tabs[i].scene.document.header.paper_space_limit_check) << 1); + for obj in self.tabs[i].scene.document.objects.values_mut() { + if let acadrust::objects::ObjectType::Layout(layout) = obj { + if layout.name == name { + layout.flags = layout_flags; + crate::scene::apply_default_page_setup(layout, &plot_style); + break; + } + } + } + self.tabs[i].scene.ensure_sheet_viewport(&name); + self.tabs[i].dirty = true; + let layouts = self.tabs[i].scene.layout_names(); + self.set_control_result(json!({ "layout": name, "layouts": layouts })); + Ok(Task::none()) + } + + /// `page_setup_set` — write a layout's plot configuration + /// Explicit fields win; everything else + /// keeps the layout's stored setup. Paper names resolve through the + /// catalog; scales are `"fit"` or `"paper:drawing"` like `"1:100"`. + pub(super) fn control_page_setup_set( + &mut self, + req: &Value, + ) -> Result, Value> { + let layout = string(req, "layout")?.to_owned(); + let i = self.active_tab; + if !self.tabs[i].scene.layout_names().iter().any(|n| n.eq_ignore_ascii_case(&layout)) { + return Err(failure( + "layout_missing", + format!("Layout '{layout}' does not exist"), + )); + } + let mut ps = self + .tabs[i] + .scene + .plot_settings_for(&layout) + .unwrap_or_else(|| acadrust::objects::PlotSettings::new(String::new())); + if let Some(paper) = req["paper"].as_str().filter(|p| !p.is_empty()) { + let resolved = crate::io::paper_catalog::resolve(paper) + .ok_or_else(|| failure("invalid_paper", format!("Unknown paper '{paper}'")))?; + ps.paper_size = resolved.canonical.to_string(); + let (width, height) = resolved.portrait_mm(); + ps.paper_width = width; + ps.paper_height = height; + ps.rotation = if req["orientation"].as_str().is_some_and(|o| o.eq_ignore_ascii_case("portrait")) { + acadrust::objects::PlotRotation::None + } else { + acadrust::objects::PlotRotation::Degrees90 + }; + } + if let Some(fit) = req["fit"].as_bool() { + if fit { + ps.set_scale_to_fit(); + ps.flags.use_standard_scale = true; + } else { + ps.flags.use_standard_scale = false; + } + } + if let Some(scale) = req["scale"].as_str().filter(|s| !s.is_empty()) { + let parsed = scale.split_once(':').and_then(|(a, b)| { + let numerator = a.trim().parse::().ok()?; + let denominator = b.trim().parse::().ok()?; + (numerator > 0.0 && denominator > 0.0).then_some((numerator, denominator)) + }); + let Some((numerator, denominator)) = parsed else { + return Err(failure( + "invalid_scale", + "scale must look like \"1:100\" (paper:drawing, both > 0)", + )); + }; + ps.scale_numerator = numerator; + ps.scale_denominator = denominator; + ps.standard_scale_factor = numerator / denominator; + ps.flags.use_standard_scale = false; + } + if let Some(center) = req["center"].as_bool() { + ps.flags.plot_centered = center; + } + if let Some(style) = req["plot_style"].as_str() { + ps.current_style_sheet = style.to_owned(); + ps.flags.plot_plot_styles = !style.is_empty(); + } + if let Some(window) = req["window"].as_array().filter(|v| v.len() == 4) { + let coord = |k: usize| window[k].as_f64().unwrap_or(0.0); + ps.set_plot_window(coord(0), coord(1), coord(2), coord(3)); + ps.plot_type = acadrust::objects::PlotType::Window; + } + self.push_undo_snapshot(i, "PAGESETUP"); + if !self.tabs[i].scene.set_layout_plot_settings(&layout, &ps) { + return Err(failure("layout_missing", format!("Layout '{layout}' vanished"))); + } + self.tabs[i].dirty = true; + self.set_control_result(json!({ + "layout": layout, + "paper": ps.paper_size, + "scale": json!({ + "numerator": ps.scale_numerator, + "denominator": ps.scale_denominator, + "fit": ps.flags.use_standard_scale, + }), + "plot_style": ps.current_style_sheet, + })); + Ok(Task::none()) + } + + /// `entities_copy_to` — copy entities into another open document + /// Referenced layer definitions travel along; + /// the new handles are returned. + pub(super) fn control_entities_copy_to( + &mut self, + req: &Value, + ) -> Result, Value> { + let handles = crate::app::control::entities::hex_handles(req, "handles")?; + let target = req["document_id"] + .as_u64() + .ok_or_else(|| failure("document_required", "Supply the target document_id"))?; + let source_index = self.active_tab; + let target_index = self + .tabs + .iter() + .position(|t| t.id == target) + .ok_or_else(|| failure("document_closed", "Target document absent"))?; + crate::app::control::entities::require_existing( + &self.tabs[source_index].scene.document, + &handles, + )?; + + // Clone out of the source first so the two tabs never borrow together. + let cloned: Vec = handles + .iter() + .filter_map(|handle| self.tabs[source_index].scene.document.get_entity(*handle)) + .map(|entity| entity.clone()) + .collect(); + + // Copy the referenced layer definitions out of the source before the + // target borrow starts (the two tabs never borrow together). + let mut missing_layers: Vec = Vec::new(); + { + let source_document = &self.tabs[source_index].scene.document; + for entity in &cloned { + let layer = entity.common().layer.clone(); + if layer.is_empty() + || layer == "0" + || source_document.layers.contains(&layer) + || missing_layers.iter().any(|l| l.name == layer) + { + continue; + } + if let Some(source_layer) = source_document.layers.get(&layer) { + missing_layers.push(source_layer.clone()); + } + } + } + + self.push_undo_snapshot(target_index, "COPYTO"); + let mut created = Vec::new(); + { + let scene = &mut self.tabs[target_index].scene; + crate::io::linetypes::populate_document(&mut scene.document); + for layer_record in &missing_layers { + if !scene.document.layers.contains(&layer_record.name) { + let mut layer_record = layer_record.clone(); + layer_record.handle = scene.document.allocate_handle(); + let _ = scene.document.layers.add(layer_record); + } + } + for entity in cloned { + created.push(format!("{:X}", scene.add_entity_clone(entity).value())); + } + } + self.tabs[target_index].dirty = true; + self.post_ref_op(target_index); + let created_count = created.len(); + self.set_control_result(json!({ + "document_id": target, + "created": created, + "count": created_count, + })); + Ok(Task::none()) + } + + /// `group_create` — attach a named group to the listed entities + /// (stored in the drawing's group dictionary). + pub(super) fn control_group_create(&mut self, req: &Value) -> Result, Value> { + let name = string(req, "name")?.to_owned(); + if name.is_empty() { + return Err(failure("name_required", "Supply a group name")); + } + let handles = crate::app::control::entities::hex_handles(req, "handles")?; + let i = self.active_tab; + crate::app::control::entities::require_existing(&self.tabs[i].scene.document, &handles)?; + self.push_undo_snapshot(i, "GROUPCREATE"); + let handle = self.tabs[i].scene.create_group(name.clone(), handles); + self.tabs[i].dirty = true; + self.set_control_result(json!({ + "group": name, + "handle": format!("{:X}", handle.value()), + })); + Ok(Task::none()) + } + + /// `selection_set_save` — remember a named handle set for this session + /// (session-scoped by design). + pub(super) fn control_selection_set_save( + &mut self, + req: &Value, + ) -> Result, Value> { + let name = string(req, "name")?.to_owned(); + if name.is_empty() { + return Err(failure("name_required", "Supply a set name")); + } + let handles = crate::app::control::entities::hex_handles(req, "handles")?; + let stored: Vec = handles + .iter() + .map(|h| format!("{:X}", h.value())) + .collect(); + self.control + .selection_sets + .insert(name.clone(), handles); + let count = self + .control + .selection_sets + .get(&name) + .map(|set| set.len()) + .unwrap_or(0); + self.set_control_result(json!({ "name": name, "handles": stored, "count": count })); + Ok(Task::none()) + } + + /// `selection_set_load` — recall a saved set. With `"select":true` + /// (default) the entities become the current selection. + pub(super) fn control_selection_set_load( + &mut self, + req: &Value, + ) -> Result, Value> { + let name = string(req, "name")?.to_owned(); + let handles = self + .control + .selection_sets + .get(&name) + .cloned() + .ok_or_else(|| failure("selection_set_missing", format!("No set named '{name}'")))?; + let stored: Vec = handles + .iter() + .map(|h| format!("{:X}", h.value())) + .collect(); + let select = req["select"].as_bool().unwrap_or(true); + let mut selected = 0; + if select { + let i = self.active_tab; + let scene = &mut self.tabs[i].scene; + scene.deselect_all(); + for handle in &handles { + if scene.document.get_entity(*handle).is_some() { + scene.select_entity(*handle, false); + selected += 1; + } + } + } + self.set_control_result(json!({ + "name": name, + "handles": stored, + "selected": selected, + })); + Ok(Task::none()) + } + + /// `new` with `"template"` — the web build reads no files from a path, so + /// a template is refused there rather than silently ignored. + #[cfg(target_arch = "wasm32")] + pub(in crate::app) fn apply_template(&mut self, _template: &str) -> Result { + Err(failure( + "invalid_request", + "starting from a template needs the desktop build", + )) + } + + /// `new` with `"template"` — start an untitled drawing from a DWG/DXF/DWT + /// file. Tables and styles come from the template; the document keeps no + /// source path. + #[cfg(not(target_arch = "wasm32"))] + pub(in crate::app) fn apply_template(&mut self, template: &str) -> Result { + let path = std::path::PathBuf::from(template); + let bytes = self + .read_drawing(&path) + .map_err(|e| failure("template_missing", e))?; + // DWT is a DWG-family file, but the io dispatcher keys on the file + // extension — hand it a .dwg name and point the document's source + // path back at the real template. + let load_path = if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("dwt")) + { + path.with_extension("dwg") + } else { + path.clone() + }; + let (mut document, purged) = crate::io::load_bytes_finalized(&load_path, bytes) + .map_err(|e| failure("template_failed", e))?; + document.source_path = Some(path.to_string_lossy().into_owned()); + let i = self.active_tab; + { + let scene = &mut self.tabs[i].scene; + scene.clear(); + scene.document = document; + scene.load_named_parameters_from_document(); + scene.load_parametric_constraints_from_document(); + scene.material_base_dir = None; + scene.rebuild_derived_caches(); + } + crate::app::style_ops::ensure_standard_styles(&mut self.tabs[i].scene.document); + self.tabs[i].adopt_active_ucs_from_header(); + self.tabs[i].current_path = None; + self.tabs[i].is_start = false; + self.tabs[i].dirty = true; + Ok(purged) + } + + /// `file_identity` — stable per-document GUID (the drawing's SPMDOCUNIQUE value, + /// Catalog §1.6). The identity lives on a single invisible marker text + /// at the origin carrying the XData application "SPMDOCUNIQUE" — the + /// same marker SPM.ACAD writes — so it survives save/open round trips. + /// Idempotent get-or-create; `"renew":true` rewrites the marker with a + /// fresh GUID instead of adding a second one. + pub(super) fn control_file_identity(&mut self, req: &Value) -> Result, Value> { + let renew = req["renew"].as_bool().unwrap_or(false); + let i = self.active_tab; + let marker = find_file_identity_marker(&self.tabs[i].scene.document); + if !renew { + if let Some(handle) = marker { + if let Some(identity) = marker_identity(&self.tabs[i].scene.document, handle) { + self.set_control_result(json!({ "identity": identity, "created": false })); + return Ok(Task::none()); + } + } + } + let identity = new_guid_v4(); + self.push_undo_snapshot(i, "FILEIDENTITY"); + match marker { + Some(handle) => { + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + handle, + FILE_IDENTITY_APP, + Some(vec![acadrust::xdata::XDataValue::String(identity.clone())]), + ); + } + None => { + let mut text = acadrust::entities::Text::with_value( + FILE_IDENTITY_APP, + acadrust::types::Vector3::ZERO, + ) + .with_height(0.0001); + text.common.invisible = true; + let handle = self.tabs[i].scene.add_entity(acadrust::EntityType::Text(text)); + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + handle, + FILE_IDENTITY_APP, + Some(vec![acadrust::xdata::XDataValue::String(identity.clone())]), + ); + } + } + self.tabs[i].dirty = true; + self.post_ref_op(i); + self.set_control_result(json!({ "identity": identity, "created": true })); + Ok(Task::none()) + } +} + +/// The XData application name that carries a drawing's stable identity. +const FILE_IDENTITY_APP: &str = "SPMDOCUNIQUE"; + +/// The handle of the entity carrying the file identity, if present. +fn find_file_identity_marker(document: &acadrust::CadDocument) -> Option { + document + .entities() + .find(|entity| { + entity + .common() + .extended_data + .get_record(FILE_IDENTITY_APP) + .is_some() + }) + .map(|entity| entity.common().handle) +} + +/// The identity GUID from the marker entity's XData. +fn marker_identity(document: &acadrust::CadDocument, handle: acadrust::Handle) -> Option { + let record = document + .get_entity(handle)? + .common() + .extended_data + .get_record(FILE_IDENTITY_APP)?; + match record.values.first() { + Some(acadrust::xdata::XDataValue::String(text)) => Some(text.clone()), + _ => None, + } +} + +/// A random RFC 4122 version-4 GUID string, from the OS entropy pool. +fn new_guid_v4() -> String { + use std::fmt::Write as _; + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("OS entropy unavailable"); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let mut out = String::with_capacity(36); + for (index, byte) in bytes.iter().enumerate() { + if matches!(index, 4 | 6 | 8 | 10) { + out.push('-'); + } + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// The readable registry; `WRITABLE_SYSVARS` is the subset `set` accepts. +const READABLE_SYSVARS: &[&str] = &[ + "celtscale", "clayer", "ctextstyle", "extmax", "extmin", "filletrad", + "insunits", "ltscale", "mirrtext", "osmode", "pdmode", "pdsize", + "textsize", +]; + +fn read_sysvar(scene: &crate::scene::Scene, name: &str) -> Option { + let header = &scene.document.header; + let value = match name.to_ascii_lowercase().as_str() { + "ltscale" => json!(header.linetype_scale), + "pdmode" => json!(header.point_display_mode), + "pdsize" => json!(header.point_display_size), + "celtscale" => json!(header.current_entity_linetype_scale), + "textsize" => json!(header.text_height), + "filletrad" => json!(header.fillet_radius), + "mirrtext" => json!(i16::from(header.mirror_text)), + "insunits" => json!(header.insertion_units), + "osmode" => json!(header.object_snap_mode), + "clayer" => json!(header.current_layer_name), + "ctextstyle" => json!(header.current_text_style_name), + "extmin" => { + let (min, _) = scene.model_space_extents()?; + json!([min.x, min.y, min.z]) + } + "extmax" => { + let (_, max) = scene.model_space_extents()?; + json!([max.x, max.y, max.z]) + } + _ => return None, + }; + Some(value) +} + +fn set_sysvar(document: &mut acadrust::CadDocument, name: &str, value: &Value) -> Result<(), Value> { + let failure = |message: &str| crate::app::control::failure("invalid_sysvar_value", message); + let header = &mut document.header; + match name.to_ascii_lowercase().as_str() { + "ltscale" => { + header.linetype_scale = positive(value, "ltscale", failure)?; + } + "pdmode" => { + header.point_display_mode = integer(value, "pdmode", failure)?; + } + "pdsize" => { + header.point_display_size = value.as_f64().filter(|v| v.is_finite()).ok_or_else(|| { + failure("pdsize needs a finite number") + })?; + } + "celtscale" => { + header.current_entity_linetype_scale = positive(value, "celtscale", failure)?; + } + "textsize" => { + header.text_height = positive(value, "textsize", failure)?; + } + "filletrad" => { + header.fillet_radius = value + .as_f64() + .filter(|v| v.is_finite() && *v >= 0.0) + .ok_or_else(|| failure("filletrad needs a finite number >= 0"))?; + } + "mirrtext" => { + header.mirror_text = value.as_i64().unwrap_or(1) != 0; + } + "insunits" => { + header.insertion_units = integer(value, "insunits", failure)?; + } + "osmode" => { + header.object_snap_mode = value + .as_i64() + .and_then(|v| i32::try_from(v).ok()) + .ok_or_else(|| failure("osmode needs an integer"))?; + } + "clayer" => { + let name = value.as_str().filter(|s| !s.is_empty()).ok_or_else(|| { + failure("clayer needs a layer name") + })?; + let handle = document + .layers + .get(name) + .map(|layer| layer.handle) + .ok_or_else(|| { + failure(&format!("Layer '{name}' does not exist")) + })?; + header.current_layer_name = name.to_owned(); + header.current_layer_handle = handle; + } + "ctextstyle" => { + let name = value.as_str().filter(|s| !s.is_empty()).ok_or_else(|| { + failure("ctextstyle needs a text style name") + })?; + let handle = document + .text_styles + .get(name) + .map(|style| style.handle) + .ok_or_else(|| { + failure(&format!("Text style '{name}' does not exist")) + })?; + header.current_text_style_name = name.to_owned(); + header.current_text_style_handle = handle; + } + other => { + return Err(crate::app::control::failure( + "unknown_sysvar", + format!( + "System variable '{other}' is read-only or unknown. Writable: {}", + "celtscale, clayer, ctextstyle, filletrad, insunits, ltscale, mirrtext, osmode, pdmode, pdsize, textsize" + ), + )) + } + } + Ok(()) +} + +fn positive( + value: &Value, + name: &str, + failure: impl Fn(&str) -> Value, +) -> Result { + value + .as_f64() + .filter(|v| v.is_finite() && *v > 0.0) + .ok_or_else(|| failure(&format!("{name} needs a finite number > 0"))) +} + +fn integer( + value: &Value, + name: &str, + failure: impl Fn(&str) -> Value, +) -> Result { + value + .as_i64() + .and_then(|v| i16::try_from(v).ok()) + .ok_or_else(|| failure(&format!("{name} needs a 16-bit integer"))) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 4b3dcb04e..4000a21b7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,6 @@ mod alias; mod automation; -mod control; +pub(crate) mod control; pub(crate) fn automation_action_names() -> &'static [&'static str] { control::action_names() } @@ -3871,7 +3871,7 @@ impl OpenCADStudio { self.videos = videos; } - fn new() -> Self { + pub(crate) fn new() -> Self { let config = config::AppConfig::load(); if let Err(error) = crate::i18n::set_language(config.settings.language) { eprintln!("Unable to apply saved UI language: {error}"); @@ -4439,6 +4439,17 @@ impl OpenCADStudio { app } + /// Test hook for transports outside `crate::app` (the REST routing + /// tests): push a fresh tab and return its document id, so a second + /// open document can be addressed without reaching into `tabs`. + #[cfg(test)] + pub(crate) fn push_test_document(&mut self) -> u64 { + let tab = document::DocumentTab::new_drawing(1000 + self.tabs.len()); + let id = tab.id; + self.tabs.push(tab); + id + } + /// Install `cmd` as the active interactive command for tab `tab`. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn set_active_command( diff --git a/src/app/record_api.rs b/src/app/record_api.rs index e024bbc20..8a6132270 100644 --- a/src/app/record_api.rs +++ b/src/app/record_api.rs @@ -499,7 +499,7 @@ fn collection_count(document: &acadrust::CadDocument, collection: &str) -> usize } } -fn compare( +pub(crate) fn compare( actual: Option<&Value>, operator: &str, expected: Option<&Value>, @@ -1221,6 +1221,29 @@ impl OpenCADStudio { "concurrency":{"document_id":true,"revision":true,"request_id":true}, "transactions":{"undo":true,"redo":true,"atomic_property_updates":true,"batch":true}, "geometry":{"query":true,"kernel_measurements":true,"spatial_filters":true,"interactive_commands":true}, + "operations":{ + "batch":true, + "block_define":true, + "block_delete":true, + "embed_image":true, + "close":true, + "entities_copy_to":true, + "entities_create":true, + "entities_delete":true, + "entities_transform":true, + "file_identity":true, + "group_create":true, + "layout_create":true, + "page_setup_set":true, + "selection_set_load":true, + "selection_set_save":true, + "sysvar":true, + "plot":true, + "view_focus":true, + "wblock":true, + "xdata_get":true, + "xdata_set":true, + }, "records":{ "read":"records", "write":"set_properties", diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 09be3f45c..121e383b9 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -112,6 +112,26 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.pending_close = Some(crate::app::PendingClose::Tab(idx)); return self.open_unsaved_dialog_window(); } + // Pending client interactive requests pinned to this drawing + // can never be answered once the tab is gone — cancel them. + let closing_tab_id = self.tabs.get(idx).map(|t| t.id); + let pinned = self + .control + .get_point + .as_ref() + .map(|s| s.document_id) + .filter(|&id| Some(id) == closing_tab_id) + .or_else(|| { + self.control + .user_select + .as_ref() + .map(|s| s.document_id) + .filter(|&id| Some(id) == closing_tab_id) + }); + if pinned.is_some() { + self.resolve_get_point(None); + self.resolve_user_select(false); + } #[cfg(not(target_arch = "wasm32"))] let tab_id = self.tabs.get(idx).map(|t| t.id); // This tab is closing for good — drop its autosave recovery copy. @@ -860,6 +880,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if let Some(cmd) = self.command_line.submit() { return self.dispatch_command_or_suggest(&cmd); } + // Empty Enter with a pending client user_select request is the + // person's answer: hand the picked set back to the caller + // before the repeat-last-command shortcut can fire. + if self.control.user_select.is_some() { + self.resolve_user_select(true); + return Task::none(); + } // Empty Enter / Space with no active command repeats the // last dispatched command — same shortcut `CommandFinalize` // already implements, mirrored here so the trailing-space @@ -1009,6 +1036,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; if self.tabs[i].active_cmd.is_some() { self.feed_command(crate::command::StepInput::Enter) + } else if self.control.user_select.is_some() { + // Enter with no command active answers a pending client + // user_select request — same contract as the command-line + // empty-submit path above. + self.resolve_user_select(true); + Task::none() } else if let Some(cmd) = self.tabs[i].last_cmd.clone() { self.dispatch_command(&cmd) } else { @@ -1017,6 +1050,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_command_escape(&mut self) -> Task { + // Escape answers a pending client getpoint request first: no point. + if self.control.get_point.is_some() { + self.resolve_get_point(None); + return Task::none(); + } + // Escape answers a pending client user_select request before anything + // else — the person on the screen is who the request is waiting for. + if self.control.user_select.is_some() { + self.resolve_user_select(false); + return Task::none(); + } if self.ribbon.escape_extension() { return Task::none(); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index b23d252c4..02058be7d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -358,6 +358,34 @@ fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> { .then_some(bounds) } +/// Stamp the owning entity's effective color index onto a plot wire so CTB +/// plot-style lookups (`wire.aci > 0`) apply: an entity's own ACI 1-255 is +/// used as-is, ByLayer resolves through the layer table, and true-color +/// objects keep `aci = 0` ("0 means true-color" — no CTB mapping). Wires +/// whose owner cannot be resolved keep the index they carry. +fn plot_owner_aci(scene: &crate::scene::Scene, wire: &mut crate::scene::WireModel) { + let Some(handle) = crate::scene::Scene::handle_from_wire_name(&wire.name) else { + return; + }; + let Some(entity) = scene.document.get_entity(handle) else { + return; + }; + let common = entity.common(); + wire.aci = match &common.color { + acadrust::types::Color::Index(index) => *index, + acadrust::types::Color::ByLayer => scene + .document + .layers + .get(&common.layer) + .map(|layer| match &layer.color { + acadrust::types::Color::Index(index) => *index, + _ => 0, + }) + .unwrap_or(7), + _ => 0, + }; +} + fn plot_scene_content( scene: &crate::scene::Scene, paper_space_last: bool, @@ -389,7 +417,10 @@ fn plot_scene_content( wires .into_iter() .zip(depths) - .map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth }) + .map(|(mut wire, draw_depth)| { + plot_owner_aci(scene, &mut wire); + crate::io::pdf_export::PlotWire { wire, draw_depth } + }) .collect::>() }; let paper_wires = with_depth(paper_wires); @@ -417,7 +448,10 @@ fn plot_scene_content( model_pattern_wires.retain(|(wire, _)| wire.plot_visible); let model_pattern_wires = model_pattern_wires .into_iter() - .map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth }) + .map(|(mut wire, draw_depth)| { + plot_owner_aci(scene, &mut wire); + crate::io::pdf_export::PlotWire { wire, draw_depth } + }) .collect::>(); let (wires, hatches, wipeouts, images, splits) = if paper_space_last { @@ -3703,10 +3737,11 @@ impl OpenCADStudio { } }; let worker_path = path.clone(); + let fallback_style = self.dialog_plot_style(&dialog); self.save_config(); self.close_active_modal(); let work = move || { - crate::io::pdf_export::export_pdf_pages(&pages, &worker_path, None) + crate::io::pdf_export::export_pdf_pages(&pages, &worker_path, fallback_style.as_ref()) .map(|_| { crate::tf!( "Exported {} layouts to {}", @@ -3745,13 +3780,14 @@ impl OpenCADStudio { } }; let options = self.plot_print_options(&dialog); + let fallback_style = self.dialog_plot_style(&dialog); let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); self.close_active_modal(); self.command_line .push_info(crate::t!("Sending selected layouts to the system printer…").as_ref()); let work = move || { - crate::io::pdf_export::export_pdf_pages(&pages, &temp_path, None) + crate::io::pdf_export::export_pdf_pages(&pages, &temp_path, fallback_style.as_ref()) .and_then(|_| { crate::io::print_to_printer::print_existing_pdf(&temp_path, &options) }) @@ -3796,7 +3832,7 @@ impl OpenCADStudio { self.layout_plot_page_for(&self.plot_dialog.area) } - fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput { + pub(in crate::app) fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput { let i = self.active_tab; let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; @@ -4945,7 +4981,7 @@ impl OpenCADStudio { } /// Load a `PlotSettings` into the dialog editor fields. - fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { + pub(in crate::app) fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { use crate::io::paper_catalog::PaperUnits; use acadrust::objects::{PlotType, ShadePlotMode, ShadePlotResolutionLevel}; self.plot_setup_template = Some(ps.clone()); @@ -5303,15 +5339,15 @@ impl OpenCADStudio { .cloned() } - fn window_plot_job(&self) -> Option { + pub(in crate::app) fn window_plot_job(&self) -> Option { self.area_plot_job(self.plot_window?) } - fn display_plot_job(&self) -> Option { + pub(in crate::app) fn display_plot_job(&self) -> Option { self.area_plot_job(self.display_plot_window()?) } - fn limits_plot_job(&self) -> Option { + pub(in crate::app) fn limits_plot_job(&self) -> Option { let (min, max) = self.tabs[self.active_tab].scene.current_drawing_limits()?; self.area_plot_job((min.x, min.y, max.x, max.y)) } @@ -5338,7 +5374,7 @@ impl OpenCADStudio { )) } - fn extents_plot_job(&self) -> Option { + pub(in crate::app) fn extents_plot_job(&self) -> Option { let scene = &self.tabs[self.active_tab].scene; if scene.current_layout == "Model" { let (min, max) = scene.model_space_extents()?; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a0201728a..d8ebf02f4 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -97,7 +97,7 @@ mod dynamic; mod file; mod page_setup_import; mod style; -mod util; +pub(in crate::app) mod util; mod viewport; mod viewport_snap; @@ -404,10 +404,13 @@ impl OpenCADStudio { self.finish_all_pending_history(); // After every message, mirror the active command step's prompt so // its history line stays pinned (non-fading) until the step changes. + // A pending client pick (`user_select` / `getpoint`) pins its labeled + // request line the same way while no command owns the prompt. let prompt = self.tabs[self.active_tab] .active_cmd .as_ref() - .map(|c| c.prompt()); + .map(|c| c.prompt()) + .or_else(|| self.pending_pick_label()); self.command_line.set_step_prompt(prompt); // Mirror the step's clickable options so they render as buttons (#304). let opts = self.tabs[self.active_tab] @@ -528,7 +531,36 @@ impl OpenCADStudio { Task::none() } Message::ControlRequest(envelope) => { + // Mirror the automation trail into the message list so the + // person at the screen can review what clients asked and + // where it failed — the JSON reply alone reaches only the + // caller. High-frequency polls are traffic, not activity. + let op = envelope.request["op"].as_str().unwrap_or("").to_owned(); let (response, task) = self.control_request(envelope.request); + if !matches!( + op.as_str(), + "" | "state" | "hello" | "operation" | "events" | "capabilities" + ) { + if response["ok"] == false { + let code = response["code"].as_str().unwrap_or("failed"); + let detail = response["error"].as_str().unwrap_or(""); + self.command_line.push_error(&format!( + "automation {op}: {code} — {detail}" + )); + } else if matches!( + response["status"].as_str(), + Some("completed" | "cancelled" | "waiting_input") + ) && !matches!( + op.as_str(), + // These already narrate themselves in richer lines. + "user_select" | "getpoint" | "cancel" + ) { + self.command_line.push_info(&format!( + "automation {op}: {}", + response["status"].as_str().unwrap_or("") + )); + } + } envelope.reply.send(response); task } diff --git a/src/app/update/util.rs b/src/app/update/util.rs index 8115d3f40..0a2998cb1 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -62,7 +62,7 @@ pub(super) fn layout_entry_name(s: &str) -> &str { s.trim_start_matches('*').trim_end_matches('*') } -pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) { +pub(in crate::app) fn parse_plot_scale(s: &str) -> (f64, f64) { if s == "Fit" { return (1.0, 1.0); } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 2ea1d6471..cde179221 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -976,6 +976,21 @@ impl OpenCADStudio { Task::none() } + /// A client automation pick (`getpoint`/`user_select`) is waiting on tab + /// `i`'s document — the cursor behaves like a command point pick: the snap + /// engine runs on every move and the marker stays until the pick settles. + pub(super) fn client_pick_pending(&self, i: usize) -> bool { + self.control + .get_point + .as_ref() + .is_some_and(|session| session.document_id == self.tabs[i].id) + || self + .control + .user_select + .as_ref() + .is_some_and(|session| session.document_id == self.tabs[i].id) + } + pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task { // A ribbon dropdown is open over the viewport. Its backdrop // cannot swallow cursor motion — in iced 0.14 mouse_area/opaque @@ -1900,6 +1915,76 @@ impl OpenCADStudio { self.tabs[i].last_cursor_world = world; } + // A pending client pick (`getpoint` or `user_select`) is a command-grade + // pick without a command: run the object-snap engine here too, so the + // snap marker shows while the person aims. `getpoint` answers with the + // snapped point (`on_viewport_left_press` resolves from + // `last_cursor_world`); for `user_select` the marker is aiming feedback + // — the click still selects the entity under the cursor. Inputs mirror + // the command snap path below; OTRACK/polar/axis locks stay command-only. + if self.tabs[i].active_cmd.is_none() && self.client_pick_pending(i) + { + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vp_size.0, + height: vp_size.1, + }; + let cursor_world = self.cursor_model_point(i, &edit_cam, p, bounds); + let (gx, gy, adaptive) = (self.grid_spacing_x, self.grid_spacing_y, self.grid_adaptive); + let visible_step = |distance: f32, fov_y: f32| { + let (sx, sy) = crate::ui::overlay::compute_grid_steps( + gx, gy, distance, fov_y, bounds, adaptive, + ); + sx.max(sy) + }; + let (view_rot, eye, grid_spacing) = match &edit_cam { + Some(cam) => ( + cam.view_proj_rte(bounds), + cam.eye(), + visible_step(cam.distance, cam.fov_y), + ), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + ( + cam.view_proj_rte(bounds), + cam.eye(), + visible_step(cam.distance, cam.fov_y), + ) + } + }; + self.snapper.grid_spacing = grid_spacing; + let all_wires = + if let (Some(_), Some(h)) = (&edit_cam, self.tabs[i].scene.active_viewport) { + self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height) + } else { + self.tabs[i].scene.hit_test_wires() + }; + let snap_candidates = self.tabs[i].scene.interaction_candidates_near( + all_wires, + cursor_world, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + ); + let (go, gr) = self.drafting_grid_basis(i); + let snap_hit = self.snapper.snap( + cursor_world, + p, + &snap_candidates, + view_rot, + eye, + bounds, + go, + gr, + None, + ); + self.tabs[i].snap_result = snap_hit; + self.tabs[i].last_cursor_world = + snap_hit.map(|hit| hit.world).unwrap_or(cursor_world); + } + // Rollover highlight: when idle (no active command, no // drag), defer the pick until the cursor stops. The full // pick (wires + hatches + block hatches + shaded meshes) is @@ -2674,7 +2759,10 @@ impl OpenCADStudio { .scene .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); - } else { + } else if !self.client_pick_pending(i) { + // Idle (no command, no pending client pick): the snap marker is + // command-only, so clear it — a pending client pick keeps the + // marker its move-time snap block above computed. self.tabs[i].snap_result = None; } @@ -3123,6 +3211,22 @@ impl OpenCADStudio { return Task::none(); } } + // A pending client `getpoint` request turns this click into the + // answer: the snapped world point under the cursor. The pick must + // not change the selection while the person is answering, and only + // the pinned document's viewport may answer. + if self.tabs[i].active_cmd.is_none() { + let answers = self + .control + .get_point + .as_ref() + .is_some_and(|session| session.document_id == self.tabs[i].id); + if answers { + let world = self.tabs[i].last_cursor_world; + self.resolve_get_point(Some([world.x, world.y, world.z])); + return Task::none(); + } + } let (p, vp_size) = { let sel = self.tabs[i].scene.selection.borrow(); let p = match sel.last_move_pos { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a614e72c8..0d98f523e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -913,6 +913,7 @@ bg={bg_ms:.1}ms n={view_count}" isometric: self.isometric_drafting, iso_plane: self.iso_plane, snap_angle_deg: self.snap_angle_deg, + pick_pending: self.pending_pick_label().is_some(), }, crate::ui::overlay::SelectionVisualOptions { area: self.model_space.selection_area, @@ -2061,6 +2062,7 @@ bg={bg_ms:.1}ms n={view_count}" self.win_size.1, self.control.enabled, self.control_busy(), + self.pending_pick_label().is_some(), ); let center_stack: Element<'_, Message> = if thumbnail_capture_clean { workspace @@ -2726,7 +2728,11 @@ impl OpenCADStudio { }) }; #[cfg(not(target_arch = "wasm32"))] - let control = super::control::subscribe().map(Message::ControlRequest); + let control = iced::Subscription::batch([ + super::control::subscribe().map(Message::ControlRequest), + // Loopback REST channel when launched with files + --http. + super::control::http_bridge::subscribe().map(Message::ControlRequest), + ]); #[cfg(target_arch = "wasm32")] let control = iced::time::every(std::time::Duration::from_millis(50)) .map(|_| Message::PollWebControl); diff --git a/src/cli.rs b/src/cli.rs index 1e2d5153c..f5dc8f41f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -85,6 +85,11 @@ pub struct Cli { #[arg(help = crate::t!("TCP port for --serve (defaults to stdin/stdout).").into_owned(), long_help = None)] pub port: Option, + /// Expose the headless automation API as a REST HTTP server on this port. + #[arg(long, value_name = "PORT")] + #[arg(help = crate::t!("Serve the automation API over REST on http://127.0.0.1:PORT").into_owned(), long_help = None)] + pub http: Option, + /// Headless convert: read IN, write OUT (format from OUT's extension), exit. #[arg(long, num_args = 2, value_names = ["IN", "OUT"])] #[arg(help = crate::t!("Convert IN to OUT using the output file extension, then exit.").into_owned(), long_help = None)] diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index c177f5e79..61131aba1 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -27,6 +27,7 @@ pub(crate) fn set_dimension_text_override(base: &mut DimensionBase, text: Option /// Recompute the fields a script cannot set: the base definition point and /// the stored measurement, exactly as grip edits do. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn normalize_scripted_dimension(dim: &mut Dimension) { let definition_point = dimension_definition_point(dim); dim.base_mut().definition_point = definition_point; diff --git a/src/entities/helix.rs b/src/entities/helix.rs index 2d479f17f..fd8c60de2 100644 --- a/src/entities/helix.rs +++ b/src/entities/helix.rs @@ -606,6 +606,7 @@ mod tests { /// Rebuild the derived spline after a scripted create or edit of the /// generating parameters. `radius` is the top radius; the base radius is the /// start point's distance from the axis. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn normalize_scripted_helix(old: Option<&Helix>, new: &mut Helix) -> Result<(), String> { let unchanged = old.is_some_and(|old| { old.axis_base_point == new.axis_base_point diff --git a/src/entities/mline.rs b/src/entities/mline.rs index e2ead2af9..4f65ab56f 100644 --- a/src/entities/mline.rs +++ b/src/entities/mline.rs @@ -421,6 +421,7 @@ fn restore_shifted_mline_segment( /// Recompute the derived MLine state after a scripted create or edit: /// vertex direction/miter, element segment layout and the per-element miter /// offsets. Dash/cut parameters survive an edit unless a vertex moved. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn normalize_scripted_mline( old: Option<&MLine>, new: &mut MLine, diff --git a/src/entities/table.rs b/src/entities/table.rs index f2de572b8..a17c8757d 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -3343,6 +3343,7 @@ impl Transformable for Table { /// Keep the per-cell merge dimensions (what DXF stores) in step with /// `merged_ranges` (what DWG stores) after a scripted create or edit. Cells a /// script did not touch keep their merge state when the ranges are unchanged. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn normalize_scripted_table(old: Option<&Table>, new: &mut Table) { if old.is_some_and(|old| old.merged_ranges == new.merged_ranges) { return; diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 4b505bc93..bb061ab60 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -1673,6 +1673,37 @@ fn emit_text( } } +/// Everything searchable in an exported file: the raw bytes (printpdf +/// leaves small content streams uncompressed) plus any zlib streams that +/// decode to mostly-printable text, so the assertions hold either way. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn pdf_stream_text(bytes: &[u8]) -> String { + use std::io::Read as _; + let mut text = String::from_utf8_lossy(bytes).into_owned(); + for start in 0..bytes.len().saturating_sub(2) { + // zlib streams start with a 2-byte header: deflate method, valid check. + let (cmf, flg) = (bytes[start], bytes[start + 1]); + if cmf & 0x0f != 8 || ((cmf as u16) << 8 | flg as u16) % 31 != 0 { + continue; + } + let mut decoded = Vec::new(); + if flate2::read::ZlibDecoder::new(&bytes[start..]) + .read_to_end(&mut decoded) + .is_ok() + && decoded.len() > 32 + { + let printable = decoded + .iter() + .filter(|&&b| matches!(b, b'\n' | b'\r' | b'\t' | 32..=126)) + .count(); + if printable * 10 >= decoded.len() * 9 { + text.push_str(&String::from_utf8_lossy(&decoded)); + } + } + } + text +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -1730,6 +1761,116 @@ mod tests { assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len()); } + + #[test] + fn export_fallback_plot_style_recolors_wires() { + // ACI 1 carrying pure device red: without a style it must export as an + // RGB operator; the monochrome fallback must recolor it. Guards the + // automation plot path, where the per-page style can be dropped. + let make_page = || { + let mut wire = PlotWire { + wire: WireModel::solid( + "test".into(), + vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]], + [1.0, 0.0, 0.0, 1.0], + false, + ), + draw_depth: 0.0, + }; + wire.wire.aci = 1; + test_page(vec![wire]) + }; + + let unstyled = pdf_stream_text(&build_pdf_pages(&[make_page()], None).unwrap()); + let monochrome = + PlotStyleTable::builtin("monochrome.ctb").expect("shipped monochrome.ctb parses"); + let styled = + pdf_stream_text(&build_pdf_pages(&[make_page()], Some(&monochrome)).unwrap()); + + assert!( + unstyled.contains("1 0 0 rg") || unstyled.contains("1 0 0 RG"), + "unstyled export should keep the wire's RGB color" + ); + assert!( + !styled.contains("1 0 0 rg") && !styled.contains("1 0 0 RG"), + "monochrome fallback must recolor the wire" + ); + } + + /// Parse every stroke-width operator (` w`) out of the content text. + fn stroke_widths(text: &str) -> Vec { + let tokens: Vec<&str> = text.split_whitespace().collect(); + tokens + .windows(2) + .filter_map(|pair| { + if pair[1] == "w" { + pair[0].parse::().ok() + } else { + None + } + }) + .collect() + } + + fn distinct_widths(mut widths: Vec) -> Vec { + widths.sort_by(|a, b| a.partial_cmp(b).unwrap()); + widths.dedup_by(|a, b| (*a - *b).abs() < 0.01); + widths + } + + #[test] + fn plot_honors_per_object_lineweight_with_and_without_style() { + // Two objects whose resolved lineweights differ (as two layers set to + // 0.13 mm and 0.50 mm produce): the PDF must carry two distinct pen + // widths, and a plot style that leaves weights at "use object" must + // keep that hierarchy. + let make_page = |px: f32| { + let mut wire = PlotWire { + wire: WireModel::solid( + "test".into(), + vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]], + [1.0, 0.0, 0.0, 1.0], + false, + ), + draw_depth: 0.0, + }; + wire.wire.aci = 7; + wire.wire.line_weight_px = px; + test_page(vec![wire]) + }; + + let monochrome = + PlotStyleTable::builtin("monochrome.ctb").expect("shipped monochrome.ctb parses"); + eprintln!( + "monochrome resolve_lineweight(7) = {:?}", + monochrome.resolve_lineweight(7) + ); + + let unstyled = stroke_widths(&pdf_stream_text( + &build_pdf_pages(&[make_page(1.0), make_page(3.0)], None).unwrap(), + )); + let unstyled = distinct_widths(unstyled); + assert_eq!(unstyled.len(), 2, "two object weights, two pens: {unstyled:?}"); + assert!( + (unstyled[1] / unstyled[0] - 3.0).abs() < 0.1, + "pen widths follow the object weights 1px:3px: {unstyled:?}" + ); + + let styled = stroke_widths(&pdf_stream_text( + &build_pdf_pages(&[make_page(1.0), make_page(3.0)], Some(&monochrome)).unwrap(), + )); + let styled = distinct_widths(styled); + assert_eq!( + styled.len(), + 2, + "monochrome.ctb leaves weights at 'use object', so the two weights survive: {styled:?}" + ); + assert!( + (styled[1] / styled[0] - 3.0).abs() < 0.1, + "weight hierarchy matches the unstyled plot: {styled:?}" + ); + } + // Build a WireModel carrying the SDF glyph quads for `text` in the embedded // "txt" stroke font, laid out into the process-wide atlas emit_text reads. fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire { diff --git a/src/lib.rs b/src/lib.rs index 6d0303ee3..b0b83da1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,8 @@ pub mod par; pub mod patreon; pub mod perf; pub mod plugin; +#[cfg(not(target_arch = "wasm32"))] +pub mod rest; pub mod scene; pub mod snap; pub mod sys; diff --git a/src/main.rs b/src/main.rs index 0975cc38a..bfccf418b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use OpenCADStudio::app; #[cfg(not(target_arch = "wasm32"))] -use OpenCADStudio::{cli, io, mcp}; +use OpenCADStudio::{cli, io, mcp, rest}; #[cfg(target_arch = "wasm32")] use OpenCADStudio::sys; @@ -101,6 +101,17 @@ fn main() -> iced::Result { let gpu = OpenCADStudio::gpu_backend::resolve_gpu(args.backend.as_deref(), args.safe_mode); // Headless modes exit without ever creating a window. + if let Some(port) = args.http { + if args.files.is_empty() { + rest::serve(port); + return Ok(()); + } + // Files + --http: boot the GUI and host the loopback REST channel + // on this very process, so a client can open a drawing, let the + // person pick sample entities, and read them back with + // get_selection (see app::control::http_bridge). + rest::set_gui_http_port(port); + } if args.serve { // `app::serve` reads --port itself from the raw args. app::serve(); @@ -124,8 +135,14 @@ fn main() -> iced::Result { // Only bare files forward. `--read-only` / `--script` / `--new` // configure the whole editor rather than a tab, so they always // get a process of their own. - let plain_open = - !args.read_only && args.script.is_none() && !args.new && !args.files.is_empty(); + let plain_open = !args.read_only + && args.script.is_none() + && !args.new + && !args.files.is_empty() + // --http hosts its REST channel in this very process, so + // the drawing must open here too, never in the existing + // editor. + && args.http.is_none(); if plain_open && io::single_instance::handoff(stream, &args.files) { return Ok(()); } diff --git a/src/mcp.rs b/src/mcp.rs index 0f7107c0d..00fe5b6b1 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -22,7 +22,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; -const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", @@ -39,6 +39,7 @@ const READ_OPS: &[&str] = &[ "commands", "events", "operation", + "xdata_get", ]; const EXECUTE_OPS: &[&str] = &[ "new", @@ -55,6 +56,26 @@ const EXECUTE_OPS: &[&str] = &[ "set_properties", "action", "embed_image", + "wblock", + "plot", + "entities_create", + "entities_delete", + "entities_transform", + "block_define", + "block_delete", + "xdata_set", + "view_focus", + "entities_copy_to", + "group_create", + "selection_set_save", + "selection_set_load", + "user_select", + "getpoint", + "close", + "sysvar", + "layout_create", + "page_setup_set", + "file_identity", "save", "stop", "batch", @@ -74,6 +95,26 @@ const BATCH_STEP_OPS: &[&str] = &[ "set_properties", "action", "embed_image", + "wblock", + "plot", + "entities_create", + "entities_delete", + "entities_transform", + "block_define", + "block_delete", + "xdata_set", + "view_focus", + "entities_copy_to", + "group_create", + "selection_set_save", + "selection_set_load", + "user_select", + "getpoint", + "close", + "sysvar", + "layout_create", + "page_setup_set", + "file_identity", "save", "stop", ]; @@ -725,6 +766,86 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { r#"{"op":"embed_image","path":"/path/logo.png","at":[0,0,0],"width":100}"#, ) } + "wblock" if request["path"].as_str().is_none_or(str::is_empty) => { + missing( + "path", + r#"{"op":"wblock","path":"/path/part.dwg","handles":["2A","31"]}"#, + ) + } + "plot" if request["path"].as_str().is_none_or(str::is_empty) => { + missing( + "path", + r#"{"op":"plot","path":"/path/pages.pdf","layout":"Model"}"#, + ) + } + "entities_create" + if request["entities"].as_array().is_none_or(Vec::is_empty) => + { + missing( + "entities", + r#"{"op":"entities_create","entities":[{"type":"Circle","center":[0,0,0],"radius":5}]}"#, + ) + } + "entities_delete" | "entities_transform" | "view_focus" + if request["handles"].as_array().is_none_or(Vec::is_empty) => + { + missing( + "handles", + r#"{"op":"entities_delete","handles":["2A","31"]}"#, + ) + } + "block_define" + if request["name"].as_str().is_none_or(str::is_empty) + || request["handles"].as_array().is_none_or(Vec::is_empty) + || request["base"].as_array().is_none() => + { + missing( + "name, handles and base", + r#"{"op":"block_define","name":"MARK","base":[0,0,0],"handles":["2A"]}"#, + ) + } + "xdata_set" + if request["app"].as_str().is_none_or(str::is_empty) + || request["handles"].as_array().is_none_or(Vec::is_empty) => + { + missing( + "app and handles", + r#"{"op":"xdata_set","app":"SPM","handles":["2A"],"data":[{"code":1000,"value":"tag"}]}"#, + ) + } + "entities_copy_to" + if request["handles"].as_array().is_none_or(Vec::is_empty) + || request["document_id"].as_u64().is_none() => + { + missing( + "handles and document_id", + r#"{"op":"entities_copy_to","handles":["2A"],"document_id":2}"#, + ) + } + "group_create" | "selection_set_save" + if request["name"].as_str().is_none_or(str::is_empty) + || request["handles"].as_array().is_none_or(Vec::is_empty) => + { + missing( + "name and handles", + r#"{"op":"group_create","name":"Frame","handles":["2A"]}"#, + ) + } + "selection_set_load" if request["name"].as_str().is_none_or(str::is_empty) => { + missing("name", r#"{"op":"selection_set_load","name":"Frame"}"#) + } + "block_delete" if request["name"].as_str().is_none_or(str::is_empty) => { + missing("name", r#"{"op":"block_delete","name":"Frame"}"#) + } + "layout_create" if request["name"].as_str().is_none_or(str::is_empty) => { + missing("name", r#"{"op":"layout_create","name":"Plan"}"#) + } + "page_setup_set" if request["layout"].as_str().is_none_or(str::is_empty) => { + missing( + "layout", + r#"{"op":"page_setup_set","layout":"Plan","paper":"ISO_A4_(210.00_x_297.00_MM)"}"#, + ) + } _ => Ok(()), } } @@ -910,7 +1031,49 @@ fn execute_request_schema() -> Value { "camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."}, "selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."}, "cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]}, - "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image."}, + "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot."}, + "block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."}, + "linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."}, + "layout":{"type":"string","description":"plot: Model (default), a layout name, or all."}, + "area":{"type":"string","enum":["extents","display","limits","window","layout"],"description":"plot area; layout applies to paper-space layouts."}, + "window":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"World [x0,y0,x1,y1] plot rectangle for area window."}, + "paper":{"type":"string","description":"plot: canonical paper-catalog sheet name."}, + "orientation":{"type":"string","enum":["Portrait","Landscape"],"description":"plot sheet orientation."}, + "fit":{"type":"boolean","description":"plot: scale content to fit the sheet (default true)."}, + "scale":{"type":"string","description":"plot scale as paper:drawing, e.g. 1:100; disables fit."}, + "center":{"type":"boolean","description":"plot: center the content on the sheet (default true)."}, + "offset_x":{"type":"number","description":"plot offset when not centered."}, + "offset_y":{"type":"number","description":"plot offset when not centered."}, + "upside_down":{"type":"boolean","description":"plot: rotate content 180 degrees."}, + "plot_style":{"type":"string","description":"plot: CTB file path or a discovered plot style name."}, + "transparency":{"type":"boolean","description":"plot: keep transparency."}, + "lineweights":{"type":"boolean","description":"plot: honor object lineweights."}, + "merge_lines":{"type":"boolean","description":"plot: merge overlapping lines."}, + "stamp":{"type":"boolean","description":"plot: draw the plot stamp."}, + "entities":{"type":"array","minItems":1,"description":"entities_create: typed definitions, one object per entity. type is one of Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch; geometry fields depend on the type; layer and color (ACI) are optional and a missing layer is created.","items":{"type":"object"}}, + "action":{"type":"string","enum":["move","copy","rotate","scale","mirror","array"],"description":"entities_transform action (default move)."}, + "vector":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"move/copy displacement [dx,dy(,dz)]."}, + "center":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"rotate/scale center point."}, + "angle_deg":{"type":"number","description":"rotate angle, degrees CCW."}, + "factor":{"type":"number","exclusiveMinimum":0,"description":"scale factor."}, + "axis":{"type":"array","minItems":2,"maxItems":2,"description":"mirror line [[x1,y1],[x2,y2]].","items":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}}, + "copy":{"type":"boolean","description":"mirror: keep the originals and return mirrored copies."}, + "rows":{"type":"integer","minimum":1,"description":"array row count (default 1)."}, + "columns":{"type":"integer","minimum":1,"description":"array column count (default 1)."}, + "row_spacing":{"type":"number","description":"array row spacing."}, + "column_spacing":{"type":"number","description":"array column spacing."}, + "base":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"block_define base point (becomes the block origin)."}, + "insert_at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"block_define Insert placement (default = base)."}, + "app":{"type":"string","minLength":1,"description":"xdata_set: application name; its RegApp table entry is registered automatically."}, + "data":{"type":"array","description":"xdata_set typed values [{code,value}]: 1000 string, 1003 layer, 1004 hex bytes, 1005 hex handle, 1010-1013 [x,y,z], 1040/1041/1042 real, 1070 int16, 1071 int32. An empty or absent list removes the application record.","items":{"type":"object","properties":{"code":{"type":"integer"},"value":{}},"required":["code","value"]}}, + "highlight":{"type":"boolean","description":"view_focus: also select the entities (default true)."}, + "template":{"type":"string","description":"new/wblock: load this DWG/DXF/DWT file as the base document so its tables and styles survive."}, + "discard":{"type":"boolean","description":"close: true erases unsaved changes instead of refusing a dirty document."}, + "per_page":{"type":"boolean","description":"plot: write one PDF per layout as -.pdf."}, + "get":{"type":"array","items":{"type":"string"},"description":"sysvar: variable names to read (ltscale, pdmode, pdsize, celtscale, textsize, filletrad, mirrtext, insunits, osmode, clayer, ctextstyle, extmin, extmax)."}, + "set":{"type":"object","description":"sysvar: name=value pairs to write in one undo step.","additionalProperties":true}, + "select":{"type":"boolean","description":"selection_set_load: also select the recalled entities (default true)."}, + "where":{"type":"array","description":"query: cross-property filters over entity properties with RFC 6901 paths and the records operator set.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}}, "at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World [x,y] or [x,y,z] placement corner for embed_image (picture grows up-right)."}, "width":{"type":"number","exclusiveMinimum":0,"description":"World width for embed_image; height follows the image aspect ratio. Defaults to pixel_width/100."}, "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."}, @@ -918,7 +1081,7 @@ fn execute_request_schema() -> Value { "point":point, "space":{"type":"string","enum":["wcs","ucs","relative"],"default":"wcs","description":"Coordinate space for point input."}, "handle":handle.clone(), - "handles":{"type":"array","items":handle,"description":"Entity handles to select."}, + "handles":{"type":"array","items":handle,"description":"Entity handles: selection filter for select, export set for wblock."}, "type":{"type":"string","description":"Entity type filter for select."}, "layer":{"type":"string","description":"Layer filter for select."}, "clear":{"type":"boolean","description":"Clear the current selection before applying select filters."}, @@ -954,6 +1117,26 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]}, {"properties":{"op":{"const":"action"}},"required":["name"]}, {"properties":{"op":{"const":"embed_image"}},"required":["path"]}, + {"properties":{"op":{"const":"wblock"}},"required":["path"]}, + {"properties":{"op":{"const":"plot"}},"required":["path"]}, + {"properties":{"op":{"const":"entities_create"}},"required":["entities"]}, + {"properties":{"op":{"const":"entities_delete"}},"required":["handles"]}, + {"properties":{"op":{"const":"entities_transform"}},"required":["handles"]}, + {"properties":{"op":{"const":"block_define"}},"required":["name","base","handles"]}, + {"properties":{"op":{"const":"block_delete"}},"required":["name"]}, + {"properties":{"op":{"const":"xdata_set"}},"required":["app","handles"]}, + {"properties":{"op":{"const":"view_focus"}},"required":["handles"]}, + {"properties":{"op":{"const":"entities_copy_to"}},"required":["handles","document_id"]}, + {"properties":{"op":{"const":"group_create"}},"required":["name","handles"]}, + {"properties":{"op":{"const":"selection_set_save"}},"required":["name","handles"]}, + {"properties":{"op":{"const":"selection_set_load"}},"required":["name"]}, + {"properties":{"op":{"const":"user_select"},"description":"Ask the person at the screen to pick entities; resolves when they press Enter (or cancel on Escape). Optional type/layer/prompt/detail/clear."}}, + {"properties":{"op":{"const":"getpoint"},"description":"Ask the person at the screen to pick one point; resolves with the picked point when they click, or cancels on Escape. Optional prompt."}}, + {"properties":{"op":{"const":"close"}}}, + {"properties":{"op":{"const":"sysvar"}}}, + {"properties":{"op":{"const":"layout_create"}},"required":["name"]}, + {"properties":{"op":{"const":"page_setup_set"}},"required":["layout"]}, + {"properties":{"op":{"const":"file_identity"}}}, {"properties":{"op":{"const":"save"}}}, {"properties":{"op":{"const":"stop"}}}, {"properties":{"op":{"const":"batch"}},"required":["steps"]} @@ -1005,7 +1188,7 @@ fn tool_definitions() -> Value { }, { "name":"ocs_execute", - "description":"Execute semantic OCS actions. Use current state fields and a unique request_id. Use run for one complete command, batch to remove round trips, or start plus input for guided steps. accepted, running and waiting_input are not completion.", + "description":"Execute semantic OCS actions. Use current state fields and a unique request_id. Use run for one complete command, batch to remove round trips, or start plus input for guided steps. user_select asks the person at the screen to pick entities and stays running until they press Enter (answer with entities) or Escape (cancelled). accepted, running and waiting_input are not completion.", "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["ocs_session_id","request"],"additionalProperties":false}, "outputSchema":execute_output_schema(), "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} diff --git a/src/modules/insert/wblock.rs b/src/modules/insert/wblock.rs index 5c9bf9554..6177a1fcc 100644 --- a/src/modules/insert/wblock.rs +++ b/src/modules/insert/wblock.rs @@ -24,6 +24,18 @@ pub fn tool() -> ToolDef { /// /// Returns `Err` if the block is not found or has no entities. pub fn extract_block_to_doc(src: &CadDocument, block_name: &str) -> Result { + let mut out = CadDocument::new(); + extract_block_into(src, block_name, &mut out)?; + Ok(out) +} + +/// Extract the block's entities into `out` (a fresh document or a loaded +/// template whose tables/styles should survive — Catalog §2.1). +pub fn extract_block_into( + src: &CadDocument, + block_name: &str, + out: &mut CadDocument, +) -> Result<(), String> { let br = src .block_records .get(block_name) @@ -38,7 +50,6 @@ pub fn extract_block_to_doc(src: &CadDocument, block_name: &str) -> Result Result Result { + let mut out = CadDocument::new(); + extract_entities_into(src, handles, &mut out)?; + Ok(out) +} + +/// Extract the listed entities into `out` (fresh document or template base). +pub fn extract_entities_into( + src: &CadDocument, + handles: &[acadrust::Handle], + out: &mut CadDocument, +) -> Result<(), String> { if handles.is_empty() { return Err(t!("No entities selected for WBLOCK.").into_owned()); } - let mut out = CadDocument::new(); - for &h in handles { if let Some(entity) = src.get_entity(h) { if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { @@ -109,5 +129,31 @@ pub fn extract_entities_to_doc( return Err(t!("None of the selected entities could be exported.").into_owned()); } - Ok(out) + Ok(()) +} + +/// Translate every entity of `out` so the overall bounds minimum lands on +/// the origin — SPM.ACAD's clone flow normalizes the new drawing to 0,0,0 +/// after the copy (Catalog §2.1/CF-01.2). Opt-in per request; the +/// interactive WBLOCK export keeps the source coordinates. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub fn normalize_to_origin(out: &mut CadDocument) { + let mut min = [f64::INFINITY; 3]; + for entity in out.entities() { + let (lo, _) = crate::scene::convert::tess::entity_bounds(entity); + for axis in 0..3 { + if lo[axis] < min[axis] { + min[axis] = lo[axis]; + } + } + } + if min.iter().any(|v| !v.is_finite()) { + return; + } + let shift = crate::command::EntityTransform::Affine(acadrust::types::Transform::from_translation( + acadrust::types::Vector3::new(-min[0], -min[1], -min[2]), + )); + for entity in out.entities_mut() { + crate::scene::view::dispatch::apply_transform(entity, &shift); + } } diff --git a/src/rest.rs b/src/rest.rs new file mode 100644 index 000000000..2c8e92373 --- /dev/null +++ b/src/rest.rs @@ -0,0 +1,1158 @@ +//! REST transport — the automation API over plain localhost HTTP. +//! +//! `OpenCADStudio --http 8090` serves the same operation dispatcher as +//! `--serve` behind resource-oriented JSON endpoints that any HTTP client — +//! curl, Python `urllib`/`requests`, JS `fetch`, C# `HttpClient` — can call +//! with no SDK and no AI-specific machinery. The REST layer is a thin +//! adapter: every route builds the protocol-1 JSON request and feeds the +//! shared `automation_op`, so GUI, stdio, TCP, MCP, wasm and HTTP all run +//! the identical handlers with identical validation, undo and idempotency. +//! +//! Conventions: one request per connection (`Connection: close`), JSON +//! bodies, permissive CORS for local browser clients, loopback bind only. +//! Connections are served one thread each: a slow op (plot, open a large +//! drawing) or a client that connects and stalls must never keep other +//! callers — `state`, `get_selection`, `cancel`, `operation` — from being +//! answered. Mutation endpoints get a server-generated `request_id` and the +//! active `document_id`; a stale-state refusal refreshes state and retries +//! once. +//! +//! The route table is shared: `plan` resolves (method, path) to a `Plan` +//! that this headless server executes directly and the GUI-hosted bridge +//! executes over the live editor's envelope queue (`app::control::http_bridge`), +//! so both `--http` modes serve the identical surface. + +use crate::app::OpenCADStudio; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +const MAX_BODY: usize = 16 * 1024 * 1024; + +/// Serial for request ids stamped onto `POST /api/v1/{op}` passthroughs that +/// arrive without one — the protocol-1 envelope requires a unique id, and +/// the caller's own id always wins when present. +static PASSTHROUGH_SERIAL: AtomicU64 = AtomicU64::new(0); + +/// Connections served at once. A client that connects and stalls pins one +/// thread until its idle timeout, not the whole listener. +const MAX_CONNECTIONS: usize = 8; + +/// How long one connection may sit between bytes before the server gives up +/// on it (a client that opened a socket and never sent the request). Bounded +/// so a stuck client cannot pin a thread forever. +const IDLE_TIMEOUT: Duration = Duration::from_secs(15); + +/// Ops that go through the protocol-1 control envelope when posted via the +/// generic `POST /api/v1/{op}` passthrough. Everything else runs legacy. +const ENVELOPE_OPS: &[&str] = &[ + "entities_create", + "entities_delete", + "entities_transform", + "block_define", + "block_delete", + "file_identity", + "xdata_set", + "view_focus", + "wblock", + "plot", + "embed_image", + "set_properties", + "property", + "activate", + "get_selection", + "getpoint", +]; + +/// Loopback REST channel hosted by the GUI process: `OpenCADStudio.exe +/// "" --http ` boots the editor with a bridge that forwards +/// requests into the live app (see `app::control::http_bridge`), so a client +/// can read what the person at the screen selected. `--http` without files +/// keeps the headless server above. +pub fn set_gui_http_port(port: u16) { + crate::app::control::http_bridge::set_gui_http_port(port); +} + +/// Failure codes worth one state refresh + retry (the request never started). +pub(crate) const RETRYABLE: &[&str] = &[ + "document_required", + "document_closed", + "document_not_active", + "stale_state", + "session_changed", +]; + +pub fn serve(port: u16) { + listen(OpenCADStudio::new(), port, Arc::new(AtomicU16::new(0))); +} + +pub(crate) struct HttpRequest { + pub(crate) method: String, + pub(crate) path: String, + query: Vec<(String, String)>, + body: Vec, +} + +impl HttpRequest { + fn param(&self, key: &str) -> Option<&str> { + self.query + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + pub(crate) fn json(&self) -> Value { + serde_json::from_slice(&self.body).unwrap_or(Value::Null) + } +} + +/// One request handed from a connection thread to the dispatcher that owns +/// the private app. `OpenCADStudio` is not `Send` (it holds `Rc`/`RefCell` +/// scene state), so — exactly like the GUI-hosted bridge queues envelopes +/// into the editor — connections funnel JSON through this channel and the +/// dispatcher (the thread that called `listen`) answers on the per-request +/// reply channel. +struct Job { + request: HttpRequest, + reply: std::sync::mpsc::Sender<(u16, Value)>, +} + +fn listen(mut app: OpenCADStudio, port: u16, bound_port: Arc) { + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => listener, + Err(error) => { + eprintln!("--http: cannot bind 127.0.0.1:{port}: {error}"); + return; + } + }; + let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port); + bound_port.store(bound, Ordering::SeqCst); + eprintln!("OpenCADStudio REST listening on http://127.0.0.1:{bound}/api/v1"); + let (jobs, incoming) = std::sync::mpsc::channel::(); + // Connections are served one thread each: a slow op (plot, open a large + // drawing) or a client that connects and stalls must never keep other + // callers — `state`, `get_selection`, `cancel`, `operation` — from being + // accepted and answered. + let clients = Arc::new(AtomicUsize::new(0)); + let acceptor = jobs.clone(); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + if clients.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + clients.fetch_sub(1, Ordering::SeqCst); + continue; + } + let jobs = acceptor.clone(); + let clients = clients.clone(); + std::thread::spawn(move || { + serve_connection(stream, jobs); + clients.fetch_sub(1, Ordering::SeqCst); + }); + } + }); + // The dispatcher keeps the private app on the thread that called + // `listen` and answers one request at a time — the same one-session + // pipeline every transport shares; concurrency lives in the connection + // threads above, not in the app. + let mut document_id: Option = None; + let mut counter: u64 = 0; + for job in incoming { + let (status, body) = route(&mut app, &job.request, &mut document_id, &mut counter); + let _ = job.reply.send((status, body)); + } +} + +/// Read one request off a connection, hand it to the dispatcher, write the +/// answer. Everything here is per-connection I/O; the app is never touched. +fn serve_connection(mut stream: TcpStream, jobs: std::sync::mpsc::Sender) { + let _ = stream.set_read_timeout(Some(IDLE_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IDLE_TIMEOUT)); + let Ok(Some(request)) = read_request(&mut stream) else { + return; + }; + if request.method == "OPTIONS" { + let _ = write_response(&mut stream, 204, &Value::Null); + return; + } + let (reply, answer) = std::sync::mpsc::channel(); + if jobs + .send(Job { + request, + reply, + }) + .is_err() + { + return; + } + if let Ok((status, mut body)) = answer.recv() { + if let Some(object) = body.as_object_mut() { + object.insert("http_status".into(), json!(status)); + } + let _ = write_response(&mut stream, status, &body); + } +} + +/// Parse one HTTP/1.1 request. `Ok(None)` = the peer hung up cleanly. +pub(crate) fn read_request(stream: &mut TcpStream) -> std::io::Result> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Ok(None); + } + let mut parts = line.split_whitespace(); + let method = parts.next().unwrap_or("").to_ascii_uppercase(); + let target = parts.next().unwrap_or("/").to_string(); + let (path, query) = match target.split_once('?') { + Some((path, query)) => (path.to_string(), parse_query(query)), + None => (target, Vec::new()), + }; + + let mut content_length = 0usize; + loop { + let mut header = String::new(); + if reader.read_line(&mut header)? == 0 { + return Ok(None); + } + let header = header.trim(); + if header.is_empty() { + break; + } + if let Some(value) = header + .strip_prefix("Content-Length:") + .or_else(|| header.strip_prefix("content-length:")) + { + content_length = value.trim().parse().unwrap_or(0); + } + } + if content_length > MAX_BODY { + return Ok(Some(HttpRequest { + method, + path, + query, + body: Vec::new(), + })); + } + let mut body = vec![0u8; content_length]; + if content_length > 0 { + reader.read_exact(&mut body)?; + } + Ok(Some(HttpRequest { + method, + path, + query, + body, + })) +} + +fn parse_query(raw: &str) -> Vec<(String, String)> { + raw.split('&') + .filter(|pair| !pair.is_empty()) + .map(|pair| match pair.split_once('=') { + Some((key, value)) => (percent_decode(key), percent_decode(value)), + None => (percent_decode(pair), String::new()), + }) + .collect() +} + +fn percent_decode(raw: &str) -> String { + let bytes = raw.replace('+', " ").into_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""); + if hex.len() == 2 { + if let Ok(byte) = u8::from_str_radix(hex, 16) { + out.push(byte); + index += 3; + continue; + } + } + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +pub(crate) fn write_response(stream: &mut TcpStream, status: u16, body: &Value) -> std::io::Result<()> { + let body = if body.is_null() { + Vec::new() + } else { + serde_json::to_vec(body).unwrap_or_default() + }; + let reason = match status { + 200 => "OK", + 201 => "Created", + 204 => "No Content", + 400 => "Bad Request", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 409 => "Conflict", + 503 => "Service Unavailable", + _ => "OK", + }; + let header = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(header.as_bytes())?; + if !body.is_empty() { + stream.write_all(&body)?; + } + stream.flush() +} + +/// One resolved REST call, shared by the two `--http` transports: this +/// headless server executes it against its private app, and the GUI-hosted +/// bridge (`app::control::http_bridge`) forwards the same request into the +/// live editor's automation queue. Building the request here keeps both +/// surfaces identical — same URLs, same ops, same document-id handling. +pub(crate) enum Plan { + /// Answer without the automation pipeline (OpenAPI, unknown routes). + Local(u16, Value), + /// `GET /ready`: a state read rebuilt as the readiness snapshot. + Ready, + /// Run one prebuilt protocol-1 request as-is — reads, and callers that + /// manage `document_id` themselves (the MCP-style clients). + Run { request: Value, created: u16 }, + /// Run one op through the mutation wrapper: envelope with a request id, + /// the cached `document_id` when the caller omitted it, one state-refresh + /// retry after a stale refusal, and the bulky `state` snapshot stripped + /// from the answer. + Mutate { op: String, fields: Value, created: u16 }, +} + +impl Plan { + /// The automation op this plan runs, for transports that vary connection + /// behavior per op (the GUI bridge parks the interactive picks). + pub(crate) fn op(&self) -> Option<&str> { + match self { + Plan::Local(..) | Plan::Ready => None, + Plan::Run { request, .. } => request["op"].as_str(), + Plan::Mutate { op, .. } => Some(op), + } + } +} + +/// The whole REST surface: map (method, path) to the automation request it +/// runs. The caller splits `/api/v1` off already; the remaining segments +/// select the arm. +pub(crate) fn plan(method: &str, rest: &[&str], request: &HttpRequest) -> Plan { + let raw = request.json(); + let body = if raw.is_object() { raw } else { json!({}) }; + match (method, rest) { + ("GET", ["ready"]) | ("GET", []) | ("GET", [""]) => Plan::Ready, + ("GET", ["capabilities"]) => Plan::Run { + request: json!({"op":"capabilities"}), + created: 200, + }, + ("GET", ["state"]) | ("GET", ["documents"]) => Plan::Run { + request: json!({"protocol":1,"op":"state"}), + created: 200, + }, + ("POST", ["documents"]) => { + if let Some(template) = body["template"].as_str() { + Plan::Mutate { op: "new".into(), fields: json!({"template": template}), created: 201 } + } else if let Some(path) = body["path"].as_str() { + Plan::Run { request: json!({"op":"open","path":path}), created: 201 } + } else { + // A fresh untitled document in + // its own tab, so cross-document operations have a target. + Plan::Mutate { op: "new".into(), fields: json!({}), created: 201 } + } + } + ("DELETE", ["documents", id]) => { + let mut fields = json!({}); + if let Some(id) = id.parse::().ok() { + fields["document_id"] = json!(id); + } + if request.param("discard").is_some_and(|v| v == "true" || v == "1") { + fields["discard"] = json!(true); + } + Plan::Mutate { op: "close".into(), fields, created: 200 } + } + ("GET", ["sysvars"]) => { + let names = request + .param("names") + .map(|csv| json!(csv.split(',').collect::>())) + .unwrap_or_else(|| json!([])); + Plan::Mutate { op: "sysvar".into(), fields: json!({"get": names}), created: 200 } + } + ("POST", ["sysvars"]) => Plan::Mutate { op: "sysvar".into(), fields: body, created: 200 }, + ("POST", ["layouts"]) => Plan::Mutate { op: "layout_create".into(), fields: body, created: 201 }, + ("PUT", ["layouts", layout, "page-setup"]) => { + let mut fields = body; + fields["layout"] = json!(layout); + Plan::Mutate { op: "page_setup_set".into(), fields, created: 200 } + } + ("POST", ["entities", "copy-to"]) => + Plan::Mutate { op: "entities_copy_to".into(), fields: body, created: 201 }, + ("POST", ["groups"]) => Plan::Mutate { op: "group_create".into(), fields: body, created: 201 }, + ("POST", ["selection-sets"]) => + Plan::Mutate { op: "selection_set_save".into(), fields: body, created: 201 }, + ("GET", ["selection-sets", name]) => { + let select = request.param("select").is_some_and(|v| v == "true" || v == "1"); + Plan::Mutate { + op: "selection_set_load".into(), + fields: json!({"name": name, "select": select}), + created: 200, + } + } + ("GET", ["entities"]) => { + let mut request_json = json!({"op":"query"}); + if let Some(v) = request.param("type") { request_json["type"] = json!(v); } + if let Some(v) = request.param("layer") { request_json["layer"] = json!(v); } + if let Some(v) = request.param("detail") { request_json["detail"] = json!(v); } + if let Some(v) = request.param("offset") { request_json["offset"] = json!(v); } + if let Some(v) = request.param("limit") { request_json["limit"] = json!(v); } + if let Some(v) = request.param("fields") { + request_json["fields"] = json!(v.split(',').collect::>()); + } + if let Some(v) = request.param("handles") { + request_json["handles"] = json!(v.split(',').collect::>()); + } + if let Some(v) = request.param("bounds") { + let numbers: Vec = v.split(',').filter_map(|n| n.parse().ok()).collect(); + if numbers.len() == 4 { + request_json["bounds"] = json!(numbers); + } + } + if let Some(v) = request.param("near") { + let numbers: Vec = v.split(',').filter_map(|n| n.parse().ok()).collect(); + request_json["near"] = json!(numbers); + } + if let Some(v) = request.param("contains_point") { + let numbers: Vec = v.split(',').filter_map(|n| n.parse().ok()).collect(); + request_json["contains_point"] = json!(numbers); + } + if let Some(v) = request.param("intersections") { + request_json["intersections"] = json!(v.split(',').collect::>()); + } + if let Some(v) = request.param("where") { + match serde_json::from_str::(v) { + Ok(filters) => request_json["where"] = filters, + Err(error) => { + return Plan::Local( + 400, + json!({"ok": false, "code": "invalid_where", "error": format!("where must be a JSON array: {error}")}), + ) + } + } + } + Plan::Run { request: request_json, created: 200 } + } + ("GET", ["entities", handle]) => Plan::Run { + request: json!({"op":"query","handles":[handle],"detail":"full"}), + created: 200, + }, + ("POST", ["entities"]) => + Plan::Mutate { op: "entities_create".into(), fields: body, created: 201 }, + ("DELETE", ["entities"]) => { + let handles = match request.param("handles") { + Some(csv) => json!(csv.split(',').collect::>()), + None => body["handles"].clone(), + }; + Plan::Mutate { op: "entities_delete".into(), fields: json!({"handles": handles}), created: 200 } + } + ("POST", ["entities", "transform"]) => + Plan::Mutate { op: "entities_transform".into(), fields: body, created: 200 }, + ("GET", ["entities", handle, "xdata"]) => { + let mut request_json = json!({"protocol":1,"op":"xdata_get","handles":[handle]}); + if let Some(app_name) = request.param("app") { + request_json["app"] = json!(app_name); + } + Plan::Run { request: request_json, created: 200 } + } + ("PUT", ["entities", handle, "xdata", app_name]) => { + let data = request.json(); + let data = if data.is_null() { json!([]) } else { data }; + Plan::Mutate { + op: "xdata_set".into(), + fields: json!({"handles":[handle], "app": app_name, "data": data}), + created: 200, + } + } + ("DELETE", ["entities", handle, "xdata", app_name]) => Plan::Mutate { + op: "xdata_set".into(), + fields: json!({"handles":[handle], "app": app_name, "data": []}), + created: 200, + }, + ("POST", ["blocks"]) => Plan::Mutate { op: "block_define".into(), fields: body, created: 201 }, + ("DELETE", ["blocks", name]) => + Plan::Mutate { op: "block_delete".into(), fields: json!({"name": name}), created: 200 }, + ("POST", ["file-identity"]) => + Plan::Mutate { op: "file_identity".into(), fields: body, created: 200 }, + ("POST", ["plot"]) => Plan::Mutate { op: "plot".into(), fields: body, created: 200 }, + ("POST", ["wblock"]) => Plan::Mutate { op: "wblock".into(), fields: body, created: 200 }, + ("POST", ["images"]) => Plan::Mutate { op: "embed_image".into(), fields: body, created: 201 }, + ("POST", ["commands"]) => Plan::Run { + request: json!({"op":"run","cmd":body["cmd"]}), + created: 200, + }, + ("POST", ["undo"]) | ("POST", ["redo"]) => { + let op = if rest == ["undo"] { "undo" } else { "redo" }; + Plan::Run { request: json!({"op":op}), created: 200 } + } + ("POST", ["save"]) => { + let mut request_json = json!({"op":"save"}); + if let Some(path) = body["path"].as_str() { + request_json["path"] = json!(path); + } + Plan::Run { request: request_json, created: 200 } + } + ("GET", ["layers"]) => Plan::Run { request: json!({"op":"layers"}), created: 200 }, + ("GET", ["header"]) => Plan::Run { request: json!({"op":"header"}), created: 200 }, + ("GET", ["records"]) => { + let mut request_json = json!({"op":"records"}); + if let Some(v) = request.param("collection") { request_json["collection"] = json!(v); } + if let Some(v) = request.param("type") { request_json["type"] = json!(v); } + if let Some(v) = request.param("offset") { request_json["offset"] = json!(v); } + if let Some(v) = request.param("limit") { request_json["limit"] = json!(v); } + Plan::Run { request: request_json, created: 200 } + } + ("GET", ["openapi"]) => Plan::Local( + 200, + serde_json::from_str(include_str!("rest_openapi.json")).unwrap_or(json!({})), + ), + ("POST", [op]) if ENVELOPE_OPS.contains(op) => + Plan::Mutate { op: op.to_string(), fields: body, created: 200 }, + ("POST", [op]) => { + let mut request_json = body; + request_json["op"] = json!(op); + // Stamp protocol and a request id the way the GUI bridge's + // `prepare` does: the passthrough exists for ops only the + // protocol-1 envelope knows (`user_select`, `operation`, + // `cancel`), and unstamped they die as "unknown op" on the + // legacy path instead of answering with their real contract. + request_json["protocol"] = json!(1); + if request_json["request_id"].as_str().is_none_or(str::is_empty) { + let serial = PASSTHROUGH_SERIAL.fetch_add(1, Ordering::Relaxed); + request_json["request_id"] = json!(format!("rest-{serial}")); + } + Plan::Run { request: request_json, created: 200 } + } + (method, _) if matches!(method, "GET" | "POST" | "PUT" | "DELETE") => { + let path = request.path.trim_end_matches('/'); + let path = if path.is_empty() { "/" } else { path }; + Plan::Local( + 404, + json!({"ok": false, "code": "unknown_route", "error": format!("No route {method} {path}")}), + ) + } + _ => Plan::Local( + 405, + json!({"ok": false, "code": "method_not_allowed", "error": format!("{method} not supported here")}), + ), + } +} + +/// The headless executor: run a resolved plan against this server's private +/// app. The GUI-hosted bridge runs the same plans over its envelope queue. +fn route( + app: &mut OpenCADStudio, + request: &HttpRequest, + document_id: &mut Option, + counter: &mut u64, +) -> (u16, Value) { + let path = request.path.trim_end_matches('/'); + let path = if path.is_empty() { "/" } else { path }; + let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + if segments.first() != Some(&"api") || segments.get(1) != Some(&"v1") { + return (404, json!({"ok": false, "code": "unknown_route", "error": "Use /api/v1/…"})); + } + match plan(&request.method, &segments[2..], request) { + Plan::Local(status, body) => (status, body), + Plan::Ready => { + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + harvest(document_id, &state); + (200, json!({ + "ok": true, + "ready": true, + "version": state["version"], + "session_id": state["session_id"], + "document_id": state["document_id"], + })) + } + Plan::Run { request, created } => { + let response = app.automation_op(&request.to_string()); + harvest(document_id, &response); + (map_status(response.clone(), created == 201), response) + } + Plan::Mutate { op, fields, created } => { + run_mutation(app, &op, fields, document_id, counter, created) + } + } +} + +/// Track the active document from automation responses so later mutations +/// address it without the caller repeating `document_id`. +fn harvest(document_id: &mut Option, response: &Value) { + let id = response + .get("document_id") + .and_then(Value::as_u64) + .or_else(|| response["state"]["document_id"].as_u64()); + if let Some(id) = id { + *document_id = Some(id); + } +} + +/// Wrap an operation in the protocol-1 envelope with a server-generated +/// request_id, address the active document, and — when the dispatcher +/// refuses because our cached state is behind — refresh once and retry. +fn run_mutation( + app: &mut OpenCADStudio, + op: &str, + mut fields: Value, + document_id: &mut Option, + counter: &mut u64, + created: u16, +) -> (u16, Value) { + let mut attempt = |app: &mut OpenCADStudio, id: u64, document_id: &mut Option| -> Value { + *counter += 1; + let cached = *document_id; + let mut envelope = json!({ + "protocol": 1, + "op": op, + "request_id": format!("http-{id}-{}", *counter), + }); + if let Some(id) = cached { + envelope["document_id"] = json!(id); + } + if let Some(object) = fields.as_object_mut() { + for (key, value) in object.clone() { + envelope[key] = value; + } + } + let response = app.automation_op(&envelope.to_string()); + // Track the active document before the snapshot is stripped below. + harvest(document_id, &response); + compact(response) + }; + + let id = u64::from(std::process::id()); + let mut response = attempt(app, id, document_id); + let code = response["code"].as_str().unwrap_or(""); + if response["ok"] == false && RETRYABLE.contains(&code) { + if let Some(state) = fetch_state(app) { + *document_id = Some(state); + } + response = attempt(app, id, document_id); + } + let status = map_status(response.clone(), created == 201); + (if status == 200 { created } else { status }, response) +} + +/// Drop the bulky `state` snapshot from settle responses; REST clients read +/// it explicitly from GET /state. +fn compact(response: Value) -> Value { + if let Some(object) = response.as_object() { + let trimmed: serde_json::Map = object + .iter() + .filter(|(key, _)| key.as_str() != "state") + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + return Value::Object(trimmed); + } + response +} + +fn fetch_state(app: &mut OpenCADStudio) -> Option { + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + state["document_id"].as_u64() +} + +/// ok:true → 200 (the caller may upgrade to 201 for resource creation); +/// failures map to the closest HTTP status so ordinary clients can branch +/// on the status code, with the symbolic `code` preserved in the body. +pub(crate) fn map_status(response: Value, created: bool) -> u16 { + if response["ok"] == true { + return if created { 201 } else { 200 }; + } + match response["code"].as_str().unwrap_or("") { + "entity_absent" | "unknown_route" => 404, + "stale_state" | "session_changed" | "document_not_active" | "gui_required" => 409, + "busy" => 503, + _ => 400, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::OpenCADStudio; + + fn request(method: &str, path: &str, body: &str) -> HttpRequest { + HttpRequest { + method: method.to_string(), + path: path.to_string(), + query: Vec::new(), + body: body.as_bytes().to_vec(), + } + } + + #[test] + fn percent_decode_handles_escaped_and_plus_spaces() { + assert_eq!(percent_decode("Walls%20and%2BFloors"), "Walls and+Floors"); + assert_eq!(percent_decode("plain"), "plain"); + assert_eq!(percent_decode("bad%2"), "bad%2"); + } + + /// One connection that never sends a request must not keep the listener + /// from answering the next caller — the whole point of + /// thread-per-connection (a wedged client used to freeze every read). + #[test] + fn listener_answers_while_one_connection_stalls() { + let bound = Arc::new(AtomicU16::new(0)); + let listen_bound = bound.clone(); + std::thread::spawn(move || { + listen(OpenCADStudio::new_for_test(), 0, listen_bound); + }); + let mut port = 0; + for _ in 0..50 { + port = bound.load(Ordering::SeqCst); + if port != 0 { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + assert_ne!(port, 0, "listener never bound a port"); + + // Stalls the thread that accepted it: no request line, ever. + let _stalled = TcpStream::connect(("127.0.0.1", port)).unwrap(); + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .write_all(b"GET /api/v1/ready HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .unwrap(); + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader.read_line(&mut status_line).expect("readiness answered"); + assert!(status_line.contains("200"), "{status_line}"); + } + + #[test] + fn failure_codes_map_to_http_statuses() { + assert_eq!(map_status(json!({"ok": true}), false), 200); + assert_eq!(map_status(json!({"ok": true}), true), 201); + assert_eq!(map_status(json!({"ok": false, "code": "entity_absent"}), false), 404); + assert_eq!(map_status(json!({"ok": false, "code": "stale_state"}), false), 409); + assert_eq!(map_status(json!({"ok": false, "code": "busy"}), false), 503); + assert_eq!(map_status(json!({"ok": false, "code": "invalid_point"}), false), 400); + } + + /// The generic `POST /api/v1/{op}` passthrough must reach the protocol-1 + /// envelope: ops like `user_select`/`operation`/`cancel` only exist + /// there, and unstamped they died as "unknown op" on the legacy path. + #[test] + fn passthrough_reaches_envelope_only_ops() { + let mut app = OpenCADStudio::new(); + app.automation_op(r#"{"op":"new"}"#); + let mut document_id: Option = None; + let mut counter: u64 = 0; + let (status, body) = route( + &mut app, + &request("POST", "/api/v1/user_select", r#"{"request_id":"us-headless"}"#), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 409, "{body}"); + assert_eq!(body["code"], "gui_required", "{body}"); + + // `operation` is a query: an unknown id answers `unknown_operation`, + // not "unknown op". + let (status, body) = route( + &mut app, + &request("POST", "/api/v1/operation", r#"{"request_id":"gone"}"#), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 400, "{body}"); + assert_eq!(body["code"], "unknown_operation", "{body}"); + } + + #[test] + fn get_selection_reports_selected_entities_read_only() { + let mut app = OpenCADStudio::new(); + let mut document_id: Option = None; + let mut counter: u64 = 0; + let (_, body) = route( + &mut app, + &request( + "POST", + "/api/v1/entities", + r#"{"entities":[ + {"type":"Line","start":[0,0],"end":[10,0]}, + {"type":"Text","value":"PAGE-01","position":[1,1],"height":2.5} + ]}"#, + ), + &mut document_id, + &mut counter, + ); + let handles = body["result"]["handles"].as_array().unwrap().clone(); + assert_eq!(handles.len(), 2); + + // The person "picks" the line and the text; the read mirrors the + // selection order and matches the `query` field formats. + app.automation_op(&format!( + r#"{{"op":"select","handles":["{}","{}"]}}"#, + handles[0].as_str().unwrap(), + handles[1].as_str().unwrap() + )); + let (status, body) = route( + &mut app, + &request("POST", "/api/v1/get_selection", "{}"), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "completed"); + assert_eq!(body["result"]["count"], 2); + assert_eq!( + body["result"]["entities"][0]["handle"], + handles[0].as_str().unwrap() + ); + assert_eq!(body["result"]["entities"][0]["type"], "Line"); + assert_eq!(body["result"]["entities"][0]["text"], Value::Null); + let sample = &body["result"]["entities"][1]; + assert_eq!(sample["type"], "Text"); + assert_eq!(sample["text"], "PAGE-01"); + assert_eq!(sample["value"], "PAGE-01"); + assert!(sample["bounds"].is_object(), "{sample}"); + // A second read is identical — the read is passive. + let (_, again) = route( + &mut app, + &request("POST", "/api/v1/get_selection", "{}"), + &mut document_id, + &mut counter, + ); + assert_eq!(again, body); + } + + #[test] + fn rest_surface_round_trips_real_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let mut document_id = None; + let mut counter = 0; + + // Readiness exposes the version and session. + let (status, body) = route(&mut app, &request("GET", "/api/v1/ready", ""), &mut document_id, &mut counter); + assert_eq!(status, 200); + assert_eq!(body["ok"], true); + assert!(body["session_id"].as_str().is_some()); + + // Create: a REST resource POST returns 201 with handles. + let (status, body) = route( + &mut app, + &request( + "POST", + "/api/v1/entities", + r#"{"entities":[ + {"type":"Line","start":[0,0],"end":[10,0],"layer":"Walls"}, + {"type":"Circle","center":[5,5],"radius":2}, + {"type":"Text","value":"PAGE-01","position":[1,1],"height":2.5} + ]}"#, + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + let handles = body["result"]["handles"].as_array().unwrap().clone(); + assert_eq!(handles.len(), 3); + assert!(document_id.is_some(), "the server cached the document id"); + + // Query through plain GET with query parameters. + let mut get = request("GET", "/api/v1/entities", ""); + get.query = vec![("type".into(), "Line".into()), ("detail".into(), "full".into())]; + let (status, body) = route(&mut app, &get, &mut document_id, &mut counter); + assert_eq!(status, 200); + assert_eq!(body["entities"][0]["start"], json!([0.0, 0.0, 0.0])); + + // One entity by handle. + let (status, body) = route( + &mut app, + &request("GET", &format!("/api/v1/entities/{}", handles[1].as_str().unwrap()), ""), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200); + assert_eq!(body["count"], 1); + assert_eq!(body["entities"][0]["type"], "Circle"); + + // Transform through the dedicated endpoint. + let (status, body) = route( + &mut app, + &request( + "POST", + "/api/v1/entities/transform", + &format!( + r#"{{"handles":["{}"],"action":"move","vector":[5,0]}}"#, + handles[1].as_str().unwrap() + ), + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200, "{body}"); + let mut get = request("GET", "/api/v1/entities", ""); + get.query = vec![("type".into(), "Circle".into()), ("detail".into(), "full".into())]; + let (_, body) = route(&mut app, &get, &mut document_id, &mut counter); + assert_eq!(body["entities"][0]["center"], json!([10.0, 5.0, 0.0])); + + // XData write via PUT, read via GET. + let handle = handles[0].as_str().unwrap().to_owned(); + let (status, body) = route( + &mut app, + &request( + "PUT", + &format!("/api/v1/entities/{handle}/xdata/SPM"), + r#"[{"code":1000,"value":"PAGE-01"}]"#, + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200, "{body}"); + let mut get = request("GET", &format!("/api/v1/entities/{handle}/xdata"), ""); + get.query = vec![("app".into(), "SPM".into())]; + let (status, body) = route(&mut app, &get, &mut document_id, &mut counter); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["items"][0]["xdata"]["SPM"][0], "PAGE-01"); + + // Unknown handles are 404 with the symbolic code preserved. + let mut delete = request("DELETE", "/api/v1/entities", ""); + delete.query = vec![("handles".into(), "FF".into())]; + let (status, body) = route(&mut app, &delete, &mut document_id, &mut counter); + assert_eq!(status, 404); + assert_eq!(body["code"], "entity_absent"); + + // The OpenAPI document is served and parses as JSON. + let (status, body) = route(&mut app, &request("GET", "/api/v1/openapi", ""), &mut document_id, &mut counter); + assert_eq!(status, 200); + assert_eq!(body["openapi"], "3.0.3"); + + // Unknown routes are 404, not panics. + let (status, _) = route(&mut app, &request("GET", "/api/v1/nope", ""), &mut document_id, &mut counter); + assert_eq!(status, 404); + } + + #[test] + fn rest_p1_document_surface_routes_end_to_end() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 1"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 20,0 5"}"#); + let mut document_id = None; + let mut counter = 0; + + // GET /state primes the document-id cache the mutations address. + let (status, body) = route(&mut app, &request("GET", "/api/v1/state", ""), &mut document_id, &mut counter); + assert_eq!(status, 200); + let doc = document_id.expect("state caches the document id"); + assert_eq!(body["document_id"].as_u64(), Some(doc)); + + // Where filters travel as the JSON-encoded `where` query parameter. + let mut get = request("GET", "/api/v1/entities", ""); + get.query = vec![ + ("type".into(), "Circle".into()), + ("detail".into(), "geometry".into()), + ("where".into(), r#"[{"path":"/radius","op":"gt","value":2}]"#.into()), + ]; + let (status, body) = route(&mut app, &get, &mut document_id, &mut counter); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["count"], 1); + assert_eq!(body["entities"][0]["radius"], 5.0); + + // A malformed filter is refused before touching the dispatcher. + let mut get = request("GET", "/api/v1/entities", ""); + get.query = vec![("where".into(), "not-json".into())]; + let (status, body) = route(&mut app, &get, &mut document_id, &mut counter); + assert_eq!(status, 400); + assert_eq!(body["code"], "invalid_where"); + + // Sysvars read through GET with a `names` CSV, write through POST. + let mut get = request("GET", "/api/v1/sysvars", ""); + get.query = vec![("names".into(), "ltscale,mirrtext".into())]; + let (status, body) = route(&mut app, &get, &mut document_id, &mut counter); + assert_eq!(status, 200, "{body}"); + assert!(body["result"]["values"]["ltscale"].is_number()); + assert_eq!(body["result"]["values"]["mirrtext"], 0); + + let (status, body) = route( + &mut app, + &request("POST", "/api/v1/sysvars", r#"{"set":{"ltscale":2.5}}"#), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200, "{body}"); + let mut get = request("GET", "/api/v1/sysvars", ""); + get.query = vec![("names".into(), "ltscale".into())]; + let (_, body) = route(&mut app, &get, &mut document_id, &mut counter); + assert_eq!(body["result"]["values"]["ltscale"], 2.5); + + // A layout is created (201) and its page setup patched via PUT. + let (status, body) = route( + &mut app, + &request("POST", "/api/v1/layouts", r#"{"name":"PLAN"}"#), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + assert!(body["result"]["layouts"].as_array().unwrap().iter().any(|n| n == "PLAN")); + let (status, body) = route( + &mut app, + &request( + "PUT", + "/api/v1/layouts/PLAN/page-setup", + r#"{"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}"#, + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["result"]["layout"], "PLAN"); + assert!(body["result"]["paper"].as_str().unwrap().contains("ISO_A4")); + assert_eq!(body["result"]["scale"]["fit"], true); + + // Groups and selection sets address the drawing's entities (the + // created layout also owns a sheet viewport, so filter to circles). + let q = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#); + let joined = q["entities"] + .as_array() + .unwrap() + .iter() + .map(|e| format!("\"{}\"", e["handle"].as_str().unwrap())) + .collect::>() + .join(","); + let (status, body) = route( + &mut app, + &request("POST", "/api/v1/groups", &format!(r#"{{"name":"FRAME","handles":[{joined}]}}"#)), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + let (status, body) = route( + &mut app, + &request( + "POST", + "/api/v1/selection-sets", + &format!(r#"{{"name":"rest-set","handles":[{joined}]}}"#), + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + app.automation_op(r#"{"op":"select","clear":true}"#); + // Default GET recalls without touching the selection; ?select=true + // additionally makes the set the current selection. + let (status, body) = route( + &mut app, + &request("GET", "/api/v1/selection-sets/rest-set", ""), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["result"]["handles"].as_array().unwrap().len(), 2); + assert_eq!(body["result"]["selected"], 0); + let mut get = request("GET", "/api/v1/selection-sets/rest-set", ""); + get.query = vec![("select".into(), "true".into())]; + let (status, body) = route(&mut app, &get, &mut document_id, &mut counter); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["result"]["selected"], 2); + + // Copy between documents: the body names the target document. + let target = app.push_test_document(); + let (status, body) = route( + &mut app, + &request( + "POST", + "/api/v1/entities/copy-to", + &format!(r#"{{"handles":[{joined}],"document_id":{target}}}"#), + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + assert_eq!(body["result"]["count"], 2); + + // Blocks: define → replace → delete; the drawing carries a stable + // file identity GUID. + let (status, body) = route( + &mut app, + &request( + "POST", + "/api/v1/blocks", + &format!(r#"{{"name":"MARK","base":[0,0,0],"handles":[{joined}]}}"#), + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + // Replace re-blocks fresh content under the same name — the define + // consumed the original circles, SPM-style re-import draws new + // content and wraps it again. + let (status, body) = route( + &mut app, + &request( + "POST", + "/api/v1/entities", + r#"{"entities":[{"type":"Point","location":[9,9]}]}"#, + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + let fresh = body["result"]["handles"][0].as_str().unwrap().to_owned(); + let (status, body) = route( + &mut app, + &request( + "POST", + "/api/v1/blocks", + &format!(r#"{{"name":"MARK","base":[0,0,0],"handles":["{fresh}"],"replace":true}}"#), + ), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + let (status, body) = route( + &mut app, + &request("DELETE", "/api/v1/blocks/MARK", ""), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200, "{body}"); + assert!(body["result"]["erased"].as_u64().unwrap() >= 1); + let (status, body) = route( + &mut app, + &request("POST", "/api/v1/file-identity", "{}"), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["result"]["identity"].as_str().unwrap().len(), 36); + + // An empty POST /documents opens a fresh + // untitled document in its own tab — a copy-to target. + let (status, body) = route( + &mut app, + &request("POST", "/api/v1/documents", "{}"), + &mut document_id, + &mut counter, + ); + assert_eq!(status, 201, "{body}"); + let fresh = document_id.expect("fresh document cached"); + assert_ne!(fresh, doc, "a second document opened"); + + // Close with discard: the dirty document is dropped, 200 returned. + let mut delete = request("DELETE", &format!("/api/v1/documents/{doc}"), ""); + delete.query = vec![("discard".into(), "true".into())]; + let (status, body) = route(&mut app, &delete, &mut document_id, &mut counter); + assert_eq!(status, 200, "{body}"); + assert_eq!(body["result"]["closed"], true); + } +} diff --git a/src/rest_openapi.json b/src/rest_openapi.json new file mode 100644 index 000000000..d3fae4eb1 --- /dev/null +++ b/src/rest_openapi.json @@ -0,0 +1,259 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "OpenCAD Studio Automation API", + "version": "1", + "description": "REST surface over the OpenCAD Studio automation dispatcher. The server runs headless via `OpenCADStudio --http `, binds 127.0.0.1 only, and keeps one drawing session alive across requests. All bodies are JSON; failure bodies carry a symbolic `code` (see the automation README for the catalogue)." + }, + "servers": [{"url": "http://127.0.0.1:8090"}], + "paths": { + "/api/v1/ready": { + "get": { + "summary": "Server readiness, version and active document id", + "responses": {"200": {"description": "Ready"}} + } + }, + "/api/v1/state": { + "get": { + "summary": "Full editor state: session, documents, selection, command, camera", + "responses": {"200": {"description": "State"}} + } + }, + "/api/v1/capabilities": { + "get": { + "summary": "Feature and collection advertisement", + "responses": {"200": {"description": "Capabilities"}} + } + }, + "/api/v1/documents": { + "get": {"summary": "Open documents", "responses": {"200": {"description": "Documents"}}}, + "post": { + "summary": "Open a drawing ({path}), start from a template ({template}, .dwt/.dwg/.dxf), or create a fresh untitled document in its own tab (empty body)", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"path": {"type": "string"}, "template": {"type": "string"}}}}}}, + "responses": {"200": {"description": "Document ready"}, "201": {"description": "Fresh document created"}, "400": {"description": "Open failed"}} + } + }, + "/api/v1/documents/{id}": { + "delete": { + "summary": "Close a document; a dirty document needs ?discard=true", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}], + "responses": {"200": {"description": "Closed"}, "400": {"description": "Dirty document without discard"}} + } + }, + "/api/v1/sysvars": { + "get": { + "summary": "Read sysvars (?names=csv; empty list returns every readable name)", + "parameters": [{"name": "names", "in": "query", "schema": {"type": "string"}}], + "responses": {"200": {"description": "Values"}} + }, + "post": { + "summary": "Set sysvars atomically ({set:{name:value,…}}; unknown_sysvar / invalid_sysvar_value refused)", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"set": {"type": "object"}, "get": {"type": "array", "items": {"type": "string"}}}}}}}, + "responses": {"200": {"description": "Applied"}, "400": {"description": "Unknown sysvar or invalid value"}} + } + }, + "/api/v1/layouts": { + "post": { + "summary": "Create a layout with the default page setup", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}}}}}, + "responses": {"201": {"description": "Layout created"}, "400": {"description": "Duplicate or invalid name"}} + } + }, + "/api/v1/layouts/{name}/page-setup": { + "put": { + "summary": "Write a layout's plot configuration (explicit fields win)", + "parameters": [{"name": "name", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": { + "paper": {"type": "string"}, "orientation": {"type": "string", "enum": ["portrait", "landscape"]}, + "fit": {"type": "boolean"}, "scale": {"type": "string"}, "center": {"type": "boolean"}, + "window": {"type": "array", "items": {"type": "number"}}, "plot_style": {"type": "string"} + }}}}}, + "responses": {"200": {"description": "Stored setup"}, "400": {"description": "Unknown layout, paper or scale"}} + } + }, + "/api/v1/entities": { + "get": { + "summary": "Query entities (type, layer, detail, offset, limit, fields, handles, bounds, near, contains_point, intersections)", + "parameters": [ + {"name": "type", "in": "query", "schema": {"type": "string"}}, + {"name": "layer", "in": "query", "schema": {"type": "string"}}, + {"name": "detail", "in": "query", "schema": {"type": "string", "enum": ["summary", "geometry", "full"]}}, + {"name": "offset", "in": "query", "schema": {"type": "integer"}}, + {"name": "limit", "in": "query", "schema": {"type": "integer"}}, + {"name": "fields", "in": "query", "schema": {"type": "string"}, "description": "Comma-separated projection"}, + {"name": "bounds", "in": "query", "schema": {"type": "string"}, "description": "min_x,min_y,max_x,max_y"}, + {"name": "near", "in": "query", "schema": {"type": "string"}, "description": "x,y[,z] distance ranking"}, + {"name": "contains_point", "in": "query", "schema": {"type": "string"}}, + {"name": "intersections", "in": "query", "schema": {"type": "string"}, "description": "handle1,handle2"}, + {"name": "where", "in": "query", "schema": {"type": "string"}, "description": "JSON-encoded filter array over serialized properties, e.g. [{\"path\":\"/radius\",\"op\":\"gt\",\"value\":2}] with ops eq,ne,lt,lte,gt,gte,contains,starts_with,ends_with,in,exists,not_exists"} + ], + "responses": {"200": {"description": "Matched entities"}, "400": {"description": "Invalid filters"}} + }, + "post": { + "summary": "Create a batch of typed entities (Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch); missing layers are created; one undo step", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["entities"], "properties": {"entities": {"type": "array", "items": {"type": "object"}}}}}}}, + "responses": {"201": {"description": "Handles of the created entities"}, "400": {"description": "Invalid definitions; nothing committed"}} + }, + "delete": { + "summary": "Erase entities by handle (?handles=a,b or body {handles})", + "responses": {"200": {"description": "Erased"}, "404": {"description": "Unknown handle; nothing erased"}} + } + }, + "/api/v1/entities/{handle}": { + "get": { + "summary": "One entity with full detail", + "parameters": [{"name": "handle", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": {"200": {"description": "Entity"}, "404": {"description": "Unknown handle"}} + } + }, + "/api/v1/entities/transform": { + "post": { + "summary": "Move/copy/rotate/scale/mirror/array entities (angles in degrees)", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["handles", "action"], "properties": { + "handles": {"type": "array", "items": {"type": "string"}}, + "action": {"type": "string", "enum": ["move", "copy", "rotate", "scale", "mirror", "array"]}, + "vector": {"type": "array", "items": {"type": "number"}}, + "center": {"type": "array", "items": {"type": "number"}}, + "angle_deg": {"type": "number"}, + "factor": {"type": "number"}, + "axis": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}}, + "copy": {"type": "boolean"}, + "rows": {"type": "integer"}, "columns": {"type": "integer"}, + "row_spacing": {"type": "number"}, "column_spacing": {"type": "number"} + }}}}}, + "responses": {"200": {"description": "Transformed; `created` lists new handles"}, "400": {"description": "Invalid action or parameters"}} + } + }, + "/api/v1/entities/copy-to": { + "post": { + "summary": "Copy entities into another open document (referenced layer definitions travel along)", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["handles", "document_id"], "properties": { + "handles": {"type": "array", "items": {"type": "string"}}, + "document_id": {"type": "integer", "description": "Target document"} + }}}}}, + "responses": {"201": {"description": "New handles in the target document"}, "404": {"description": "Unknown handle"}, "400": {"description": "Target absent"}} + } + }, + "/api/v1/entities/{handle}/xdata": { + "get": { + "summary": "Read extended data (?app= filters one application)", + "parameters": [ + {"name": "handle", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "app", "in": "query", "schema": {"type": "string"}} + ], + "responses": {"200": {"description": "Per-application records"}, "404": {"description": "Unknown handle"}} + } + }, + "/api/v1/entities/{handle}/xdata/{app}": { + "put": { + "summary": "Replace one application's extended data (typed [{code,value}]; empty list removes it; RegApp registered implicitly)", + "parameters": [ + {"name": "handle", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "app", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "object", "properties": {"code": {"type": "integer"}, "value": {}}}}}}}, + "responses": {"200": {"description": "Written"}, "400": {"description": "Unsupported code"}} + }, + "delete": { + "summary": "Remove one application's extended data", + "parameters": [ + {"name": "handle", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "app", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": {"200": {"description": "Removed"}} + } + }, + "/api/v1/blocks": { + "post": { + "summary": "Define a block from entities (BLOCK semantics: sources become the definition, one Insert placed at insert_at or base; replace:true drops a same-named definition first)", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["name", "base", "handles"], "properties": { + "name": {"type": "string"}, "base": {"type": "array", "items": {"type": "number"}}, + "handles": {"type": "array", "items": {"type": "string"}}, + "insert_at": {"type": "array", "items": {"type": "number"}}, + "replace": {"type": "boolean"} + }}}}}, + "responses": {"201": {"description": "Definition + insert handle"}, "400": {"description": "Invalid name, duplicate or empty selection"}} + } + }, + "/api/v1/blocks/{name}": { + "delete": { + "summary": "Delete a block definition with its children, markers and every Insert referencing it", + "parameters": [{"name": "name", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": {"200": {"description": "Erased count"}, "404": {"description": "Unknown block"}} + } + }, + "/api/v1/file-identity": { + "post": { + "summary": "Stable per-document GUID (the drawing's SPMDOCUNIQUE value); idempotent get-or-create, renew:true mints a fresh one", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"renew": {"type": "boolean"}}}}}}, + "responses": {"200": {"description": "The identity GUID"}} + } + }, + "/api/v1/groups": { + "post": { + "summary": "Create a named group over existing entities (stored in the group dictionary)", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["name", "handles"], "properties": { + "name": {"type": "string"}, "handles": {"type": "array", "items": {"type": "string"}} + }}}}}, + "responses": {"201": {"description": "Group name + handle"}, "404": {"description": "Unknown handle"}} + } + }, + "/api/v1/selection-sets": { + "post": { + "summary": "Save a named handle set for this session (session-scoped by design)", + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["name", "handles"], "properties": { + "name": {"type": "string"}, "handles": {"type": "array", "items": {"type": "string"}} + }}}}}, + "responses": {"201": {"description": "Stored set"}} + } + }, + "/api/v1/selection-sets/{name}": { + "get": { + "summary": "Recall a named selection set (?select=true also makes it the current selection)", + "parameters": [ + {"name": "name", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "select", "in": "query", "schema": {"type": "boolean"}} + ], + "responses": {"200": {"description": "Handles (+ selected count)"}, "404": {"description": "Unknown set"}} + } + }, + "/api/v1/plot": { + "post": { + "summary": "Render to PDF (layout Model|name|all, area, paper, fit/scale, plot_style; per_page:true with layout all writes one PDF per layout and returns result.files)", + "responses": {"200": {"description": "Pages written"}, "400": {"description": "Plot failed"}} + } + }, + "/api/v1/wblock": { + "post": { + "summary": "Export entities (handles) or a block definition to a new DWG/DXF; template: bases the new database on that file's tables/styles, normalize:true shifts the export so its minimum lands on the origin", + "responses": {"200": {"description": "File written"}, "400": {"description": "Export failed"}} + } + }, + "/api/v1/images": { + "post": { + "summary": "Attach a picture (embedded OLE2FRAME default; linked:true for path-linked)", + "responses": {"201": {"description": "Image handle"}, "400": {"description": "Attach failed"}} + } + }, + "/api/v1/commands": { + "post": { + "summary": "Run one editor command line (e.g. \"LINE 0,0 10,10\")", + "responses": {"200": {"description": "Command status"}, "400": {"description": "Command failed"}} + } + }, + "/api/v1/undo": {"post": {"summary": "Undo one step", "responses": {"200": {"description": "Undone"}}}}, + "/api/v1/redo": {"post": {"summary": "Redo one step", "responses": {"200": {"description": "Redone"}}}}, + "/api/v1/save": {"post": {"summary": "Save the active document ({path} optional once opened); a *.dwt path writes DWG bytes through a scratch file renamed into place", "responses": {"200": {"description": "Saved"}, "400": {"description": "Save failed"}}}}, + "/api/v1/layers": {"get": {"summary": "Layer table", "responses": {"200": {"description": "Layers"}}}}, + "/api/v1/header": {"get": {"summary": "Drawing header summary", "responses": {"200": {"description": "Header"}}}}, + "/api/v1/records": {"get": {"summary": "Database records (collection, type, offset, limit)", "responses": {"200": {"description": "Records"}, "400": {"description": "Invalid query"}}}}, + "/api/v1/openapi": {"get": {"summary": "This document", "responses": {"200": {"description": "OpenAPI JSON"}}}}, + "/api/v1/{op}": { + "post": { + "summary": "Generic passthrough: any automation operation name with its JSON body (mutations are wrapped in the protocol envelope automatically)", + "parameters": [{"name": "op", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": {"200": {"description": "Operation response"}} + } + } + } +} diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 7f1393b5e..cb7c0d798 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -160,6 +160,7 @@ impl Scene { /// Script-facing replacement: preserve exactly the validated input order. /// UI picks still use `select_entity` and its leader/annotation expansion. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn replace_selection_exact(&mut self, handles: &[Handle]) { if self.selected_handles_in_order() == handles { return; } self.selected_constraint = None; diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index d4259f78c..855e1d4f5 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -51,12 +51,21 @@ fn cmd_input_id() -> iced::widget::Id { iced::widget::Id::new(CMD_INPUT_ID) } -fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) { +/// Footer pill state for the automation channel. `waiting` — a client +/// `user_select` / `getpoint` is parked and the person at the screen must +/// answer — outranks `busy`: the screen has to say "act now", not just +/// "something is running". +fn mcp_status(enabled: bool, busy: bool, waiting: bool) -> (&'static str, Color) { if !enabled { ( "MCP control is off", Color::from_rgb(0.90, 0.35, 0.35), ) + } else if waiting { + ( + "MCP is waiting for you to pick — Enter confirms, Esc cancels", + Color::from_rgb(0.30, 0.55, 0.98), + ) } else if busy { ( "MCP is handling a request", @@ -633,6 +642,7 @@ impl CommandLine { window_height: f32, control_enabled: bool, control_busy: bool, + pick_pending: bool, ) -> Element<'a, Message> { // Only the most recent entries pushed within COMMANDLINEFADETIME // show on the overlay (0 skips transient lines). The dropdown button @@ -844,7 +854,7 @@ impl CommandLine { .align_y(iced::alignment::Vertical::Center), ] .width(Length::Fill); - let (mcp_tooltip, mcp_color) = mcp_status(control_enabled, control_busy); + let (mcp_tooltip, mcp_color) = mcp_status(control_enabled, control_busy, pick_pending); let mcp_btn = button(text("MCP").size(11)) .on_press(Message::ControlToggle) .style(move |theme: &Theme, status| { @@ -1157,9 +1167,14 @@ mod tests { #[test] fn mcp_status_distinguishes_off_ready_and_busy() { - assert_eq!(mcp_status(false, false).0, "MCP control is off"); - assert_eq!(mcp_status(true, false).0, "MCP control is ready"); - assert_eq!(mcp_status(true, true).0, "MCP is handling a request"); + assert_eq!(mcp_status(false, false, false).0, "MCP control is off"); + assert_eq!(mcp_status(true, false, false).0, "MCP control is ready"); + assert_eq!(mcp_status(true, true, false).0, "MCP is handling a request"); + // A parked pick outranks the plain busy state: the person must act. + assert_eq!( + mcp_status(true, true, true).0, + "MCP is waiting for you to pick — Enter confirms, Esc cancels" + ); } #[test] diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index aafd36b3d..49af00c39 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -223,6 +223,10 @@ pub struct CrosshairOptions { pub iso_plane: IsoPlane, pub snap_angle_deg: f32, pub point_mode: bool, + /// A client pick (`user_select` / `getpoint`) is waiting for the person: + /// the crosshair arms disappear and a blue pickbox remains — the + /// screen-level "the client wants YOU to pick" signal. + pub pick_pending: bool, } /// Rendering style for the viewport grid. @@ -1769,8 +1773,16 @@ impl canvas::Program for SelectionCanvas { }, ); let color = Color { r, g, b, a }; + // Pending client pick: drop the arms, keep a blue pickbox — + // the same blue as the MCP "waiting for you to pick" pill. + let pick_pending = self.crosshair.pick_pending; + let color = if pick_pending { + Color::from_rgb(0.30, 0.55, 0.98) + } else { + color + }; let stroke = canvas::Stroke { - width: 1.0, + width: if pick_pending { 1.5 } else { 1.0 }, style: canvas::Style::Solid(color), ..Default::default() }; @@ -1786,33 +1798,43 @@ impl canvas::Program for SelectionCanvas { } else { [0.0, 90.0] }; - for angle in base_angles { - let rad = (angle + self.crosshair.snap_angle_deg as f64).to_radians(); - let dir = Point::new(rad.cos() as f32, -rad.sin() as f32); - let gap = if point_mode { - 9.0 - } else if sq > 0.0 { - sq / dir.x.abs().max(dir.y.abs()).max(1e-6) - } else { - 0.0 - }; - let arms = canvas::Path::new(|path| { - path.move_to(Point::new(cp.x + dir.x * gap, cp.y + dir.y * gap)); - path.line_to(Point::new(cp.x + dir.x * arm, cp.y + dir.y * arm)); - path.move_to(Point::new(cp.x - dir.x * gap, cp.y - dir.y * gap)); - path.line_to(Point::new(cp.x - dir.x * arm, cp.y - dir.y * arm)); - }); - frame.stroke(&arms, stroke.clone()); + // The arms stay only for the normal (non-pending) cursor: a + // waiting pick shows the box alone, regardless of the UCS + // rotation, so the square is unmistakable. + if !pick_pending { + for angle in base_angles { + let rad = (angle + self.crosshair.snap_angle_deg as f64).to_radians(); + let dir = Point::new(rad.cos() as f32, -rad.sin() as f32); + let gap = if point_mode { + 9.0 + } else if sq > 0.0 { + sq / dir.x.abs().max(dir.y.abs()).max(1e-6) + } else { + 0.0 + }; + let arms = canvas::Path::new(|path| { + path.move_to(Point::new(cp.x + dir.x * gap, cp.y + dir.y * gap)); + path.line_to(Point::new(cp.x + dir.x * arm, cp.y + dir.y * arm)); + path.move_to(Point::new(cp.x - dir.x * gap, cp.y - dir.y * gap)); + path.line_to(Point::new(cp.x - dir.x * arm, cp.y - dir.y * arm)); + }); + frame.stroke(&arms, stroke.clone()); + } } if point_mode { let dot = canvas::Path::circle(cp, 1.75); frame.fill(&dot, color); - } else if sq > 0.0 { - let square = canvas::Path::rectangle( - Point::new(cp.x - sq, cp.y - sq), - Size::new(sq * 2.0, sq * 2.0), - ); - frame.stroke(&square, stroke); + } else { + // A pending pick keeps the square visible even when the + // user's PICKBOX setting would hide it. + let sq = if pick_pending { sq.max(8.0) } else { sq }; + if sq > 0.0 { + let square = canvas::Path::rectangle( + Point::new(cp.x - sq, cp.y - sq), + Size::new(sq * 2.0, sq * 2.0), + ); + frame.stroke(&square, stroke); + } } // Locked-layer badge: a small padlock beside the crosshair when