Remove personal names, local paths and fork-only process notes from code comments, docs and the plugin manifest (repository now points at the upstream project), and drop the handoff section from the plan. Co-Authored-By: Claude Sonnet 5 <[email protected]>
23 KiB
Open CAD Studio — Python Scripting & AutoLISP Migration Plan
Status: Phase 1 is done and host-verified, and the general document model that
followed (entities, drawing tables, block contents and the command runner) is described in
../../docs/plugin-host-model.md. Phase 2 (an AutoLISP
translator) is a separate project that is not part of this repository. This file is kept as
the original planning document and Phase 1 build log.
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 and maintainer sign-off | 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
- New repo from OpenCADStudio's
docs/plugin-template/;plugin.toml+Cargo.tomlpinningocs_plugin_api(hostfeature) and matching the target host'sacadrust_source/rustc_version(see Risks). Scaffolded locally at~/Documents/MacApps/opencad-python(this repo — no remote yet at the time, not pushed anywhere). - Add RustPython as a dependency; confirm it builds cleanly as part of a
cdylibon macOS (arm64) — Windows/Linux still unverified. Used crates.iorustpython-vm = "0.5"(not therustpythonfacade crate; matches how the upstreamexamples/hello_embed.rsembeds it). Builds clean, no warnings:libopencad_python.dylib, 17MB, ~80s from a coldcargo cleanrelease build. Exports the expectedocs_plugin_register/ocs_plugin_api_versionC-ABI symbols. APY_HELLOcommand runs a real RustPython smoke test (compiles and evaluates1 + 1viaInterpreter::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'sVm::compiletakes an ownedStringfilename (not&str), andCompileErrorhas nointo_pyexceptionmethod — worked around by matching on theResultdirectly instead of using?withPyResult.
1.2 — Minimal ocs module (read-only)
ocs.selection()→ list of entity handles. Implemented as a cache, not a live query —HostApihas no synchronous "current selection" call (only a best-effortSelectionChanged/SelectionChangedV4notification). Populated viaBuiltinPlugin::on_notificationinto a process-wide static (selection_cache.rs) — see the host-runtime verification note below for whydispatch-time polling doesn't work. Documented as a real limitation inPLUGIN.md, not silently papered over.ocs.get(handle)→ read-only geometry/property access, viaHostApi::document_reader()(zero-copy) +for_each_entity, matching by handle. Returns{handle, kind, layer, point}orNone.PY_EVAL <expr>command (command_prefixes = ["PY_"]): evaluate one inline expression, print the result viahost.push_output. Runs a fresh, stdlib-freeInterpreterper call with only theocsnative module registered (Interpreter::builder(...).add_native_module(...)) — noimport 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.- Errors: both compile errors (
CompileError, which implementsDisplaydirectly — nointo_pyexceptionneeded) and runtime exceptions (formatted as"<TypeName>: <message>", no traceback yet) go tohost.push_error. - Bridging
host: &mut dyn HostApiinto the#[pymodule]native functions needed a thread-local raw-pointer scratch slot (host_ctx.rs) with a documented safety argument, sinceadd_native_modulewants&'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-bearingunsafeblock in the plugin so far. - Verified:
cargo build/--releaseclean, zero warnings, against the realocs_plugin_api/rustpython-vm0.5.0 APIs (not guessed — traced through the actual crate sources forHostApi,DocumentReader,InterpreterBuilder,ToPyObject/DictKeyimpls, etc.). - Host-runtime verified, not just "compiles": built the actual OCS
host (debug, ~1m43s), installed the plugin, drove it through the real
--mcpautomation 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"viahost.push_error— not a silent no-op. -ocs.get(handle)on a drawnLINE→ correct{'handle': 98, 'kind': 'line', 'layer': '0', 'point': None}. - Along the way found and fixed a real bug:PY_EVAL'socsname was never bound in scope (scripts would needimport ocs, impossible underMode::Eval, which only accepts one expression). Fixed byvm.import("ocs", 0)+scope.globals.set_itembefore compiling —ocsis 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_v4inrunner.rs) drainsHostApi::try_recv_notification()on every iteration and forwards each notification toBuiltinPlugin::on_notification— before aDispatchrequest ever reachesdispatch(). Pollingtry_recv_notification()from insidedispatch(what the plugin did) therefore always sees an empty queue; the host'sSelectionChangedV4broadcast (notify_plugins_selection_changed/publish_selection_changed_v4insrc/plugin/v4_support.rs) was firing correctly the whole time. Fix: overrideon_notificationand stash the selection in a process-wide cache instead (opencad-python'sselection_cache.rs);dispatchandocs.selection()just read that. Verified end-to-end: select an entity, thenPY_EVAL ocs.selection()returns its handle. - Worth folding into this doc's guidance for anyone writing a V4+ plugin: notifications must be consumed viaon_notification, not by pollingtry_recv_notification()fromdispatch— the stock runner loop already drains that queue for you. - Testing pitfall worth remembering:--mcpdoesn'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()insrc/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$HOMEfor the test subprocess to a scratch directory, which isolatesconfig_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
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 onehost.push_undogroup, not one per call (host_ctx::ensure_undo_started, called lazily by the first write). Verified end-to-end: aPY_RUNscript that callsocs.add_linetwice undoes both lines in oneundo. Takerustpython_vm::function::ArgIntoFloat, not a baref64— found by actually runningocs.add_line(0, 0, 10, 10)(plain ints, the natural way to write it) and hitting aTypeError: unlike CPython's C-function argument parsing, a baref64#[pyfunction]parameter in RustPython does not implicitly coerce anint.PY_RUN <path.py>to run a file (Mode::Exec, so statements are allowed, unlikePY_EVAL'sMode::Eval). Same error reporting asPY_EVAL: a missing file or a raised exception is never a silent no-op. Reusing the automationrunop needed no transport changes, as expected —{"op":"run","cmd":"PY_RUN <path>"}just works.ocs.command(cmd)to invoke existing built-in commands by name — implemented, required a host change. AddedHostApi::run_command(defaultErrimpl, no forced version bump) by reusingrun_command_line+drive_headless_task— the exact machinery--serve/--mcpautomation's"run"op already uses — via a newPluginRequest::RunCommandand one match arm inhandle_plugin_request. Full detail: OpenCADStudio'sdocs/plugin-architecture.md'sHostApitable, and this plugin's ownPLUGIN.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'sdispatch()is still running on its one runner thread — sends that same plugin a secondDispatchit 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 fullcargo test --libsuite (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 justLINE: pre-selecting two non-parallel lines thenocs.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 trailingPCONSTRAINTtokens starts the command but ends in "Command cancelled", not a constraint. AddedHostApi::set_selection(same shape asrun_command: newPluginRequest::SetSelection, default-Errtrait method, no forced version bump; validates every handle exists before changing anything) andocs.select(handles)on top of it. Re-verified fully scripted, no automation-API help: a plain.pyfile viaPY_RUNcallingocs.select([h1, h2])thenocs.command("PCONSTRAINT")produces the same genuinely-parallel result as before, now reachable entirely from a script. Confirmed no regressions via the fullcargo test --libsuite both times (1040 passed, 1 pre-existing unrelated font-fallback failure).ocs.add_points(points)and XDATA CRUD (ocs.read_record/write_record/remove_record), ported from reviewingschoeller/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) andread_record/write_record/remove_record(XDATA) already existed inocs_plugin_api, unused by this plugin until now — unlikerun_command/set_selectionin §1.3, which needed newHostApimethods.add_pointsbatches many points into onehost.add_entities()call instead of loopingadd_entity, so the host pays for undo/dirty/geometry-rebuild bookkeeping once per batch, not once per point — the in-process analogue ofocs_python_repl'socs.doc.add_many(...), which batches to avoid one IPC round trip per point instead. The XDATA functions deliberately reuseocs_python_repl's exact{app_name, values}/{kind, value}record shape (see this plugin'sPLUGIN.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 liveHostApi) and assert that every new function gets all the way through its own argument/dict parsing — down to everyXDataValuekind — before failing on the expected "not running inside a PY_ command" sentinel;cargo test/cargo clippy --all-targetsboth 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 forocs.command()/ocs.select()above.
1.4 — Usability
- Open question resolved by reading
ribbon.rsend to end, not assumed: a script manager UI (or a dockable REPL console) is not possible with today'socs_plugin_api.CadModuleexposes 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 newHostApi/CadModulecapability — anocs_plugin_apiversion bump, a host feature request. Out of scope for a plugin repo; documented as a real, checked answer in this plugin's ownPLUGIN.md, not left open. ModuleEvent::PluginFileDialog(already exists) for "Run Script…": opens a native file picker filtered to.py, and on selection the host dispatchesPY_RUN <path>back to the plugin — no new plugin code needed beyond the ribbon entry, sincePY_RUNalready 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
- Audit: the
ocsmodule 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, soimport os/socket/subprocessis already impossible" — true for those three names, but RustPython 0.5.0'shost_envCargo feature is on by default and bakesposix/_ctypesinto every interpreter's core module registry regardless ofrustpython-stdlib. Confirmed by actually running it: withhost_envon,PY_EVAL __import__('posix').getcwd()andPY_EVAL open('/etc/passwd').read()both worked — full filesystem read/write from any script. Fixed:rustpython-vmnow builds withdefault-features = false, features = ["compiler", "gc"]. Re-verified the full matrix after:os/posix/nt/subprocess/socket/ctypes/_ctypes/_socket/_subprocess/_signal/pwdall blocked,open()blocked for both read and write (itsFileIOneedshost_envinternally too, even though_ioitself is always registered). No functional regression —ocs.add_lineand the fullexamples/example.py(both real constraints) still run correctly. Audited what remains exhaustively rather than assuming "fewer is enough": 15sys.modulesentries, all core language/import/codec plumbing plusocs. One accepted, documented residual gap:_threadis 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 ownPLUGIN.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 curatedplugins/registry.jsonis 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
This phase lives in a separate project that is not part of this repository. 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
- Pull a representative sample from Lee Mac's routines and the Whole-Spec catalog (157 curated routines) to find the actual distribution of constructs used in real scripts — don't guess coverage priorities, measure them.
- 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 neededmarker in the generated output, never silently dropped.
2.2 — Reader & core-language transform
- S-expression tokenizer/parser (standalone, no OCS runtime dependency).
- 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
entget→ocs.get(handle);ssget(no-arg) →ocs.selection().commandsequences →ocs.command("...").getvar/setvar— confirmed no host system-variable surface exists; documented as unsupported rather than guessed at.- Widened well past this original list via measured corpus frequency — see that project'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
- 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
HostApican support without a version bump. - Versioning story for the
ocsmodule surface as it grows: same strictApiVersiongate asocs_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/ |