# Python Scripting (`opencad.python`) ## Sandboxing (Phase 1.5) **Audited, not assumed — and the assumption was wrong.** Earlier phases claimed "no stdlib is loaded, so `import os`/`socket`/`subprocess` is already impossible." True for those three names, but incomplete: RustPython 0.5.0's `host_env` Cargo feature — **on by default**, and `rustpython-vm = "0.5"` in this crate's `Cargo.toml` used to pull in the full default set — bakes `posix`/`_ctypes` (and `os`/`nt` on other platforms) into every interpreter's *core* module registry, entirely independent of ever adding the separate `rustpython-stdlib` crate. Confirmed by actually running it, not by reading feature flags: with `host_env` on, `PY_EVAL __import__('posix').getcwd()` and `PY_EVAL open('/etc/passwd').read()` both worked — full filesystem read (and write) access from any script. **Fixed**: `Cargo.toml` now depends on `rustpython-vm` with `default-features = false, features = ["compiler", "gc"]` — no `host_env`. Re-verified the full matrix after the fix, all blocked (`ModuleNotFoundError` or, for `open()`, `UnsupportedOperation`): `os`, `posix`, `nt`, `subprocess`, `socket`, `ctypes`, `_ctypes`, `_socket`, `_subprocess`, `_signal`, `pwd`, and both read and write through the built-in `open()` — its `FileIO` backing turned out to depend on `host_env`-gated code internally too, even though the `_io` module itself is always registered. Confirmed no functional regression: `ocs.add_line` and the full `examples/example.py` (including both real constraints) still run correctly with `host_env` off. What's left in `sys.modules` after that fix, for a fresh interpreter — audited exhaustively, not just "fewer than before": `_ast`, `_codecs`, `_frozen_importlib`, `_imp`, `_io`, `_thread`, `_typing`, `_warnings`, `_weakref`, `builtins`, `codecs`, `encodings_ascii`, `encodings_utf_8`, `ocs`, `sys` — core language/import/codec plumbing needed to run Python at all, plus `ocs`. None of it reaches the filesystem, network, or another process. **One known, accepted residual gap, not silently ignored: `_thread` is real and functional** (`_thread.start_new_thread` exists and is callable, verified — not a stub). It's registered unconditionally in `rustpython-vm` 0.5.0's `stdlib/mod.rs` with no feature guard at all — not `host_env`, not even its own `threading` Cargo feature — so there is no dependency-level way to remove it short of patching RustPython itself, which is out of scope here. It grants no filesystem/network/process access (everything that would matter for *that* is confirmed blocked above); the realistic risk is a script spawning a thread that busy-loops or otherwise misbehaves inside the plugin's own process — a resource/stability concern local to the already crash-isolated plugin process (per `plugin-architecture.md`'s out-of-process design), not a host compromise or data exfiltration path. Deleting `_thread` from `sys.modules` after interpreter setup was considered and rejected as a fix: the module stays *registered* at the VM level regardless, so a script could just re-import it — the deletion would be security theater, not a real control. ## Commands | Command | Description | |---------|-------------| | `PY_EVAL ` | Evaluate one Python expression and print the result (or error) to the command line. Example: `PY_EVAL 1 + 1`. | | `PY_RUN ` | Run a `.py` file as a script (statements, not just one expression). Reports an error the same way as `PY_EVAL` (missing file, syntax error, exception) — never a silent no-op. | ## The `ocs` module Pre-bound as `ocs` in both commands — no `import` needed (and for `PY_EVAL`, none is possible: it compiles in `Mode::Eval`, which only accepts a single expression, not statements). Phases 1.2 (read), 1.3 (2D write) and part of 1.4 (`Run Script…`) of this plugin's `ROADMAP.md`. | Function | Returns | |----------|---------| | `ocs.selection()` | List of the selected entities' handles (`int`), as of the last selection change — see note below. | | `ocs.get(handle)` | Dict of `{handle, kind, layer, point}` for that entity, or `None` if it doesn't exist. `point` is `{x, y, z}` or `None` for non-point entities. | | `ocs.get_text(handle)` | For single-line TEXT, returns its active alignment point, OCS normal, rotation, direction, and height; otherwise `None`. Reads the host's full document snapshot. | | `ocs.text_entities()` | Lists top-level TEXT/MTEXT entities as `{handle, kind, value}` dictionaries from the active document. | | `ocs.block_references()` | Lists top-level INSERT entities as `{handle, name, effective_name, count, is_xref, is_dynamic, visibility}` dictionaries; `count` includes MINSERT row × column multiplicity and unresolved visibility is `None`. | | `ocs.layers()` | Lists active-document layer names, off/frozen/locked flags, and top-level entity counts. | | `ocs.set_entity_layer(handle, layer_name)` | Moves an existing top-level entity to an existing layer, preserving its handle and layout ownership. | | `ocs.export_layer_dwg(layer_name, output_path)` | Writes a new DWG containing one layer's top-level entities plus needed block definitions; requires an existing directory and refuses overwrite. | | `ocs.current_layer()` | Returns the active document's current layer name (`CLAYER`). | | `ocs.layout_for_entity(handle)` | Returns an entity's owning layout name, tab order, and paperspace sheet count; `None` when unresolved. | | `ocs.viewport_model_outline(handle)` | Returns four XY modelspace corners and elevation for a rectangular, unclipped +Z paperspace viewport; otherwise `None`. | | `ocs.add_closed_polyline(vertices, elevation)` | Adds a closed lightweight polyline from at least three finite `[x, y]` vertices. | | `ocs.text_box_vertices(handle, padding)` | Returns an approximate rotated XY box for left/baseline +Z TEXT; otherwise `None`. | | `ocs.update_closed_polyline(handle, vertices, elevation)` | Replaces a closed lightweight polyline's vertices/elevation, preserving its handle and common properties. | | `ocs.copy_model_to_paper(handles, viewport_handle)` | Copies planar modelspace LINE/ARC/CIRCLE/LWPolyline geometry through a rectangular +Z viewport to its paperspace layout. | | `ocs.mirror_xy_entities(handles, axis_handle, erase_source)` | Mirrors planar LINE/ARC/CIRCLE/LWPolyline entities across an existing XY LINE; optionally removes sources in the same undo group. | | `ocs.mirror_xy_about_line(handles, point, tangent, erase_source)` | Mirrors planar curves across the XY line through a point in a tangent direction; supports curved-axis midpoint workflows. | | `ocs.break_xy_line(handle, first, second)` | Removes the span between two interior XY points on a LINE, preserving its first fragment's handle and properties. | | `ocs.break_xy_arc(handle, first, second)` | Removes the angular span between two interior XY ARC points, retaining the first fragment's handle. | | `ocs.break_xy_circle(handle, first, second)` | Replaces an XY CIRCLE with the complementary ARC after removing the counterclockwise span from first to second. | | `ocs.break_xy_polyline(handle, first, second)` | Removes a path interval from an open, straight, zero-width XY LWPolyline, keeping the first fragment's handle. | | `ocs.align_xy_entities(handles, source, destination, source_direction, target_direction, copy_mode)` | Rigidly moves and rotates planar LINE/ARC/CIRCLE/LWPolyline entities in XY; `copy_mode=True` adds transformed copies instead of changing originals. | | `ocs.block_insert_point(handle)` | Returns `[x, y, z]` for an INSERT, or `None`. | | `ocs.block_bounds_xy(handle)` | Returns `[xmin, ymin, xmax, ymax]` for a simple unrotated single INSERT whose definition contains only LINE/ARC/CIRCLE/LWPolyline geometry; otherwise `None`. | | `ocs.block_attributes(handle)` | Lists `{tag, value}` pairs for one INSERT, or `None` for another entity. | | `ocs.set_block_attribute(handle, tag, value)` | Updates one INSERT attribute by case-insensitive tag, preserving the block's other data. | | `ocs.set_text_value(handle, value)` | Replaces one TEXT/MTEXT entity's content, preserving other properties. Raises if the entity cannot be updated. | | `ocs.set_mtext_mask(handle, mode, scale)` | Sets one MTEXT background mode (`off`, `mask`, or `fill`) and border scale; raises if invalid or not MTEXT. | | `ocs.move_text(handle, x, y, z)` | Moves only a TEXT entity's active alignment point, preserving its other properties. Raises if it cannot update the entity. | | `ocs.set_text_pose(handle, x, y, z, rotation)` | Moves and rotates one XY TEXT entity while preserving its other properties. | | `ocs.get_area(handle)` | Returns `{area, point}` for a circle or straight, closed lightweight polyline in the world XY plane; otherwise `None`. `point` is suitable for an area-number label. | | `ocs.get_curve_measure(handle)` | Returns `{length, point, rotation, tangent}` at half length for an XY line, circle, arc, or straight lightweight polyline; `tangent` is an XY unit vector. Otherwise `None`. | | `ocs.line_endpoints(handle)` | Returns two `[x, y, z]` points for a nonzero XY LINE; otherwise `None`. | | `ocs.polyline_segments(handle)` | Returns segment endpoint, width, length, and optional bulge-arc centre/radius dictionaries for an XY lightweight polyline; otherwise `None`. | | `ocs.write_new_text_file(path, content)` | Writes a new UTF-8 report file at an explicit path during a `PY_` command. Refuses to overwrite or create parent directories. | | `ocs.curve_samples(handle, segments)` | Returns equally spaced XY sample vertices, elevation, and closure for a line, arc, or circle; otherwise `None`. | | `ocs.replace_with_polyline(handle, vertices, elevation, closed)` | Replaces a line, arc, or circle in place with a lightweight polyline, preserving the handle and common drawing properties. | | `ocs.add_line(x1, y1, x2, y2)` | Adds a 2D line (z=0, layer `"0"`); returns its handle. | | `ocs.add_circle(x, y, radius)` | Adds a 2D circle (z=0, layer `"0"`); returns its handle. | | `ocs.add_arc(x, y, radius, start_deg, end_deg)` | Adds a 2D arc (z=0, layer `"0"`), angles in degrees; returns its handle. | | `ocs.add_polyline(vertices, elevation)` | Adds an open 2D lightweight polyline. Each vertex is `[x, y, start_width, end_width]`; returns its handle. | | `ocs.add_text_centered(value, x, y, z, height, rotation_radians)` | Adds middle-center aligned single-line text; returns its handle. | | `ocs.add_text_left(value, x, y, z, height, rotation_radians)` | Adds left/baseline single-line text at its insertion point; returns its handle. | | `ocs.add_mtext(value, point, height, width, rotation_radians)` | Adds top-left anchored MTEXT at `[x, y, z]` using Standard style; returns its handle. | | `ocs.add_points(points)` | Adds many 3D points (each `[x, y, z]`) in one `HostApi::add_entities` batch call; returns the new handles in order. | | `ocs.remove_entity(handle)` | Deletes one entity in the script's undo group; raises if the handle cannot be removed. | | `ocs.add_table(rows, x, y, z, row_height, column_width)` | Adds a native table from a rectangular list of string rows; returns its handle. | | `ocs.read_record(handle, app_name)` | Returns `{app_name, values}` for the XDATA record `app_name` on `handle`, or `None` if the entity or the record doesn't exist. See "XDATA" below for the `values` shape. | | `ocs.write_record(handle, app_name, values)` | Attaches an XDATA record to `handle` for `app_name`, replacing any existing record for that application. Raises if `handle` doesn't exist. | | `ocs.remove_record(handle, app_name)` | Removes the XDATA record for `app_name` from `handle`, if any. Returns `True` if a record was actually removed. | | `ocs.command(cmd)` | Experimental only; absent from the portable default build. Requires unreleased host API support and can hang on OCS 2026.37. See historical notes below. | | `ocs.select(handles)` | Experimental only; absent from the portable default build. Requires unreleased host API support. | | `ocs.system_variable(name)` / `ocs.set_system_variable(name, value)` | Development-only `experimental-host-settings` feature. Reads or sets host-managed CLAYER (text) and SNAPANG (degrees) without nested command dispatch. Requires the Felix OCS fork's `plugin/host-model-api` branch; absent from the portable default build. | `add_line`/`add_circle`/`add_arc` take plain Python numbers — `ocs.add_line(0, 0, 10, 10)` works with ints, not just floats. (Needed an explicit fix: a bare `f64` `#[pyfunction]` parameter in RustPython, unlike CPython's C-function argument parsing, does *not* implicitly coerce an `int` — it raises `TypeError`. Using `rustpython_vm::function::ArgIntoFloat` instead accepts anything with `__float__`/`__index__`, matching how a script author actually writes coordinates.) ### Layers: `ocs.active_document.layers` The layer table, with every change validated by the host, recorded as one undo step, and refused (a `RuntimeError`, nothing changed) when it cannot be honoured. ```python L = ocs.active_document.layers L.create("Walls", color=1, lineweight=50, description="load bearing") L.create("Grid", color=(10, 200, 30), linetype="Continuous", off=True) L.modify("Walls", color=5, locked=True) # only the properties given L.rename("Walls", "Structure") # entities on it follow L.set_current("Grid") L.delete("Structure", erase_objects=True) # refused if it holds objects otherwise "Grid" in L, L["Grid"]["color"], L.current, L.names() ``` Properties: `color` (ACI 1-255, an `(r, g, b)` tuple or a Color dict), `linetype` (must exist in the drawing), `lineweight` (1/100 mm, 0-211; -1 ByLayer, -2 ByBlock, -3 Default), `off`, `frozen`, `locked`, `plottable`, `transparency` (percent 0-90), `description`. Records also carry `handle`, `entity_count` and `current`. Refused: an existing or invalid name (empty, over 255 characters, or containing `<>/\":;?*|=` or a backquote), a color of ByLayer/ByBlock/0, an unknown property, renaming or deleting layer `0` or `Defpoints`, deleting or freezing the current layer, deleting an externally referenced layer, and modifying with no properties. Making a layer current is a setting, not an undo step. ### Historical command-replay experiment (not in the default build) The following records an earlier experiment. The bundled build does **not** expose `ocs.command()` or `ocs.select()`. An OCS 2026.37 diagnostic found nested `ocs.command("CLAYER ...")` could hang, so these examples are not instructions to run against the released host. **`ocs.command()` + `ocs.select()` together can apply a real, persistent constraint, entirely from a script** — this required two host changes (`HostApi::run_command` and `HostApi::set_selection`, `ocs_plugin_api` v0.2.1+ built from a worktree that isn't upstream yet — see the note at the bottom of this file), not something achievable from the plugin alone. Verified end-to-end against a real running host, not just "it compiles": ```python ocs.select([line1_handle, line2_handle]) ocs.command("PCONSTRAINT") ``` run as a plain `.py` file via `PY_RUN` — no automation-API help, no manual pre-selection — ran the actual geometric solver and visibly adjusted both lines' coordinates to be genuinely parallel (checked numerically: direction-vector cross product ≈ 0 after, nonzero before). A real, persistent `PCONSTRAINT` object. Same result verified for `TCONSTRAINT` (circle-to-line tangency): center-to-line distance equals the radius exactly after. `examples/example.py` does both — its "parallel" lines and "tangent" circle are real constraint objects, not geometry computed once. Why two calls, not one: constraint commands read a prior *selection* (a `"Select objects:"` prompt), not picks fed as command-line tokens the way `LINE`'s points are — feeding `ocs.command("PCONSTRAINT {h1} {h2}")` with handles as trailing tokens starts the command but ends in "Command cancelled", not a constraint. `ocs.select()` sets that selection state first; `ocs.command()` then reads it. **Not every command completes cleanly this way — `ALIGN` is the found counterexample.** `ocs.command()`'s point-feeding sends exactly *one* trailing Enter after the fed tokens, to finish the command. `ALIGN` needs two after its point pairs (skip the optional 3rd source/destination pair, then answer a "Scale objects? [Yes/No]" question) — so `ocs.command("ALIGN 0,0 20,20 2,0 22,22")` **does** apply the actual move/rotate transform correctly (verified: the entities land at the right transformed coordinates), but leaves that Scale prompt open instead of cleanly finishing the command. Not attempted in `examples/example.py` — shipping something that half-completes seemed worse than leaving it out. A general fix would need `ocs.command()` to accept a trailing-Enter *count* (or an explicit terminator convention) instead of always exactly one; not implemented. **`ocs.command()` cannot invoke itself or any other plugin's command** — only built-ins. This call is a nested plugin→host request arriving while the plugin's own `dispatch()` is still running (its one runner thread is blocked waiting for this exact call to return); routing it back through normal plugin dispatch would hand that same plugin (or any plugin) a second `Dispatch` request it has no free thread to answer, deadlocking both sides. Found this the hard way — `ocs.command("LINE 0,0 10,10")` hung completely before the host-side fix. `run_command` skips plugin dispatch entirely, by design, not as an oversight. ### Which built-in commands `ocs.command()` can reach `ocs.command()` goes through the exact same dispatcher as the interactive command line — `HostApi::run_command` → the host's `run_command_line_no_plugin_reentry` → `dispatch_command_inner(cmd, allow_suggest=false, try_plugins=false)` — so almost everything typeable in OCS also works from a script, minus one deliberate carve-out. **Reachable: the full built-in command set**, spread across ten dispatch families in the host's `src/app/commands/` (`fileops`, `layers`, `blocks`, `draw`, `dim`, `inquiry`, `view`, `layerprops`, `styleprops`, `display`). That covers everything AutoCAD-compatible the host implements natively — drawing entities and constraints (`LINE`, `CIRCLE`, `ARC`, `POLYLINE`, `MOVE`, `ROTATE`, `ALIGN`, `PCONSTRAINT`, `TCONSTRAINT`, in `dispatch_draw`), dimensions (`DIMLINEAR`, `DIMALIGNED`, `DIMSTYLE`, ...), blocks (`BLOCK`, `INSERT`, `WBLOCK`, `ATTSYNC`, ...), layers/properties (`LAYER`, `LAYISO`, `LAYDEL`, `CHPROP`, `PROPERTIES`, ...), file ops (`NEW`, `OPEN`, `SAVE`, `SAVEAS`, `EXPORT...`, `PLOT`, ...), inquiry (`LIST`, `DIST`, `AREA`, `DBLIST`, ...), view (`ZOOM`, `PAN`, `VIEW`, `REGEN`, ...), and directly-settable system variables typed as commands (`PICKBOX 5`, `MIRRTEXT 1`, `LUPREC 4`, ...). Aliases still resolve too (`ocs.command("L 0,0 10,10")` works the same as `LINE`), since alias expansion happens before the plugin-skip. **Not reachable: any plugin-provided command, including this plugin's own** (`PYTHONSHELL`, `PY_EVAL`, `PY_RUN`) — see the paragraph above for why. This is the *only* restriction versus the interactive command line; there is no narrower allow-list beyond it. `allow_suggest=false` also means no autocomplete/fuzzy-matching of partial verbs (unlike typing `BAC` and pressing Enter for `BACKGROUND`) — a script must spell out the exact command name or a real alias. Usage notes already verified end-to-end (see the constraint example above and `examples/example.py`): - Multi-point/interactive commands take their args inline on one string, exactly as if typed then Enter: `ocs.command("LINE 0,0 10,10")`. - A command that reads a *prior selection* rather than inline picks (constraint commands are the concrete case) needs `ocs.select(handles)` called first — `ocs.command()` alone can't feed a "Select objects:" prompt. - `ALIGN` needs two trailing Enters (skip the optional 3rd point pair, then answer the Scale prompt); `ocs.command()` only ever sends one, so it applies the transform but leaves that prompt dangling — see above. For the exhaustive, exact list rather than this summary, the source of truth is the `inventory::submit!(CommandRegistration { names: &[...] })` blocks scattered across the host's `src/app/commands/*.rs` (105 files register at least one command as of this writing); the largest single block — everything without its own interactive command module — is `src/app/commands/mod.rs`'s command-line autocomplete registry. Since it's the same dispatcher, OCS's own command-line autocomplete is also a live index of what `ocs.command()` can run. **One `push_undo` group per script, not per `ocs.add_*` call.** A `PY_RUN` script that calls `ocs.add_line` ten times undoes as one step. Verified end-to-end: a two-line script undoes both lines in a single `undo`. Implemented via `host_ctx::ensure_undo_started`, called lazily by the first write in a given `PY_EVAL`/`PY_RUN` — a read-only script never touches the undo stack. Note: `ocs.command()` does *not* go through this — a built-in command manages its own undo the same way it would if typed, so `ocs.command` calls are not folded into the script's shared undo group. **`ocs.selection()` is a cache, not a live query.** `HostApi` has no synchronous "give me the current selection" call — only a best-effort `SelectionChanged` / `SelectionChangedV4` notification. It reflects the selection as of the last time it changed, not necessarily the instant the script calls it. That cache is populated via `BuiltinPlugin::on_notification`, **not** by calling `HostApi::try_recv_notification()` from `dispatch()`. Found the hard way: `ocs_plugin_api::runner`'s own V4 event loop (`run_v4` in `runner.rs`) drains that queue itself on every iteration and forwards each notification to `on_notification` *before* a `Dispatch` request ever reaches `dispatch()` — so polling it from inside `dispatch` always finds it empty. This plugin overrides `on_notification` and stashes the latest selection in a process-wide cache (`selection_cache.rs`); `ocs.selection()` reads that. Verified end-to-end against a real host build: select an entity, then `PY_EVAL ocs.selection()` returns its handle. Each `PY_EVAL`/`PY_RUN` runs in a fresh interpreter (no stdlib, no session state across calls) — REPL/session persistence is an open question for a later phase. **Typing or pasting `PY_EVAL`/`PY_RUN` directly into the interactive command line is broken for anything beyond trivial arithmetic — a host issue, not this plugin.** `Message::CommandInput` in `src/app/update/mod.rs` (this repo) uppercases the *entire* input line on every keystroke *and* on paste, and auto-submits the instant it contains a space (both deliberate, for classic single-line CAD commands like `LINE 0,0 10,10`). That mangles case (`ocs.add_line` → `OCS.ADD_LINE`, a `NameError`) and truncates arguments (`PY_EVAL ` submits before you finish typing the expression) for *any* multi-word, case-sensitive plugin argument — automation (`ocs_execute`/`--mcp`) bypasses this UI path entirely, which is why it never surfaced in this session's own testing until a live user tried typing it by hand. **`Run Script…` (below) sidesteps this correctly** — the host authors already solved the identical problem for file paths via `ModuleEvent::PluginFileDialog`, which dispatches with original case intact. There is no equivalent bypass for `PY_EVAL` today; typed/pasted `PY_EVAL` only survives if it has no spaces and no case-sensitive identifiers. ## Ribbon Two tools in the "Tools" group: | Tool | Behavior | |------|----------| | Eval 1+1 | Dispatches `PY_EVAL 1 + 1` directly — a smoke test. | | Run Script… | `ModuleEvent::PluginFileDialog` opens a native "pick a `.py` file" dialog; on selection the host dispatches `PY_RUN ` back to the plugin. Cancel does nothing. | **A real script manager UI (list/run/save scripts, or a dockable REPL console) is not possible with today's `ocs_plugin_api`.** This was an open question in `ROADMAP.md` — now answered by reading `ribbon.rs`: `CadModule` exposes exactly one hook, `ribbon_groups()`, which returns a **static** set of ribbon buttons/dropdowns (`RibbonItem`). There is no API for a plugin to host a persistent custom panel, list dynamic content, or render arbitrary UI — the ribbon vocabulary is deliberately "plain data the host renders," not a widget toolkit. Getting a script list or REPL panel would need a new `HostApi`/`CadModule` capability, i.e. an `ocs_plugin_api` version bump — a host-side feature, out of scope for a plugin repo. `Run Script…` (above) is the most a plugin can do today: reuse the host's own native file picker via the existing `PluginFileDialog` event, which was already there for other plugins to import files. ## lisp2py — AutoLISP macro transpiler `../lisp2py/` (an independent sibling workspace — zero dependency on this crate or `ocs_plugin_api`) is a **best-effort, offline, source-to-source** translator from AutoLISP (`.lsp`) to the `ocs.*` Python dialect documented above. Not an interpreter: it never executes AutoLISP, and its output is a draft a human reviews and finishes, then runs through `PY_RUN` like any other script. This is Phase 2.2/2.3 of `ROADMAP.md` — see [`../lisp2py/README.md`](../lisp2py/README.md) for the full pipeline, the corpus-measured builtin coverage, and every known limitation; this is just the quick-start. ```sh cargo run --manifest-path ../lisp2py/Cargo.toml --bin ocs-lisp2py -- macro.lsp > macro.py # then, inside OCS: PY_RUN macro.py ``` (Or pipe source on stdin: `cat macro.lsp | cargo run --manifest-path ../lisp2py/Cargo.toml --bin ocs-lisp2py -- > macro.py`.) Anything the translator can't confidently map — an unrecognized builtin, a construct with no Python equivalent — becomes a `_todo_manual_port(...)` call in the generated file (raises `NotImplementedError` if actually run) plus a warning printed to stderr, never a silent guess. `vla-*`/`vlax-*` (ActiveX/COM) and `vlr-*` (reactors) are explicitly out of scope, for the same reason as everywhere else in this plugin's design: COM doesn't run under AutoCAD for Mac either. Builtin coverage was widened using measured call-frequency data from a real AutoLISP corpus sample rather than guesswork, biased toward 2D geometry (`angle`/`distance`/`polar`) over 3D/solid-modeling per current project priority. ## XDATA `ocs.read_record`/`ocs.write_record`/`ocs.remove_record` wrap `HostApi::read_record`/`write_record`/`remove_record` — already implemented by the host (unlike `run_command`/`set_selection`, these needed no host change or `[patch]` entry). A record is `{"app_name": str, "values": [...]}`; each value is `{"kind": str, "value": ...}`. Supported kinds and their Python `value` shape: | `kind` | Python `value` | |---|---| | `String`, `ControlString`, `LayerName` | `str` | | `BinaryData` | `bytes` | | `Handle` | `int` | | `Point3D`, `Position3D`, `Displacement3D`, `Direction3D` | `[x, y, z]` (`float`) | | `Real`, `Distance`, `ScaleFactor` | `float` | | `Integer16`, `Integer32` | `int` | ```python ocs.write_record(handle, "MYAPP", [ {"kind": "String", "value": "part-042"}, {"kind": "Integer32", "value": 7}, ]) record = ocs.read_record(handle, "MYAPP") # {"app_name": "MYAPP", "values": [...]} ocs.remove_record(handle, "MYAPP") ``` This `{app_name, values}`/`{kind, value}` shape is deliberately the same one `schoeller/ocs_python_repl` (a separate, out-of-process PyO3-based Python plugin for OCS) uses in its `ocs.doc.read_record`/`write_record`, so a script's XDATA handling ports between the two plugins unchanged. `write_record` starts the script's shared undo group the same way `ocs.add_*` does (see below); `remove_record` only starts it when a record actually existed to remove. ## Build note: bundled host API `Cargo.toml` uses the repository-relative `ocs_plugin_api` crate. Packaging builds the plugin with the same compiler and CAD codec as the host and stages matching metadata beside the library. The `experimental-command-replay` Cargo feature keeps the old bridge code available, but the tested nested-command path can hang. Do not enable it for production.