forked from 1b-agent/opencadstudio-upstream
Bundle the first-party Python scripting plugin
This commit is contained in:
1 parent
889042c1f1
commit
de51c1a17f
26 files changed
+11459
-27
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+3059
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/ |
|
||||
@@ -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, ®istry);
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
})
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¬ification);
|
||||
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
@@ -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
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user