Bundle the first-party Python scripting plugin

This commit is contained in:
Felix Riestra committed 2026-09-19 23:16:27 +02:00
1 parent 889042c1f1
commit de51c1a17f
26 files changed
+11459 -27

No files matched your search

+5
View File
@@ -416,6 +416,11 @@ jobs:
chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App"
cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
cp DWG.icns DXF.icns "$APP/Contents/Resources/"
# First-party Python scripting plugin. It remains out of process and
# uses the same API/toolchain as this host build.
bash plugins/opencad-python/tools/stage-bundled.sh \
"$APP/Contents/Resources/plugins/opencad.python" \
--target aarch64-apple-darwin
# QuickLook thumbnail extension.
cp -R DWGThumbnail.appex "$APP/Contents/PlugIns/"
# Substitute version into Info.plist.
+6 -6
View File
@@ -9,12 +9,12 @@ with Open CAD Studio. The model follows [QGIS](https://plugins.qgis.org/)-style
extensibility: a small metadata file, a single entry point, an optional separate
engine crate, and user-installable packages from a curated index.
> **Open CAD Studio ships no built-in plugins.** Every add-on is an **external
> dynamic library** (`cdylib`) the host loads at runtime from the user plugins
> folder. The host source only contains the generic plugin *runtime*
> Plugins are **out-of-process dynamic libraries** (`cdylib`). The host loads
> first-party packages from its application resources and third-party packages
> from the user plugins folder. Both use the generic runtime
> (`src/plugin/`, `src/app/plugin_host.rs`) and the stable contract crate
> (`crates/ocs_plugin_api`). Add-ons live in their own repositories and
> **consume** that contract.
> (`crates/ocs_plugin_api`). A user package with the same id explicitly
> overrides a bundled package.
---
@@ -24,7 +24,7 @@ engine crate, and user-installable packages from a curated index.
|------|-----------|
| **One package, one entry point** | Manifest, ribbon tab and commands ship together in the plugin crate; no edits to the host. |
| **Stable contract** | Authors target the semver-versioned `ocs_plugin_api` crate, not `OpenCADStudio` internals. |
| **Out-of-tree by default** | A plugin is its own repo + crate; the host never recompiles to gain one. |
| **Out-of-tree by default** | Third-party plugins remain independent; selected first-party plugins may build and ship with OCS. |
| **DWG round-trip** | Domain data lives on entities as XDATA, not in an opaque side database. |
| **Engine reuse** | A headless `std`-only engine crate can run in WASM/CLI without the CAD host. |
+7
View File
@@ -89,6 +89,8 @@ cp "target/$TARGET/release/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App
chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App"
cp "$DIST/AppIcon.icns" "$DIST/DWG.icns" "$DIST/DXF.icns" "$APP/Contents/Resources/"
cp -R "$EXT" "$APP/Contents/PlugIns/"
bash plugins/opencad-python/tools/stage-bundled.sh \
"$APP/Contents/Resources/plugins/opencad.python" --target "$TARGET"
sed "s/__VERSION__/$VERSION/g" packaging/Info.plist > "$APP/Contents/Info.plist"
echo "==> codesign"
@@ -104,6 +106,8 @@ if [ "$DEVELOPER_ID" = "-" ]; then
# not the blocker here — carrying the entitlements through is.
codesign --force --sign - --timestamp=none \
"$APP/Contents/MacOS/OpenCADStudio-App"
codesign --force --sign - --timestamp=none \
"$APP/Contents/Resources/plugins/opencad.python/libopencad_python.dylib"
codesign --force --sign - --timestamp=none \
--entitlements crates/dwg-thumbnailer/macos/entitlements.plist \
"$APP/Contents/PlugIns/DWGThumbnail.appex"
@@ -114,6 +118,9 @@ else
# and a secure timestamp on every layer for notarization.
codesign --force --timestamp --options runtime \
-s "$DEVELOPER_ID" "$APP/Contents/MacOS/OpenCADStudio-App"
codesign --force --timestamp --options runtime \
-s "$DEVELOPER_ID" \
"$APP/Contents/Resources/plugins/opencad.python/libopencad_python.dylib"
codesign --force --timestamp --options runtime \
--entitlements crates/dwg-thumbnailer/macos/entitlements.plist \
-s "$DEVELOPER_ID" "$APP/Contents/PlugIns/DWGThumbnail.appex"
+3059
View File
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
[package]
name = "opencad-python"
version = "0.1.0"
edition = "2021"
description = "Python scripting for Open CAD Studio (embeds RustPython)."
license = "GPL-3.0-only"
[workspace]
resolver = "2"
# A plugin is a C-ABI dynamic library the host loads at runtime.
[lib]
crate-type = ["cdylib"]
[features]
default = []
# Requires an external HostApi patch providing run_command/set_selection.
# Never enable against the released OCS host: nested command replay can hang.
experimental-command-replay = []
# Requires the general HostApi system-variable methods in this OCS fork.
experimental-host-settings = []
# Requires the in-tree OCS host API v7.
experimental-host-model = ["dep:serde_json"]
[dependencies]
# The stable contract, with the runtime `host` surface (HostApi / BuiltinPlugin).
ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] }
# Embedded interpreter for Phase 1 (see this crate's PLUGIN.md and ROADMAP.md).
# default-features = false, no "host_env": that default feature bakes `posix`
# (full filesystem access, confirmed: open('/etc/passwd').read() works with
# it on) and `_ctypes` (raw native-code-calling primitives) into every
# interpreter unconditionally, regardless of never adding rustpython-stdlib —
# see this crate's PLUGIN.md's sandboxing section for the audit that found
# this. "compiler" is required (Vm::compile); "gc" matches the default set
# for normal memory behavior. "wasmbind"/"stdio" are irrelevant off-web.
rustpython-vm = { version = "0.5", default-features = false, features = ["compiler", "gc"] }
serde_json = { version = "1", optional = true }
[build-dependencies]
# No "host" feature: `type_registry`/`type_registry_types` (the only modules
# build.rs touches, via `get_embedded_type_registry_json`) are unconditionally
# compiled and depend on nothing but `serde` — the "host" feature only adds
# the acadrust-typed runtime surface build.rs has no use for. See this crate's
# own `src/lib.rs` doc comment on the module split.
ocs_plugin_api = { path = "../../crates/ocs_plugin_api" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+386
View File
@@ -0,0 +1,386 @@
# 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 <expr>` | Evaluate one Python expression and print the result (or error) to the command line. Example: `PY_EVAL 1 + 1`. |
| `PY_RUN <path.py>` | 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.)
### 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 <chosen path>` 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.
+120
View File
@@ -0,0 +1,120 @@
# opencad-python
Python scripting for Open CAD Studio, shipped as a bundled first-party plugin. Embeds
[RustPython](https://github.com/RustPython/RustPython) and exposes an `ocs`
module wrapping `HostApi`, so `PY_`-prefixed commands can run Python against
the open document.
The crate remains an independent process plugin under `plugins/` so Python
cannot crash the editor process and all writes still cross the versioned
`HostApi`. Its API dependency is repository relative, and the macOS packaging
flow builds and places it in the application bundle automatically.
Full design and phased plan: `ROADMAP.md` in this directory.
Command reference, the `ocs` module's functions, and every real gap/limit
found while building this: `PLUGIN.md` in this directory.
## Status
The API v7 build provides `PY_EVAL`/`PY_RUN`, the read/write `ocs` module, and
the ribbon integration; these have live OCS 2026.37 tests. Experimental
`ocs.command()`/`ocs.select()` constraint work
is documented in `PLUGIN.md` and `examples/example.py`, but those functions
are not in the default build: their host APIs are unreleased, and nested
command replay can hang. The general document model is built through the
`experimental-host-model` feature until that name is retired in a later API
cleanup.
## Files
| File | Purpose |
|------|---------|
| `Cargo.toml` | `cdylib` crate depending on `ocs_plugin_api` (`host` feature) and `rustpython-vm` (stdlib disabled — see `PLUGIN.md`'s sandboxing section) |
| `src/lib.rs` | manifest + `CadModule` ribbon + `BuiltinPlugin` + `export_plugin!` + `PY_EVAL`/`PY_RUN` dispatch |
| `src/ocs_module.rs` | the `ocs` Python module (`selection`, `get`, drawing access; experimental `command`/`select` are feature-gated) |
| `src/host_ctx.rs` | thread-local bridge from `dispatch()`'s `HostApi` into the `#[pymodule]` native functions |
| `src/selection_cache.rs` | process-wide selection cache, populated via `on_notification` |
| `src/document_model.py` | API v7 Python document view and transaction context manager |
| `src/event_cache.rs`, `src/input_cache.rs` | bounded event queue and interactive-pick results |
| `tools/stage-bundled.sh` | build and stage the matching API v7 package |
| `plugin.toml` | metadata read by the host (mirrors the manifest) |
| `examples/example.py` | a script demonstrating shapes plus two real, persistent constraints |
| `../../.github/workflows/release.yml` | bundles the cdylib into the macOS application |
| `PLUGIN.md` | command reference, the `ocs` module, and every real gap/limit found |
## Build
```sh
cargo build --release --locked --features experimental-host-model \
--manifest-path plugins/opencad-python/Cargo.toml
```
## Stage the bundled plugin
The staging script builds against the in-tree `ocs_plugin_api`, writes exact
compiler and CAD codec metadata, and creates the directory layout consumed by
bundled plugin discovery.
```sh
bash plugins/opencad-python/tools/stage-bundled.sh /path/to/stage-directory
```
Add `--debug` for a faster local build. Build with the same Rust toolchain as
the OCS binary; the host checks the compiler version when loading plugins.
```python
doc = ocs.active_document
line = doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0},
end={"x": 10, "y": 0, "z": 0})
line = doc.entities[handle]
print(line.start, line.end, line.layer, line.coverage)
with doc.transaction("Move line"):
line.start = (0, 0, 0)
line.end = (10, 0, 0)
doc.selection = [line]
token = doc.request_point("Pick destination")
# After the host has collected the click, run another script:
result = doc.poll_input(token)
events = doc.poll_events()
doc.delete_entity(line)
```
`doc.entities` iterates every document entity. The generated Python schema
currently edits Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D,
Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D,
Insert, Tolerance, Shape, AttributeDefinition, AttributeEntity, and Hatch.
Insert block references support edits to placement and transform
properties; their block name is readable and creation is not yet exposed.
Other canvas kinds expose
handle, kind, and layer; their layer can be changed in a transaction. Internal
and opaque kinds remain read-only. Each
mapped entity reports its actual readable/editable keys through `coverage`.
The host-owned catalog classifies all 48 entity variants and tracks every
traced field, including fields marked `unmapped`. Query it with
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
type, access, and current validation status. Geometry edits remain limited to
the 22 mapped kinds.
For any entity, `entity.snapshot` returns a detached dictionary of its raw
CAD fields, including `snapshot["common"]` and kind-specific nested values.
It can inspect an unmapped kind; editing the dictionary does
not commit changes to the drawing.
Point and entity requests return a token because `PY_RUN` uses a fresh
interpreter for each invocation; `poll_input` reports pending, picked, or
cancelled on a later run in the same drawing tab. Another tab cannot consume
that token. `doc.selection` preserves the exact requested handle order and
rejects duplicate or absent handles. `poll_events` drains tab-scoped drawing, selection,
and active-command state changes. Each tab retains up to 256 events; an
`{"type": "overflow", "dropped": n}` event means older events were lost, so
refresh the document and selection before acting on cached state.
## Test locally as a bundled plugin
```sh
bash plugins/opencad-python/tools/stage-bundled.sh \
work/bundled-plugins/opencad.python --debug
OCS_BUNDLED_PLUGINS_DIR="$PWD/work/bundled-plugins" cargo run -- --new
```
The macOS release and signed packaging scripts perform this staging inside
`OpenCADStudio.app/Contents/Resources/plugins/opencad.python` automatically.
+393
View File
@@ -0,0 +1,393 @@
# Open CAD Studio — Python Scripting & AutoLISP Migration Plan
**Status:** Phase 1 done and host-verified; Phase 2 (the `lisp2py`
transpiler) has progressed well past what's checked off below — see
[`../lisp2py/README.md`](../lisp2py/README.md) and
[`../../PLUGIN_DEVELOPMENT_RECIPE.md`](../../PLUGIN_DEVELOPMENT_RECIPE.md) for
current status. Kept here as the original planning document and Phase 1
build log, moved from `OpenCADStudio`'s `docs/` (where it was originally
drafted, before this repo held the plugin work) unchanged except for
cross-repo reference fixes below.
**Author:** Open CAD Studio contributors
**Date:** September 2026
This document plans embedded Python scripting for Open CAD Studio as an
**external plugin** (not a host change), followed by a best-effort AutoLISP →
Python transpiler built on top of it. It supersedes the host-embedded design
sketched in an earlier draft of this document: scripting fits OCS's existing
plugin model better than it fits inside the host. OpenCADStudio's
`docs/plugin-architecture.md` explicitly lists "sandboxed scripting
(Python/Lua)" as a non-goal of the external-plugin *system* itself — this
plan doesn't touch that system, it just builds a plugin that happens to
embed an interpreter, same as any plugin embeds whatever dependency it
needs.
**Why a plugin, not a host feature:**
| | Host-embedded | Plugin (this plan) |
|---|---|---|
| Requires OCS core changes / Hakan's buy-in | Yes | No — ships from an independent repo, installed via the existing Plugin Manager |
| Crash isolation | Relies on RustPython's memory safety alone | Gets OS-process isolation for free (plugins already run out-of-process) |
| Distribution | Bundled into every OCS release, forever | Opt-in install, versioned independently, can iterate fast |
| Cost | New maintenance burden on the host | Same ABI-pinning cost every plugin already has (see Phase 1, Risk) |
---
## Phase 1 — Python scripting plugin
**Goal:** a working bundled `opencad-python` plugin (`cdylib`) that embeds
RustPython and exposes an `ocs` module wrapping `HostApi` while retaining the
out-of-process runner boundary.
### 1.1 — Scaffold
- [x] New repo from OpenCADStudio's `docs/plugin-template/`; `plugin.toml`
+ `Cargo.toml` pinning `ocs_plugin_api` (`host` feature) and matching the
target host's `acadrust_source` / `rustc_version` (see Risks). Scaffolded
locally at `~/Documents/MacApps/opencad-python` (this repo — no remote
yet at the time, not pushed anywhere).
- [x] Add RustPython as a dependency; confirm it builds cleanly as part of a
`cdylib` on **macOS** (arm64) — Windows/Linux still unverified. Used
crates.io `rustpython-vm = "0.5"` (not the `rustpython` facade crate;
matches how the upstream `examples/hello_embed.rs` embeds it). Builds
clean, no warnings: `libopencad_python.dylib`, 17MB, ~80s from a cold
`cargo clean` release build. Exports the expected
`ocs_plugin_register` / `ocs_plugin_api_version` C-ABI symbols. A
`PY_HELLO` command runs a real RustPython smoke test (compiles and
evaluates `1 + 1` via `Interpreter::without_stdlib`) — confirms the
interpreter actually runs inside the cdylib, not just that it links.
One API gap vs. the upstream example: 0.5.0's `Vm::compile` takes an
owned `String` filename (not `&str`), and `CompileError` has no
`into_pyexception` method — worked around by matching on the `Result`
directly instead of using `?` with `PyResult`.
### 1.2 — Minimal `ocs` module (read-only)
- [x] `ocs.selection()` → list of entity handles. Implemented as a cache, not
a live query — `HostApi` has no synchronous "current selection" call
(only a best-effort `SelectionChanged`/`SelectionChangedV4`
notification). Populated via `BuiltinPlugin::on_notification` into a
process-wide static (`selection_cache.rs`) — see the host-runtime
verification note below for why `dispatch`-time polling doesn't work.
Documented as a real limitation in `PLUGIN.md`, not silently papered
over.
- [x] `ocs.get(handle)` → read-only geometry/property access, via
`HostApi::document_reader()` (zero-copy) + `for_each_entity`, matching
by handle. Returns `{handle, kind, layer, point}` or `None`.
- [x] `PY_EVAL <expr>` command (`command_prefixes = ["PY_"]`): evaluate one
inline expression, print the result via `host.push_output`. Runs a
fresh, stdlib-free `Interpreter` per call with only the `ocs` native
module registered (`Interpreter::builder(...).add_native_module(...)`)
— no `import os`/`socket`/etc. is even possible since no stdlib is
loaded, which happens to satisfy most of §1.5's sandboxing goal already
as a side effect of keeping this phase minimal, not a deliberate
Phase 1.5 pass yet.
- [x] Errors: both compile errors (`CompileError`, which implements
`Display` directly — no `into_pyexception` needed) and runtime
exceptions (formatted as `"<TypeName>: <message>"`, no traceback yet)
go to `host.push_error`.
- [ ] Bridging `host: &mut dyn HostApi` into the `#[pymodule]` native
functions needed a thread-local raw-pointer scratch slot
(`host_ctx.rs`) with a documented safety argument, since
`add_native_module` wants `&'static PyModuleDef` — there's no closure
capture available to a native pyfunction. Worth a second pair of eyes;
it's sound as written (guard clears the slot before the real borrow
ends, single-threaded, non-reentrant) but is the one genuinely
load-bearing `unsafe` block in the plugin so far.
- [x] Verified: `cargo build`/`--release` clean, zero warnings, against the
real `ocs_plugin_api`/`rustpython-vm` 0.5.0 APIs (not guessed — traced
through the actual crate sources for `HostApi`, `DocumentReader`,
`InterpreterBuilder`, `ToPyObject`/`DictKey` impls, etc.).
- [x] **Host-runtime verified**, not just "compiles": built the actual OCS
host (debug, ~1m43s), installed the plugin, drove it through the real
`--mcp` automation API with an isolated `$HOME` (so the test couldn't
collide with an already-running instance — see below, that mistake cost
real time). Confirmed working end-to-end:
- `PY_EVAL 6 * 7` → `"42"`.
- `PY_EVAL 1 / 0` → `"ZeroDivisionError: division by zero"` via
`host.push_error` — not a silent no-op.
- `ocs.get(handle)` on a drawn `LINE` → correct
`{'handle': 98, 'kind': 'line', 'layer': '0', 'point': None}`.
- Along the way found and fixed a real bug: `PY_EVAL`'s `ocs` name was
never bound in scope (scripts would need `import ocs`, impossible
under `Mode::Eval`, which only accepts one expression). Fixed by
`vm.import("ocs", 0)` + `scope.globals.set_item` before compiling —
`ocs` is now pre-bound, no import needed or possible.
- **Found and fixed a second real bug, this one plugin-side, not the
host's**: `ocs.selection()` always returned `[]`. Root cause (found by
temporarily instrumenting the host, then reverted — no host changes
were needed): `ocs_plugin_api::runner`'s own V4 event loop (`run_v4`
in `runner.rs`) drains `HostApi::try_recv_notification()` on every
iteration and forwards each notification to
`BuiltinPlugin::on_notification` — *before* a `Dispatch` request ever
reaches `dispatch()`. Polling `try_recv_notification()` from inside
`dispatch` (what the plugin did) therefore always sees an empty
queue; the host's `SelectionChangedV4` broadcast
(`notify_plugins_selection_changed` /
`publish_selection_changed_v4` in `src/plugin/v4_support.rs`) was
firing correctly the whole time. Fix: override `on_notification` and
stash the selection in a process-wide cache instead
(`opencad-python`'s `selection_cache.rs`); `dispatch` and
`ocs.selection()` just read that. Verified end-to-end: select an
entity, then `PY_EVAL ocs.selection()` returns its handle.
- Worth folding into this doc's guidance for anyone writing a V4+
plugin: **notifications must be consumed via `on_notification`, not
by polling `try_recv_notification()` from `dispatch`** — the stock
runner loop already drains that queue for you.
- **Testing pitfall worth remembering**: `--mcp` doesn't run headless —
it's a thin JSON-RPC client that reuses any already-running OCS
instance it finds under `$HOME/Library/Application Support/
OpenCADStudio/automation` (`descriptors()` in `src/mcp.rs`), spawning
a fresh one only if none exists. First test run silently talked to
the *user's own already-running production app* (which obviously had
never seen this plugin — plugins load once at startup) instead of the
freshly built debug binary. Fix: override `$HOME` for the test
subprocess to a scratch directory, which isolates `config_dir()`,
`plugins_dir()` and the automation discovery directory all at once.
- macOS only, per current scope; Windows/Linux still unverified.
### 1.3 — Write access + scripts
- [x] `ocs.add_line(x1, y1, x2, y2)` / `ocs.add_circle(x, y, radius)` — 2D
only for now (z=0, layer `"0"`), wrapped so a whole script is **one**
`host.push_undo` group, not one per call (`host_ctx::ensure_undo_started`,
called lazily by the first write). Verified end-to-end: a `PY_RUN`
script that calls `ocs.add_line` twice undoes both lines in one `undo`.
Take `rustpython_vm::function::ArgIntoFloat`, not a bare `f64` — found
by actually running `ocs.add_line(0, 0, 10, 10)` (plain ints, the
natural way to write it) and hitting a `TypeError`: unlike CPython's
C-function argument parsing, a bare `f64` `#[pyfunction]` parameter in
RustPython does not implicitly coerce an `int`.
- [x] `PY_RUN <path.py>` to run a file (`Mode::Exec`, so statements are
allowed, unlike `PY_EVAL`'s `Mode::Eval`). Same error reporting as
`PY_EVAL`: a missing file or a raised exception is never a silent
no-op. Reusing the automation `run` op needed no transport changes, as
expected — `{"op":"run","cmd":"PY_RUN <path>"}` just works.
- [x] `ocs.command(cmd)` to invoke existing built-in commands by name —
**implemented, required a host change**. Added `HostApi::run_command`
(default `Err` impl, no forced version bump) by reusing
`run_command_line` + `drive_headless_task` — the exact machinery
`--serve`/`--mcp` automation's `"run"` op already uses — via a new
`PluginRequest::RunCommand` and one match arm in
`handle_plugin_request`. Full detail: OpenCADStudio's
`docs/plugin-architecture.md`'s `HostApi` table, and this plugin's own
`PLUGIN.md`.
- **Found and fixed a real deadlock along the way**: the naive version
routed back through normal plugin dispatch, which — since this call
is a nested plugin→host request arriving *while* the calling
plugin's `dispatch()` is still running on its one runner thread —
sends that same plugin a second `Dispatch` it has no free thread to
answer. Both sides wait forever. Fixed by skipping plugin dispatch
entirely for this one call path
(`dispatch_command_no_plugin_reentry` /
`run_command_line_no_plugin_reentry`); confirmed via the full
`cargo test --lib` suite (1040 passed, 1 pre-existing unrelated
failure) that normal command-line/automation behavior is unchanged.
Consequence, by design: `ocs.command()` can reach built-ins only,
never another plugin's command.
- **Verified against a real, non-trivial case, not just `LINE`**:
pre-selecting two non-parallel lines then `ocs.command("PCONSTRAINT")`
ran the actual geometric solver and adjusted both lines to be
genuinely parallel — checked numerically (direction-vector cross
product ≈ 0 after), a real persistent constraint object.
- **Gap found, then closed same session**: constraint commands read a
prior *selection*, not picks fed as command tokens — feeding handles
as trailing `PCONSTRAINT` tokens starts the command but ends in
"Command cancelled", not a constraint. Added `HostApi::set_selection`
(same shape as `run_command`: new `PluginRequest::SetSelection`,
default-`Err` trait method, no forced version bump; validates every
handle exists before changing anything) and `ocs.select(handles)` on
top of it. Re-verified **fully scripted, no automation-API help**: a
plain `.py` file via `PY_RUN` calling `ocs.select([h1, h2])` then
`ocs.command("PCONSTRAINT")` produces the same genuinely-parallel
result as before, now reachable entirely from a script. Confirmed no
regressions via the full `cargo test --lib` suite both times (1040
passed, 1 pre-existing unrelated font-fallback failure).
- [x] **`ocs.add_points(points)` and XDATA CRUD
(`ocs.read_record`/`write_record`/`remove_record`), ported from
reviewing `schoeller/ocs_python_repl`** — a separate, independently
maintained out-of-process PyO3-based Python plugin for OCS (real
CPython + IPython in a terminal, generated typed bindings) reviewed for
reusable ideas. Both needed **no host change**: `HostApi::add_entities`
(batch add) and `read_record`/`write_record`/`remove_record` (XDATA)
already existed in `ocs_plugin_api`, unused by this plugin until now —
unlike `run_command`/`set_selection` in §1.3, which needed new `HostApi`
methods. `add_points` batches many points into one
`host.add_entities()` call instead of looping `add_entity`, so the host
pays for undo/dirty/geometry-rebuild bookkeeping once per batch, not
once per point — the in-process analogue of `ocs_python_repl`'s
`ocs.doc.add_many(...)`, which batches to avoid one IPC round trip per
point instead. The XDATA functions deliberately reuse
`ocs_python_repl`'s exact `{app_name, values}`/`{kind, value}` record
shape (see this plugin's `PLUGIN.md`'s XDATA section) so a script's
XDATA handling ports between the two plugins unchanged. Verified with
unit tests that run a real RustPython interpreter (no live `HostApi`)
and assert that every new function gets all the way through its own
argument/dict parsing — down to every `XDataValue` kind — before
failing on the expected "not running inside a PY_ command" sentinel;
`cargo test`/`cargo clippy --all-targets` both clean. Not yet verified
against a real running host (the "host-runtime verified" bar the rest
of this phase holds itself to) — that still needs a live OCS build, as
noted for `ocs.command()`/`ocs.select()` above.
### 1.4 — Usability
- [x] **Open question resolved by reading `ribbon.rs` end to end, not
assumed**: a script manager UI (or a dockable REPL console) is **not
possible** with today's `ocs_plugin_api`. `CadModule` exposes exactly
one hook, `ribbon_groups()`, returning a *static* set of
buttons/dropdowns (`RibbonItem`) — there is no API for a plugin to host
a persistent custom panel or render dynamic content (a script list, a
REPL buffer, ...). That needs a new `HostApi`/`CadModule` capability —
an `ocs_plugin_api` version bump, a host feature request. Out of scope
for a plugin repo; documented as a real, checked answer in this
plugin's own `PLUGIN.md`, not left open.
- [x] `ModuleEvent::PluginFileDialog` (already exists) for "Run Script…":
opens a native file picker filtered to `.py`, and on selection the host
dispatches `PY_RUN <path>` back to the plugin — no new plugin code
needed beyond the ribbon entry, since `PY_RUN` already exists from
§1.3. Verified the plugin still loads and dispatches correctly with the
new ribbon entry; the file-picker interaction itself isn't automatable
headlessly over `--mcp` (no way to drive a native OS dialog over
JSON-RPC), so that specific interaction is unverified beyond "doesn't
crash". "Save Script As…" was dropped — with no in-app editor (which
needs the same missing custom-panel capability above), there's no
script content to save.
### 1.5 — Sandboxing
- [x] Audit: the `ocs` module is the *only* thing a script can call — done,
and **the earlier assumption was wrong, which is exactly why this was
worth auditing instead of assuming**. Phase 1.2/1.3 claimed "no stdlib
is loaded, so `import os`/`socket`/`subprocess` is already impossible"
— true for those three names, but RustPython 0.5.0's `host_env` Cargo
feature is **on by default** and bakes `posix`/`_ctypes` into every
interpreter's core module registry regardless of `rustpython-stdlib`.
Confirmed by actually running it: with `host_env` on,
`PY_EVAL __import__('posix').getcwd()` and
`PY_EVAL open('/etc/passwd').read()` both worked — full filesystem
read/write from any script. Fixed: `rustpython-vm` now builds with
`default-features = false, features = ["compiler", "gc"]`.
Re-verified the full matrix after: `os`/`posix`/`nt`/`subprocess`/
`socket`/`ctypes`/`_ctypes`/`_socket`/`_subprocess`/`_signal`/`pwd`
all blocked, `open()` blocked for both read and write (its `FileIO`
needs `host_env` internally too, even though `_io` itself is always
registered). No functional regression — `ocs.add_line` and the full
`examples/example.py` (both real constraints) still run correctly.
Audited what remains exhaustively rather than assuming "fewer is
enough": 15 `sys.modules` entries, all core language/import/codec
plumbing plus `ocs`. One accepted, documented residual gap: `_thread`
is real and functional (verified callable) and has no feature guard
at all in this RustPython version — no dependency-level way to
remove it without patching RustPython. Grants no filesystem/network/
process access; worst case is a resource/stability issue local to
the already crash-isolated plugin process. Full detail in this
plugin's own `PLUGIN.md`.
### 1.6 — Distribution
- [ ] Publish binaries per platform on GitHub Releases (matches every other
plugin's release shape already documented in OpenCADStudio's
`plugin-architecture.md`).
- [ ] Manual repo link works immediately (`owner/repo`, no OCS PR needed);
listing in OpenCADStudio's curated `plugins/registry.json` is a
separate, optional later step once the plugin is stable.
**Risk to track continuously:** a plugin must match the host's exact
`rustc_version` and `acadrust_source` (API v4+ enforces this at load time).
Every OCS host release potentially requires a matching rebuild + republish of
this plugin, or users get a version-mismatch refusal. Budget for this as an
ongoing CI job (build matrix triggered off OCS's release tags), not a one-time
cost.
---
## Phase 2 — AutoLISP → Python parser/translator
**See [`../lisp2py/`](../lisp2py) — this phase has its own crate, README,
and builtin-coverage writeup now.** The sub-goals below are the original
scoping; §2.2/2.3 shipped with substantially wider builtin coverage than
first planned (measured from a real AutoLISP corpus sample, not just the
constructs listed here).
**Goal:** a best-effort, offline source-to-source translator from `.lsp` to
the Phase 1 plugin's Python dialect. It is explicitly **not** an interpreter —
output is a draft a human reviews and finishes, not a guaranteed-correct
program. That's what makes this tractable where a live AutoLISP runtime isn't
(see the earlier discussion: the language core is easy, AutoCAD's builtin
library is the hard, open-ended part — a translator only needs a lookup table
for the builtins it recognizes, not a faithful live implementation of all of
them).
### 2.1 — Corpus & scoping
- [x] Pull a representative sample from [Lee Mac's routines](https://lee-mac.com/)
and the [Whole-Spec catalog](https://whole-spec.com/en/cad-tips/lisp-catalog/)
(157 curated routines) to find the actual distribution of constructs
used in real scripts — don't guess coverage priorities, measure them.
- [x] From that sample, explicitly decide and document what's out of scope:
**`vla-*`/`vlax-*` (COM/ActiveX)** — doesn't run in AutoCAD for Mac
either, so this isn't a new limitation — and **DCL dialogs** (no
renderer in OCS). Both get left as commented-out LISP with a
`# TODO: manual port needed` marker in the generated output, never
silently dropped.
### 2.2 — Reader & core-language transform
- [x] S-expression tokenizer/parser (standalone, no OCS runtime dependency).
- [x] AST-to-AST transform for the Lisp core: `setq`, `defun`
(`C:CMDNAME` → a Python function), `if`/`cond`, `lambda`/`function`,
arithmetic, string/list operations, `foreach`/`while`.
### 2.3 — Builtin mapping table
- [x] `entget` → `ocs.get(handle)`; `ssget` (no-arg) → `ocs.selection()`.
- [x] `command` sequences → `ocs.command("...")`.
- [x] `getvar`/`setvar` — confirmed no host system-variable surface exists;
documented as unsupported rather than guessed at.
- [x] Widened well past this original list via measured corpus frequency —
see `lisp2py`'s own README for the full table.
### 2.4 — Validation loop
- [ ] Run translated output through the Phase 1 plugin's interpreter against
the same corpus; every failure either fixes the mapping table or gets
added to the documented unsupported list. This is the actual acceptance
criterion for "done" — not 100% translation, but every gap being a known,
documented one.
### 2.5 — Packaging
- [x] Ship as a standalone CLI (`ocs-lisp2py file.lsp > file.py`) — needs
nothing from the OCS runtime, useful even to someone not running OCS yet.
- [ ] Optionally surface as an "Import AutoLISP Macro…" button in the Phase 1
plugin's ribbon, calling the same conversion logic in-process.
---
## Phase 3 — polish (not yet scoped in detail)
- [ ] Decide the open question from Phase 1.4: dockable console REPL vs.
run-only. Depends on what `HostApi` can support without a version bump.
- [ ] Versioning story for the `ocs` module surface as it grows: same strict
`ApiVersion` gate as `ocs_plugin_api`, or a looser contract since scripts
aren't compiled artifacts and can tolerate softer compatibility breaks?
- [ ] Whether scripts should ever be able to register permanent ribbon tools
(leaning: no — keep that exclusive to compiled plugins, per
OpenCADStudio's `plugin-architecture.md`'s "one package, one entry
point" goal).
---
## Reference
| Piece | Location |
|-------|----------|
| Existing plugin architecture (non-goal note) | OpenCADStudio's `docs/plugin-architecture.md` |
| Plugin host API this plugin wraps | OpenCADStudio's `crates/ocs_plugin_api` (`HostApi` trait) |
| Plugin scaffold this was started from | OpenCADStudio's `docs/plugin-template/` |
| Existing automation/headless surface | OpenCADStudio's `src/app/control/transport.rs` |
| RustPython | https://github.com/RustPython/RustPython |
| AutoLISP corpus — Lee Mac | https://lee-mac.com/ |
| AutoLISP corpus — Whole-Spec catalog (157 routines) | https://whole-spec.com/en/cad-tips/lisp-catalog/ |
+28
View File
@@ -0,0 +1,28 @@
//! Generates `OUT_DIR/entity_crud.rs` — the `dict_to_entity`/`entity_to_dict`
//! conversion functions `ocs_module.rs` `include!`s — from `entity_manifest.json`
//! plus the type registry `ocs_plugin_api` embeds at its own build time (see
//! `crates/ocs_plugin_api::get_embedded_type_registry_json`, always compiled,
//! not gated behind the `host` feature — see that crate's `src/lib.rs`). See
//! `build/generate.rs` for the actual codegen.
use std::env;
use std::path::PathBuf;
include!("build/generate.rs");
fn main() {
println!("cargo:rerun-if-changed=entity_manifest.json");
println!("cargo:rerun-if-changed=build/generate.rs");
let registry_json = ocs_plugin_api::get_embedded_type_registry_json();
let registry: TypeRegistry = serde_json::from_str(registry_json)
.expect("ocs_plugin_api's embedded type registry should be valid JSON");
let manifest = Manifest::load(include_str!("entity_manifest.json"));
let generated = generate_entity_crud(&manifest, &registry);
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo"));
std::fs::write(out_dir.join("entity_crud.rs"), generated)
.expect("failed to write generated entity_crud.rs");
}
+891
View File
@@ -0,0 +1,891 @@
// Codegen for `ocs`'s generic entity conversion functions (`dict_to_entity`
// / `entity_to_dict`), driven by `entity_manifest.json` plus the type
// registry embedded in `ocs_plugin_api` (`get_embedded_type_registry_json`,
// traced by `crates/ocs_plugin_api/build.rs` in the OCS fork).
//
// Adapted from the RustPython-flavored port of `schoeller/ocs_python_repl`'s
// `build/generate.rs` (a PyO3-based generator over the same registry +
// manifest shape) — see `project_opencad_python_generic_entity_crud_plan.md`
// in this session's memory for the full design rationale. The two crates
// target different Python runtimes (PyO3/real CPython vs. RustPython), so
// the leaf conversion code differs, but the manifest schema and the
// registry-driven-with-overrides approach carries over directly.
//
// `include!`'d directly into `build.rs` (not a separate crate) so it shares
// `build.rs`'s own dependency graph without adding a workspace member.
use std::collections::BTreeSet;
use ocs_plugin_api::{FieldInfo, TypeId, TypeInfo, TypeKind, TypeRegistry};
use serde::Deserialize;
// ════════════════════════════════════════════════════════════════════════════
// Manifest schema
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize)]
pub struct Manifest {
/// Entity kinds exposed to `ocs.add`/`ocs.update`/`entity_to_dict`. The
/// central, easy-to-widen filter for Phase 2 (see the plan doc).
pub type_filter: Vec<String>,
/// `EntityCommon` fields promoted onto every entity dict. Only `handle`
/// and `layer` are supported today — the rest of `EntityCommon` (color,
/// line weight, XDATA, ...) has its own existing `ocs.*` accessors and is
/// deliberately out of scope for this generic path.
#[serde(default = "default_base_fields")]
pub base_fields: Vec<String>,
#[serde(default)]
pub overrides: std::collections::BTreeMap<String, EntityOverride>,
}
fn default_base_fields() -> Vec<String> {
vec!["handle".to_string(), "layer".to_string()]
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct EntityOverride {
/// Rust expression constructing a fresh value when this entity has no Default.
#[serde(default)]
pub rust_constructor: Option<String>,
/// Existing entities can be inspected and patched, but `ocs.add` cannot
/// create one without host-side reference setup.
#[serde(default)]
pub update_only: bool,
#[serde(default)]
pub fields: std::collections::BTreeMap<String, FieldOverride>,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct FieldOverride {
/// Emit a getter but reject patches to this field.
#[serde(default)]
pub read_only: bool,
/// Rename the dict key for this field.
#[serde(default)]
pub python_name: Option<String>,
/// Raw Rust *value* expression (not yet wrapped in `vm.new_pyobj`),
/// with `VAR` standing in for the entity's local variable name.
/// Requires `rust_setter` too.
#[serde(default)]
pub rust_getter: Option<String>,
/// Raw Rust *statement* (semicolon-terminated), with `VAR` standing in
/// for the entity's local variable name and `dict`/`vm` bound as usual.
/// Requires `rust_getter` too.
#[serde(default)]
pub rust_setter: Option<String>,
/// Drop this field from both directions entirely (e.g. a field whose
/// type this generator doesn't support yet, such as a tagged enum).
#[serde(default)]
pub exclude: bool,
/// `ocs.add()` errors if this field is missing rather than silently
/// defaulting it to zero/empty (Felix's Phase 1 review: "add should
/// reject missing required geometry rather than defaulting to zero").
/// Only meaningful on the generic (non-`rust_getter`/`rust_setter`)
/// path — `ocs.update()` never requires a field (a merge onto an
/// already-valid entity never needs it re-specified).
#[serde(default)]
pub required: bool,
}
impl Manifest {
pub fn load(json: &str) -> Self {
serde_json::from_str(json).expect("entity_manifest.json should be valid JSON")
}
}
fn get<'a>(registry: &'a TypeRegistry, name: &str) -> Option<&'a TypeInfo> {
registry.types.get(&TypeId::new(name))
}
// ════════════════════════════════════════════════════════════════════════════
// Type classification
// ════════════════════════════════════════════════════════════════════════════
const BUILTIN: &[&str] = &[
"f64", "f32", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "bool", "String", "str",
];
/// Types with their own hand-written conversion helpers in `ocs_module.rs`
/// (`vector3_to_py`/`py_to_vector3`, etc.) rather than generated ones.
const SPECIAL: &[&str] = &["Vector3", "Vector2", "Handle"];
enum Classify {
/// A C-like enum: every variant is a unit variant. Represented in Python
/// as its variant name (a plain string).
UnitEnum,
/// An enum whose variants carry one payload. Represented as
/// `{ "kind": "Variant", "value": ... }` so nested geometry such as
/// hatch boundary edges remains explicit and round-trippable.
TaggedEnum,
/// A struct whose only field is `bits` (an acadrust bitflags newtype,
/// e.g. `PolylineFlags`/`VertexFlags`). Represented in Python as a plain
/// integer via `.bits()`/`::from_bits(..)` — the named boolean accessors
/// some of these types have (`is_closed`/`set_closed`, ...) are exposed
/// per-field instead, via a manifest override, not generically.
BitFlags,
/// An ordinary struct: represented in Python as a nested dict.
Struct,
}
fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify {
match info.kind {
TypeKind::Struct => {
if info.fields.len() == 1 && info.fields[0].name == "bits" {
Classify::BitFlags
} else {
Classify::Struct
}
}
TypeKind::Enum => {
if info.variants.iter().any(|v| !v.fields.is_empty()) {
if info.variants.iter().all(|v| v.fields.len() == 1) {
Classify::TaggedEnum
} else {
panic!("entity_manifest.json: {name} has a tagged variant without exactly one payload");
}
} else {
Classify::UnitEnum
}
}
other => panic!("entity_manifest.json: {name} has unsupported registry kind {other:?}"),
}
}
fn bits_field_type(info: &TypeInfo) -> &str {
info.fields[0].type_id.as_str()
}
// ════════════════════════════════════════════════════════════════════════════
// Helper-type closure (topologically ordered so each generated fn only calls
// ones already emitted above it)
// ════════════════════════════════════════════════════════════════════════════
fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec<String> {
let mut needed: BTreeSet<String> = BTreeSet::new();
let mut queue: Vec<String> = Vec::new();
for kind in &manifest.type_filter {
let Some(info) = get(registry, kind) else {
panic!("entity_manifest.json: unknown entity kind {kind}");
};
let override_def = manifest.overrides.get(kind);
for f in &info.fields {
if f.name == "common" {
continue;
}
if override_def
.and_then(|o| o.fields.get(&f.name))
.map(|o| o.exclude || o.rust_getter.is_some())
.unwrap_or(false)
{
// Excluded, or fully hand-supplied by the override — its
// type (if any) never needs a generated conversion pair.
continue;
}
queue.push(f.type_id.as_str().to_string());
}
}
while let Some(name) = queue.pop() {
if BUILTIN.contains(&name.as_str()) || SPECIAL.contains(&name.as_str()) {
continue;
}
if !needed.insert(name.clone()) {
continue;
}
let Some(info) = get(registry, &name) else {
panic!("entity_manifest.json: type {name} (reached transitively) is not in the type registry");
};
for f in &info.fields {
queue.push(f.type_id.as_str().to_string());
}
for v in &info.variants {
for f in &v.fields {
queue.push(f.type_id.as_str().to_string());
}
}
}
// Fixpoint topo sort: repeatedly emit any type whose remaining
// dependencies (among `needed`) are already ordered.
let mut ordered = Vec::new();
let mut remaining: Vec<String> = needed.into_iter().collect();
while !remaining.is_empty() {
let mut progressed = false;
for name in remaining.clone() {
let info = get(registry, &name).unwrap();
let deps_pending = info
.fields
.iter()
.chain(info.variants.iter().flat_map(|v| v.fields.iter()))
.any(|f| {
let t = f.type_id.as_str();
t != name && remaining.iter().any(|r| r == t)
});
if !deps_pending {
ordered.push(name.clone());
remaining.retain(|n| n != &name);
progressed = true;
}
}
if !progressed {
// Shouldn't happen for our closed, cycle-free set of types, but
// don't hang the build if it ever does.
ordered.append(&mut remaining);
break;
}
}
ordered
}
// ════════════════════════════════════════════════════════════════════════════
// Leaf conversion expressions (single value, not sequence/optional wrapping)
// ════════════════════════════════════════════════════════════════════════════
fn snake_case(name: &str) -> String {
let mut out = String::new();
for (i, ch) in name.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
}
out
}
fn native_rust_type(type_id: &str) -> &'static str {
match type_id {
"f64" | "f32" => "f64",
"i8" => "i8",
"i16" => "i16",
"i32" => "i32",
"i64" => "i64",
"u8" => "u8",
"u16" => "u16",
"u32" => "u32",
"u64" => "u64",
"bool" => "bool",
"String" | "str" => "String",
other => panic!("not a native type: {other}"),
}
}
fn is_native(type_id: &str) -> bool {
BUILTIN.contains(&type_id)
}
fn default_expr(type_id: &str) -> String {
match type_id {
"f64" | "f32" => "0.0".to_string(),
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => "0".to_string(),
"bool" => "false".to_string(),
"String" | "str" => "String::new()".to_string(),
"Vector3" => "acadrust::types::Vector3::ZERO".to_string(),
"Vector2" => "acadrust::types::Vector2::ZERO".to_string(),
"Handle" => "acadrust::Handle::new(0)".to_string(),
other => format!("default_{}()", snake_case(other)),
}
}
/// `item_expr` must already be a `&T` expression (typically a bare place like
/// `&value.field`, sometimes already parenthesized). Result: a
/// `PyResult<PyObjectRef>` expression.
fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String {
// A plain function argument (`f(item_expr)`) never needs parens; a method
// call or cast directly on `item_expr` (`item_expr.method()`,
// `item_expr as T`) does, since `&` binds looser than both.
let parenthesized = format!("({item_expr})");
match type_id {
"f64" | "f32" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "bool" => {
format!("Ok(vm.new_pyobj(*{parenthesized}))")
}
"String" | "str" => format!("Ok(vm.new_pyobj({parenthesized}.clone()))"),
"Vector3" => format!("vector3_to_py_dict(vm, {item_expr})"),
"Vector2" => format!("vector2_to_py(vm, {item_expr})"),
"Handle" => format!("Ok(vm.new_pyobj({parenthesized}.value()))"),
other => {
let info = get(registry, other)
.unwrap_or_else(|| panic!("type {other} missing from registry"));
match classify(other, info) {
Classify::UnitEnum => {
format!("Ok(vm.new_pyobj({}_name({item_expr}).to_owned()))", snake_case(other))
}
Classify::TaggedEnum => format!("{}_to_dict(vm, {item_expr})", snake_case(other)),
Classify::BitFlags => format!("Ok(vm.new_pyobj({parenthesized}.bits() as i64))"),
Classify::Struct => format!("{}_to_dict(vm, {item_expr})", snake_case(other)),
}
}
}
}
/// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult<T>` expression.
fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String {
if is_native(type_id) {
return format!("{value_expr}.try_into_value::<{}>(vm)", native_rust_type(type_id));
}
match type_id {
"Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"),
"Vector2" => format!("py_to_vector2({value_expr}, vm)"),
"Handle" => format!("{value_expr}.try_into_value::<u64>(vm).map(acadrust::Handle::new)"),
other => {
let info = get(registry, other)
.unwrap_or_else(|| panic!("type {other} missing from registry"));
match classify(other, info) {
Classify::UnitEnum => format!(
"{value_expr}.try_into_value::<String>(vm).and_then(|s| str_to_{}(s, vm))",
snake_case(other)
),
Classify::TaggedEnum => format!("dict_to_{}({value_expr}, vm)", snake_case(other)),
Classify::BitFlags => {
let path = format!("acadrust::entities::{other}");
let bits_ty = bits_field_type(info);
format!(
"{value_expr}.try_into_value::<i64>(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))"
)
}
Classify::Struct => format!("dict_to_{}({value_expr}, vm)", snake_case(other)),
}
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Field-level (sequence/optional-aware) getter/setter expressions
// ════════════════════════════════════════════════════════════════════════════
/// `field_place` is a place expression for the field itself (e.g. `"value.radius"`),
/// reached through an already-borrowed parent. Result: a `PyResult<PyObjectRef>` expression.
fn getter_for_field(
registry: &TypeRegistry,
type_id: &str,
optional: bool,
is_sequence: bool,
field_place: &str,
) -> String {
if is_sequence {
let inner = leaf_to_py(registry, type_id, "item");
format!(
"{{ let items = {field_place}.iter().map(|item| {inner}).collect::<PyResult<Vec<_>>>()?; Ok(vm.ctx.new_list(items).into()) }}"
)
} else if optional {
let inner = leaf_to_py(registry, type_id, "v");
format!("match &{field_place} {{ None => Ok(vm.ctx.none()), Some(v) => {inner} }}")
} else {
leaf_to_py(registry, type_id, &format!("&{field_place}"))
}
}
/// `py_name` is the dict key to read; `keep_expr` is the expression to use
/// when the key is absent (or explicitly `None`, for non-`Option` fields) —
/// the fresh constructor's value for `ocs.add()`, the field's current value
/// (`{var}.{field}.clone()`) for `ocs.update()`, or a diverging `return Err(..)` block for
/// a required-but-missing `ocs.add()` field. Result: a plain (non-`PyResult`)
/// expression of the field's own Rust type (`T`, `Vec<T>`, or `Option<T>`).
fn setter_for_field(
registry: &TypeRegistry,
type_id: &str,
optional: bool,
is_sequence: bool,
py_name: &str,
keep_expr: &str,
) -> String {
if is_sequence {
let inner = leaf_from_py(registry, type_id, "item");
format!(
"match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) => {{ let items: Vec<PyObjectRef> = v.try_into_value(vm)?; items.into_iter().map(|item| {inner}).collect::<PyResult<Vec<_>>>()? }} }}"
)
} else if optional {
let inner = leaf_from_py(registry, type_id, "v");
format!(
"match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) if vm.is_none(&v) => None, Some(v) => Some({inner}?) }}"
)
} else {
let inner = leaf_from_py(registry, type_id, "v");
format!(
"match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) if vm.is_none(&v) => {keep_expr}, Some(v) => {inner}? }}"
)
}
}
fn field_default_expr(f: &FieldInfo) -> String {
if f.is_sequence {
"Vec::new()".to_string()
} else if f.optional {
"None".to_string()
} else {
default_expr(f.type_id.as_str())
}
}
/// A diverging block expression: aborts `dict_to_entity` with a clear error
/// instead of producing a value. Valid anywhere an expression of any type is
/// expected (Rust's `!` coerces to everything), so it drops straight into a
/// `match` arm exactly like a real value would.
fn required_error_expr(entity_kind: &str, py_name: &str) -> String {
format!(
"{{ return Err(vm.new_value_error(\"ocs.add: {entity_kind} requires \\\"{py_name}\\\"\".to_owned())); }}"
)
}
/// Wraps an already-built sequence-field setter expression with a
/// non-empty check, for a required sequence field (e.g. a polyline's
/// `vertices`) — covers both "key missing" and "key present but `[]`" in
/// one place, since both parse to the same empty `Vec`.
fn wrap_required_nonempty(setter_expr: &str, entity_kind: &str, py_name: &str) -> String {
format!(
"{{ let parsed = {setter_expr}; if parsed.is_empty() {{ return Err(vm.new_value_error(\"ocs.add: {entity_kind} requires at least one \\\"{py_name}\\\"\".to_owned())); }} parsed }}"
)
}
// ════════════════════════════════════════════════════════════════════════════
// Per-helper-type generation
// ════════════════════════════════════════════════════════════════════════════
fn gen_unit_enum(name: &str, info: &TypeInfo) -> String {
let path = format!("acadrust::entities::{name}");
let snake = snake_case(name);
let mut name_arms = String::new();
let mut parse_arms = String::new();
for v in &info.variants {
name_arms.push_str(&format!(" {path}::{v} => \"{v}\",\n", v = v.name));
parse_arms.push_str(&format!(" \"{v}\" => Ok({path}::{v}),\n", v = v.name));
}
let first_variant = info
.variants
.first()
.unwrap_or_else(|| panic!("{name}: unit enum has no variants"))
.name
.clone();
format!(
r#"fn {snake}_name(value: &{path}) -> &'static str {{
match value {{
{name_arms} }}
}}
fn str_to_{snake}(value: String, vm: &VirtualMachine) -> PyResult<{path}> {{
match value.as_str() {{
{parse_arms} other => Err(vm.new_value_error(format!("ocs: unsupported {name} value: {{other}}"))),
}}
}}
#[allow(dead_code)]
fn default_{snake}() -> {path} {{
{path}::{first_variant}
}}
"#
)
}
fn gen_bitflags(name: &str) -> String {
let path = format!("acadrust::entities::{name}");
let snake = snake_case(name);
format!(
r#"#[allow(dead_code)]
fn default_{snake}() -> {path} {{
{path}::new()
}}
"#
)
}
fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String {
let path = format!("acadrust::entities::{name}");
let snake = snake_case(name);
let mut to_arms = String::new();
let mut from_arms = String::new();
for variant in &info.variants {
let payload = &variant.fields[0];
let getter = leaf_to_py(registry, payload.type_id.as_str(), "value");
let setter = leaf_from_py(registry, payload.type_id.as_str(), "payload");
to_arms.push_str(&format!(
" {path}::{variant}(value) => {{ let dict = vm.ctx.new_dict(); dict.set_item(\"kind\", vm.new_pyobj(\"{variant}\"), vm)?; dict.set_item(\"value\", {getter}?, vm)?; Ok(dict.into()) }}\n",
variant = variant.name,
));
from_arms.push_str(&format!(
" \"{variant}\" => {{ let payload = dict.get_item(\"value\", vm)?; Ok({path}::{variant}({setter}?)) }}\n",
variant = variant.name,
));
}
let first = &info.variants[0];
let default_payload = default_expr(first.fields[0].type_id.as_str());
format!(
r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult<PyObjectRef> {{
match value {{
{to_arms} }}
}}
fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{
let dict = value.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let kind = dict.get_item("kind", vm)?.try_into_value::<String>(vm)?;
match kind.as_str() {{
{from_arms} other => Err(vm.new_value_error(format!("ocs: unsupported {name} kind: {{other}}"))),
}}
}}
#[allow(dead_code)]
fn default_{snake}() -> {path} {{
{path}::{first_variant}({default_payload})
}}
"#,
first_variant = first.name,
)
}
fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String {
let path = format!("acadrust::entities::{name}");
let snake = snake_case(name);
let mut to_dict_body = String::new();
let mut from_dict_fields = String::new();
let mut default_fields = String::new();
for f in &info.fields {
let getter = getter_for_field(
registry,
f.type_id.as_str(),
f.optional,
f.is_sequence,
&format!("value.{}", f.name),
);
to_dict_body.push_str(&format!(
" dict.set_item(\"{fname}\", {getter}?, vm)?;\n",
fname = f.name
));
let setter = setter_for_field(
registry,
f.type_id.as_str(),
f.optional,
f.is_sequence,
&f.name,
&field_default_expr(f),
);
from_dict_fields.push_str(&format!(" {fname}: {setter},\n", fname = f.name));
default_fields.push_str(&format!(
" {fname}: {def},\n",
fname = f.name,
def = field_default_expr(f)
));
}
format!(
r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult<PyObjectRef> {{
let dict = vm.ctx.new_dict();
{to_dict_body} Ok(dict.into())
}}
fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{
let dict = value.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
Ok({path} {{
{from_dict_fields} }})
}}
#[allow(dead_code)]
fn default_{snake}() -> {path} {{
{path} {{
{default_fields} }}
}}
"#
)
}
// ════════════════════════════════════════════════════════════════════════════
// Top-level entity fields (registry + manifest overrides)
// ════════════════════════════════════════════════════════════════════════════
struct ResolvedEntityField {
py_name: String,
/// A `PyResult<PyObjectRef>` expression.
getter: String,
/// A full, semicolon-terminated Rust statement.
setter: String,
}
/// `ocs.add()` builds an entity from scratch (missing fields fall back to a
/// type default, or error if `required`); `ocs.update()` merges onto an
/// already-`.clone()`d existing entity (missing fields simply keep whatever
/// `var` already holds — see [[project_opencad_python_generic_entity_crud_plan]]
/// in this session's memory for why: reconstructing from a partial dict on
/// update could silently reset properties this generator doesn't represent
/// at all, e.g. `EntityCommon`'s color/line-weight/XDATA).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Add,
Update,
}
fn resolve_entity_fields(
manifest: &Manifest,
registry: &TypeRegistry,
kind: &str,
info: &TypeInfo,
var: &str,
mode: Mode,
) -> Vec<ResolvedEntityField> {
let override_def = manifest.overrides.get(kind).cloned().unwrap_or_default();
let mut out = Vec::new();
for f in &info.fields {
if f.name == "common" {
continue;
}
let field_override = override_def.fields.get(&f.name).cloned();
if let Some(ov) = &field_override {
if ov.exclude {
continue;
}
if let (Some(getter_expr), Some(setter_stmt)) = (&ov.rust_getter, &ov.rust_setter) {
assert!(!ov.read_only, "{kind}.{} cannot be read_only with a rust_setter", f.name);
assert!(
!ov.required,
"entity_manifest.json: {kind}.{}: \"required\" only applies to the generic \
field path, not a rust_getter/rust_setter override",
f.name
);
let py_name = ov.python_name.clone().unwrap_or_else(|| f.name.clone());
let getter = format!("Ok(vm.new_pyobj({}))", getter_expr.replace("VAR", var));
let setter = setter_stmt.replace("VAR", var);
out.push(ResolvedEntityField { py_name, getter, setter });
continue;
}
}
let py_name = field_override
.as_ref()
.and_then(|o| o.python_name.clone())
.unwrap_or_else(|| f.name.clone());
let required = field_override.as_ref().map(|o| o.required).unwrap_or(false);
let getter = getter_for_field(
registry,
f.type_id.as_str(),
f.optional,
f.is_sequence,
&format!("{var}.{}", f.name),
);
if field_override.as_ref().is_some_and(|ov| ov.read_only) {
let setter = format!("if dict.get_item_opt(\"{py_name}\", vm)?.is_some() {{ return Err(vm.new_value_error(\"{kind}.{py_name} is read-only\".to_owned())); }}");
out.push(ResolvedEntityField { py_name, getter, setter });
continue;
}
let keep_expr = match mode {
Mode::Update => format!("{var}.{}.clone()", f.name),
Mode::Add if required && !f.is_sequence => required_error_expr(kind, &py_name),
Mode::Add => format!("{var}.{}.clone()", f.name),
};
let mut setter_expr =
setter_for_field(registry, f.type_id.as_str(), f.optional, f.is_sequence, &py_name, &keep_expr);
if mode == Mode::Add && required && f.is_sequence {
setter_expr = wrap_required_nonempty(&setter_expr, kind, &py_name);
}
let setter = format!("{var}.{} = {setter_expr};", f.name);
out.push(ResolvedEntityField { py_name, getter, setter });
}
out
}
fn gen_entity_to_dict(manifest: &Manifest, registry: &TypeRegistry) -> String {
let mut arms = String::new();
for kind in &manifest.type_filter {
let info = get(registry, kind).unwrap();
let var = "value";
let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add);
let mut body = String::new();
body.push_str(" let dict = vm.ctx.new_dict();\n");
body.push_str(&format!(" dict.set_item(\"kind\", vm.new_pyobj(\"{kind}\"), vm)?;\n"));
body.push_str(&format!(
" dict.set_item(\"handle\", vm.new_pyobj({var}.common.handle.value()), vm)?;\n"
));
body.push_str(&format!(
" dict.set_item(\"layer\", vm.new_pyobj({var}.common.layer.clone()), vm)?;\n"
));
if manifest.base_fields.iter().any(|field| field == "owner_handle") {
body.push_str(&format!(
" dict.set_item(\"owner_handle\", vm.new_pyobj({var}.common.owner_handle.value()), vm)?;\n"
));
}
for f in &fields {
body.push_str(&format!(
" dict.set_item(\"{}\", {}?, vm)?;\n",
f.py_name, f.getter
));
}
body.push_str(" Ok(dict.into())\n");
arms.push_str(&format!(
" acadrust::EntityType::{kind}({var}) => {{\n{body} }}\n"
));
}
format!(
r#"/// Convert any generic-CRUD-supported entity into a plain dict with a
/// `"kind"` key (matching the convention every other `ocs.*` dict already
/// uses, e.g. XDATA's `{{"kind": ..., "value": ...}}`), plus `"handle"` and
/// `"layer"` and every entity-specific field the type registry knows about.
pub(crate) fn entity_to_dict(vm: &VirtualMachine, entity: &acadrust::EntityType) -> PyResult<PyObjectRef> {{
match entity {{
{arms} _ => Err(vm.new_value_error(
"ocs: this entity kind is not supported by the generic add/update API yet".to_owned(),
)),
}}
}}
"#
)
}
fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String {
let mut arms = String::new();
for kind in &manifest.type_filter {
if manifest.overrides.get(kind).is_some_and(|override_def| override_def.update_only) {
continue;
}
let info = get(registry, kind).unwrap();
let var = "value";
let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add);
let mut body = String::new();
let mut allowed = vec!["kind", "handle", "layer"];
if manifest.base_fields.iter().any(|field| field == "owner_handle") {
allowed.push("owner_handle");
}
allowed.extend(fields.iter().map(|field| field.py_name.as_str()));
body.push_str(&format!(
" ensure_known_entity_keys(dict, \"{kind}\", &{:?}, vm)?;\n",
allowed
));
let constructor = manifest.overrides.get(kind)
.and_then(|override_def| override_def.rust_constructor.as_deref())
.map(str::to_owned)
.unwrap_or_else(|| format!("acadrust::entities::{kind}::default()"));
body.push_str(&format!(" let mut {var} = {constructor};\n"));
body.push_str(&format!(
" {var}.common.handle = acadrust::Handle::new(get_opt_u64(dict, \"handle\", vm)?);\n"
));
body.push_str(&format!(
" {var}.common.layer = get_opt_string(dict, \"layer\", \"0\", vm)?;\n"
));
if manifest.base_fields.iter().any(|field| field == "owner_handle") {
body.push_str(&format!(
" {var}.common.owner_handle = acadrust::Handle::new(get_opt_u64(dict, \"owner_handle\", vm)?);\n"
));
}
for f in &fields {
body.push_str(&format!(" {}\n", f.setter));
}
body.push_str(&format!(" acadrust::EntityType::{kind}({var})\n"));
arms.push_str(&format!(" \"{kind}\" => {{\n{body} }}\n"));
}
format!(
r#"/// Build a brand-new entity from a dict with a `"kind"` key (see
/// `entity_to_dict`) — the `ocs.add()` direction. Fields the manifest marks
/// `"required"` (the geometry that actually defines the shape — a line's
/// endpoints, a circle's center/radius, ...) error if missing rather than
/// silently defaulting to zero; every other field defaults sensibly (what a
/// fresh `acadrust::entities::X::default()` would already hold).
pub(crate) fn dict_to_entity(dict: &rustpython_vm::builtins::PyDictRef, vm: &VirtualMachine) -> PyResult<acadrust::EntityType> {{
let kind = dict.get_item("kind", vm)?.try_into_value::<String>(vm)?;
Ok(match kind.as_str() {{
{arms} other => {{
return Err(vm.new_value_error(format!("ocs: unsupported entity kind: {{other}}")));
}}
}})
}}
"#
)
}
fn gen_apply_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String {
let mut arms = String::new();
for kind in &manifest.type_filter {
let info = get(registry, kind).unwrap();
let var = "value";
let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Update);
let mut body = String::new();
let mut allowed = vec!["kind", "handle", "layer"];
if manifest.base_fields.iter().any(|field| field == "owner_handle") {
allowed.push("owner_handle");
}
allowed.extend(fields.iter().map(|field| field.py_name.as_str()));
body.push_str(&format!(
" ensure_known_entity_keys(dict, \"{kind}\", &{:?}, vm)?;\n",
allowed
));
body.push_str(&format!(" let mut {var} = existing_value.clone();\n"));
// `handle` is never touched — an update can't rehome an entity's
// identity. `layer` (unlike every other field) is promoted onto
// every dict by `entity_to_dict`/read by `dict_to_entity` without a
// manifest entry, so it's handled by hand here too, the same way
// `dict_to_entity` hand-writes it above — merge semantics: keep the
// existing layer unless the dict actually specifies one.
body.push_str(&format!(
" {var}.common.layer = match dict.get_item_opt(\"layer\", vm)? {{ \
None => {var}.common.layer.clone(), \
Some(v) if vm.is_none(&v) => {var}.common.layer.clone(), \
Some(v) => v.try_into_value::<String>(vm)? }};\n"
));
if manifest.base_fields.iter().any(|field| field == "owner_handle") {
body.push_str(&format!(
" if let Some(owner) = dict.get_item_opt(\"owner_handle\", vm)? {{ \
let owner = owner.try_into_value::<u64>(vm)?; \
if owner != {var}.common.owner_handle.value() {{ \
return Err(vm.new_value_error(\"owner_handle is read-only after creation\".to_owned())); }} }};\n"
));
}
for f in &fields {
body.push_str(&format!(" {}\n", f.setter));
}
body.push_str(&format!(" acadrust::EntityType::{kind}({var})\n"));
arms.push_str(&format!(
" acadrust::EntityType::{kind}(existing_value) => {{\n{body} }}\n"
));
}
format!(
r#"/// Apply a dict's keys onto a *clone* of `existing` — the `ocs.update()`
/// direction. Unlike `dict_to_entity`, a key missing from `dict` means
/// "leave this field as it already was", not "reset it to a type default":
/// reconstructing a fresh entity from a necessarily-partial dict would
/// silently drop whatever this generator can't represent at all (most of
/// `EntityCommon` — color, line weight, XDATA, ...) the moment a script
/// updates just one field. `existing`'s own kind is authoritative; a `dict`
/// `"kind"` (if present at all — round-tripped from a prior `get_entity()`)
/// is never consulted, since an update can't change an entity's shape.
pub(crate) fn apply_dict_to_entity(
existing: &acadrust::EntityType,
dict: &rustpython_vm::builtins::PyDictRef,
vm: &VirtualMachine,
) -> PyResult<acadrust::EntityType> {{
Ok(match existing {{
{arms} _ => {{
return Err(vm.new_value_error(
"ocs: this entity kind is not supported by the generic add/update API yet"
.to_owned(),
));
}}
}})
}}
"#
)
}
// ════════════════════════════════════════════════════════════════════════════
// Entry point
// ════════════════════════════════════════════════════════════════════════════
pub fn generate_entity_crud(manifest: &Manifest, registry: &TypeRegistry) -> String {
let mut out = String::new();
out.push_str(
"// @generated by opencad-python/build.rs from entity_manifest.json + the\n\
// ocs_plugin_api embedded type registry. Do not edit by hand — edit\n\
// entity_manifest.json or build/generate.rs instead.\n\n",
);
for name in helper_closure(manifest, registry) {
let info = get(registry, &name).unwrap();
match classify(&name, info) {
Classify::UnitEnum => out.push_str(&gen_unit_enum(&name, info)),
Classify::TaggedEnum => out.push_str(&gen_tagged_enum(&name, info, registry)),
Classify::BitFlags => out.push_str(&gen_bitflags(&name)),
Classify::Struct => out.push_str(&gen_struct(&name, info, registry)),
}
}
out.push_str(&gen_entity_to_dict(manifest, registry));
out.push_str(&gen_dict_to_entity(manifest, registry));
out.push_str(&gen_apply_dict_to_entity(manifest, registry));
out
}
+233
View File
@@ -0,0 +1,233 @@
{
"type_filter": [
"Point",
"Line",
"Circle",
"Arc",
"Ellipse",
"Polyline",
"Polyline2D",
"Polyline3D",
"LwPolyline",
"Spline",
"Text",
"MText",
"Ray",
"XLine",
"Solid",
"Face3D",
"Insert",
"Tolerance",
"Shape",
"AttributeDefinition",
"AttributeEntity",
"Hatch"
],
"base_fields": ["handle", "layer", "owner_handle"],
"overrides": {
"Point": {
"fields": {
"location": { "required": true }
}
},
"Line": {
"fields": {
"start": { "required": true },
"end": { "required": true }
}
},
"Circle": {
"fields": {
"center": { "required": true },
"radius": { "required": true }
}
},
"Arc": {
"fields": {
"center": { "required": true },
"radius": { "required": true },
"start_angle": { "required": true },
"end_angle": { "required": true }
}
},
"Ellipse": {
"fields": {
"center": { "required": true },
"major_axis": { "required": true },
"minor_axis_ratio": { "required": true }
}
},
"Polyline": {
"fields": {
"vertices": { "required": true },
"flags": {
"python_name": "closed",
"rust_getter": "VAR.flags.is_closed()",
"rust_setter": "VAR.flags.set_closed(get_opt_bool(dict, \"closed\", vm)?);"
}
}
},
"Polyline2D": {
"fields": {
"vertices": { "required": true },
"flags": {
"python_name": "closed",
"rust_getter": "VAR.flags.is_closed()",
"rust_setter": "VAR.flags.set_closed(get_opt_bool(dict, \"closed\", vm)?);"
}
}
},
"Polyline3D": {
"fields": {
"vertices": { "required": true },
"smooth_type": {
"exclude": true,
"_comment": "acadrust defines two distinct SmoothSurfaceType enums with the same name (polyline.rs, used by Polyline2D's smooth_surface — and a separate one private to polyline3d.rs, used here); the type registry's tracer keys purely by name and collapses them, so the generically generated conversion resolves to the wrong one. Needs a hand-written override with the fully qualified acadrust::entities::polyline3d::SmoothSurfaceType path to support, not just a rename."
}
}
},
"LwPolyline": {
"fields": {
"vertices": { "required": true }
}
},
"Spline": {
"fields": {
"control_points": { "required": true }
}
},
"Text": {
"fields": {
"value": { "python_name": "text", "required": true },
"insertion_point": { "python_name": "insertion", "required": true }
}
},
"MText": {
"fields": {
"value": { "python_name": "text", "required": true },
"insertion_point": { "python_name": "insertion", "required": true },
"background_color": {
"exclude": true,
"_comment": "Color is a tagged enum (Index(u8)/Rgb{r,g,b} carry data, not just unit variants) — this generator only handles C-like unit enums generically; EntityCommon's own color/line_weight/transparency fields are out of scope for the same reason (see base_fields)."
}
}
},
"Ray": {
"fields": {
"base_point": { "required": true },
"direction": { "required": true }
}
},
"XLine": {
"fields": {
"base_point": { "required": true },
"direction": { "required": true }
}
},
"Solid": {
"rust_constructor": "acadrust::entities::Solid::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)",
"fields": {
"first_corner": { "required": true },
"second_corner": { "required": true },
"third_corner": { "required": true },
"fourth_corner": { "required": true }
}
},
"Face3D": {
"rust_constructor": "acadrust::entities::Face3D::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)",
"fields": {
"first_corner": { "required": true },
"second_corner": { "required": true },
"third_corner": { "required": true },
"fourth_corner": { "required": true }
}
},
"Insert": {
"update_only": true,
"fields": {
"block_name": { "read_only": true },
"x_scale": {
"rust_getter": "VAR.x_scale()",
"rust_setter": "if let Some(v) = dict.get_item_opt(\"x_scale\", vm)? { let scale = v.try_into_value::<f64>(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.x_scale must be finite and nonzero\".to_owned())); } VAR.set_x_scale(scale); }"
},
"y_scale": {
"rust_getter": "VAR.y_scale()",
"rust_setter": "if let Some(v) = dict.get_item_opt(\"y_scale\", vm)? { let scale = v.try_into_value::<f64>(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.y_scale must be finite and nonzero\".to_owned())); } VAR.set_y_scale(scale); }"
},
"z_scale": {
"rust_getter": "VAR.z_scale()",
"rust_setter": "if let Some(v) = dict.get_item_opt(\"z_scale\", vm)? { let scale = v.try_into_value::<f64>(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.z_scale must be finite and nonzero\".to_owned())); } VAR.set_z_scale(scale); }"
},
"dwg_minsert": { "exclude": true },
"attributes": { "exclude": true },
"view_rep_handle": { "exclude": true },
"seqend_handle": { "exclude": true }
}
},
"Tolerance": {
"fields": {
"insertion_point": { "required": true },
"text": { "required": true },
"dimension_style_name": {
"rust_getter": "VAR.dimension_style_name.clone()",
"rust_setter": "if let Some(v) = dict.get_item_opt(\"dimension_style_name\", vm)? { VAR.dimension_style_name = v.try_into_value::<String>(vm)?; VAR.dimension_style_handle = None; }"
},
"dimension_style_handle": { "read_only": true },
"dwg_unknown_short": { "exclude": true }
}
},
"Shape": {
"fields": {
"insertion_point": { "required": true },
"size": { "required": true },
"shape_name": { "required": true },
"shape_number": { "required": true },
"style_name": {
"rust_getter": "VAR.style_name.clone()",
"rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::<String>(vm)?; VAR.style_handle = None; }"
},
"style_handle": { "read_only": true }
}
},
"AttributeDefinition": {
"fields": {
"tag": { "required": true },
"insertion_point": { "required": true },
"height": { "required": true },
"embedded_mtext": {
"exclude": true,
"_comment": "Embedded R2018+ MTEXT carries linked layout state. It remains snapshot-readable but is preserved untouched by Python edits until linked-payload mutation has dedicated validation."
}
}
},
"AttributeEntity": {
"fields": {
"tag": { "required": true },
"value": { "required": true },
"insertion_point": { "required": true },
"height": { "required": true },
"attdef_handle": { "read_only": true },
"embedded_mtext": {
"exclude": true,
"_comment": "The embedded R2018+ MTEXT payload remains snapshot-readable and is preserved by partial edits."
}
}
},
"Hatch": {
"fields": {
"paths": { "required": true },
"is_mpolygon": {
"exclude": true,
"_comment": "MPOLYGON is a distinct class-backed record and is preserved without exposing Hatch creation semantics."
},
"gradient_color": {
"exclude": true,
"_comment": "Gradient payload mutation requires a separate validated model; partial Hatch edits preserve it."
},
"mpolygon_hatch_color": { "exclude": true },
"mpolygon_x_direction": { "exclude": true },
"mpolygon_boundary_handle_count": { "exclude": true }
}
}
}
}
@@ -0,0 +1,54 @@
//! Create one disposable DWG with a paper layout and rectangular viewport.
//! Usage: cargo run --example create_layout_fixture -- /absolute/output.dwg
use std::io::Write;
use ocs_plugin_api::host::acadrust::{
entities::{Line, Text, Viewport},
CadDocument, DwgWriter, EntityType, Vector3,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let output = std::env::args().nth(1).ok_or("expected output DWG path")?;
let output = std::path::PathBuf::from(output);
let twisted = std::env::args().nth(2).as_deref() == Some("--twisted");
if output.extension().is_none_or(|value| value != "dwg")
|| output.parent().is_none_or(|parent| !parent.is_dir())
{
return Err("output must be a .dwg in an existing directory".into());
}
let mut document = CadDocument::new();
document.add_layout("Sheet")?;
document.add_entity(EntityType::Line(Line::from_coords(
-50.0, 0.0, 0.0, 50.0, 0.0, 0.0,
)))?;
document.add_entity_to_layout(
EntityType::Text(Text::with_value(
"Sheet note",
Vector3::new(20.0, 20.0, 0.0),
)),
"Sheet",
)?;
let mut viewport = Viewport::new();
viewport.id = 2;
viewport.center = Vector3::new(100.0, 80.0, 0.0);
viewport.width = 100.0;
viewport.height = 50.0;
viewport.view_height = 100.0;
viewport.view_center = Vector3::new(10.0, 20.0, 0.0);
viewport.view_target = Vector3::ZERO;
if twisted {
viewport.twist_angle = std::f64::consts::FRAC_PI_2;
viewport.view_target = Vector3::new(5.0, 10.0, 0.0);
}
document.add_entity_to_layout(EntityType::Viewport(viewport), "Sheet")?;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&output)?;
DwgWriter::write_to_writer(&mut file, &document)?;
file.flush()?;
println!("Created {}", output.display());
Ok(())
}
@@ -0,0 +1,90 @@
# Example script for opencad-python's PY_RUN command.
#
# Historical experimental command-replay example. The portable default
# build omits ocs.command()/ocs.select(); nested replay may hang in OCS 2026.37.
#
# Draws two rectangles, a triangle, an arc, a pair of lines made parallel by
# a real PCONSTRAINT, a circle made tangent to a line by a real TCONSTRAINT,
# and a row of points added in one ocs.add_points() batch call, one of which
# gets an XDATA record written, read back, and removed. Every ocs.add_*/
# ocs.write_record call in one PY_RUN shares a script undo group. Command
# replay has separate host undo semantics.
#
# These ARE real, persistent constraint objects, not just geometrically
# correct-looking shapes: drag one of the two "parallel" lines afterward and
# the other follows to stay parallel, same for the tangent circle. Verified
# end-to-end (not just "it compiles"): after ocs.command("PCONSTRAINT") /
# ("TCONSTRAINT"), the geometry visibly changes and satisfies the
# relationship exactly (checked numerically — direction-vector cross
# product ~0 for parallel, center-to-line distance == radius for tangent).
#
# ocs.select(handles) + ocs.command(name) is what makes this possible:
# constraint commands read a prior *selection* (a "Select objects:"
# prompt), not picks fed as command-line tokens the way LINE's points are —
# select the entities first, then run the bare command name. See PLUGIN.md
# for the two host-side additions (HostApi::run_command / set_selection)
# this needed and exactly what they do and don't cover.
if not hasattr(ocs, "command") or not hasattr(ocs, "select"):
raise RuntimeError("This example requires experimental command replay host APIs")
def rectangle(x, y, w, h):
l1 = ocs.add_line(x, y, x + w, y)
l2 = ocs.add_line(x + w, y, x + w, y + h)
l3 = ocs.add_line(x + w, y + h, x, y + h)
l4 = ocs.add_line(x, y + h, x, y)
return [l1, l2, l3, l4]
# ── Two rectangles (not aligned to each other — see the note below) ───────
rectangle(0, 0, 20, 10)
rectangle(30, 2, 18, 9)
# ── A triangle (three lines closing a loop) ────────────────────────────────
ocs.add_line(60, 0, 75, 0)
ocs.add_line(75, 0, 67.5, 12)
ocs.add_line(67.5, 12, 60, 0)
# ── An arc, for variety ─────────────────────────────────────────────────────
ocs.add_arc(67.5, 20, 8, 0, 180)
# ── Two lines, deliberately NOT parallel — then made parallel by a real ───
# ── persistent PCONSTRAINT (not just recomputed coordinates).
line_a = ocs.add_line(0, 20, 20, 24)
line_b = ocs.add_line(0, 30, 20, 33)
ocs.select([line_a, line_b])
ocs.command("PCONSTRAINT")
# ── A line and a circle, deliberately NOT touching — then made tangent by ──
# ── a real persistent TCONSTRAINT.
tangent_line = ocs.add_line(0, 40, 20, 40)
tangent_circle = ocs.add_circle(10, 44, 3)
ocs.select([tangent_line, tangent_circle])
ocs.command("TCONSTRAINT")
# ── What's NOT in this script: aligning the two rectangles to each other ──
# ── (a real ALIGN, not just matching coordinates). Tried it separately:
# ── ocs.command("ALIGN <points...>") DOES apply the transform correctly,
# ── but ALIGN asks a trailing "Scale objects? [Yes/No]" question that
# ── ocs.command()'s point-feeding doesn't answer (it sends exactly one
# ── trailing Enter, and ALIGN needs two — one to skip the optional 3rd
# ── point pair, one for the scale question) — it leaves that prompt open
# ── instead of cleanly finishing. Left out of this script rather than
# ── shipping something that half-works; see PLUGIN.md for the detail.
# ── A batch of points, added in one HostApi::add_entities() call instead ──
# ── of one ocs.add_line-style call per point.
point_handles = ocs.add_points([[x, 50, 0] for x in range(0, 21, 2)])
# ── Tag one of those points with an XDATA record, read it back, then ───────
# ── remove it — round-tripping every supported value kind.
tagged = point_handles[0]
ocs.write_record(tagged, "OPENCAD_EXAMPLE", [
{"kind": "String", "value": "origin marker"},
{"kind": "Point3D", "value": [0.0, 50.0, 0.0]},
{"kind": "Integer32", "value": 1},
])
record = ocs.read_record(tagged, "OPENCAD_EXAMPLE")
assert record["app_name"] == "OPENCAD_EXAMPLE"
assert record["values"][0] == {"kind": "String", "value": "origin marker"}
ocs.remove_record(tagged, "OPENCAD_EXAMPLE")
assert ocs.read_record(tagged, "OPENCAD_EXAMPLE") is None
+22
View File
@@ -0,0 +1,22 @@
[plugin]
id = "opencad.python"
name = "Python Scripting"
version = "0.1.0"
description = "Embedded Python scripting (RustPython) for Open CAD Studio."
author = "Felix Riestra"
license = "GPL-3.0-only"
repository = "felixriestra/OpenCADStudio"
[opencad]
api_version = 7
# Both filled by release.yml at build time from the actual compiler and
# Cargo.lock's resolved acadrust source. Declare acadrust_source even though
# it's optional: the host's compatibility check only runs at all if this
# field is present, so an omitted field is silently treated as compatible
# rather than rejected (see OpenCADStudio's plugin-architecture.md,
# "Build-time ABI metadata").
rustc_version = "__RUSTC_VERSION__"
acadrust_source = "__ACADRUST_SOURCE__"
ribbon_order = 60
command_prefixes = ["PY_"]
xdata_apps = []
@@ -0,0 +1,133 @@
"""Thin Python view over OCS host API v7 entity transactions and coverage."""
class _Entity:
def __init__(self, document, handle):
object.__setattr__(self, "_document", document)
object.__setattr__(self, "handle", handle)
def _data(self):
data = ocs.entity_descriptor(self.handle)
if data is None:
raise KeyError("entity %s is absent or outside the Python schema" % self.handle)
return data
def __getattr__(self, name):
data = self._data()
if name not in data:
raise AttributeError("property %s is not in the Python entity schema" % name)
pending = self._document._pending or {}
value = pending.get(self.handle, {}).get(name, data[name])
if isinstance(value, dict) and all(axis in value for axis in ("x", "y")):
return tuple(value[axis] for axis in ("x", "y", "z") if axis in value)
return value
@property
def coverage(self):
return ocs.entity_coverage(self._data()["kind"])
@property
def snapshot(self):
"""Detached copy of all serialized CAD fields for this entity."""
data = ocs.entity_snapshot(self.handle)
if data is None:
raise KeyError("entity %s is absent" % self.handle)
return data[self._data()["kind"]]
def __setattr__(self, name, value):
if self._document._pending is None:
raise RuntimeError("entity writes require doc.transaction(...)")
data = self._data()
can_edit = name in self.coverage["editable"]
if not can_edit or name not in data or name in ("kind", "handle", "_editable"):
raise AttributeError("property %s is not editable through this schema" % name)
old = data[name]
if isinstance(old, dict) and all(axis in old for axis in ("x", "y")):
axes = tuple(axis for axis in ("x", "y", "z") if axis in old)
if not isinstance(value, (tuple, list)) or len(value) != len(axes):
raise ValueError("%s expects %s coordinates" % (name, len(axes)))
value = dict(zip(axes, value))
self._document._pending.setdefault(self.handle, {})[name] = value
class _Entities:
def __init__(self, document):
self._document = document
def __getitem__(self, handle):
entity = _Entity(self._document, int(handle))
entity._data()
return entity
def __iter__(self):
for handle in ocs.entity_handles():
yield self[handle]
def __len__(self):
return len(ocs.entity_handles())
class _Transaction:
def __init__(self, document, label):
self.document, self.label = document, label
def __enter__(self):
if self.document._pending is not None:
raise RuntimeError("nested document transactions are unsupported")
self.document._pending = {}
return self.document
def __exit__(self, exc_type, exc, tb):
updates = self.document._pending
self.document._pending = None
if exc_type is None and updates:
ocs.update_many(self.label, [dict(handle=handle, **patch) for handle, patch in updates.items()])
return False
class _Document:
def __init__(self):
self._pending = None
self.entities = _Entities(self)
def transaction(self, label):
return _Transaction(self, label)
def create_entity(self, kind, **properties):
"""Create one mapped entity and return its live document descriptor."""
if "kind" in properties or "handle" in properties:
raise ValueError("kind and handle are managed by create_entity")
handle = ocs.add(dict(kind=kind, **properties))
return self.entities[handle]
def delete_entity(self, entity):
"""Delete an entity by descriptor or handle."""
handle = entity.handle if isinstance(entity, _Entity) else int(entity)
ocs.remove_entity(handle)
def coverage(self, kind=None):
if kind is not None:
return ocs.entity_coverage(kind)
return {name: ocs.entity_coverage(name) for name in ocs.entity_kinds()}
def poll_events(self):
return ocs.poll_events(ocs.tab_id())
def request_point(self, prompt="Pick a point"):
return ocs.request_input(prompt, False)
def request_entity(self, prompt="Pick an entity"):
return ocs.request_input(prompt, True)
def poll_input(self, token):
return ocs.poll_input(token)
@property
def selection(self):
return [self.entities[handle] for handle in ocs.selection()]
@selection.setter
def selection(self, entities):
ocs.select([entity.handle if isinstance(entity, _Entity) else int(entity) for entity in entities])
ocs.active_document = _Document()
+223
View File
@@ -0,0 +1,223 @@
//! Bounded, tab-scoped notifications retained between fresh Python runs.
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use ocs_plugin_api::host::HostNotification;
static EVENTS: Mutex<Option<HashMap<u64, TabEvents>>> = Mutex::new(None);
const CAPACITY_PER_TAB: usize = 256;
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Event {
Drawing {
tab_id: u64,
version: u64,
},
Selection {
tab_id: u64,
handles: Vec<u64>,
},
Command {
tab_id: u64,
command: Option<String>,
},
Overflow {
tab_id: u64,
dropped: u64,
},
}
impl Event {
fn tab_id(&self) -> u64 {
match self {
Event::Drawing { tab_id, .. }
| Event::Selection { tab_id, .. }
| Event::Command { tab_id, .. }
| Event::Overflow { tab_id, .. } => *tab_id,
}
}
}
#[derive(Default)]
struct TabEvents {
queue: VecDeque<Event>,
dropped: u64,
}
pub(crate) fn observe(notification: &HostNotification) {
if let HostNotification::DocumentTabClosed { tab_id } = notification {
if let Ok(mut lock) = EVENTS.lock() {
if let Some(tabs) = lock.as_mut() {
tabs.remove(tab_id);
}
}
return;
}
let event = match notification {
#[cfg(feature = "experimental-host-model")]
HostNotification::DrawingChanged { tab_id, epoch } => Event::Drawing {
tab_id: *tab_id,
version: *epoch,
},
HostNotification::SelectionChangedV4 { tab_id, handles } => Event::Selection {
tab_id: *tab_id,
handles: handles.iter().map(|h| h.value()).collect(),
},
#[cfg(feature = "experimental-host-model")]
HostNotification::CommandStateChanged { tab_id, command } => Event::Command {
tab_id: *tab_id,
command: command.clone(),
},
_ => return,
};
if let Ok(mut lock) = EVENTS.lock() {
let tab = lock
.get_or_insert_with(HashMap::new)
.entry(event.tab_id())
.or_default();
if tab.queue.len() == CAPACITY_PER_TAB {
tab.queue.pop_front();
tab.dropped += 1;
}
tab.queue.push_back(event);
}
}
pub(crate) fn drain_for(tab_id: u64) -> Vec<Event> {
let Ok(mut lock) = EVENTS.lock() else {
return Vec::new();
};
let Some(tabs) = lock.as_mut() else {
return Vec::new();
};
let Some(tab) = tabs.remove(&tab_id) else {
return Vec::new();
};
let mut events = Vec::with_capacity(tab.queue.len() + usize::from(tab.dropped > 0));
if tab.dropped > 0 {
events.push(Event::Overflow {
tab_id,
dropped: tab.dropped,
});
}
events.extend(tab.queue);
events
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polling_one_tab_preserves_other_tabs_events() {
observe(&HostNotification::DrawingChanged {
tab_id: 101,
epoch: 1,
});
observe(&HostNotification::DrawingChanged {
tab_id: 102,
epoch: 2,
});
assert_eq!(
drain_for(101),
vec![Event::Drawing {
tab_id: 101,
version: 1
}]
);
assert_eq!(
drain_for(102),
vec![Event::Drawing {
tab_id: 102,
version: 2
}]
);
}
#[test]
fn noisy_tab_reports_its_own_overflow_without_evicting_another_tab() {
observe(&HostNotification::DrawingChanged {
tab_id: 201,
epoch: 7,
});
for epoch in 0..=CAPACITY_PER_TAB as u64 {
observe(&HostNotification::DrawingChanged { tab_id: 202, epoch });
}
assert_eq!(
drain_for(201),
vec![Event::Drawing {
tab_id: 201,
version: 7
}]
);
let events = drain_for(202);
assert_eq!(events.len(), CAPACITY_PER_TAB + 1);
assert_eq!(
events[0],
Event::Overflow {
tab_id: 202,
dropped: 1
}
);
assert_eq!(
events[1],
Event::Drawing {
tab_id: 202,
version: 1
}
);
}
#[test]
fn closing_tab_discards_its_pending_events() {
observe(&HostNotification::DrawingChanged {
tab_id: 301,
epoch: 3,
});
observe(&HostNotification::DocumentTabClosed { tab_id: 301 });
assert!(drain_for(301).is_empty());
}
#[test]
fn drawing_selection_and_command_events_keep_delivery_order() {
use ocs_plugin_api::host::Handle;
observe(&HostNotification::DrawingChanged {
tab_id: 401,
epoch: 9,
});
observe(&HostNotification::SelectionChangedV4 {
tab_id: 401,
handles: vec![Handle::new(17)],
});
observe(&HostNotification::CommandStateChanged {
tab_id: 401,
command: Some("LINE".into()),
});
observe(&HostNotification::CommandStateChanged {
tab_id: 401,
command: None,
});
assert_eq!(
drain_for(401),
vec![
Event::Drawing {
tab_id: 401,
version: 9
},
Event::Selection {
tab_id: 401,
handles: vec![17]
},
Event::Command {
tab_id: 401,
command: Some("LINE".into())
},
Event::Command {
tab_id: 401,
command: None
},
]
);
}
}
+81
View File
@@ -0,0 +1,81 @@
//! Thread-local scratch slot that gives the `ocs` module's native functions
//! access to the `HostApi` for the currently running `PY_EVAL` call.
//!
//! RustPython native functions registered via `#[pymodule]` are plain free
//! functions — `add_native_module` takes a `&'static PyModuleDef`, so there is
//! no per-call closure environment to capture `host: &mut dyn HostApi` in.
//! This is the standard pattern for bridging an embedder's per-call context
//! into embedded callbacks (the same shape as Lua userdata or a C callback's
//! `void *ctx`): stash a pointer for the duration of one interpreter run, and
//! clear it before the run's borrow of `host` ends.
use std::cell::Cell;
use ocs_plugin_api::host::HostApi;
thread_local! {
static CURRENT_HOST: Cell<Option<*mut dyn HostApi>> = const { Cell::new(None) };
static UNDO_LABEL: Cell<&'static str> = const { Cell::new("") };
static UNDO_STARTED: Cell<bool> = const { Cell::new(false) };
}
/// Holds the thread-local host pointer for its lifetime; clears it on drop.
/// Must not outlive the `&mut dyn HostApi` borrow it was constructed from.
pub(crate) struct HostGuard;
impl HostGuard {
/// `undo_label` is the label a later `ensure_undo_started()` call (from
/// an `ocs` write function) will pass to `host.push_undo` — see its doc
/// comment for why this is one group per script, not one per call.
pub(crate) fn set(host: &mut dyn HostApi, undo_label: &'static str) -> Self {
// SAFETY: erases `host`'s borrow lifetime so the fat pointer fits in a
// `'static` thread_local slot (same layout either way: two words,
// data + vtable). Sound only because `Drop` clears the slot before
// this borrow's real lifetime ends — see `with_host`'s safety note.
let ptr: *mut dyn HostApi =
unsafe { std::mem::transmute::<&mut dyn HostApi, *mut dyn HostApi>(host) };
CURRENT_HOST.with(|cell| cell.set(Some(ptr)));
UNDO_LABEL.with(|cell| cell.set(undo_label));
UNDO_STARTED.with(|cell| cell.set(false));
HostGuard
}
}
impl Drop for HostGuard {
fn drop(&mut self) {
CURRENT_HOST.with(|cell| cell.set(None));
}
}
/// Call from an `ocs` write function (`add_line`, ...) right before it
/// mutates the document. Starts exactly one `host.push_undo` group per
/// `PY_EVAL`/`PY_RUN` call, however many entities the script adds — a script
/// that adds ten lines should undo as one step, not ten. A no-op for a
/// script that never writes, so a pure `PY_EVAL 1 + 1` doesn't clutter the
/// undo stack.
pub(crate) fn ensure_undo_started(host: &mut dyn HostApi) {
UNDO_STARTED.with(|started| {
if !started.get() {
host.push_undo(UNDO_LABEL.with(|label| label.get()));
started.set(true);
}
});
}
/// Run `f` with the `HostApi` active for the current `PY_EVAL` call, if any.
/// Returns `None` outside of a `HostGuard`-covered call (a script should never
/// observe this in practice, since the guard spans the whole interpreter run).
///
/// Not reentrant: `f` must not call `with_host` again itself, since that would
/// materialize two live `&mut dyn HostApi` referring to the same host at once.
/// Fine for Phase 1.2's `selection()`/`get()`, which don't call each other.
pub(crate) fn with_host<R>(f: impl FnOnce(&mut dyn HostApi) -> R) -> Option<R> {
CURRENT_HOST.with(|cell| {
let ptr = cell.get()?;
// SAFETY: `ptr` is only ever set by `HostGuard::set`, which is created
// from a live `&mut dyn HostApi` and cleared (via `Drop`) before that
// borrow's scope ends. The interpreter run this pointer is valid for
// happens entirely on this thread, within that same scope.
Some(f(unsafe { &mut *ptr }))
})
}
+159
View File
@@ -0,0 +1,159 @@
//! Results of host-driven point/entity picks, retained across Python runs.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use ocs_plugin_api::host::{CommandStep, Handle, InteractiveCommand};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
static RESULTS: Mutex<Option<HashMap<u64, (u64, PickResult)>>> = Mutex::new(None);
#[derive(Clone)]
pub(crate) enum PickResult {
Pending,
Point([f64; 3]),
Entity { handle: u64, point: [f64; 3] },
Cancelled,
}
pub(crate) struct PickCommand {
id: u64,
tab_id: u64,
prompt: String,
entity: bool,
}
pub(crate) fn begin(tab_id: u64, prompt: String, entity: bool) -> (u64, PickCommand) {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
if let Ok(mut lock) = RESULTS.lock() {
let results = lock.get_or_insert_with(HashMap::new);
if results.len() >= 1024 {
if let Some(oldest) = results.keys().min().copied() {
results.remove(&oldest);
}
}
results.insert(id, (tab_id, PickResult::Pending));
}
(
id,
PickCommand {
id,
tab_id,
prompt,
entity,
},
)
}
pub(crate) fn take(tab_id: u64, id: u64) -> Option<PickResult> {
let mut lock = RESULTS.lock().ok()?;
let results = lock.as_mut()?;
let (owner, result) = results.get(&id)?;
if *owner != tab_id {
return None;
}
if matches!(result, PickResult::Pending) {
return Some(PickResult::Pending);
}
results.remove(&id).map(|(_, result)| result)
}
impl PickCommand {
fn finish(&self, result: PickResult) {
if let Ok(mut lock) = RESULTS.lock() {
lock.get_or_insert_with(HashMap::new)
.insert(self.id, (self.tab_id, result));
}
}
}
impl Drop for PickCommand {
fn drop(&mut self) {
if let Ok(mut lock) = RESULTS.lock() {
if let Some((owner, PickResult::Pending)) = lock.as_mut().and_then(|r| r.get(&self.id))
{
if *owner == self.tab_id {
lock.as_mut()
.unwrap()
.insert(self.id, (self.tab_id, PickResult::Cancelled));
}
}
}
}
}
impl InteractiveCommand for PickCommand {
fn prompt(&self) -> String {
self.prompt.clone()
}
fn needs_object_pick(&self) -> bool {
self.entity
}
fn on_point(&mut self, point: [f64; 3]) -> CommandStep {
if self.entity {
return CommandStep::NeedPoint;
}
self.finish(PickResult::Point(point));
CommandStep::Done
}
fn on_object_pick(&mut self, handle: Handle, point: [f64; 3]) -> CommandStep {
if !self.entity {
return CommandStep::NeedPoint;
}
self.finish(PickResult::Entity {
handle: handle.value(),
point,
});
CommandStep::Done
}
fn on_enter(&mut self) -> CommandStep {
self.finish(PickResult::Cancelled);
CommandStep::Cancel
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn point_and_entity_picks_complete_tokens() {
let (point_token, mut point) = begin(7, "Point".into(), false);
assert!(matches!(take(7, point_token), Some(PickResult::Pending)));
assert!(take(8, point_token).is_none());
assert!(matches!(point.on_point([1.0, 2.0, 3.0]), CommandStep::Done));
assert!(take(8, point_token).is_none());
assert!(matches!(
take(7, point_token),
Some(PickResult::Point([1.0, 2.0, 3.0]))
));
let (entity_token, mut entity) = begin(8, "Entity".into(), true);
assert!(entity.needs_object_pick());
assert!(matches!(
entity.on_object_pick(Handle::new(17), [2.0, 0.0, 0.0]),
CommandStep::Done
));
assert!(matches!(
take(8, entity_token),
Some(PickResult::Entity { handle: 17, .. })
));
}
#[test]
fn abandoned_pick_reports_cancellation() {
let (token, command) = begin(7, "Point".into(), false);
drop(command);
assert!(take(8, token).is_none());
assert!(matches!(take(7, token), Some(PickResult::Cancelled)));
}
#[test]
fn enter_cancels_pick_without_returning_a_point() {
let (token, mut command) = begin(9, "Pick".into(), false);
assert!(matches!(command.on_enter(), CommandStep::Cancel));
assert!(matches!(take(9, token), Some(PickResult::Cancelled)));
drop(command);
assert!(take(9, token).is_none());
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Python scripting plugin for Open CAD Studio.
//!
//! Phase 1.2 of `ROADMAP.md`: a read-only `ocs` module (`ocs.selection()`,
//! `ocs.get(handle)`) plus `PY_EVAL <expr>` to evaluate one inline Python
//! expression. Phase 1.3: 2D write access (`ocs.add_line`/`ocs.add_circle`)
//! plus `PY_RUN <path.py>` to run a script file. The experimental
//! `ocs.command("...")` / `ocs.select(handles)` functions remain disabled in
//! the bundled build because nested command replay can hang.
#[cfg(feature = "experimental-host-model")]
mod event_cache;
mod host_ctx;
#[cfg(feature = "experimental-host-model")]
mod input_cache;
mod ocs_module;
mod selection_cache;
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, HostNotification};
use ocs_plugin_api::manifest::{ApiVersion, PluginManifest};
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
pub(crate) const PLUGIN_ID: &str = "opencad.python";
// Keep these fields in sync with `plugin.toml`.
static MANIFEST: PluginManifest = PluginManifest {
id: PLUGIN_ID,
name: "Python Scripting",
version: env!("CARGO_PKG_VERSION"),
description: "Embedded Python scripting (RustPython) for Open CAD Studio.",
api_version: ApiVersion::CURRENT,
ribbon_order: 60,
xdata_apps: &[],
command_prefixes: &["PY_"],
};
/// The ribbon tab.
struct PythonModule;
impl CadModule for PythonModule {
fn id(&self) -> &'static str {
"opencad_python"
}
fn title(&self) -> &'static str {
"Python Scripting"
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| {
vec![RibbonGroup {
title: "Tools",
tools: vec![
RibbonItem::LargeTool(ToolDef {
id: "PY_EVAL",
label: "Eval 1+1",
icon: IconKind::Glyph("★"),
event: ModuleEvent::Command("PY_EVAL 1 + 1".to_string()),
}),
RibbonItem::LargeTool(ToolDef {
id: "PY_RUN",
label: "Run Script…",
icon: IconKind::Glyph("▶"),
event: ModuleEvent::PluginFileDialog {
command: "PY_RUN".to_string(),
title: "Run Python Script".to_string(),
filter_name: "Python Scripts".to_string(),
extensions: vec!["py".to_string()],
},
}),
],
}]
})
}
}
/// The plugin entry point.
struct PythonPlugin;
impl BuiltinPlugin for PythonPlugin {
fn manifest(&self) -> &'static PluginManifest {
&MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(PythonModule)
}
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
if let Some(rest) = cmd.strip_prefix("PY_EVAL") {
let source = rest.trim();
if source.is_empty() {
host.push_error("PY_EVAL: expected a Python expression, e.g. PY_EVAL 1 + 1");
} else {
run_eval(host, source);
}
return true;
}
if let Some(rest) = cmd.strip_prefix("PY_RUN") {
let path = rest.trim();
if path.is_empty() {
host.push_error("PY_RUN: expected a file path, e.g. PY_RUN script.py");
} else {
run_file(host, path);
}
return true;
}
false
}
/// The runner's own event loop drains `HostApi::try_recv_notification()`
/// *before* any `Dispatch` request reaches `dispatch()` — calling it from
/// there always sees an empty queue. This callback is the only place a
/// V4+ plugin actually observes notifications; see `selection_cache.rs`.
fn on_notification(&mut self, _command_id: Option<u64>, notification: HostNotification) {
#[cfg(feature = "experimental-host-model")]
event_cache::observe(&notification);
match notification {
HostNotification::SelectionChanged { handles } => selection_cache::set(handles),
HostNotification::SelectionChangedV4 { handles, .. } => selection_cache::set(handles),
_ => {}
}
}
}
/// Evaluate one Python expression against a fresh interpreter (no session
/// persistence yet — that's a Phase 3 open question) and report the result or
/// error via the command line. A script bug must never look like a silent
/// no-op, so both paths always push something.
fn run_eval(host: &mut dyn HostApi, source: &str) {
let guard = host_ctx::HostGuard::set(host, "PY_EVAL");
let builder = rustpython_vm::Interpreter::builder(Default::default());
let ocs_def = ocs_module::module_def(&builder.ctx);
let interp = builder.add_native_module(ocs_def).build();
let outcome = interp.enter(|vm| -> Result<(String, String), String> {
let output = install_output_capture(vm)?;
let scope = vm.new_scope_with_builtins();
// Pre-bind `ocs` in scope so a one-expression `PY_EVAL` can call
// `ocs.selection()` directly — `Mode::Eval` only accepts a single
// expression, so a script has no way to run an `import` statement
// itself.
let ocs = vm
.import("ocs", 0)
.map_err(|exc| exception_to_string(vm, &exc))?;
scope
.globals
.set_item("ocs", ocs, vm)
.map_err(|exc| exception_to_string(vm, &exc))?;
#[cfg(feature = "experimental-host-model")]
install_document_model(vm, &scope)?;
let code_obj = vm
.compile(
source,
rustpython_vm::compiler::Mode::Eval,
"<py_eval>".to_string(),
)
.map_err(|err| err.to_string())?;
let result = match vm.run_code_obj(code_obj, scope) {
Ok(result) => result
.str(vm)
.map(|s| s.to_string())
.map_err(|exc| exception_to_string(vm, &exc)),
Err(exc) => Err(exception_to_string(vm, &exc)),
}?;
Ok((result, captured_output(vm, &output)?))
});
drop(guard);
match outcome {
Ok((text, printed)) => {
if !printed.is_empty() {
host.push_output(&printed);
}
host.push_output(&text);
}
Err(err) => host.push_error(&err),
}
}
/// Run a `.py` file (`PY_RUN <path>`) — same interpreter setup as `PY_EVAL`,
/// but `Mode::Exec` (statements, not a single expression) since a file can
/// hold a whole script. Reports errors the same way as `PY_EVAL`; on success
/// reports that the script ran rather than a value (`Mode::Exec` doesn't
/// produce one).
fn run_file(host: &mut dyn HostApi, path: &str) {
let source = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) => {
host.push_error(&format!("PY_RUN: cannot read {path}: {err}"));
return;
}
};
let guard = host_ctx::HostGuard::set(host, "PY_RUN");
let builder = rustpython_vm::Interpreter::builder(Default::default());
let ocs_def = ocs_module::module_def(&builder.ctx);
let interp = builder.add_native_module(ocs_def).build();
let outcome = interp.enter(|vm| -> Result<String, String> {
let output = install_output_capture(vm)?;
let scope = vm.new_scope_with_builtins();
let ocs = vm
.import("ocs", 0)
.map_err(|exc| exception_to_string(vm, &exc))?;
scope
.globals
.set_item("ocs", ocs, vm)
.map_err(|exc| exception_to_string(vm, &exc))?;
#[cfg(feature = "experimental-host-model")]
install_document_model(vm, &scope)?;
let code_obj = vm
.compile(
&source,
rustpython_vm::compiler::Mode::Exec,
path.to_string(),
)
.map_err(|err| err.to_string())?;
vm.run_code_obj(code_obj, scope)
.map_err(|exc| exception_to_string(vm, &exc))?;
captured_output(vm, &output)
});
drop(guard);
match outcome {
Ok(printed) => {
if !printed.is_empty() {
host.push_output(&printed);
}
host.push_output(&format!("PY_RUN: {path} completed"));
}
Err(err) => host.push_error(&err),
}
}
#[cfg(feature = "experimental-host-model")]
fn install_document_model(
vm: &rustpython_vm::VirtualMachine,
scope: &rustpython_vm::scope::Scope,
) -> Result<(), String> {
let code = vm
.compile(
include_str!("document_model.py"),
rustpython_vm::compiler::Mode::Exec,
"<ocs_document_model>".to_owned(),
)
.map_err(|error| error.to_string())?;
vm.run_code_obj(code, scope.clone())
.map_err(|exc| exception_to_string(vm, &exc))?;
Ok(())
}
#[cfg(all(test, feature = "experimental-host-model"))]
#[test]
fn document_model_bootstraps_in_rustpython() {
let builder = rustpython_vm::Interpreter::builder(Default::default());
let ocs_def = ocs_module::module_def(&builder.ctx);
let interp = builder.add_native_module(ocs_def).build();
interp.enter(|vm| {
let scope = vm.new_scope_with_builtins();
let ocs = vm.import("ocs", 0).unwrap();
scope.globals.set_item("ocs", ocs, vm).unwrap();
install_document_model(vm, &scope).unwrap();
let code = vm
.compile(
"ocs.active_document.transaction('Move line').label",
rustpython_vm::compiler::Mode::Eval,
"<model_test>".to_owned(),
)
.unwrap();
let label = vm.run_code_obj(code, scope).unwrap();
assert_eq!(label.str(vm).unwrap().to_string(), "Move line");
});
}
/// RustPython is built without its stdio feature, so sys.stdout/stderr are
/// None by default. Give each fresh interpreter a memory stream for print().
fn install_output_capture(
vm: &rustpython_vm::VirtualMachine,
) -> Result<rustpython_vm::PyObjectRef, String> {
let sys = vm
.import("sys", 0)
.map_err(|exc| exception_to_string(vm, &exc))?;
let io = vm
.import("_io", 0)
.map_err(|exc| exception_to_string(vm, &exc))?;
let output = vm
.call_method(&io, "StringIO", ())
.map_err(|exc| exception_to_string(vm, &exc))?;
sys.set_attr("stdout", output.clone(), vm)
.map_err(|exc| exception_to_string(vm, &exc))?;
sys.set_attr("stderr", output.clone(), vm)
.map_err(|exc| exception_to_string(vm, &exc))?;
Ok(output)
}
fn captured_output(
vm: &rustpython_vm::VirtualMachine,
output: &rustpython_vm::PyObjectRef,
) -> Result<String, String> {
vm.call_method(output, "getvalue", ())
.and_then(|value| value.str(vm))
.map(|value| value.to_string().trim_end_matches('\n').to_owned())
.map_err(|exc| exception_to_string(vm, &exc))
}
/// `"<TypeName>: <message>"`, the same shape `str(exception)` plus its type
/// name produces in CPython — no full traceback yet (Phase 1.2 is minimal;
/// see the roadmap's error-handling note).
fn exception_to_string(
vm: &rustpython_vm::VirtualMachine,
exc: &rustpython_vm::builtins::PyBaseExceptionRef,
) -> String {
let exc_obj: rustpython_vm::PyObjectRef = exc.clone().into();
let type_name = exc_obj.class().name().to_string();
let message = exc_obj.str(vm).map(|s| s.to_string()).unwrap_or_default();
format!("{type_name}: {message}")
}
// Emit the C-ABI symbols the host loader looks for.
ocs_plugin_api::export_plugin!(PythonPlugin);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
//! Process-wide cache of the most recently observed selection.
//!
//! `HostApi` has no synchronous "give me the current selection" call — only
//! the best-effort `SelectionChanged` / `SelectionChangedV4` notification.
//! Naively calling `HostApi::try_recv_notification()` from inside `dispatch`
//! doesn't work: `ocs_plugin_api::runner`'s own V4 event loop
//! (`run_v4` in `runner.rs`) drains that queue itself on every iteration,
//! forwarding each notification to `BuiltinPlugin::on_notification` *before*
//! a `Dispatch` request ever reaches `dispatch()` — so by the time `dispatch`
//! runs, the queue is always empty. The only way to observe notifications is
//! to override `on_notification` and stash what's needed here; `dispatch`
//! (and the `ocs.selection()` pyfunction) then just reads this cache.
//!
//! A plain process-wide static is fine here: one plugin process hosts exactly
//! one `PythonPlugin`, so this is equivalent to an instance field, but a
//! static is far simpler to reach from the `#[pymodule]` native functions in
//! `ocs_module.rs`, which have no path back to the `BuiltinPlugin` instance.
use std::sync::Mutex;
use ocs_plugin_api::host::Handle;
static CACHE: Mutex<Vec<Handle>> = Mutex::new(Vec::new());
pub(crate) fn set(handles: Vec<Handle>) {
if let Ok(mut cache) = CACHE.lock() {
*cache = handles;
}
}
pub(crate) fn get() -> Vec<Handle> {
CACHE.lock().map(|cache| cache.clone()).unwrap_or_default()
}
@@ -0,0 +1,124 @@
import pathlib
import types
import unittest
MODEL = pathlib.Path(__file__).resolve().parents[1] / "src" / "document_model.py"
class DocumentModelTests(unittest.TestCase):
def setUp(self):
self.records = {
1: {"handle": 1, "kind": "Line", "layer": "0", "start": {"x": 1, "y": 2, "z": 3},
"end": {"x": 4, "y": 5, "z": 6}, "_editable": True},
2: {"handle": 2, "kind": "Hatch", "layer": "0", "_editable": False},
}
self.calls = []
self.ocs = types.SimpleNamespace(
entity_descriptor=lambda handle: self.records.get(handle),
entity_coverage=lambda kind: {"kind": kind, "scope": "canvas",
"editable": ["start", "end", "layer"] if kind == "Line" else ["layer"],
"readable": ["handle", "kind", "layer"], "unmapped": []},
entity_kinds=lambda: ["Line", "Hatch"],
entity_snapshot=lambda handle: {self.records[handle]["kind"]: {
"common": {"layer": self.records[handle]["layer"]},
"pattern_name": "SOLID" if handle == 2 else None,
}} if handle in self.records else None,
entity_handles=lambda: list(self.records),
update_many=lambda label, updates: self.calls.append((label, updates)),
add=lambda entity: self.calls.append(("add", entity)) or 1,
remove_entity=lambda handle: self.calls.append(("remove", handle)),
selection=lambda: [1],
select=lambda handles: self.calls.append(("selection", handles)),
poll_events=lambda tab: [{"type": "drawing", "tab_id": tab}],
tab_id=lambda: 7,
request_input=lambda prompt, entity: (prompt, entity),
poll_input=lambda token: {"status": "point", "point": [1, 2, 3]},
)
namespace = {"ocs": self.ocs}
exec(MODEL.read_text(), namespace)
self.doc = self.ocs.active_document
def test_batch_edit_and_rollback(self):
line = self.doc.entities[1]
self.assertEqual(line.start, (1, 2, 3))
with self.doc.transaction("Move line"):
line.start = (0, 0, 0)
line.end = (10, 0, 0)
self.assertEqual(line.end, (10, 0, 0))
self.assertEqual(self.calls, [("Move line", [{"handle": 1,
"start": {"x": 0, "y": 0, "z": 0}, "end": {"x": 10, "y": 0, "z": 0}}])])
with self.assertRaises(ValueError):
with self.doc.transaction("Abort"):
line.layer = "A"
raise ValueError()
self.assertEqual(len(self.calls), 1)
def test_create_and_delete_use_document_model(self):
line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0},
end={"x": 10, "y": 0, "z": 0})
self.assertEqual(line.handle, 1)
self.assertEqual(self.calls[-1], ("add", {"kind": "Line",
"start": {"x": 0, "y": 0, "z": 0}, "end": {"x": 10, "y": 0, "z": 0}}))
self.doc.delete_entity(line)
self.assertEqual(self.calls[-1], ("remove", 1))
with self.assertRaises(ValueError):
self.doc.create_entity("Line", handle=4)
def test_coverage_and_read_only_entity(self):
self.assertEqual(len(self.doc.entities), 2)
self.assertEqual([entity.handle for entity in self.doc.entities], [1, 2])
self.assertIn("start", self.doc.entities[1].coverage["editable"])
self.assertEqual(self.doc.entities[2].coverage["editable"], ["layer"])
self.assertEqual(set(self.doc.coverage()), {"Line", "Hatch"})
self.assertEqual(self.doc.coverage("Hatch")["editable"], ["layer"])
snapshot = self.doc.entities[2].snapshot
self.assertEqual(snapshot["pattern_name"], "SOLID")
snapshot["pattern_name"] = "OTHER"
self.assertEqual(self.doc.entities[2].snapshot["pattern_name"], "SOLID")
with self.doc.transaction("Move hatch"):
self.doc.entities[2].layer = "HATCHES"
self.assertEqual(self.calls[-1], ("Move hatch", [{"handle": 2, "layer": "HATCHES"}]))
with self.doc.transaction("Unsupported geometry"):
with self.assertRaises(AttributeError):
self.doc.entities[2].pattern_name = "OTHER"
def test_rejected_host_transaction_leaves_python_model_ready(self):
def reject(label, updates):
raise ValueError("Line.start must contain finite coordinates")
self.ocs.update_many = reject
with self.assertRaisesRegex(ValueError, "finite coordinates"):
with self.doc.transaction("Invalid"):
self.doc.entities[1].start = (float("nan"), 0, 0)
self.assertIsNone(self.doc._pending)
self.assertEqual(self.doc.entities[1].start, (1, 2, 3))
def test_readable_insert_identity_cannot_be_patched(self):
self.records[3] = {"handle": 3, "kind": "Insert", "layer": "0",
"block_name": "DOOR", "insert_point": {"x": 1, "y": 2, "z": 0},
"_editable": True}
old_coverage = self.ocs.entity_coverage
self.ocs.entity_coverage = lambda kind: (
{"kind": "Insert", "scope": "canvas", "editable": ["layer", "insert_point"],
"readable": ["kind", "handle", "layer", "block_name", "insert_point"],
"unmapped": []} if kind == "Insert" else old_coverage(kind))
insert = self.doc.entities[3]
self.assertEqual(insert.block_name, "DOOR")
with self.doc.transaction("Move block"):
with self.assertRaises(AttributeError):
insert.block_name = "WINDOW"
insert.insert_point = (4, 5, 0)
self.assertEqual(self.calls[-1], ("Move block", [{"handle": 3,
"insert_point": {"x": 4, "y": 5, "z": 0}}]))
def test_selection_events_and_input(self):
self.assertEqual([entity.handle for entity in self.doc.selection], [1])
self.doc.selection = [self.doc.entities[1]]
self.assertEqual(self.calls[-1], ("selection", [1]))
self.assertEqual(self.doc.poll_events(), [{"type": "drawing", "tab_id": 7}])
self.assertEqual(self.doc.request_entity("Pick"), ("Pick", True))
self.assertEqual(self.doc.poll_input(10)["status"], "point")
if __name__ == "__main__":
unittest.main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Build the in-tree API v7 Python plugin and stage a complete plugin package.
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "usage: $0 <stage directory> [--debug] [--target <triple>]" >&2
exit 2
fi
stage_dir=$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$1")
shift
profile=release
target=""
while [[ $# -gt 0 ]]; do
case "$1" in
--debug) profile=debug; shift ;;
--target)
[[ $# -ge 2 ]] || { echo "--target requires a triple" >&2; exit 2; }
target=$2
shift 2
;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
plugin_dir=$(cd "$(dirname "$0")/.." && pwd)
ocs_repo=$(cd "$plugin_dir/../.." && pwd)
cd "$plugin_dir"
build_args=(build --locked --features experimental-host-model)
[[ $profile == release ]] && build_args+=(--release)
[[ -n $target ]] && build_args+=(--target "$target")
cargo "${build_args[@]}"
case "$(uname -s)" in
Darwin) library="libopencad_python.dylib" ;;
Linux) library="libopencad_python.so" ;;
MINGW*|MSYS*|CYGWIN*) library="opencad_python.dll" ;;
*) echo "unsupported platform" >&2; exit 2 ;;
esac
target_dir="$plugin_dir/target"
[[ -n $target ]] && target_dir="$target_dir/$target"
source_library="$target_dir/$profile/$library"
[[ -f $source_library ]] || { echo "build did not produce $source_library" >&2; exit 1; }
mkdir -p "$stage_dir"
cp "$source_library" "$stage_dir/$library"
python3 - "$stage_dir/plugin.toml" "$(rustc --version)" "$ocs_repo/Cargo.lock" <<'PY'
from pathlib import Path
import re
import sys
def acadrust_source(path):
packages = re.findall(r"(?ms)^\[\[package\]\]\n(.*?)(?=^\[\[package\]\]|\Z)", Path(path).read_text())
sources = []
for package in packages:
if re.search(r'^name = "acadrust"$', package, re.M):
match = re.search(r'^source = "([^"]+)"$', package, re.M)
if match:
sources.append(match.group(1))
if len(sources) != 1:
raise SystemExit(f"expected one acadrust source in {path}, found {len(sources)}")
return sources[0]
manifest = Path("plugin.toml").read_text()
if "api_version = 7" not in manifest:
raise SystemExit("bundled plugin.toml must target API v7")
manifest = manifest.replace("__RUSTC_VERSION__", sys.argv[2])
manifest = manifest.replace("__ACADRUST_SOURCE__", acadrust_source(sys.argv[3]))
if "__" in manifest:
raise SystemExit("unresolved manifest placeholder")
Path(sys.argv[1]).write_text(manifest)
PY
echo "Staged bundled API v7 plugin in $stage_dir"
+87 -12
View File
@@ -15,7 +15,8 @@
#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
use std::path::PathBuf;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// One entry in the curated plugin registry (`plugins/registry.json`).
#[derive(Debug, Clone)]
@@ -68,6 +69,9 @@ pub struct ExternalPlugin {
pub command_prefixes: Vec<String>,
/// The package directory under the plugins folder.
pub dir: PathBuf,
/// True when the package ships inside the application rather than the
/// per-user plugin directory.
pub bundled: bool,
/// Whether a native library for this platform sits beside `plugin.toml`.
pub lib_present: bool,
}
@@ -147,6 +151,24 @@ pub fn plugins_dir() -> Option<PathBuf> {
Some(p)
}
/// Read-only plugins shipped with the application. The environment override
/// makes development and packaging tests independent of a particular bundle
/// layout.
pub fn bundled_plugins_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("OCS_BUNDLED_PLUGINS_DIR") {
return Some(PathBuf::from(p));
}
let exe = std::env::current_exe().ok()?;
#[cfg(target_os = "macos")]
{
return Some(exe.parent()?.parent()?.join("Resources").join("plugins"));
}
#[cfg(not(target_os = "macos"))]
{
Some(exe.parent()?.join("plugins"))
}
}
/// Delete an installed package's folder. It stays loaded for the current
/// session (the library is resident); the removal takes effect on next start.
#[cfg(not(target_arch = "wasm32"))]
@@ -171,16 +193,33 @@ fn lib_extension() -> &'static str {
}
}
/// Discover every package under the plugins directory, sorted by `ribbon_order`
/// then id. Missing directory → empty list (not an error).
/// Discover bundled and per-user packages, sorted by `ribbon_order` then id.
/// A per-user package with the same id explicitly overrides the bundled copy.
/// Missing directories are ignored.
pub fn discover() -> Vec<ExternalPlugin> {
let Some(root) = plugins_dir() else {
return Vec::new();
discover_from_roots(bundled_plugins_dir().as_deref(), plugins_dir().as_deref())
}
fn discover_from_roots(
bundled_root: Option<&Path>,
user_root: Option<&Path>,
) -> Vec<ExternalPlugin> {
let mut found = BTreeMap::new();
if let Some(root) = bundled_root {
discover_root(root, true, &mut found);
}
if let Some(root) = user_root {
discover_root(root, false, &mut found);
}
let mut found: Vec<_> = found.into_values().collect();
found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id)));
found
}
fn discover_root(root: &Path, bundled: bool, found: &mut BTreeMap<String, ExternalPlugin>) {
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
let Ok(entries) = std::fs::read_dir(&root) else {
return Vec::new();
};
let mut found = Vec::new();
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
@@ -198,11 +237,10 @@ pub fn discover() -> Vec<ExternalPlugin> {
}
p.lib_present = lib_present_in(&dir);
p.dir = dir;
found.push(p);
p.bundled = bundled;
found.insert(p.id.clone(), p);
}
}
found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id)));
found
}
/// True when a file with this platform's dynamic-library extension exists in
@@ -290,6 +328,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
ribbon_order,
command_prefixes,
dir: PathBuf::new(),
bundled: false,
lib_present: false,
})
}
@@ -523,6 +562,42 @@ xdata_apps = ["MYPLUGIN_RECORD"]
assert!(parse_plugin_toml("name = \"x\"").is_none());
}
#[test]
fn discovers_bundled_plugins_and_allows_a_user_override() {
let base = std::env::temp_dir().join(format!(
"ocs-bundled-plugin-discovery-{}",
std::process::id()
));
let bundled = base.join("bundled").join("opencad.python");
let user = base.join("user").join("opencad.python");
std::fs::create_dir_all(&bundled).unwrap();
std::fs::create_dir_all(&user).unwrap();
let manifest = |version: &str| {
format!(
"[plugin]\nid=\"opencad.python\"\nversion=\"{version}\"\n[opencad]\napi_version=7\n"
)
};
std::fs::write(bundled.join("plugin.toml"), manifest("bundled")).unwrap();
std::fs::write(user.join("plugin.toml"), manifest("user")).unwrap();
std::fs::write(
bundled.join(format!("plugin.{}", lib_extension())),
b"fixture",
)
.unwrap();
std::fs::write(user.join(format!("plugin.{}", lib_extension())), b"fixture").unwrap();
let bundled_only = discover_from_roots(Some(&base.join("bundled")), None);
assert_eq!(bundled_only.len(), 1);
assert!(bundled_only[0].bundled);
assert_eq!(bundled_only[0].version, "bundled");
let overridden = discover_from_roots(Some(&base.join("bundled")), Some(&base.join("user")));
assert_eq!(overridden.len(), 1);
assert!(!overridden[0].bundled);
assert_eq!(overridden[0].version, "user");
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn incompatible_api_flagged() {
let p = parse_plugin_toml("id=\"a\"\napi_version = 9999").unwrap();
+4 -4
View File
@@ -1,6 +1,6 @@
// Open CAD Studio plugin runtime. Plugins are external cdylibs loaded from the
// user plugins folder (see `external`) and installed via the marketplace; the
// host ships no built-in add-ons. See `docs/plugin-architecture.md`.
// Open CAD Studio plugin runtime. Plugins are out-of-process cdylibs loaded
// from the application bundle or user plugins folder (see `external`).
// See `docs/plugin-architecture.md`.
pub mod external;
pub mod host;
@@ -81,4 +81,4 @@ mod tests {
"expected an error queued for the command line, got: {queued:?}"
);
}
}
}
+13 -5
View File
@@ -314,11 +314,19 @@ fn external_card<'a>(
actions = actions.push(toggle_button(&p.id, disabled));
actions = actions.push(Space::new().width(6));
}
actions = actions.push(pill_button(
t!("Uninstall"),
Message::PluginUninstall(p.id.clone()),
button::danger,
));
if p.bundled {
actions = actions.push(
text(t!("Bundled with Open CAD Studio"))
.size(11)
.style(muted_style),
);
} else {
actions = actions.push(pill_button(
t!("Uninstall"),
Message::PluginUninstall(p.id.clone()),
button::danger,
));
}
container(column![info, actions].spacing(8).padding([10, 12]))
.width(Fill)