merge origin/main: unify our web branch with 277 upstream commits

Conflicts resolved as unions of behavior wherever possible:

- #1350 (set_properties numeric CAS): kept our stricter
  json_values_match (guards u64>f64 precision loss); upstream's
  duplicate removed.
- #1351 (web open-from-bytes): our implementation wins (guarded
  queue, settle semantics, protocol 2) using upstream's validation
  helper so both sides' tests pass.
- #1358/#1359 (Windows printer routing/media): merged both engines —
  upstream's route planning, media tables, GDI raster path and driver
  options on top of our level-2 enumeration fallback, true-default
  resolution, quoted printto params, existence probe, and error
  descriptions.
- #1360 (plot-style leniency): decompress_ctb now carries both our
  CTBVER1/STBVER1 container tags and structured flag, and upstream's
  lenient reader with Windows-1252 handling.
- 3D snapping precedence: upstream's F4 3D osnap system takes
  precedence over our #1030 solid edge wires when the mesh cache has
  geometry edges; our wires remain the fallback for triangle-only
  caches and mesh entities (both test suites pass).
- Fixed upstream-side bugs surfaced by the merge: parametric arc
  grips impl/test contradiction, psetupin test path separators, and
  our pdf_raster regression where the new dpi argument was threaded
  but ignored (GDI 300-DPI would have cached at 150).
- ocs_save repinned to the upstream acadrust rev; docs/command-
  reference.md regenerated (the staleness test caught it).
This commit is contained in:
Afonsop committed 2026-09-21 13:33:39 +01:00
commit 55e3a6a9e2
323 files changed
+82297 -4351

No files matched your search

