forked from 1b-agent/opencadstudio-upstream
Per the upstream review of the XDATA-based storage: the host no longer writes its own state into the saved file. The marker record under the host-owned OCS APPID, the on-first-use APPID registration and the verbatim-EED rewrite handling are removed; the single source of truth is a scene-scoped in-memory set of entity handles (Scene::reference_only) that dies with the drawing tab, so a reopened file starts undesignated and the owning plugin re-marks on the document-opened notification. All behaviour sites are unchanged, driven by the set: the wire stamp moves from an XDATA read to a set lookup threaded through the tessellation pipelines (tessellate_entity and the dimension text path take the handle set as a plain parameter), and the pick filters, selection retains and update/erase refusals read Scene::is_reference_only. Undo keeps the entity-write contract: set mutations are recorded as explicit first-touch before/after values on each history delta (the parametric- constraints channel pattern). A designation inside a plugin push_undo scope joins it, a scope-less change is one step of its own, an idempotent re-mark records nothing, erasing a designated entity drops the set entry with the entity, and undo/redo restores entity and designation together. The round-trip test is inverted: reference_only_designation_is_not_written_ to_dwg_or_dxf now asserts the marker appears in neither format's bytes, that no OCS APPID or marker record survives a reload, and that the designation stays live in memory.
243 lines
14 KiB
Markdown
243 lines
14 KiB
Markdown
# Scripting host model (API v7)
|
|
|
|
This note describes how a script controls OCS and what the host guarantees. The
|
|
per-kind evidence is in the [coverage ledger](plugin-host-model-coverage-ledger.md),
|
|
the increment-by-increment record in the [completion plan](plugin-host-model-completion-plan.md),
|
|
and the script-facing reference in `plugins/opencad-python/PLUGIN.md`. A reviewer new to
|
|
the change should start with the [review guide](plugin-host-review-guide.md).
|
|
|
|
## Shape
|
|
|
|
Python runs in RustPython inside a separate plugin process
|
|
(`plugins/opencad-python`), so a script cannot crash the editor. It talks to the
|
|
host over the versioned IPC in `crates/ocs_plugin_api`. **The host owns the
|
|
drawing**: it validates every request, records the undo step, refreshes scene
|
|
caches and the shared document view, and marks the document dirty. The Python
|
|
adapter is thin: it turns dictionaries into typed values and back, and holds no
|
|
CAD rules of its own. Everything below is additive to API v7; a plugin built
|
|
without it keeps working, and the default `HostApi` methods refuse.
|
|
|
|
## What the host offers
|
|
|
|
| Area | `HostApi` entry | What it does |
|
|
|---|---|---|
|
|
| Read | `document()`, `entity_snapshot` | The full `CadDocument` snapshot; a detached JSON view of any typed entity. |
|
|
| Entities | `add_entity`, `remove_entity`, `update_entities_transaction`, `set_reference_only` | Create, delete and replace entities. A transaction validates every replacement first and either commits all of them as one undo step or none. `set_reference_only` designates plugin-owned display geometry (API v9, see below). |
|
|
| Selection | `selection`, `set_selection` | Ordered, tab-scoped, exact (missing or duplicate handles reject the request). |
|
|
| Solids | `solid_operation` | Kernel-backed create, transform, booleans, extrude, region and surface from a profile, picture embedding. Every result is verified to lift back from its ACIS payload without loss; a refusal leaves the operands untouched. |
|
|
| Tables | `table_operation` | Layers, text and dimension styles, blocks and their contents, linetypes, layouts (see below). |
|
|
| Objects | `create_xrecord`, `read_xrecord`, `update_xrecord`, `delete_xrecord`, `list_xrecords` | Name-addressed XRecords under the drawing's named-object dictionary (API v8, see below): a non-graphical record carrier with strict create-vs-update semantics and one undo step per change. |
|
|
| Commands | `run_command` | Drives the real OCS command one step at a time (see below). |
|
|
| Settings | `system_variable`, `set_system_variable` | `CLAYER`, `SNAPANG`, and a read-only `CTAB` (the current layout). |
|
|
|
|
Entity kinds outside a generated schema still read (handle, kind, layer) and take
|
|
`layer` changes; nothing is writable that the host has not validated.
|
|
|
|
## Entity validation
|
|
|
|
The host embeds a coverage catalog generated from its traced `EntityType`
|
|
registry and `crates/ocs_plugin_api/entity_coverage_policy.json`. It classifies all
|
|
48 variants: 43 canvas kinds, three internal records (`Block`, `BlockEnd`,
|
|
`Seqend`) and two opaque fallbacks (`Extended`, `Unknown`), and records for each
|
|
property its source field, type, shape, snapshot readability, Python access
|
|
(`read_write`, `read_only`, `unmapped`) and validation status.
|
|
|
|
Creation and edits are validated per kind (finite coordinates, positive radii and
|
|
scales, unit directions, non-parallel vectors, in-range flags, reference targets
|
|
that exist and have the right kind, and so on). Creation must be valid; an edit
|
|
is refused only for problems it introduces, so already-invalid legacy values stay
|
|
editable. Derived state is computed by the host, never trusted from a script (MLine
|
|
geometry, Dimension measurement, Helix spline, Table merge dimensions, Spline
|
|
rational flag, SectionSymbol counts, Viewport id and scale).
|
|
|
|
Nested records are checked too. A dictionary for a vertex, edge or face may not
|
|
carry an unknown key, and the fields that define its geometry are required
|
|
(`entity_manifest.json`, `required_struct_fields`); a typo can no longer turn into
|
|
a zero coordinate. Everything else in a nested record defaults when absent.
|
|
|
|
Every kind is exercised through the real plugin runner by a per-kind audit that
|
|
checks create, read, edit, delete, undo, invalid input with an atomic rollback,
|
|
and a DWG and DXF round trip. A kind is `Complete` in the ledger only when all of
|
|
those pass.
|
|
|
|
## Tables
|
|
|
|
`TableOperation` is one additive request with typed variants, executed by
|
|
`HostSession::table_operation`. Every refusal is decided **before** the undo step is
|
|
recorded, so a failure changes nothing and leaves the history alone.
|
|
|
|
- **Layers:** create, modify, rename (entities follow), delete (refused while it
|
|
holds objects unless erasing them is asked for), set current. Layer `0`,
|
|
`Defpoints` and the current layer are protected.
|
|
- **Text and dimension styles:** create, modify, rename, delete, set current. Dimension
|
|
style fields travel as a validated JSON object; handles, xref fields and the name are
|
|
host-managed. Renaming a text style updates the dimension styles that name it.
|
|
`Standard` and any style that is current or in use are protected.
|
|
- **Blocks:** create from entities (copied in shifted by the base point, optionally
|
|
erasing the originals), modify, rename (inserts follow), delete (refused while
|
|
referenced), and add an entity to a definition through the normal validation path.
|
|
A block may not nest itself, directly or through other blocks.
|
|
- **Linetypes:** simple dash, gap and dot patterns; rename follows layers and
|
|
entities; delete is refused while anything uses it.
|
|
- **Layouts:** create with the default page setup and sheet viewport, rename, delete,
|
|
switch (through OCS's own layout switch), and page setup (paper size, rotation,
|
|
custom scale). `Model` is protected.
|
|
|
|
Style and layout operations act on the active document, because they use the
|
|
application's shared style and view machinery.
|
|
|
|
## XRecords (API v8)
|
|
|
|
`create_xrecord`, `read_xrecord`, `update_xrecord`, `delete_xrecord` and
|
|
`list_xrecords` address XRecords by name under a dictionary path walked from the
|
|
named-object dictionary root (an empty path is the root itself); missing
|
|
dictionaries are created on create only, so the first call seeds a plugin's
|
|
namespace. The payload is the codec's typed `XRecordEntry` pairs — the host puts
|
|
no opinion on the content. Names and path fragments are symbol-validated, and
|
|
dictionary addressing follows the codec's case-insensitive lookup. Create never
|
|
overwrites and update never creates; each refusal is decided before the undo
|
|
scope is touched, so a failure changes nothing, and each change joins a
|
|
plugin-opened `push_undo` scope — the entity-write contract — so an operation
|
|
composed of several writes (entities and XRecords) is a single undo step; with
|
|
no scope open a change is one undo step of its own, whose undo restores the
|
|
objects map byte-exactly, the auto-created dictionaries included.
|
|
`list_xrecords` reports only the keys that address XRecords, in the
|
|
dictionary's own order, so audits need no prior knowledge. Not yet mapped to
|
|
Python.
|
|
|
|
## Reference-only entities (API v9)
|
|
|
|
`set_reference_only` designates plugin-owned display geometry: a
|
|
reference-only entity renders and snaps normally, but the built-in edit paths
|
|
treat it like an entity on a locked layer — it is not grip-editable, not
|
|
click-pickable (a click passes through to the editable entity underneath),
|
|
and window, box, lasso and fence selections skip it. User-driven writes
|
|
(grip drags, modify commands, the properties panel, user erase, MCP
|
|
`set_properties`) refuse it; the owning plugin keeps full write access:
|
|
`add_entity`, `update_entity`, `update_entities_transaction`, `remove_entity`
|
|
and the XDATA record methods are unaffected, so a plugin can refresh derived
|
|
display geometry in place. The record-method policy applies first: absent
|
|
entities and locked layers refuse, and like them every refusal is decided
|
|
before the undo scope is touched, so a failure changes nothing. A
|
|
re-designation that would not change the state succeeds without recording an
|
|
undo step. Undo composition is the entity-write contract: a change inside a
|
|
plugin-opened `push_undo` scope joins it, so an operation composed of several
|
|
writes is a single undo step under the plugin's label; with no scope open a
|
|
change is one undo step of its own.
|
|
|
|
**Storage is a scene-scoped in-memory set, not a file format.** The host
|
|
writes nothing of its own into the saved DWG/DXF — no APPID registration, no
|
|
XDATA marker on the entity — so a saved drawing never depends on host state
|
|
to load correctly. The set lives and dies with the drawing tab: closing a
|
|
tab forgets its designations, and a freshly opened drawing starts
|
|
undesignated. Because the designation is per handle, an in-place refresh
|
|
(clone → edit → replace) keeps it by construction. Undo keeps the contract
|
|
through an explicit designation channel on each history step (the same
|
|
pattern the parametric-constraint directory uses): undo/redo restores the
|
|
designation together with the entity it belongs to — erasing a designated
|
|
entity inside a scope, then undoing, brings back the entity *and* its
|
|
designation. Out of scope today: entities nested inside block instances
|
|
(the block cache rebuilds instance wires without the flag) and a Python
|
|
mapping. The MCP `entities` read exposes the flag as a `reference_only`
|
|
boolean on every entity record.
|
|
|
|
### Usage from a plugin
|
|
|
|
Build the derived geometry and designate it inside one `push_undo` scope, so
|
|
creating a preview plus marking it is a single undo step under the plugin's
|
|
label:
|
|
|
|
```rust
|
|
host.push_undo("CVI: refresh path preview");
|
|
let preview = host.add_entity(EntityType::LwPolyline(computed_path));
|
|
if let Err(error) = host.set_reference_only(preview, true) {
|
|
host.push_error(&format!("set_reference_only failed: {error}"));
|
|
}
|
|
```
|
|
|
|
Refresh the display data in place through the same API — reads before the
|
|
scope, mutations inside it (the entity-write contract's ordering rule):
|
|
|
|
```rust
|
|
let found = host
|
|
.document()
|
|
.entities()
|
|
.find(|entity| entity.common().handle == preview)
|
|
.cloned();
|
|
if let Some(mut entity) = found {
|
|
// edit the clone; the designation travels with the entity
|
|
host.push_undo("CVI: recompute preview");
|
|
host.update_entity(entity);
|
|
}
|
|
```
|
|
|
|
Notes for plugin authors:
|
|
|
|
- `add_entity` ignores layer locks; `set_reference_only` does not — it
|
|
refuses absent entities and locked layers exactly like the XDATA record
|
|
methods, every refusal changes nothing, and an idempotent re-mark records
|
|
no undo step.
|
|
- The designation travels with the entity: a clone → edit → replace refresh
|
|
keeps it, so a plugin marks its display data once and refreshes freely.
|
|
- The designation is scene state, not file state: it does not survive
|
|
DWG/DXF save and reload — a plugin re-marks its display data when the
|
|
document opens, and idempotent re-marks keep that loop finite.
|
|
- Rendering and object snap are unaffected; grips, picking and the built-in
|
|
write paths are the only observers.
|
|
- Plugins that read the document through the IPC client (the
|
|
out-of-process runner) resolve entities by iterating
|
|
`document().entities()`: a received `DocumentSnapshot` does not serve
|
|
`get_entity(handle)` lookups. This is a property of the snapshot path,
|
|
not of this API — host-side lookups are unaffected.
|
|
|
|
## Commands
|
|
|
|
`run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`,
|
|
`Entity`, `Selection`, `Enter`, `Cancel`) and answers with a `CommandOutcome`:
|
|
whether the command is done or waiting, its prompt, what input it accepts, its
|
|
keyword options, how many entities it added, tokens nobody asked for, and any
|
|
error. It uses the same primitives as the automation channel and finishes each
|
|
step synchronously on the host thread, so a step never waits for anything that has
|
|
to arrive from outside; an earlier fire-and-forget replay could hang, this does not.
|
|
|
|
Guards: refused while another command is active or off the active tab; refused for
|
|
commands that could end the session or re-enter Python (`QUIT`, `EXIT`, `CLOSE*`,
|
|
`NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`, `RUNSCRIPT`, `SCRIPTCOMMANDS`, `PY_*`); an editor or
|
|
dialog a command opens is reported and closed by `Cancel`. The user can turn the whole
|
|
facility off with the saved `SCRIPTCOMMANDS` preference (on by default; `SCRIPTCOMMANDS 0`
|
|
at the command line): while it is off, every request except `Cancel` is refused (so a command a script left
|
|
waiting can still be closed). A script cannot change
|
|
it, because the runner refuses that command. The runner does not consult the automation
|
|
on/off switch, which governs the external MCP and serve channels. The Python layer cancels
|
|
any step sequence that leaves a command waiting.
|
|
|
|
## Python surface
|
|
|
|
`ocs.active_document` exposes `entities`, `create_entity`, `delete_entity`,
|
|
`transaction`, `selection`, `solids`, `layers`, `text_styles`, `dim_styles`, `blocks`,
|
|
`linetypes`, `layouts`, `command`, `start_command` and `modify` (offset, trim, extend,
|
|
fillet, chamfer, move, copy, rotate, scale, mirror, erase, rectangular, polar, path and
|
|
3-D arrays, explode, join, break, stretch, lengthen and polyline edits). `doc.coverage()`
|
|
lists the catalog, and `doc.entities[handle].coverage` one entry, with the readable and
|
|
editable keys and the fields the typed snapshot carries but the model does not map.
|
|
|
|
Interactive picks use a token-based request and poll API because `PY_RUN` uses a
|
|
fresh interpreter per invocation and a script cannot suspend while the user clicks.
|
|
Drawing, selection and command notifications are kept in a bounded 256-event queue
|
|
per tab between runs and drained with `doc.poll_events()`; an overflow is reported
|
|
as `{"type": "overflow", "dropped": n}`. Tokens are bound to the tab that asked.
|
|
|
|
## Known limits
|
|
|
|
- Twelve of the 43 canvas kinds are not yet `Complete`; each has a named blocker in the
|
|
ledger. Seven are opencadcodec DXF bugs whose fixes are submitted upstream but not yet adopted.
|
|
- The DXF codec also drops some table properties (text style generation flags, block
|
|
descriptions, a dimension style's text-style name) and mis-scales one angle; these are
|
|
pinned by canaries and listed in `cadcodec-reader-gaps.md`.
|
|
- Not available from a script: complex (text and shape) linetypes, plot devices and named
|
|
page setups, PEDIT's fit and spline options, and interactive-only commands such as HATCH.
|
|
- A command step nests inside the editor's message handling. In a debug build a deep step
|
|
(PEDIT converting a line to a polyline) needs between 2 and 4 MiB of stack; the native
|
|
builds link with at least 8 MiB (16 MiB on Windows, `.cargo/config.toml`), the nesting depth
|
|
is fixed rather than recursive (`PY_*` is refused), and a release build uses far less.
|