+26 -1
View File
@@ -17,6 +17,10 @@ The server provides four tools:
- `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor.
- `ocs_capture` returns a bounded PNG of the drawing viewport or complete window.
Native builds also accept a second, headless entry point (`OpenCADStudio --serve`, one
JSON request per line) for scripts and CI; the two native channels, their startup dialogs
and their limits are described in [native.md](native.md).
The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `ocs_session_id` to the other tools, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape:
```json
@@ -138,7 +142,27 @@ Execute responses use `response_detail: "compact"` by default and return only th
For unfamiliar or conditional commands, use `start`, then inspect `state.command` in every response. Its `accepts` array gives the valid MCP input kinds, `options` gives the current tokens, and `input_example` gives the next request shape. Add the current state fields and a new `request_id` to each step.
Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes. Geometry stays in OpenCADStudio and its geometry kernel.
Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes.
A `run` reply also carries two fields that describe what happened to the line:
* `blocked_by` — `null` when the command finished, otherwise what *this line*
left open and waiting: `"command"` (an interactive command), `"text_editor"`
(the in-place editor opened by the `TEXT` content step), `"mtext_editor"`, or
`"modal:<Kind>"` for a dialog. A surface that was already open when the line
started (an earlier line's editor, another tab, a startup dialog) is not
attributed to this line, so a caller polling for `completed` is never stuck
waiting on someone else's editor. `blocked_by` is set whenever `status` is
`waiting_input`.
* `unconsumed` — the tokens of the line that no prompt ever asked for, in order.
A fully consumed line reports `[]`; `CIRCLE 0,0 5 9` reports `["9"]` because
the radius step ends the command. Over-typed input is reported instead of
being dropped on the floor.
When a line ends at the `TEXT` content step (`TEXT 0,0 5 0 hello`) the text is
typed into the in-place editor and committed, so the line behaves exactly as the
same input typed into the GUI command line would: one `Text` entity, nothing
unconsumed, and the command closed rather than left armed for a second line. Geometry stays in OpenCADStudio and its geometry kernel.
`ocs_capture` defaults to the drawing viewport and a longest edge of 1600 pixels. Set `scope` to `window` for the full interface or change `max_dimension` between 256 and 4096.
@@ -198,3 +222,4 @@ The operation stays pending until the open settles; if another open holds the sl
### Startup prompts during automation
The first control request of a session marks it automated: a donation prompt that has not been shown yet is dropped, and no new one is queued while control stays enabled. A prompt already on screen is left for its reader — close it with `{"op":"action","name":"close_modal"}`. A web host that intends to automate from the first page load can set `localStorage["opencadstudio.automation"] = "1"` before loading the app so the prompt never appears (the same pre-load mechanism the settings use).
The web build is driven through two JavaScript functions instead of a process; the protocol details are summarised above and in [web.md](web.md).
+131
View File
@@ -0,0 +1,131 @@
# Driving the native builds
Every native build has three entry points besides the editor window: the MCP
server described in the [README](README.md) (`OpenCADStudio --mcp`), a headless
JSON-line channel for scripts and CI (`OpenCADStudio --serve`), and a one-shot
converter (`OpenCADStudio --export IN OUT`). The browser entry is described in
[web.md](web.md).
## Choosing a channel
| | `--mcp` | `--serve` |
|---|---|---|
| Transport | MCP (JSON-RPC over stdio) with the four tools of the README | one JSON request per line on stdin, one reply per line on stdout; `--port N` listens on `127.0.0.1:N` instead |
| Editor | attaches to a running desktop editor, or starts one when there is none (`ocs_sessions`, `launch_if_none`) | no window; a document session of its own that lives as long as the process |
| Needs a display | yes (`xvfb-run` works on a headless machine; software rendering may raise the GPU warning dialog) | no |
| Needs a writable `HOME` / `XDG_CONFIG_HOME` | yes — the session descriptors live in the user configuration directory; without one `ocs_sessions` answers `No user configuration directory` | no |
| Startup dialogs | those of the editor (below) | none |
| `capture` | `ocs_capture` | not available (`gui_required`): there is no window to capture |
| Good for | an MCP client driving a real editor | batch jobs, CI, agents without a display |
## `--serve`
The first line written is a readiness banner, before any request is read:
```json
{"ok":true,"ready":true,"version":"2026.38"}
```
Over `--port N` the banner opens every connection; the listener serves one
client at a time and the document session persists across reconnects.
Two request styles share the channel, told apart by the `protocol` field:
| Style | Shape | Operations |
|---|---|---|
| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` |
| legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` |
The two styles differ in how a waiting command is treated. A protocol-1 `run`
or `start` while a command is still active is refused with `command_busy`:
continue it with `input` or drop it with `cancel`. A legacy `run` line ends the
waiting command and starts the new one.
### The legacy `run` line
`{"op":"run","cmd":"LINE 0,0 10,10"}` feeds the tokens after the command name
to the prompts in order and then presses Enter. The reply is
```json
{"ok":true,"cmd":"CIRCLE 0,0 5 9","status":"completed","blocked_by":null,"entities":1,"added":1,"unconsumed":["9"]}
```
| Field | Meaning |
|---|---|
| `status` | `completed`, or `waiting_input` when the line left something open |
| `blocked_by` | what *this line* left open: `"command"`, `"text_editor"`, `"mtext_editor"`, `"modal:<Kind>"`, or `null`. A surface that was already open when the line started is not attributed to it |
| `entities`, `added` | entity count after the line, and the difference to before |
| `unconsumed` | tokens no prompt asked for, in order (`["9"]` above: the radius step ended the command); `[]` when everything was consumed |
`waiting_input` means input is still required, not that nothing happened:
`SPLINE` and `ARC` keep accepting input after the entity exists, and
`zoom_extents` reports it after the view has already moved.
`TEXT 0,0 5 0 hello world` creates one `Text` entity holding `hello world`: the
tail of the line is typed into the in-place editor, committed, and the command
is closed so it does not stay armed for a second line. A `TEXT` line without
content answers `waiting_input` with `blocked_by: "text_editor"`.
Known gap (#1390): `MTEXT`, `HATCH` and `POLYGON` answer `completed` with
`added: 0` on this path — they need interactive surfaces (the rich-text editor,
a pattern choice, a boundary pick) that a single line has no tokens to fill.
## `--mcp`
The MCP server is a client of the desktop editor: `ocs_sessions` lists the live
editor sessions found in the configuration directory and starts one when none
is running, so several MCP connections can share one editor and its state.
A fresh profile opens two dialogs before any drawing exists, and both block
document creation:
| Step | `ocs_sessions` reports |
|---|---|
| right after launch | `modal: "AssocPrompt"` |
| after `close_modal` | `modal: "DonationPrompt"` |
| after a second `close_modal` | `modal: null` |
While a dialog is open, `{"op":"new"}` answers `ok` but no tab is created.
Close the dialogs first:
```python
while (info := sessions())["modal"]:
execute({"op": "action", "name": "close_modal", "document_id": info["document_id"]})
```
The donation prompt appears once per application version per profile, so an
automated profile meets it once per version rather than on every run.
`new` makes the new drawing the active document. Requests carry `document_id`;
take it from the reply's state and pass it on the following requests, as the
README describes.
## `--export`
`OpenCADStudio --export IN OUT` loads `IN` and writes `OUT` without a window or
a session. The output format follows the extension: `.dxf` writes DXF, any other
extension writes DWG at the document's own version. The exit code is 0 on
success and 1 when the input cannot be read or the output cannot be written.
## Interactive steps
For commands whose answers cannot be written on one line, use `start` and then
`input`. `start` replies with `state.command`, whose `accepts`, `options` and
`input_example` say what the current step wants:
| Step | Request | Note |
|---|---|---|
| a point | `{"op":"input","kind":"point","point":[x,y,0],"space":"wcs"}` | the point is an array; `space` is `wcs` (default), `ucs` or `relative` |
| a typed value (height, rotation) | `{"op":"input","kind":"text","text":"5"}` | digits go through `text`, not `token`; `{"op":"input","kind":"enter"}` accepts the default |
| a keyword (`J`, `ST`, `C`) | `{"op":"input","kind":"token","text":"C"}` | the payload field is named `text` |
| an object | `{"op":"input","kind":"entity","handle":"HANDLE","point":[x,y,0]}` | `kind: "structure"` for a sub-entity pick, `kind: "selection"` to hand over the current selection |
| the content of `TEXT` | `{"op":"action","name":"text_input","value":"hello"}` then `{"op":"action","name":"text_commit"}` | the command has already ended; the in-place editor owns the input |
| the content of `MTEXT` | `{"op":"action","name":"mtext_insert","value":"hello"}` then `{"op":"action","name":"mtext_commit"}` | the rich-text editor has its own actions; the `TEXT` ones answer `editor_closed` |
## Native-only operations
| Operation | `--serve` (protocol 1) | `--mcp` |
|---|---|---|
| measure | `{"protocol":1,"op":"measure","handles":["2A"]}` — kernel-computed length, area, bounds and mass properties; no window needed | `ocs_read` with `op: "measure"` and `parameters.handles` |
| capture | `{"protocol":1,"op":"capture","path":"out.png"}` — needs the editor window, so it answers `gui_required` here | `ocs_capture` with `scope` (`viewport` or `window`) and `max_dimension` |
| `set_properties` | requires `collection`; colours are the serialised enum — `{"Index": 1}` or `"ByLayer"`; a colour name such as `"Red"` is rejected with `invalid_value` | same request through `ocs_execute` |
+68
View File
@@ -0,0 +1,68 @@
# Driving the web build
The web build exposes the same control core as `--serve` and `--mcp`, through two
functions on the WebAssembly module. This page covers only what is specific to the
browser; the operations, request envelope and replies are the ones in the
[README](README.md).
## Reaching the control channel
The Trunk loader publishes the wasm-bindgen module as `window.wasmBindings`. The
control channel is ready once `window.wasmBindings?.ocs_control_submit` is defined
(the [save smoke test](web_save_smoke.cjs) waits for exactly that).
| Function | Behaviour |
|---|---|
| `ocs_control_submit(request: string) → string` | Queues one request (a JSON string) and returns a ticket immediately. |
| `ocs_control_take(ticket: string) → string \| undefined` | Returns the reply (a JSON string) once it is ready, `undefined` before that. A reply is removed when taken. |
Poll `ocs_control_take` until it returns a reply, as the smoke test does:
```js
async function control(request) {
const api = window.wasmBindings;
const ticket = api.ocs_control_submit(JSON.stringify(request));
for (let i = 0; i < 600; i++) {
const reply = api.ocs_control_take(ticket);
if (reply) return JSON.parse(reply);
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error('control timed out');
}
```
Limits: at most 64 requests wait in the queue (a further `submit` is answered with the
`busy` error), and only the newest 64 replies are kept, so take a reply soon after it is
ready. A request that is not valid JSON is answered with `op: "invalid"` and a
`parse_error`.
## Opening a drawing from bytes
The web build has no file system, so `open` takes the file's bytes instead of a path:
```json
{"op":"open","name":"plan.dwg","data_base64":"…"}
```
`name` is reduced to its file name and labels the tab and the recent-files entry;
`data_base64` must be valid base64 and non-empty (`invalid_request` otherwise). While an
earlier drawing is still opening the request is answered with `busy`; retry it once
`state` shows the tab. A `path` is resolved against the browser's "recent files" store
rather than a disk, so use `data_base64` for a file the page has not seen.
`data_base64` is rejected on native builds; use `path` there.
## Startup dialogs
A dialog can be open when the page is ready. The donation prompt appears once per
application version in each browser profile, so a fresh or disposable profile always
sees it. While a modal is open `state.modal` is set, and `{"op":"new"}` returns `ok`
without creating a tab until the modal is closed. Close it first:
```js
const state = await control({ op: 'state' });
if (state.modal) await control({ op: 'action', name: 'close_modal' });
await control({ op: 'new' });
```
Native builds can show a second dialog at launch (the file-association prompt), so check
`state.modal` again after closing one.
+49 -15
View File
@@ -109,11 +109,13 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `STRETCH` | | current selection or prompt |
| `TRIM` | | command-driven |
## Other (514)
## Other (529)
| Command | Default shortcut | Selection |
|---|---|---|
| `-PSETUPIN` | | command-driven |
| `-SETBYLAYER` | | command-driven |
| `-XREF` | | command-driven |
| `3DALIGN` | | command-driven |
| `3DARRAY` | | command-driven |
| `3DFACE` | | command-driven |
@@ -174,6 +176,7 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `AUPREC` | | command-driven |
| `AUTOCONSTRAIN` | | command-driven |
| `BACKGROUND` | | command-driven |
| `BACKGROUNDPLOT` | | command-driven |
| `BASE` | | command-driven |
| `BATTMAN` | | command-driven |
| `BEDIT` | | command-driven |
@@ -188,6 +191,7 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `BMAKE` | | command-driven |
| `BOUNDARY` | | command-driven |
| `BREAKATPOINT` | | command-driven |
| `BROWSER` | | command-driven |
| `BYLAYER` | | command-driven |
| `CAL` | | command-driven |
| `CANNOSCALE` | | command-driven |
@@ -224,6 +228,7 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `COLORTHEME` | | command-driven |
| `COLOUR` | | command-driven |
| `COMMANDLINEFADETIME` | | command-driven |
| `CONSTRAINTBAR` | | command-driven |
| `CONSTRAINTSETTINGS` | | command-driven |
| `CONSTRUCTIONLINE` | | command-driven |
| `CONTENTBROWSER` | | command-driven |
@@ -233,7 +238,9 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `COPYCLIP` | Ctrl+C | current selection or prompt |
| `COUNT` | | command-driven |
| `CPCONSTRAINT` | | command-driven |
| `CREATESKETCH` | | command-driven |
| `CROSSINGAREACOLOR` | | command-driven |
| `CTAB` | | command-driven |
| `CUI` | | command-driven |
| `CUIEXPORT` | | command-driven |
| `CUIIMPORT` | | command-driven |
@@ -265,7 +272,6 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `DDPTYPE` | | command-driven |
| `DDUNITS` | | command-driven |
| `DE` | | command-driven |
| `DELCONSTRAINT` | | command-driven |
| `DELOBJ` | | command-driven |
| `DESELALL` | | command-driven |
| `DESELECT` | | command-driven |
@@ -286,7 +292,6 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `DWGPROPS` | | command-driven |
| `DWGUNITS` | | command-driven |
| `EATTEXT` | | command-driven |
| `ECONSTRAINT` | | command-driven |
| `EDCONSTRAINT` | | command-driven |
| `EDGE` | | command-driven |
| `ELEVATION` | | command-driven |
@@ -298,6 +303,7 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `EXPORTPDF` | | command-driven |
| `EXPORTSTEP` | | command-driven |
| `EXPORTSTL` | | command-driven |
| `EXTERNALREFERENCES` | | command-driven |
| `EXTRIM` | | command-driven |
| `FI` | | command-driven |
| `FILEASSOC` | | command-driven |
@@ -306,6 +312,7 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `FILTER` | | command-driven |
| `FIND` | Ctrl+F | command-driven |
| `FINDNONPURGEABLE` | | command-driven |
| `FINISHSKETCH` | | command-driven |
| `FITSPLINE` | | command-driven |
| `FLATSHOT` | | command-driven |
| `FLATTEN` | | command-driven |
@@ -314,13 +321,14 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `FRAMES1` | | command-driven |
| `FRAMES2` | | command-driven |
| `FXCONSTRAINT` | | command-driven |
| `GCCOINCIDENT` | | command-driven |
| `GCFIX` | | command-driven |
| `GCHIDE` | | command-driven |
| `GCHIDEALL` | | command-driven |
| `GCRESET` | | command-driven |
| `GCSHOW` | | command-driven |
| `GCSHOWALL` | | command-driven |
| `GEOMCONSTRAINT` | | command-driven |
| `GCSMOOTH` | | command-driven |
| `GCVERTICAL` | | command-driven |
| `GRADIENT` | | command-driven |
| `GRID` | F7 | command-driven |
| `GRIPCOLOR` | | command-driven |
@@ -333,7 +341,6 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `HATCHEDIT` | | command-driven |
| `HATCHTOBACK` | | command-driven |
| `HB` | | command-driven |
| `HCONSTRAINT` | | command-driven |
| `HELIX` | | command-driven |
| `HELP` | F1 | command-driven |
| `HI` | | command-driven |
@@ -344,6 +351,7 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `IM` | | command-driven |
| `IMAGE` | | command-driven |
| `IMAGEATTACH` | | command-driven |
| `IMAGEEMBED` | | command-driven |
| `IMAGEFRAME` | | command-driven |
| `IMPORTOBJ` | | command-driven |
| `INF` | | command-driven |
@@ -379,7 +387,6 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `LAYTRANS` | | command-driven |
| `LAYULK` | | current selection or prompt |
| `LAYUNISO` | | command-driven |
| `LCONSTRAINT` | | command-driven |
| `LENGTHEN` | | command-driven |
| `LI` | | command-driven |
| `LIMCHECK` | | command-driven |
@@ -413,11 +420,9 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `MULTIPOINT` | | command-driven |
| `MVIEW` | | command-driven |
| `NAVVCUBE` | | command-driven |
| `NCONSTRAINT` | | command-driven |
| `NCOPY` | | command-driven |
| `NCOPYALL` | | command-driven |
| `NEW` | Ctrl+N | command-driven |
| `NRCONSTRAINT` | | command-driven |
| `OBJECTSCALE` | | command-driven |
| `OBJIMPORT` | | command-driven |
| `OCCONSTRAINT` | | command-driven |
@@ -431,12 +436,12 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `OVERKILL` | | command-driven |
| `P` | | command-driven |
| `PAGESETUP` | | command-driven |
| `PAPERUPDATE` | | command-driven |
| `PARAMETERS` | | command-driven |
| `PASTE` | | command-driven |
| `PASTEBLOCK` | Ctrl+SHIFT+V | command-driven |
| `PASTECLIP` | Ctrl+V | command-driven |
| `PASTEORIG` | | command-driven |
| `PCONSTRAINT` | | command-driven |
| `PDFATTACH` | | command-driven |
| `PDFFRAME` | | command-driven |
| `PDMODE` | | command-driven |
@@ -450,9 +455,12 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `PLIMCHECK` | | command-driven |
| `PLINEGEN` | | command-driven |
| `PLOT` | Ctrl+P | command-driven |
| `PLOTOFFSET` | | command-driven |
| `PLOTROTMODE` | | command-driven |
| `PLOTSTYLE` | | command-driven |
| `PLOTSTYLEEDITOR` | | command-driven |
| `PLOTSTYLEPANEL` | | command-driven |
| `PLOTTRANSPARENCYOVERRIDE` | | command-driven |
| `PLOTWINDOW` | | command-driven |
| `PLUGINMANAGER` | | command-driven |
| `PLUGINS` | | command-driven |
@@ -465,15 +473,16 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `PR` | | command-driven |
| `PRINT` | | command-driven |
| `PRINTALL` | | command-driven |
| `PRINTERS` | | command-driven |
| `PROPERTIES` | Ctrl+1 | command-driven |
| `PROPS` | | command-driven |
| `PSETUPIN` | | command-driven |
| `PSLTSCALE` | | command-driven |
| `PSPACE` | | command-driven |
| `PURGE` | | command-driven |
| `PYR` | | command-driven |
| `PYRAMID` | | command-driven |
| `QC` | | command-driven |
| `QCONSTRAINT` | | command-driven |
| `QDIM` | | command-driven |
| `QL` | | command-driven |
| `QLEADER` | | command-driven |
@@ -531,6 +540,8 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `SHADEMODE` | | command-driven |
| `SHEETSET` | | command-driven |
| `SHELL` | | command-driven |
| `SHORTCUTMENU` | | command-driven |
| `SHORTCUTMENUDURATION` | | command-driven |
| `SHORTCUTS` | | command-driven |
| `SKETCH` | | command-driven |
| `SKETCHINC` | | command-driven |
@@ -538,7 +549,6 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `SKTOLERANCE` | | command-driven |
| `SL` | | command-driven |
| `SLICE` | | command-driven |
| `SMOOTHCONSTRAINT` | | command-driven |
| `SNAP` | F9 | command-driven |
| `SNAPANG` | | command-driven |
| `SO` | | command-driven |
@@ -548,6 +558,12 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `SOLIDEDIT` | | command-driven |
| `SOLIDFILLET` | | command-driven |
| `SORTENTS` | | command-driven |
| `SPACEMOUSE` | | command-driven |
| `SPACEMOUSE3D` | | command-driven |
| `SPACEMOUSEAUTO` | | command-driven |
| `SPACEMOUSEPAN` | | command-driven |
| `SPACEMOUSEPANZOOM` | | command-driven |
| `SPACEMOUSEPAUSE` | | command-driven |
| `SPLFRAME` | | command-driven |
| `SPLINECV` | | command-driven |
| `SPLINEFIT` | | command-driven |
@@ -567,7 +583,6 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `TABLEDIT` | | command-driven |
| `TABLESTYLE` | | command-driven |
| `TCASE` | | command-driven |
| `TCONSTRAINT` | | command-driven |
| `TCOUNT` | | command-driven |
| `TEDIT` | | command-driven |
| `TEXTEDIT` | | command-driven |
@@ -580,6 +595,7 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `TEXTTOFRONT` | | command-driven |
| `THICKEN` | | command-driven |
| `THICKNESS` | | command-driven |
| `TILEMODE` | | command-driven |
| `TOOLPALETTES` | | command-driven |
| `TORIENT` | | command-driven |
| `TP` | | command-driven |
@@ -597,7 +613,6 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `USERI` | | command-driven |
| `USERR` | | command-driven |
| `USRTIMER` | | command-driven |
| `VCONSTRAINT` | | command-driven |
| `VERTICAL` | | command-driven |
| `VIEW` | | command-driven |
| `VISRETAIN` | | command-driven |
@@ -628,6 +643,25 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `ZOOMWHEEL` | | command-driven |
| `ZS` | | command-driven |
## Parametric (14)
| Command | Default shortcut | Selection |
|---|---|---|
| `DELCONSTRAINT` | | command-driven |
| `ECONSTRAINT` | | command-driven |
| `GCCOINCIDENT` | | command-driven |
| `GCCONCENTRIC` | | command-driven |
| `GCEQUAL` | | command-driven |
| `GCHORIZONTAL` | | command-driven |
| `GCPERPENDICULAR` | | command-driven |
| `GEOMCONSTRAINT` | | command-driven |
| `LCONSTRAINT` | | command-driven |
| `NRCONSTRAINT` | | command-driven |
| `PCONSTRAINT` | | command-driven |
| `QCONSTRAINT` | | command-driven |
| `TCONSTRAINT` | | command-driven |
| `VCONSTRAINT` | | command-driven |
## View (6)
| Command | Default shortcut | Selection |
@@ -639,4 +673,4 @@ Generated from the in-repo command registry; manual edits are overwritten. Regen
| `VPORTS` | | command-driven |
| `ZOOM` | | command-driven |
_602 commands across 7 categories._
_631 commands across 8 categories._
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+38
View File
@@ -0,0 +1,38 @@
# Perspective draw-order clipping regression
Switching a flat model view from orthographic to perspective could hide otherwise
visible lines. The camera applied an OpenGL-to-WebGPU depth conversion to a
projection that already used WebGPU's depth range. The shaders then applied a
fixed draw-order offset that could push geometry past the far clipping boundary.
The camera now uses the DirectX/WebGPU projection directly. The shared
`draw_order.wgsl` function keeps the existing offset away from clipping planes
and reduces it near either plane. Its adjustment is monotonic in draw order,
leaves neutral geometric depth unchanged, and does not move clipped vertices
back into range. It is shared by the native and compatibility shader paths.
## GPU reproduction
These are offscreen renders from the application's wire pipelines. All 17 lines
are on the same plane; only their draw-order values differ. The first two lines
(red when visible) were clipped in perspective. Both projections now show all
17 lines. This synthetic drawing contains no user CAD geometry.
| Before | After |
| --- | --- |
| ![Two leftmost lines missing](images/perspective-depth-before.png) | ![All 17 lines visible](images/perspective-depth-after.png) |
## Validation
```sh
cargo test --locked --lib
cargo test --locked --lib scene::pipeline::depth_tests -- --ignored --nocapture
```
The GPU tests exercise storage and packed wire pipelines, near/far clipping,
coplanar draw order in both submission orders, and wire occlusion by a triangle
mesh. Normal tests check camera depth endpoints, picking, and validation of all
14 composed draw-order shaders.
Set `OCS_DEPTH_TEST_IMAGES` to an output directory to save the orthographic and
perspective reproduction images while running the GPU tests.
+41 -11
View File
@@ -101,13 +101,16 @@ affect whether a prebuilt plugin can safely be loaded:
A plugin's `plugin.toml` declares the same values under `[opencad]`. For API v4
and later, `rustc_version` is required and the host refuses to load the plugin
unless it matches exactly. This turns the current silent runner crash into a
one-line diagnosis such as:
unless it matches exactly — omitting it is treated as a mismatch, not skipped.
This turns the current silent runner crash into a one-line diagnosis such as:
```
Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required
```
`acadrust_source` is also required for API v4 and later. Missing, empty, or
mismatched metadata makes the package incompatible.
### `PluginManifest`
```rust
@@ -202,11 +205,14 @@ struct ExampleModule;
impl CadModule for ExampleModule {
fn id(&self) -> &'static str { "example" }
fn title(&self) -> &'static str { "Example" }
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef {
// Returns a slice, not an owned Vec — build once into a static and hand
// back a reference, rather than rebuilding (and dropping) it every call.
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef {
id: "EX_HELLO", label: "Hello", icon: IconKind::Glyph("◆"),
event: ModuleEvent::Command("EX_HELLO".to_string()),
})]}]
})]}]})
}
}
@@ -235,8 +241,9 @@ api_version = 5
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
# Filled with the exact compiler output while staging the release asset.
# Both are filled at release-build time; see the CI step below.
rustc_version = "rustc 1.98.0 (hash date)"
acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=<short>#<full-40-char-commit>"
```
The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template);
@@ -255,6 +262,10 @@ tool fires `ModuleEvent::Command("EX_FOO")`, which round-trips to
tool request a native file picker; on selection the host dispatches
`"<command> <path>"` back with the path's original case preserved.
A leading `>` or the persistent literal-input toggle preserves both spaces
and case. Automation and `ModuleEvent::PluginFileDialog` already preserve
case without this prefix.
### Interactive (click-to-place) commands
For tools that collect points — placing a structure, drawing a pipe — call
@@ -306,7 +317,18 @@ plugin.toml
```
A GitHub Actions matrix workflow (see the example repo / template) cross-builds
and uploads these on a `v*` tag.
and uploads these on a `v*` tag. It also fills `plugin.toml`'s `rustc_version`
and `acadrust_source` placeholders from the CI run's actual toolchain and
resolved dependency, rather than hand-editing them per release — see
`docs/plugin-template/.github/workflows/release.yml` for the exact `sed`
substitutions, including reading `acadrust_source` out of `Cargo.lock`'s
resolved `acadrust` package entry (`source = "git+...#<hash>"`) since that's
the same value the host embeds at its own build time. The template's CI pins
`rust-toolchain@stable`, which is a floating target — a real release pipeline
should pin the exact toolchain version the host was built with (check
`crates/ocs_plugin_api/src/version_info.rs`'s embedded value, or the host's
own CI config) rather than trusting "whatever's current stable today" to
keep matching it.
---
@@ -383,6 +405,9 @@ dynamic libraries:
- **Enable/disable** — toggles a loaded plugin's ribbon tab + dispatch without
uninstalling.
One linked repository is one marketplace entry. Publish separate plugins from
separate repositories so release and update histories stay unambiguous.
---
## Compatibility & ABI
@@ -391,9 +416,9 @@ Plugins are loaded as `cdylib`s by a plugin-runner child process. The host spawn
this child from its own executable (`--ocs-plugin-runner` mode), so the runner
and host always share the same `ocs_plugin_api` build. The runner checks
`ocs_plugin_api_version` before any plugin code runs. Each plugin runs in its
own OS process, so the host is protected from plugin crashes and memory
corruption. Process isolation removes the need for the host and plugin to share
a Rust toolchain ABI beyond the stable `ocs_plugin_api` contract.
own OS process, so a plugin crash or memory-corruption bug cannot take down the
host process itself. The runner loads the plugin library in-process, however,
so API v4+ requires matching `rustc_version` and `acadrust_source` metadata.
A future hardening step is a `#[repr(C)]` vtable (a true C ABI) so binaries built
by any toolchain interoperate — required before trusting prebuilt binaries from
@@ -414,6 +439,12 @@ Done:
reinstall / uninstall, enable/disable.
- [x] Interactive command round-trip over IPC (prompt, point/enter/object-pick).
- [x] Spawn and per-call IPC timeouts so a stuck runner cannot freeze the host.
- [x] External automation (`--mcp` — local stdio MCP/JSON-RPC server for AI
clients; `--serve` — headless JSON automation server; both drive OCS
from a separate process without the GUI, per `src/cli.rs`/`src/mcp.rs`/
`app::serve`). This closes the "drive OCS headless from a process" goal
of issue #29; this line previously and incorrectly listed it as not yet
started.
Next:
@@ -421,7 +452,6 @@ Next:
- [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries.
- [ ] Trust: checksums / signatures before spawning plugin processes.
- [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks.
- [ ] External automation API (drive OCS headless from a process) — issue #29.
---
+1 -1
View File
@@ -12,4 +12,4 @@ crate-type = ["cdylib"]
[dependencies]
ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c" }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8a28c21" }
+4 -2
View File
@@ -64,8 +64,10 @@ From the workspace root:
cargo build -p plugin-template-v2
```
The resulting cdylib plus `plugin.toml` can be installed into the host's
plugins folder (see `docs/plugin-architecture.md`).
The resulting cdylib plus `plugin.toml` (checked in alongside this file) can
be installed into the host's plugins folder (see `docs/plugin-architecture.md`).
Replace `plugin.toml`'s `rustc_version` and `acadrust_source` placeholders in
your packaging step. The release workflow in `docs/plugin-template` shows how.
---
+23
View File
@@ -0,0 +1,23 @@
[plugin]
id = "com.example.plugin-template-v2"
name = "Plugin Template v2"
version = "0.1.0"
description = "Demonstrates read/write object round-trips with the host."
author = "Your Name"
license = "GPL-3.0-only"
repository = "your-account/your-plugin-repo"
[opencad]
api_version = 5
# Replace both placeholders in the packaging step. The v1 template's
# release workflow contains a working example.
rustc_version = "__RUSTC_VERSION__"
acadrust_source = "__ACADRUST_SOURCE__"
ribbon_order = 100
command_prefixes = [
"COUNT_SURVEY_POINTS",
"ADD_SURVEY_POINT",
"MARK_FIRST_SURVEY_POINT",
"COUNT_MARKED_SURVEY_POINTS",
]
xdata_apps = ["SURVEYMARK"]
+13 -1
View File
@@ -29,6 +29,14 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
# NOTE: @stable floats to whatever's current when this job runs, which
# is NOT the same thing as "matches the host's rustc exactly" — the API
# v4+ load check compares plugin.toml's rustc_version to the host's
# build-time compiler byte-for-byte (see plugin-architecture.md's
# "Build-time ABI metadata"). Pin this to the host's actual toolchain
# version (check crates/ocs_plugin_api/src/version_info.rs's embedded
# value, or the host's own CI config) before a real release, or your
# binary may be refused at load with a version-mismatch error.
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --release
- name: Stage release asset
@@ -36,7 +44,11 @@ jobs:
run: |
mkdir -p dist
cp target/release/*.${{ matrix.ext }} "dist/${{ matrix.asset }}"
sed "s|__RUSTC_VERSION__|$(rustc --version)|" plugin.toml > dist/plugin.toml
acadrust_source=$(grep -A3 '^name = "acadrust"' Cargo.lock | grep '^source' | head -1 | sed 's/^source = "//;s/"$//')
sed \
-e "s|__RUSTC_VERSION__|$(rustc --version)|" \
-e "s|__ACADRUST_SOURCE__|$acadrust_source|" \
plugin.toml > dist/plugin.toml
- uses: softprops/action-gh-release@v2
with:
files: |
+2
View File
@@ -10,6 +10,8 @@ repository = "your-account/your-plugin-repo"
[opencad]
api_version = 5
rustc_version = "__RUSTC_VERSION__"
# release.yml fills both placeholders from the compiler and Cargo.lock.
acadrust_source = "__ACADRUST_SOURCE__"
ribbon_order = 60
command_prefixes = ["MP_"]
xdata_apps = ["MYPLUGIN_RECORD"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+145
View File
@@ -0,0 +1,145 @@
# SpaceMouse
The Windows desktop app supports 3Dconnexion SpaceMouse devices through the
installed [3DxWare driver](https://3dconnexion.com/drivers/). Open CAD Studio
loads NavLib from the Windows system directory at runtime. An SDK installation
is not needed to build or run the app. macOS, Linux, and the web build currently
show an unavailable state; their device adapters are not implemented.
## Navigation
Open **Options → User Preferences → SpaceMouse**, or enter `SPACEMOUSE`.
| Mode | Behavior |
| --- | --- |
| Follow context | Pan and zoom on a paper sheet; pan, zoom, and orbit in model views. |
| Pan only | Push or tilt the puck to pan in the current screen plane. Twist and vertical pressure are ignored. Scale, depth, and orientation stay fixed, including in perspective. |
| Pan and zoom | Uses the same push/tilt panning as Pan only. Lift or press the puck to zoom about the view center. Twist is ignored and orientation stays fixed. |
| 3D navigation | Pan, zoom, and orbit in model views. Paper sheets keep orientation fixed. |
For a flat drawing, choose **Pan only** to use the puck as a panning joystick.
Push or tilt right/left to move the drawing right/left; push or tilt away/toward
you to move it up/down. Pushing and tilting together does not double the speed.
Enable **Reverse pan direction** if you prefer moving the viewpoint in the same
direction as the puck. **Reverse pan direction** and **Pan speed** apply to both
flat-navigation modes, including paper sheets in Follow context. A small dead zone
filters incidental forces, and releasing the puck stops movement.
The app does not infer that a model-space file is 2D from its extension or from
the current camera angle. These preferences belong to the user and persist
across drawings and sessions.
The status bar shows the effective mode after a device connects, and keeps the
control available after disconnection. An explicitly selected navigation mode
also makes it visible. Its menu provides mode selection, Pause/Resume, preferences,
and a shortcut to 3Dconnexion settings. Status-bar customization can hide it.
Only the focused drawing receives input. Dialogs suspend navigation. The
view metadata remains available to the driver while its settings UI has focus.
Native 3DxWare chooses the foreground application; the adapter marks its view
active without forcing the NLServer-specific keyboard-focus property. Entered
paper-space viewports use their own cameras; their display locks are respected.
The active model tile receives navigation in a split view. Moving the puck
preserves an unfinished drawing command, refreshes its preview at a stationary
pointer, and keeps an active selection-box anchor attached to the drawing.
![SpaceMouse menu with Pan only selected](images/spacemouse-menu.png)
![SpaceMouse settings in User Preferences](images/spacemouse-preferences.png)
## Buttons and radial menus
Use **Open 3Dconnexion settings…** to assign buttons and radial-menu entries.
The app exports registered CAD command IDs and reuses ribbon labels and icons
where available. Speed, axis inversion, dominant-axis filtering, and radial-menu
layout for SDK navigation remain in the driver settings. Pan only and Pan and zoom
share the app's push/tilt mapping and Pan speed control; driver axis remappings do
not apply to these flat-navigation modes. Button assignments continue to work in
all modes.
For orbiting with a fixed up direction, enable **Lock Horizon** in the driver's
Advanced Settings for Open CAD Studio. The adapter reads `settings.LockHorizon`
through NavLib at connection/focus and after `settings.changed` notifications.
When enabled, accepted orbit matrices remove roll relative to world Z. Pitch
stops just short of the top/bottom pole, retaining heading instead of flipping
the view. In the driver's Object mode the eye receives the same pitch correction
around the SDK's orbit pivot, so the object stays in place; Camera/Helicopter
modes retain their proposed eye position. Disabling Lock Horizon restores
unrestricted driver rotation.
The app does not duplicate this control, the driver's rotation lock, or
its object/camera navigation modes.
Useful actions include Undo, Redo, Cancel, Fit view, Top view, and these commands:
| Command | Action |
| --- | --- |
| `SPACEMOUSE` | Open SpaceMouse preferences |
| `SPACEMOUSEPAUSE` | Pause/resume puck navigation for this session |
| `SPACEMOUSEAUTO` | Follow context |
| `SPACEMOUSEPAN` | Pan only |
| `SPACEMOUSEPANZOOM` | Pan and zoom |
| `SPACEMOUSE3D` | 3D navigation |
Fit view (`SPACEMOUSEFIT`) and Top view (`SPACEMOUSETOP`) are also available as
button/keyboard actions. Button actions share the keyboard dispatcher: for
example, Undo removes the last entered point of an unfinished polyline. A
Pause/Resume button remains usable while puck navigation is paused.
## Implementation
- `src/input/spacemouse/`: portable preferences, camera snapshots, and a bounded
thread-safe bridge. `navlib.rs` contains the Windows ABI and runtime loading.
`raw_input.rs` receives SpaceMouse multi-axis HID reports for planar navigation;
`pan.rs` maps pushes and tilts to screen velocity and vertical pressure to zoom.
The planar path suppresses SDK camera updates so one gesture cannot be applied
twice. Only 3Dconnexion/legacy Logitech multi-axis
controllers are accepted. Other HID devices and report types are ignored.
`worker.rs` waits for app work and dispatches Windows messages on the SDK's
owning thread, including settings changes while the drawing is unfocused.
- `src/app/navigation.rs`: focus/context policy, cached selection bounds, camera
application, pivot feedback, and the action catalog.
- `src/scene/mspace.rs`: routing to the active model or floating-viewport camera.
- `src/ui/window/options/spacemouse.rs` and `src/ui/statusbar/spacemouse.rs`:
controls within the existing Options and status-menu systems.
- `src/app/settings.rs`: preferences; `src/app/shortcuts.rs`: shared actions.
Driver callbacks operate on snapshots and never access the scene or GUI. A
navigation transaction is coalesced into one pending camera update. Target
stamps discard late motion after a tab, layout, viewport, tile, or camera change.
Acknowledging an applied frame retains newer callbacks from the same gesture;
an external camera change resets the SDK navigation model. Raw pan state is
cleared on context/focus changes, pause, disconnection, and stale reports.
Button requests retain their context across camera revisions. Losing focus or
changing drawing context discards queued input. Connection shutdown closes
NavLib before releasing callback, command, image, or library storage.
The subscription joins its worker at shutdown so NavLib can finish persisting
the driver's per-application preferences before the process exits.
For driver-setting diagnostics, set
`RUST_LOG=OpenCADStudio::input::spacemouse=debug` before launching. The trace
records settings-change notifications and the Lock Horizon value read from
NavLib; it does not change the user's driver settings.
Iced supplies monotonic frame timestamps while navigation is active. Idle
drawings do not request continuous rendering. Driver-originated camera updates
are not echoed back as external-camera notifications, which would reset the
driver's navigation model during a held input.
The optional SDK `view.target` property returns no-data: the app's camera center
does not represent the SDK's separate target-camera navigation model.
The adapter uses the NavLib C ABI described in the 3DxWare SDK headers
(`navlib.h`, `navlib_types.h`, and `siappcmd_types.h`). No vendor headers or
runtime binaries are included. SpaceMouse and 3Dconnexion are trademarks of
3Dconnexion.
## Validation
```sh
cargo test --locked --lib spacemouse
```
An opt-in smoke test checks the installed driver, command/icon export, device
presence, and reading its Lock Horizon setting:
```sh
cargo test --locked --lib installed_driver_connects_and_reports_device_presence -- --ignored --nocapture
```
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="350" viewBox="0 0 900 350">
<style>text{font-family:Arial,sans-serif;fill:#263238} .title{font-size:22px;font-weight:600} .label{font-size:16px} .small{font-size:13px;fill:#54616a} .model{stroke:#1479b8;stroke-width:4;fill:none} .paper{stroke:#a94610;stroke-width:2;stroke-dasharray:9 5} .dim{stroke:#263238;stroke-width:1.5;fill:none}</style>
<defs><marker id="arrow" viewBox="0 0 8 8" refX="4" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M8 0L0 4L8 8" fill="#263238"/></marker></defs>
<rect width="900" height="350" rx="10" fill="white"/>
<text class="title" x="30" y="38">The dimension follows its paper-space centerline</text>
<rect x="30" y="65" width="400" height="220" fill="#f5f8fa" stroke="#c3ced5"/>
<rect x="470" y="65" width="400" height="220" fill="#f5f8fa" stroke="#c3ced5"/>
<text class="label" x="48" y="93">Before</text><text class="label" x="488" y="93">Centerline moved 1 paper unit right</text>
<path class="model" d="M85 160H385M525 160H825"/>
<circle cx="385" cy="160" r="5" fill="#1479b8"/><circle cx="825" cy="160" r="5" fill="#1479b8"/>
<path class="paper" d="M235 113V257M705 113V257"/>
<path class="dim" d="M385 171V244M825 171V244"/>
<path class="dim" d="M235 232H385" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<path class="dim" d="M705 232H825" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<rect x="291" y="217" width="38" height="28" fill="#f5f8fa"/><rect x="746" y="217" width="38" height="28" fill="#f5f8fa"/>
<text class="label" x="310" y="237" text-anchor="middle">50</text><text class="label" x="765" y="237" text-anchor="middle">40</text>
<text class="label" x="30" y="316">The model endpoint stays fixed. The dimension updates from 50 to 40.</text>
<text class="small" x="30" y="340">Association schematic; viewport 1:10, dimension style unit factor = 1, DIMASSOC = 2.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="350" viewBox="0 0 900 350">
<style>text{font-family:Arial,sans-serif;fill:#263238} .title{font-size:22px;font-weight:600} .label{font-size:16px} .small{font-size:13px;fill:#54616a} .model{stroke:#1479b8;stroke-width:4;fill:none} .paper{stroke:#a94610;stroke-width:2;stroke-dasharray:9 5} .dim{stroke:#263238;stroke-width:1.5;fill:none}</style>
<defs><marker id="arrow" viewBox="0 0 8 8" refX="4" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M8 0L0 4L8 8" fill="#263238"/></marker></defs>
<rect width="900" height="350" rx="10" fill="white"/>
<text class="title" x="30" y="38">Dimension from a paper centerline to a model endpoint</text>
<rect x="30" y="65" width="430" height="245" fill="#f5f8fa" stroke="#c3ced5"/>
<text class="small" x="45" y="89">Viewport 1:10</text>
<path class="model" d="M100 160H400"/>
<circle cx="400" cy="160" r="5" fill="#1479b8"/>
<path class="paper" d="M250 110V260"/>
<text class="small" x="100" y="144">Model geometry</text>
<path class="dim" d="M400 171V244"/>
<path class="dim" d="M250 232H400" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<rect x="306" y="217" width="38" height="28" fill="#f5f8fa"/>
<text class="label" x="325" y="237" text-anchor="middle">50</text>
<text class="small" x="45" y="290">Brown dashed line: paper-space centerline</text>
<text class="label" x="500" y="116">5 paper units between the picked points</text>
<text class="label" x="500" y="155">5 × 10 = 50 model units</text>
<text class="label" x="500" y="206">The viewport supplies the length scale.</text>
<text class="label" x="500" y="240">Either pick order gives the same value.</text>
<text class="small" x="30" y="336">Measurement schematic; dimension style unit factor = 1.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+74
View File
@@ -0,0 +1,74 @@
# Dimensions through layout viewports
In paper space, snap to model geometry displayed through a planar, orthographic
viewport. `DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER`
also support their usual object-pick modes through these viewports.
## Measurement
- Model geometry supplies the viewport scale. A 100-unit model length displayed
at 1:10 measures 100, although it occupies 10 paper units.
- A paper-space centerline or free sheet point can be combined with model
geometry through one viewport, in either pick order. The paper-space span is
measured using that viewport's scale.
- Sheet-only dimensions measure paper units, even inside a viewport rectangle.
- Model picks from a second viewport are rejected without advancing the command.
Dimension-line and text placement do not change the measurement scale.
- The current dimension style's unit conversion is preserved. Angular dimensions
do not use length compensation. `DIMASSOC=0` explodes the compensated dimension.
Snapping and object picking respect visibility, clipping, and viewport draw order.
Paper geometry has priority in object picks. Ordinary selection uses the active
drawing space. Object picks reuse the resident interaction index and resolve
nested block transforms.
![Measurement schematic: a five-unit sheet span measures 50 through a 1:10 viewport.](viewport-dimension-creation.svg)
## Saved scale
Length dimensions store paper-space definition points and a negative `DIMLFAC`
override: user unit factor multiplied by `1 / viewport scale`.
`OCS_VIEWPORT_MEASUREMENT` XData stores version `1` (integer16), the positive user
factor (real), and viewport compensation (real). Keeping the factors separate
allows compensation to change without losing the user's unit conversion.
Both the override and XData survive DXF/DWG saves and undo/redo.
## Associations
With `DIMASSOC=2`, acquired features retain their viewport, block-instance path,
source entity, and snap feature. Intersections retain both source chains.
Source edits and viewport changes update the dimension and its compensation,
preserving style overrides and user-positioned text on the sheet.
A sheet centerline stays on the sheet; a model endpoint follows its viewport.
Moving the viewport therefore changes the distance between them. Undo/redo and
DXF/DWG saves preserve the references.
Properties shows association status. If a required reference is broken or cannot
be resolved, the entire dimension keeps its last valid geometry and reference
records. A successful refresh regenerates its displayed and exported geometry.
![Association schematic: moving the sheet centerline changes the dimension from 50 to 40.](viewport-dimension-association.svg)
## Limits
Unsupported cases preserve saved dimensions:
- Perspective or oblique views; missing or malformed reference chains.
- Array instances without an addressable instance index, nesting beyond eight
levels, and radial measurements of nonuniformly scaled circles or arcs.
- Intersections without two identifiable source chains inside a batched block.
- Ambiguous imported endpoint, midpoint, or quadrant markers. A stored point can
validate a feature; it cannot establish an otherwise unknown identity.
- Tangency without a verified solution, or two perpendicular/tangent references
requiring a coupled solve.
- Imported viewport scaling without OCS scale metadata or
`ACAD_DIMASSOC_CALC_DIMLFAC` identifying the saved compensation.
## Tests
Run `cargo test --locked --lib viewport_dimension`. Tests cover the five commands,
mixed sheet/model input, styles, clipping, object picks, source/viewport edits,
snap features, nested paths, intersections, preserved overrides, undo/redo, and
DXF/DWG persistence. The synthetic association fixture is in
`tests/fixtures/dim_assoc/`.