From de51c1a17f81ef9885cb334f962808bdb7e1eae2 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 23:16:27 +0200 Subject: [PATCH] Bundle the first-party Python scripting plugin --- .github/workflows/release.yml | 5 + docs/plugin-architecture.md | 12 +- packaging/build_macos_signed.sh | 7 + plugins/opencad-python/Cargo.lock | 3059 +++++++++++ plugins/opencad-python/Cargo.toml | 47 + plugins/opencad-python/PLUGIN.md | 386 ++ plugins/opencad-python/README.md | 120 + plugins/opencad-python/ROADMAP.md | 393 ++ plugins/opencad-python/build.rs | 28 + plugins/opencad-python/build/generate.rs | 891 +++ plugins/opencad-python/entity_manifest.json | 233 + .../examples/create_layout_fixture.rs | 54 + plugins/opencad-python/examples/example.py | 90 + plugins/opencad-python/plugin.toml | 22 + plugins/opencad-python/src/document_model.py | 133 + plugins/opencad-python/src/event_cache.rs | 223 + plugins/opencad-python/src/host_ctx.rs | 81 + plugins/opencad-python/src/input_cache.rs | 159 + plugins/opencad-python/src/lib.rs | 321 ++ plugins/opencad-python/src/ocs_module.rs | 4865 +++++++++++++++++ plugins/opencad-python/src/selection_cache.rs | 33 + .../tests/test_document_model.py | 124 + plugins/opencad-python/tools/stage-bundled.sh | 75 + src/plugin/external.rs | 99 +- src/plugin/mod.rs | 8 +- src/ui/window/plugin_manager.rs | 18 +- 26 files changed, 11459 insertions(+), 27 deletions(-) create mode 100644 plugins/opencad-python/Cargo.lock create mode 100644 plugins/opencad-python/Cargo.toml create mode 100644 plugins/opencad-python/PLUGIN.md create mode 100644 plugins/opencad-python/README.md create mode 100644 plugins/opencad-python/ROADMAP.md create mode 100644 plugins/opencad-python/build.rs create mode 100644 plugins/opencad-python/build/generate.rs create mode 100644 plugins/opencad-python/entity_manifest.json create mode 100644 plugins/opencad-python/examples/create_layout_fixture.rs create mode 100644 plugins/opencad-python/examples/example.py create mode 100644 plugins/opencad-python/plugin.toml create mode 100644 plugins/opencad-python/src/document_model.py create mode 100644 plugins/opencad-python/src/event_cache.rs create mode 100644 plugins/opencad-python/src/host_ctx.rs create mode 100644 plugins/opencad-python/src/input_cache.rs create mode 100644 plugins/opencad-python/src/lib.rs create mode 100644 plugins/opencad-python/src/ocs_module.rs create mode 100644 plugins/opencad-python/src/selection_cache.rs create mode 100644 plugins/opencad-python/tests/test_document_model.py create mode 100755 plugins/opencad-python/tools/stage-bundled.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e659b1b8..fba2c1098 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 19b1a0968..b83e315a6 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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. | diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index aadce077c..b5905a902 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -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" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock new file mode 100644 index 000000000..1679360ae --- /dev/null +++ b/plugins/opencad-python/Cargo.lock @@ -0,0 +1,3059 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "acadrust" +version = "0.5.5" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=568a12c#568a12cb97c24e14b1cc488085b3f81cf89b94d9" +dependencies = [ + "ahash 0.8.12", + "anyhow", + "bitflags", + "byteorder", + "encoding_rs", + "flate2", + "indexmap", + "itoa", + "memmap2", + "nom", + "once_cell", + "rayon", + "ryu", + "serde", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitflagset" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b6ee310aa7af14142c8c9121775774ff601ae055ed98ba7fac96098bcde1b9" +dependencies = [ + "num-integer", + "num-traits", + "radium 1.1.1", + "ref-cast", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium 0.7.0", + "tap", + "wyz", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cargo-lock" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50524592e6bfbb1bf6f94e8184a786f637faeaf1cf37bbe65502b9a2c5c48939" +dependencies = [ + "semver", + "serde", + "toml", + "url", +] + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-discriminant" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a6df962265a53221f29081896c412ef325c17fa7d638cd9578febe53d3c82c" +dependencies = [ + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "exitcode" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "get-size-derive2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b6d1e2f75c16bfbcd0f95d84f99858a6e2f885c2287d1f5c3a96e8444a34b4" +dependencies = [ + "attribute-derive", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "get-size2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cf31a6d70300cf81461098f7797571362387ef4bf85d32ac47eaa59b3a5a1a" +dependencies = [ + "compact_str", + "get-size-derive2", + "hashbrown 0.16.1", + "ordermap", + "smallvec", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "interprocess" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb02ffbf25e8ef0f4a95bff054eef75f6d1bf16919474cf2caa63b21997478a" +dependencies = [ + "doctest-file", + "libc", + "recvmsg", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "is-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8267aa6001e25494f3015f9663bbd88a18240c74483afa5f0934a1b3e4c388e9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "junction" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libffi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8d526bc07aa02e6826944ec446ecd8d228bd97b1cbd21e5bc5e3407a09a585" +dependencies = [ + "libc", + "libffi-sys", +] + +[[package]] +name = "libffi-sys" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab801a4ae9065bfea87f37204a7940523e752056e4a090eaaaab9b47fc826b5" +dependencies = [ + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "malachite-base" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f44099731f17094b07825c88ccb5fbd1bfa1f82fafff7daa33e8b8652db16e" +dependencies = [ + "hashbrown 0.16.1", + "itertools", + "libm", + "ryu", +] + +[[package]] +name = "malachite-bigint" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc58206ba15e9c406e20c95c5f86efa07b12f94080945908e910b3a0faa23fef" +dependencies = [ + "malachite-base", + "malachite-nz", + "num-integer", + "num-traits", + "paste", +] + +[[package]] +name = "malachite-nz" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a137660cdba20f136c8a223125f08088adb4e0b72fbb8466f08c43e31cc0427d" +dependencies = [ + "itertools", + "libm", + "malachite-base", + "wide", +] + +[[package]] +name = "malachite-q" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffcbeed95e34c0fcc3864ccd146e129cbbf7de1513d3afbcfb47c7674c82d94" +dependencies = [ + "itertools", + "libm", + "malachite-base", + "malachite-nz", +] + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "ocs_plugin_api" +version = "0.2.1" +dependencies = [ + "acadrust", + "bincode", + "cargo-lock", + "getrandom 0.2.17", + "interprocess", + "libloading 0.8.9", + "memmap2", + "rkyv", + "serde", + "serde-reflection", + "serde_bytes", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opencad-python" +version = "0.1.0" +dependencies = [ + "ocs_plugin_api", + "rustpython-vm", + "serde", + "serde_json", +] + +[[package]] +name = "optional" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978aa494585d3ca4ad74929863093e87cac9790d81fe7aba2b3dc2890643a0fc" + +[[package]] +name = "ordermap" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a756ddcdeb6c0efc8927bdf0ed112885815a35ba350472a467be5104f01ca1" +dependencies = [ + "indexmap", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pmutil" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radium" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1775bc532a9bfde46e26eba441ca1171b91608d14a3bae71fea371f18a00cffe" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "result-like" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bffa194499266bd8a1ac7da6ac7355aa0f81ffa1a5db2baaf20dd13854fd6f4e" +dependencies = [ + "result-like-derive", +] + +[[package]] +name = "result-like-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d3b03471c9700a3a6bd166550daaa6124cb4a146ea139fb028e4edaa8f4277" +dependencies = [ + "pmutil", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-codegen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12e4fc1331357a7afc6d52d637796a946b1efaaca07c914132f8fd4fd69d595f" +dependencies = [ + "ahash 0.8.12", + "bitflags", + "indexmap", + "itertools", + "log", + "malachite-bigint", + "memchr", + "num-complex", + "num-traits", + "rustpython-compiler-core", + "rustpython-literal", + "rustpython-ruff_python_ast", + "rustpython-ruff_text_size", + "rustpython-wtf8", + "thiserror 2.0.20", + "unicode_names2 2.0.0", +] + +[[package]] +name = "rustpython-common" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f3c0808a170ea900e0489a06d6aa04ce5d27d62b347836c2f98df958f47f243" +dependencies = [ + "ascii", + "bitflags", + "cfg-if", + "getrandom 0.3.4", + "itertools", + "libc", + "lock_api", + "malachite-base", + "malachite-bigint", + "malachite-q", + "nix", + "num-complex", + "num-traits", + "radium 1.1.1", + "rustpython-literal", + "rustpython-wtf8", + "siphasher", + "unicode_names2 2.0.0", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-compiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fa83b852bbc5461c5214060099d7597785d201aa0bf1a87d77967518f60144" +dependencies = [ + "rustpython-codegen", + "rustpython-compiler-core", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_parser", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "thiserror 2.0.20", +] + +[[package]] +name = "rustpython-compiler-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f18ba67b55aec49c3e2d223bbdcacff0d7e0f5d304fb0e64b044b8dca13c7c" +dependencies = [ + "bitflags", + "bitflagset", + "itertools", + "lz4_flex", + "malachite-bigint", + "num-complex", + "rustpython-ruff_source_file", + "rustpython-wtf8", +] + +[[package]] +name = "rustpython-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6c04350bee9e54b241b93675a0558c2b33fe982213e47a6da0924898af0f2" +dependencies = [ + "rustpython-compiler", + "rustpython-derive-impl", + "syn 2.0.119", +] + +[[package]] +name = "rustpython-derive-impl" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322d64ea8a21d52cd769db0f6190b6e7c17963b13c8c17f39d7364e68af96731" +dependencies = [ + "itertools", + "maplit", + "proc-macro2", + "quote", + "rustpython-compiler-core", + "rustpython-doc", + "syn 2.0.119", + "syn-ext", + "textwrap", +] + +[[package]] +name = "rustpython-doc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f286d8b5872aa53dceb7a8d8c6a29fe85150f0a137fecf1c9a3e0a8345cbc19b" +dependencies = [ + "phf 0.13.1", +] + +[[package]] +name = "rustpython-literal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb46f7afce00e4797f82062e09881ec5e7226d858104bf4a8a9c3b996de2b4a" +dependencies = [ + "hexf-parse", + "is-macro", + "lexical-parse-float", + "num-traits", + "rustpython-wtf8", + "unic-ucd-category", +] + +[[package]] +name = "rustpython-ruff_python_ast" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f021ff72cabf5e2cd6d8ec8813d376a8445a228dc610ab56c27bd9054cda70d4" +dependencies = [ + "aho-corasick", + "bitflags", + "compact_str", + "get-size2", + "is-macro", + "memchr", + "rustc-hash", + "rustpython-ruff_python_trivia", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "thiserror 2.0.20", +] + +[[package]] +name = "rustpython-ruff_python_parser" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e6ee78bd9671fb5766664b2695fe1f2a92a961f4d9101646c570d8acdb1e0b" +dependencies = [ + "bitflags", + "bstr", + "compact_str", + "get-size2", + "memchr", + "rustc-hash", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_trivia", + "rustpython-ruff_text_size", + "static_assertions", + "unicode-ident", + "unicode-normalization", + "unicode_names2 1.3.0", +] + +[[package]] +name = "rustpython-ruff_python_trivia" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e7cfd1056f3a02ff0d2d0e4474286ca963260782f878b7b81c1dd87432e682" +dependencies = [ + "itertools", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "unicode-ident", +] + +[[package]] +name = "rustpython-ruff_source_file" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948107aad62ddb12a11fc7bf68a49e52a0b0a3737d415a2505e54f5a9edac737" +dependencies = [ + "memchr", + "rustpython-ruff_text_size", +] + +[[package]] +name = "rustpython-ruff_text_size" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8291ee0f5a779e54ccd4e0151a0c426f8b49a123f99b5b6545db17ccdd4277aa" +dependencies = [ + "get-size2", +] + +[[package]] +name = "rustpython-sre_engine" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad4b7bd82b2a531f7c2ad96864ce11285959fc7d8524f1ba54db8ce3a23a3d2c" +dependencies = [ + "bitflags", + "num_enum", + "optional", + "rustpython-wtf8", +] + +[[package]] +name = "rustpython-vm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1880770161cef896bf9c7e065dae574e3b4c3cf6172e485f1ce86f0483816ab2" +dependencies = [ + "ahash 0.8.12", + "ascii", + "bitflags", + "bstr", + "caseless", + "cfg-if", + "chrono", + "constant_time_eq", + "crossbeam-utils", + "errno", + "exitcode", + "getrandom 0.3.4", + "glob", + "half", + "hex", + "indexmap", + "is-macro", + "itertools", + "junction", + "libc", + "libffi", + "libloading 0.9.0", + "log", + "malachite-bigint", + "memchr", + "nix", + "num-complex", + "num-integer", + "num-traits", + "num_cpus", + "num_enum", + "optional", + "parking_lot", + "paste", + "psm", + "result-like", + "rustix", + "rustpython-codegen", + "rustpython-common", + "rustpython-compiler", + "rustpython-compiler-core", + "rustpython-derive", + "rustpython-literal", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_parser", + "rustpython-ruff_text_size", + "rustpython-sre_engine", + "rustyline", + "scoped-tls", + "scopeguard", + "static_assertions", + "strum", + "strum_macros", + "thiserror 2.0.20", + "timsort", + "uname", + "unic-ucd-bidi", + "unic-ucd-category", + "unic-ucd-ident", + "unicode-casing", + "which", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-wtf8" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada88d2f69ff5516d69e0f3294e9db2ff8ee71a15291b8f3f8584f07ad1ca28d" +dependencies = [ + "ascii", + "bstr", + "itertools", + "memchr", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-reflection" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af0fdf9c543af37502581e3d6cf4e6cd1628d6616d6a1f8f4ca88aae3a405f5c" +dependencies = [ + "erased-discriminant", + "once_cell", + "serde", + "serde_json", + "thiserror 1.0.69", + "typeid", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-ext" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b126de4ef6c2a628a68609dd00733766c3b015894698a438ebdf374933fc31d1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "timsort" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639ce8ef6d2ba56be0383a94dd13b92138d58de44c62618303bb798fa92bdc00" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-bidi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1d568b51222484e1f8209ce48caa6b430bf352962b877d592c29ab31fb53d8c" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-category" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8d4591f5fcfe1bd4453baaf803c40e1b1e69ff8455c47620440b46efef91c0" +dependencies = [ + "matches", + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-casing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061dbb8cc7f108532b6087a0065eff575e892a4bcb503dc57323a197457cc202" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf 0.11.3", + "unicode_names2_generator 1.3.0", +] + +[[package]] +name = "unicode_names2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d189085656ca1203291e965444e7f6a2723fbdd1dd9f34f8482e79bafd8338a0" +dependencies = [ + "phf 0.11.3", + "unicode_names2_generator 2.0.0", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand", +] + +[[package]] +name = "unicode_names2_generator" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1262662dc96937c71115228ce2e1d30f41db71a7a45d3459e98783ef94052214" +dependencies = [ + "phf_codegen", + "rand", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "wide" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/opencad-python/Cargo.toml b/plugins/opencad-python/Cargo.toml new file mode 100644 index 000000000..600cd2d5b --- /dev/null +++ b/plugins/opencad-python/Cargo.toml @@ -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" diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md new file mode 100644 index 000000000..f885cdf0e --- /dev/null +++ b/plugins/opencad-python/PLUGIN.md @@ -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 ` | Evaluate one Python expression and print the result (or error) to the command line. Example: `PY_EVAL 1 + 1`. | +| `PY_RUN ` | Run a `.py` file as a script (statements, not just one expression). Reports an error the same way as `PY_EVAL` (missing file, syntax error, exception) — never a silent no-op. | + +## The `ocs` module + +Pre-bound as `ocs` in both commands — no `import` needed (and for `PY_EVAL`, +none is possible: it compiles in `Mode::Eval`, which only accepts a single +expression, not statements). Phases 1.2 (read), 1.3 (2D write) and part of +1.4 (`Run Script…`) of this plugin's `ROADMAP.md`. + +| Function | Returns | +|----------|---------| +| `ocs.selection()` | List of the selected entities' handles (`int`), as of the last selection change — see note below. | +| `ocs.get(handle)` | Dict of `{handle, kind, layer, point}` for that entity, or `None` if it doesn't exist. `point` is `{x, y, z}` or `None` for non-point entities. | +| `ocs.get_text(handle)` | For single-line TEXT, returns its active alignment point, OCS normal, rotation, direction, and height; otherwise `None`. Reads the host's full document snapshot. | +| `ocs.text_entities()` | Lists top-level TEXT/MTEXT entities as `{handle, kind, value}` dictionaries from the active document. | +| `ocs.block_references()` | Lists top-level INSERT entities as `{handle, name, effective_name, count, is_xref, is_dynamic, visibility}` dictionaries; `count` includes MINSERT row × column multiplicity and unresolved visibility is `None`. | +| `ocs.layers()` | Lists active-document layer names, off/frozen/locked flags, and top-level entity counts. | +| `ocs.set_entity_layer(handle, layer_name)` | Moves an existing top-level entity to an existing layer, preserving its handle and layout ownership. | +| `ocs.export_layer_dwg(layer_name, output_path)` | Writes a new DWG containing one layer's top-level entities plus needed block definitions; requires an existing directory and refuses overwrite. | +| `ocs.current_layer()` | Returns the active document's current layer name (`CLAYER`). | +| `ocs.layout_for_entity(handle)` | Returns an entity's owning layout name, tab order, and paperspace sheet count; `None` when unresolved. | +| `ocs.viewport_model_outline(handle)` | Returns four XY modelspace corners and elevation for a rectangular, unclipped +Z paperspace viewport; otherwise `None`. | +| `ocs.add_closed_polyline(vertices, elevation)` | Adds a closed lightweight polyline from at least three finite `[x, y]` vertices. | +| `ocs.text_box_vertices(handle, padding)` | Returns an approximate rotated XY box for left/baseline +Z TEXT; otherwise `None`. | +| `ocs.update_closed_polyline(handle, vertices, elevation)` | Replaces a closed lightweight polyline's vertices/elevation, preserving its handle and common properties. | +| `ocs.copy_model_to_paper(handles, viewport_handle)` | Copies planar modelspace LINE/ARC/CIRCLE/LWPolyline geometry through a rectangular +Z viewport to its paperspace layout. | +| `ocs.mirror_xy_entities(handles, axis_handle, erase_source)` | Mirrors planar LINE/ARC/CIRCLE/LWPolyline entities across an existing XY LINE; optionally removes sources in the same undo group. | +| `ocs.mirror_xy_about_line(handles, point, tangent, erase_source)` | Mirrors planar curves across the XY line through a point in a tangent direction; supports curved-axis midpoint workflows. | +| `ocs.break_xy_line(handle, first, second)` | Removes the span between two interior XY points on a LINE, preserving its first fragment's handle and properties. | +| `ocs.break_xy_arc(handle, first, second)` | Removes the angular span between two interior XY ARC points, retaining the first fragment's handle. | +| `ocs.break_xy_circle(handle, first, second)` | Replaces an XY CIRCLE with the complementary ARC after removing the counterclockwise span from first to second. | +| `ocs.break_xy_polyline(handle, first, second)` | Removes a path interval from an open, straight, zero-width XY LWPolyline, keeping the first fragment's handle. | +| `ocs.align_xy_entities(handles, source, destination, source_direction, target_direction, copy_mode)` | Rigidly moves and rotates planar LINE/ARC/CIRCLE/LWPolyline entities in XY; `copy_mode=True` adds transformed copies instead of changing originals. | +| `ocs.block_insert_point(handle)` | Returns `[x, y, z]` for an INSERT, or `None`. | +| `ocs.block_bounds_xy(handle)` | Returns `[xmin, ymin, xmax, ymax]` for a simple unrotated single INSERT whose definition contains only LINE/ARC/CIRCLE/LWPolyline geometry; otherwise `None`. | +| `ocs.block_attributes(handle)` | Lists `{tag, value}` pairs for one INSERT, or `None` for another entity. | +| `ocs.set_block_attribute(handle, tag, value)` | Updates one INSERT attribute by case-insensitive tag, preserving the block's other data. | +| `ocs.set_text_value(handle, value)` | Replaces one TEXT/MTEXT entity's content, preserving other properties. Raises if the entity cannot be updated. | +| `ocs.set_mtext_mask(handle, mode, scale)` | Sets one MTEXT background mode (`off`, `mask`, or `fill`) and border scale; raises if invalid or not MTEXT. | +| `ocs.move_text(handle, x, y, z)` | Moves only a TEXT entity's active alignment point, preserving its other properties. Raises if it cannot update the entity. | +| `ocs.set_text_pose(handle, x, y, z, rotation)` | Moves and rotates one XY TEXT entity while preserving its other properties. | +| `ocs.get_area(handle)` | Returns `{area, point}` for a circle or straight, closed lightweight polyline in the world XY plane; otherwise `None`. `point` is suitable for an area-number label. | +| `ocs.get_curve_measure(handle)` | Returns `{length, point, rotation, tangent}` at half length for an XY line, circle, arc, or straight lightweight polyline; `tangent` is an XY unit vector. Otherwise `None`. | +| `ocs.line_endpoints(handle)` | Returns two `[x, y, z]` points for a nonzero XY LINE; otherwise `None`. | +| `ocs.polyline_segments(handle)` | Returns segment endpoint, width, length, and optional bulge-arc centre/radius dictionaries for an XY lightweight polyline; otherwise `None`. | +| `ocs.write_new_text_file(path, content)` | Writes a new UTF-8 report file at an explicit path during a `PY_` command. Refuses to overwrite or create parent directories. | +| `ocs.curve_samples(handle, segments)` | Returns equally spaced XY sample vertices, elevation, and closure for a line, arc, or circle; otherwise `None`. | +| `ocs.replace_with_polyline(handle, vertices, elevation, closed)` | Replaces a line, arc, or circle in place with a lightweight polyline, preserving the handle and common drawing properties. | +| `ocs.add_line(x1, y1, x2, y2)` | Adds a 2D line (z=0, layer `"0"`); returns its handle. | +| `ocs.add_circle(x, y, radius)` | Adds a 2D circle (z=0, layer `"0"`); returns its handle. | +| `ocs.add_arc(x, y, radius, start_deg, end_deg)` | Adds a 2D arc (z=0, layer `"0"`), angles in degrees; returns its handle. | +| `ocs.add_polyline(vertices, elevation)` | Adds an open 2D lightweight polyline. Each vertex is `[x, y, start_width, end_width]`; returns its handle. | +| `ocs.add_text_centered(value, x, y, z, height, rotation_radians)` | Adds middle-center aligned single-line text; returns its handle. | +| `ocs.add_text_left(value, x, y, z, height, rotation_radians)` | Adds left/baseline single-line text at its insertion point; returns its handle. | +| `ocs.add_mtext(value, point, height, width, rotation_radians)` | Adds top-left anchored MTEXT at `[x, y, z]` using Standard style; returns its handle. | +| `ocs.add_points(points)` | Adds many 3D points (each `[x, y, z]`) in one `HostApi::add_entities` batch call; returns the new handles in order. | +| `ocs.remove_entity(handle)` | Deletes one entity in the script's undo group; raises if the handle cannot be removed. | +| `ocs.add_table(rows, x, y, z, row_height, column_width)` | Adds a native table from a rectangular list of string rows; returns its handle. | +| `ocs.read_record(handle, app_name)` | Returns `{app_name, values}` for the XDATA record `app_name` on `handle`, or `None` if the entity or the record doesn't exist. See "XDATA" below for the `values` shape. | +| `ocs.write_record(handle, app_name, values)` | Attaches an XDATA record to `handle` for `app_name`, replacing any existing record for that application. Raises if `handle` doesn't exist. | +| `ocs.remove_record(handle, app_name)` | Removes the XDATA record for `app_name` from `handle`, if any. Returns `True` if a record was actually removed. | +| `ocs.command(cmd)` | Experimental only; absent from the portable default build. Requires unreleased host API support and can hang on OCS 2026.37. See historical notes below. | +| `ocs.select(handles)` | Experimental only; absent from the portable default build. Requires unreleased host API support. | +| `ocs.system_variable(name)` / `ocs.set_system_variable(name, value)` | Development-only `experimental-host-settings` feature. Reads or sets host-managed CLAYER (text) and SNAPANG (degrees) without nested command dispatch. Requires the Felix OCS fork's `plugin/host-model-api` branch; absent from the portable default build. | + +`add_line`/`add_circle`/`add_arc` take plain Python numbers — +`ocs.add_line(0, 0, 10, 10)` works with ints, not just floats. (Needed an +explicit fix: a bare `f64` `#[pyfunction]` parameter in RustPython, unlike +CPython's C-function argument parsing, does *not* implicitly coerce an +`int` — it raises `TypeError`. Using `rustpython_vm::function::ArgIntoFloat` +instead accepts anything with `__float__`/`__index__`, matching how a script +author actually writes coordinates.) + +### Historical command-replay experiment (not in the default build) + +The following records an earlier experiment. The bundled build does **not** +expose `ocs.command()` or `ocs.select()`. An OCS 2026.37 diagnostic found +nested `ocs.command("CLAYER ...")` could hang, so these +examples are not instructions to run against the released host. + +**`ocs.command()` + `ocs.select()` together can apply a real, persistent +constraint, entirely from a script** — this required two host changes +(`HostApi::run_command` and `HostApi::set_selection`, `ocs_plugin_api` +v0.2.1+ built from a worktree that isn't upstream yet — see the note at the +bottom of this file), not something achievable from the plugin alone. +Verified end-to-end against a real running host, not just "it compiles": +```python +ocs.select([line1_handle, line2_handle]) +ocs.command("PCONSTRAINT") +``` +run as a plain `.py` file via `PY_RUN` — no automation-API help, no manual +pre-selection — ran the actual geometric solver and visibly adjusted both +lines' coordinates to be genuinely parallel (checked numerically: +direction-vector cross product ≈ 0 after, nonzero before). A real, +persistent `PCONSTRAINT` object. Same result verified for `TCONSTRAINT` +(circle-to-line tangency): center-to-line distance equals the radius +exactly after. `examples/example.py` does both — its "parallel" lines and +"tangent" circle are real constraint objects, not geometry computed once. + +Why two calls, not one: constraint commands read a prior *selection* (a +`"Select objects:"` prompt), not picks fed as command-line tokens the way +`LINE`'s points are — feeding `ocs.command("PCONSTRAINT {h1} {h2}")` with +handles as trailing tokens starts the command but ends in "Command +cancelled", not a constraint. `ocs.select()` sets that selection state +first; `ocs.command()` then reads it. + +**Not every command completes cleanly this way — `ALIGN` is the found +counterexample.** `ocs.command()`'s point-feeding sends exactly *one* +trailing Enter after the fed tokens, to finish the command. `ALIGN` needs +two after its point pairs (skip the optional 3rd source/destination pair, +then answer a "Scale objects? [Yes/No]" question) — so +`ocs.command("ALIGN 0,0 20,20 2,0 22,22")` **does** apply the actual +move/rotate transform correctly (verified: the entities land at the right +transformed coordinates), but leaves that Scale prompt open instead of +cleanly finishing the command. Not attempted in `examples/example.py` — +shipping something that half-completes seemed worse than leaving it out. +A general fix would need `ocs.command()` to accept a trailing-Enter *count* +(or an explicit terminator convention) instead of always exactly one; +not implemented. + +**`ocs.command()` cannot invoke itself or any other plugin's command** — +only built-ins. This call is a nested plugin→host request arriving while the +plugin's own `dispatch()` is still running (its one runner thread is +blocked waiting for this exact call to return); routing it back through +normal plugin dispatch would hand that same plugin (or any plugin) a second +`Dispatch` request it has no free thread to answer, deadlocking both sides. +Found this the hard way — `ocs.command("LINE 0,0 10,10")` hung completely +before the host-side fix. `run_command` skips plugin dispatch entirely, by +design, not as an oversight. + +### Which built-in commands `ocs.command()` can reach + +`ocs.command()` goes through the exact same dispatcher as the interactive +command line — `HostApi::run_command` → the host's +`run_command_line_no_plugin_reentry` → `dispatch_command_inner(cmd, +allow_suggest=false, try_plugins=false)` — so almost everything typeable in +OCS also works from a script, minus one deliberate carve-out. + +**Reachable: the full built-in command set**, spread across ten dispatch +families in the host's `src/app/commands/` (`fileops`, `layers`, `blocks`, +`draw`, `dim`, `inquiry`, `view`, `layerprops`, `styleprops`, `display`). +That covers everything AutoCAD-compatible the host implements natively — +drawing entities and constraints (`LINE`, `CIRCLE`, `ARC`, `POLYLINE`, +`MOVE`, `ROTATE`, `ALIGN`, `PCONSTRAINT`, `TCONSTRAINT`, in `dispatch_draw`), +dimensions (`DIMLINEAR`, `DIMALIGNED`, `DIMSTYLE`, ...), blocks (`BLOCK`, +`INSERT`, `WBLOCK`, `ATTSYNC`, ...), layers/properties (`LAYER`, `LAYISO`, +`LAYDEL`, `CHPROP`, `PROPERTIES`, ...), file ops (`NEW`, `OPEN`, `SAVE`, +`SAVEAS`, `EXPORT...`, `PLOT`, ...), inquiry (`LIST`, `DIST`, `AREA`, +`DBLIST`, ...), view (`ZOOM`, `PAN`, `VIEW`, `REGEN`, ...), and +directly-settable system variables typed as commands (`PICKBOX 5`, +`MIRRTEXT 1`, `LUPREC 4`, ...). Aliases still resolve too (`ocs.command("L +0,0 10,10")` works the same as `LINE`), since alias expansion happens before +the plugin-skip. + +**Not reachable: any plugin-provided command, including this plugin's own** +(`PYTHONSHELL`, `PY_EVAL`, `PY_RUN`) — see the paragraph above for why. This +is the *only* restriction versus the interactive command line; there is no +narrower allow-list beyond it. `allow_suggest=false` also means no +autocomplete/fuzzy-matching of partial verbs (unlike typing `BAC` and +pressing Enter for `BACKGROUND`) — a script must spell out the exact command +name or a real alias. + +Usage notes already verified end-to-end (see the constraint example above +and `examples/example.py`): + +- Multi-point/interactive commands take their args inline on one string, + exactly as if typed then Enter: `ocs.command("LINE 0,0 10,10")`. +- A command that reads a *prior selection* rather than inline picks + (constraint commands are the concrete case) needs `ocs.select(handles)` + called first — `ocs.command()` alone can't feed a "Select objects:" + prompt. +- `ALIGN` needs two trailing Enters (skip the optional 3rd point pair, then + answer the Scale prompt); `ocs.command()` only ever sends one, so it + applies the transform but leaves that prompt dangling — see above. + +For the exhaustive, exact list rather than this summary, the source of +truth is the `inventory::submit!(CommandRegistration { names: &[...] })` +blocks scattered across the host's `src/app/commands/*.rs` (105 files +register at least one command as of this writing); the largest single block +— everything without its own interactive command module — is +`src/app/commands/mod.rs`'s command-line autocomplete registry. Since it's +the same dispatcher, OCS's own command-line autocomplete is also a live +index of what `ocs.command()` can run. + +**One `push_undo` group per script, not per `ocs.add_*` call.** A `PY_RUN` +script that calls `ocs.add_line` ten times undoes as one step. Verified +end-to-end: a two-line script undoes both lines in a single `undo`. +Implemented via `host_ctx::ensure_undo_started`, called lazily by the first +write in a given `PY_EVAL`/`PY_RUN` — a read-only script never touches the +undo stack. Note: `ocs.command()` does *not* go through this — a built-in +command manages its own undo the same way it would if typed, so `ocs.command` +calls are not folded into the script's shared undo group. + +**`ocs.selection()` is a cache, not a live query.** `HostApi` has no +synchronous "give me the current selection" call — only a best-effort +`SelectionChanged` / `SelectionChangedV4` notification. It reflects the +selection as of the last time it changed, not necessarily the instant the +script calls it. + +That cache is populated via `BuiltinPlugin::on_notification`, **not** by +calling `HostApi::try_recv_notification()` from `dispatch()`. Found the hard +way: `ocs_plugin_api::runner`'s own V4 event loop (`run_v4` in `runner.rs`) +drains that queue itself on every iteration and forwards each notification to +`on_notification` *before* a `Dispatch` request ever reaches `dispatch()` — so +polling it from inside `dispatch` always finds it empty. This plugin +overrides `on_notification` and stashes the latest selection in a +process-wide cache (`selection_cache.rs`); `ocs.selection()` reads that. +Verified end-to-end against a real host build: select an entity, then +`PY_EVAL ocs.selection()` returns its handle. + +Each `PY_EVAL`/`PY_RUN` runs in a fresh interpreter (no stdlib, no session +state across calls) — REPL/session persistence is an open question for a +later phase. + +**Typing or pasting `PY_EVAL`/`PY_RUN` directly into the interactive command +line is broken for anything beyond trivial arithmetic — a host issue, not +this plugin.** `Message::CommandInput` in `src/app/update/mod.rs` (this +repo) uppercases the *entire* input line on every keystroke *and* on paste, +and auto-submits the instant it contains a space (both deliberate, for +classic single-line CAD commands like `LINE 0,0 10,10`). That mangles case +(`ocs.add_line` → `OCS.ADD_LINE`, a `NameError`) and truncates arguments +(`PY_EVAL ` submits before you finish typing the expression) for *any* +multi-word, case-sensitive plugin argument — automation +(`ocs_execute`/`--mcp`) bypasses this UI path entirely, which is why it +never surfaced in this session's own testing until a live user tried typing +it by hand. **`Run Script…` (below) sidesteps this correctly** — the host +authors already solved the identical problem for file paths via +`ModuleEvent::PluginFileDialog`, which dispatches with original case intact. +There is no equivalent bypass for `PY_EVAL` today; typed/pasted `PY_EVAL` +only survives if it has no spaces and no case-sensitive identifiers. + +## Ribbon + +Two tools in the "Tools" group: + +| Tool | Behavior | +|------|----------| +| Eval 1+1 | Dispatches `PY_EVAL 1 + 1` directly — a smoke test. | +| Run Script… | `ModuleEvent::PluginFileDialog` opens a native "pick a `.py` file" dialog; on selection the host dispatches `PY_RUN ` back to the plugin. Cancel does nothing. | + +**A real script manager UI (list/run/save scripts, or a dockable REPL +console) is not possible with today's `ocs_plugin_api`.** This was an open +question in `ROADMAP.md` — now answered by reading +`ribbon.rs`: `CadModule` exposes exactly one hook, `ribbon_groups()`, which +returns a **static** set of ribbon buttons/dropdowns (`RibbonItem`). There is +no API for a plugin to host a persistent custom panel, list dynamic content, +or render arbitrary UI — the ribbon vocabulary is deliberately "plain data +the host renders," not a widget toolkit. Getting a script list or REPL panel +would need a new `HostApi`/`CadModule` capability, i.e. an `ocs_plugin_api` +version bump — a host-side feature, out of scope for a plugin repo. `Run +Script…` (above) is the most a plugin can do today: reuse the host's own +native file picker via the existing `PluginFileDialog` event, which was +already there for other plugins to import files. + +## lisp2py — AutoLISP macro transpiler + +`../lisp2py/` (an independent sibling workspace — zero dependency on this +crate or `ocs_plugin_api`) is a **best-effort, offline, source-to-source** +translator from AutoLISP (`.lsp`) to the `ocs.*` Python dialect documented +above. Not an interpreter: it never executes AutoLISP, and its output is a +draft a human reviews and finishes, then runs through `PY_RUN` like any +other script. This is Phase 2.2/2.3 of `ROADMAP.md` — see +[`../lisp2py/README.md`](../lisp2py/README.md) +for the full pipeline, the corpus-measured builtin coverage, and every +known limitation; this is just the quick-start. + +```sh +cargo run --manifest-path ../lisp2py/Cargo.toml --bin ocs-lisp2py -- macro.lsp > macro.py +# then, inside OCS: +PY_RUN macro.py +``` + +(Or pipe source on stdin: `cat macro.lsp | cargo run --manifest-path ../lisp2py/Cargo.toml --bin +ocs-lisp2py -- > macro.py`.) + +Anything the translator can't confidently map — an unrecognized builtin, a +construct with no Python equivalent — becomes a `_todo_manual_port(...)` +call in the generated file (raises `NotImplementedError` if actually run) +plus a warning printed to stderr, never a silent guess. `vla-*`/`vlax-*` +(ActiveX/COM) and `vlr-*` (reactors) are explicitly out of scope, for the +same reason as everywhere else in this plugin's design: COM doesn't run +under AutoCAD for Mac either. Builtin coverage was widened using measured +call-frequency data from a real AutoLISP corpus sample rather than +guesswork, biased toward 2D geometry (`angle`/`distance`/`polar`) over +3D/solid-modeling per current project priority. + +## XDATA + +`ocs.read_record`/`ocs.write_record`/`ocs.remove_record` wrap +`HostApi::read_record`/`write_record`/`remove_record` — already implemented +by the host (unlike `run_command`/`set_selection`, these needed no host +change or `[patch]` entry). A record is `{"app_name": str, "values": [...]}`; +each value is `{"kind": str, "value": ...}`. Supported kinds and their +Python `value` shape: + +| `kind` | Python `value` | +|---|---| +| `String`, `ControlString`, `LayerName` | `str` | +| `BinaryData` | `bytes` | +| `Handle` | `int` | +| `Point3D`, `Position3D`, `Displacement3D`, `Direction3D` | `[x, y, z]` (`float`) | +| `Real`, `Distance`, `ScaleFactor` | `float` | +| `Integer16`, `Integer32` | `int` | + +```python +ocs.write_record(handle, "MYAPP", [ + {"kind": "String", "value": "part-042"}, + {"kind": "Integer32", "value": 7}, +]) +record = ocs.read_record(handle, "MYAPP") # {"app_name": "MYAPP", "values": [...]} +ocs.remove_record(handle, "MYAPP") +``` + +This `{app_name, values}`/`{kind, value}` shape is deliberately the same one +`schoeller/ocs_python_repl` (a separate, out-of-process PyO3-based Python +plugin for OCS) uses in its `ocs.doc.read_record`/`write_record`, so a +script's XDATA handling ports between the two plugins unchanged. `write_record` +starts the script's shared undo group the same way `ocs.add_*` does (see +below); `remove_record` only starts it when a record actually existed to +remove. + +## Build note: bundled host API + +`Cargo.toml` uses the repository-relative `ocs_plugin_api` crate. Packaging +builds the plugin with the same compiler and CAD codec as the host and stages +matching metadata beside the library. The `experimental-command-replay` Cargo +feature keeps the old bridge code available, but the tested nested-command +path can hang. Do not enable it for production. diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md new file mode 100644 index 000000000..10da6d43c --- /dev/null +++ b/plugins/opencad-python/README.md @@ -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. diff --git a/plugins/opencad-python/ROADMAP.md b/plugins/opencad-python/ROADMAP.md new file mode 100644 index 000000000..1d13796d7 --- /dev/null +++ b/plugins/opencad-python/ROADMAP.md @@ -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 ` 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 `": "`, 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 ` 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 "}` 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 ` 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/ | diff --git a/plugins/opencad-python/build.rs b/plugins/opencad-python/build.rs new file mode 100644 index 000000000..0fe185325 --- /dev/null +++ b/plugins/opencad-python/build.rs @@ -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"); +} diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs new file mode 100644 index 000000000..9867780a2 --- /dev/null +++ b/plugins/opencad-python/build/generate.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, + /// `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, + #[serde(default)] + pub overrides: std::collections::BTreeMap, +} + +fn default_base_fields() -> Vec { + 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, + /// 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, +} + +#[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, + /// 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, + /// 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, + /// 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 { + let mut needed: BTreeSet = BTreeSet::new(); + let mut queue: Vec = 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 = 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` 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` 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::(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::(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::(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` 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::>>()?; 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`, or `Option`). +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 = v.try_into_value(vm)?; items.into_iter().map(|item| {inner}).collect::>>()? }} }}" + ) + } 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 {{ + match value {{ +{to_arms} }} +}} + +fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{ + let dict = value.try_into_value::(vm)?; + let kind = dict.get_item("kind", vm)?.try_into_value::(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 {{ + 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::(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` 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 { + 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 {{ + 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 {{ + let kind = dict.get_item("kind", vm)?.try_into_value::(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::(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::(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 {{ + 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 +} diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json new file mode 100644 index 000000000..43b71be06 --- /dev/null +++ b/plugins/opencad-python/entity_manifest.json @@ -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::(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::(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::(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::(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::(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 } + } + } + } +} diff --git a/plugins/opencad-python/examples/create_layout_fixture.rs b/plugins/opencad-python/examples/create_layout_fixture.rs new file mode 100644 index 000000000..26e61e341 --- /dev/null +++ b/plugins/opencad-python/examples/create_layout_fixture.rs @@ -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> { + 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(()) +} diff --git a/plugins/opencad-python/examples/example.py b/plugins/opencad-python/examples/example.py new file mode 100644 index 000000000..464ffc8f9 --- /dev/null +++ b/plugins/opencad-python/examples/example.py @@ -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 ") 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 diff --git a/plugins/opencad-python/plugin.toml b/plugins/opencad-python/plugin.toml new file mode 100644 index 000000000..ad4188a5e --- /dev/null +++ b/plugins/opencad-python/plugin.toml @@ -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 = [] diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py new file mode 100644 index 000000000..1e5d4281d --- /dev/null +++ b/plugins/opencad-python/src/document_model.py @@ -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() diff --git a/plugins/opencad-python/src/event_cache.rs b/plugins/opencad-python/src/event_cache.rs new file mode 100644 index 000000000..780fba65e --- /dev/null +++ b/plugins/opencad-python/src/event_cache.rs @@ -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>> = 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, + }, + Command { + tab_id: u64, + command: Option, + }, + 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, + 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 { + 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 + }, + ] + ); + } +} diff --git a/plugins/opencad-python/src/host_ctx.rs b/plugins/opencad-python/src/host_ctx.rs new file mode 100644 index 000000000..344f36362 --- /dev/null +++ b/plugins/opencad-python/src/host_ctx.rs @@ -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> = const { Cell::new(None) }; + static UNDO_LABEL: Cell<&'static str> = const { Cell::new("") }; + static UNDO_STARTED: Cell = 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(f: impl FnOnce(&mut dyn HostApi) -> R) -> Option { + 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 })) + }) +} diff --git a/plugins/opencad-python/src/input_cache.rs b/plugins/opencad-python/src/input_cache.rs new file mode 100644 index 000000000..dd839835b --- /dev/null +++ b/plugins/opencad-python/src/input_cache.rs @@ -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>> = 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 { + 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()); + } +} diff --git a/plugins/opencad-python/src/lib.rs b/plugins/opencad-python/src/lib.rs new file mode 100644 index 000000000..1603fbfe0 --- /dev/null +++ b/plugins/opencad-python/src/lib.rs @@ -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 ` to evaluate one inline Python +//! expression. Phase 1.3: 2D write access (`ocs.add_line`/`ocs.add_circle`) +//! plus `PY_RUN ` 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> = 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 { + 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, 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, + "".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 `) — 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 { + 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, + "".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, + "".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 { + 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 { + 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)) +} + +/// `": "`, 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); diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs new file mode 100644 index 000000000..086afb826 --- /dev/null +++ b/plugins/opencad-python/src/ocs_module.rs @@ -0,0 +1,4865 @@ +//! The `ocs` Python module: read-only entity access (Phase 1.2) plus 2D write +//! access (Phase 1.3) of `docs/python-scripting-roadmap.md`. + +pub(crate) use ocs::module_def; + +#[rustpython_vm::pymodule] +mod ocs { + use std::io::Write; + + #[cfg(feature = "experimental-host-settings")] + use ocs_plugin_api::host::HostSettingValue; + #[cfg(feature = "experimental-host-settings")] + use rustpython_vm::convert::TryFromObject; + use rustpython_vm::{function::ArgIntoFloat, PyObjectRef, PyResult, VirtualMachine}; + + use acadrust::entities::{ + Arc, LwPolyline, LwVertex, MText, TableBuilder, Text, TextHorizontalAlignment, + TextVerticalAlignment, + }; + use acadrust::objects::ObjectType; + use acadrust::types::{Transform, Vector2, Vector3}; + use ocs_plugin_api::host::{ + acadrust, EntityType, ExtendedDataRecord, Handle, ReaderEntityKind, XDataValue, + }; + + use crate::host_ctx; + + /// Write one new UTF-8 report file. The caller must provide an explicit + /// path; existing files are never overwritten and parent directories are + /// not created. + #[pyfunction] + fn write_new_text_file(path: String, content: String, vm: &VirtualMachine) -> PyResult<()> { + if host_ctx::with_host(|_| ()).is_none() { + return Err(vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())); + } + if path.is_empty() { + return Err(vm.new_value_error("report path must not be empty".to_owned())); + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|error| { + vm.new_runtime_error(format!("cannot create report {path}: {error}")) + })?; + if let Err(error) = file.write_all(content.as_bytes()) { + return Err(vm.new_runtime_error(format!( + "cannot finish report {path}: {error}; a partial file may remain" + ))); + } + Ok(()) + } + + fn top_level_blocks(document: &acadrust::CadDocument) -> std::collections::HashSet { + let mut blocks: std::collections::HashSet = document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) if !layout.block_record.is_null() => { + Some(layout.block_record) + } + _ => None, + }) + .collect(); + if !document.header.model_space_block_handle.is_null() { + blocks.insert(document.header.model_space_block_handle); + } + if let Some(model) = document.block_records.get("*Model_Space") { + blocks.insert(model.handle); + } + blocks + } + + /// Handles of the entities selected as of the last `SelectionChangedV4` + /// notification (see `selection_cache.rs` — a best-effort cache, not a + /// live query). + #[pyfunction] + fn selection(vm: &VirtualMachine) -> PyResult { + #[cfg(feature = "experimental-host-model")] + let handles = host_ctx::with_host(|host| host.selection()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + #[cfg(not(feature = "experimental-host-model"))] + let handles = crate::selection_cache::get(); + let items = handles + .into_iter() + .map(|h| vm.new_pyobj(h.value())) + .collect(); + Ok(vm.ctx.new_list(items).into()) + } + + /// Drain best-effort drawing, selection, and command-state notifications. + /// Events accumulated since the previous poll, up to a bounded queue. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn poll_events(tab_id: u64, vm: &VirtualMachine) -> PyResult { + use crate::event_cache::Event; + let current_tab = host_ctx::with_host(|host| host.tab_id()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + if tab_id != current_tab { + return Err( + vm.new_value_error("ocs.poll_events: tab_id is not the active document".to_owned()) + ); + } + let mut items = Vec::new(); + for event in crate::event_cache::drain_for(tab_id) { + let row = vm.ctx.new_dict(); + match event { + Event::Drawing { tab_id, version } => { + row.set_item("type", vm.new_pyobj("drawing"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("version", vm.new_pyobj(version), vm)?; + } + Event::Selection { tab_id, handles } => { + row.set_item("type", vm.new_pyobj("selection"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item( + "handles", + vm.ctx + .new_list(handles.into_iter().map(|h| vm.new_pyobj(h)).collect()) + .into(), + vm, + )?; + } + Event::Command { tab_id, command } => { + row.set_item("type", vm.new_pyobj("command"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("command", vm.new_pyobj(command), vm)?; + } + Event::Overflow { tab_id, dropped } => { + row.set_item("type", vm.new_pyobj("overflow"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("dropped", vm.new_pyobj(dropped), vm)?; + } + } + items.push(row.into()); + } + Ok(vm.ctx.new_list(items).into()) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn tab_id(vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| host.tab_id()) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Start a host-driven point or entity pick. Read the result in a later + /// script invocation with `poll_input(token)`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn request_input(prompt: String, entity: bool, vm: &VirtualMachine) -> PyResult { + if prompt.trim().is_empty() { + return Err(vm.new_value_error("prompt must not be empty".to_owned())); + } + host_ctx::with_host(|host| { + let (token, command) = crate::input_cache::begin(host.tab_id(), prompt, entity); + host.start_interactive(Box::new(command)); + token + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn poll_input(token: u64, vm: &VirtualMachine) -> PyResult { + use crate::input_cache::PickResult; + let tab_id = host_ctx::with_host(|host| host.tab_id()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(result) = crate::input_cache::take(tab_id, token) else { + return Ok(vm.ctx.none()); + }; + let row = vm.ctx.new_dict(); + match result { + PickResult::Pending => row.set_item("status", vm.new_pyobj("pending"), vm)?, + PickResult::Cancelled => row.set_item("status", vm.new_pyobj("cancelled"), vm)?, + PickResult::Point(point) => { + row.set_item("status", vm.new_pyobj("point"), vm)?; + row.set_item( + "point", + vm.ctx + .new_list(point.into_iter().map(|v| vm.new_pyobj(v)).collect()) + .into(), + vm, + )?; + } + PickResult::Entity { handle, point } => { + row.set_item("status", vm.new_pyobj("entity"), vm)?; + row.set_item("handle", vm.new_pyobj(handle), vm)?; + row.set_item( + "point", + vm.ctx + .new_list(point.into_iter().map(|v| vm.new_pyobj(v)).collect()) + .into(), + vm, + )?; + } + } + Ok(row.into()) + } + + /// Read-only geometry/properties for one entity handle, or `None` if it + /// doesn't exist. Backed by `HostApi::document_reader()` (zero-copy read). + #[pyfunction] + fn get(handle: u64, vm: &VirtualMachine) -> PyResult { + let target = Handle::new(handle); + let found = host_ctx::with_host(|host| { + let reader = host.document_reader(); + let mut found = None; + reader.for_each_entity(&mut |entity| { + if found.is_none() && entity.handle == target { + found = Some((entity.kind, entity.layer_name.to_string(), entity.point)); + } + }); + found + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + + let Some((kind, layer, point)) = found else { + return Ok(vm.ctx.none()); + }; + + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("kind", vm.new_pyobj(kind_name(kind)), vm)?; + dict.set_item("layer", vm.new_pyobj(layer), vm)?; + let point_obj = match point { + Some(p) => { + let pd = vm.ctx.new_dict(); + pd.set_item("x", vm.new_pyobj(p.x), vm)?; + pd.set_item("y", vm.new_pyobj(p.y), vm)?; + pd.set_item("z", vm.new_pyobj(p.z), vm)?; + pd.into() + } + None => vm.ctx.none(), + }; + dict.set_item("point", point_obj, vm)?; + Ok(dict.into()) + } + + /// Full single-line TEXT geometry from the host's document snapshot. + /// `point` is the active DXF alignment point (10 for left/baseline, + /// otherwise 11), in the entity's OCS coordinate system. + #[pyfunction] + fn get_text(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Text(text)) => Some(text.clone()), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(text) = found else { + return Ok(vm.ctx.none()); + }; + let point = active_text_point(&text); + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + dict.set_item( + "normal", + vm.ctx + .new_list(vec![ + vm.new_pyobj(text.normal.x), + vm.new_pyobj(text.normal.y), + vm.new_pyobj(text.normal.z), + ]) + .into(), + vm, + )?; + dict.set_item("rotation", vm.new_pyobj(text.rotation), vm)?; + dict.set_item( + "direction", + vm.ctx + .new_list(vec![ + vm.new_pyobj(text.rotation.cos()), + vm.new_pyobj(text.rotation.sin()), + ]) + .into(), + vm, + )?; + dict.set_item("height", vm.new_pyobj(text.height), vm)?; + dict.set_item("value", vm.new_pyobj(text.value), vm)?; + dict.set_item("style", vm.new_pyobj(text.style), vm)?; + Ok(dict.into()) + } + + /// Approximate XY box of left/baseline TEXT, using height and character count. + #[pyfunction] + fn text_box_vertices( + handle: u64, + padding: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let padding = padding.into_float(); + if !padding.is_finite() || padding < 0.0 { + return Err(vm.new_value_error( + "ocs.text_box_vertices: padding must be finite and nonnegative".to_owned(), + )); + } + let found = host_ctx::with_host(|host| { + let Some(EntityType::Text(text)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if text.normal != Vector3::UNIT_Z + || text.horizontal_alignment != TextHorizontalAlignment::Left + || text.vertical_alignment != TextVerticalAlignment::Baseline + || text.value.is_empty() + || ![ + text.height, + text.width_factor, + text.rotation, + text.insertion_point.x, + text.insertion_point.y, + text.insertion_point.z, + ] + .iter() + .all(|value| value.is_finite()) + || text.height <= 0.0 + || text.width_factor <= 0.0 + { + return None; + } + let width = text.value.chars().count() as f64 * text.height * 0.6 * text.width_factor; + let local = [ + (-padding, -padding), + (width + padding, -padding), + (width + padding, text.height + padding), + (-padding, text.height + padding), + ]; + let (sin, cos) = text.rotation.sin_cos(); + let vertices = local.map(|(x, y)| { + [ + text.insertion_point.x + x * cos - y * sin, + text.insertion_point.y + x * sin + y * cos, + ] + }); + Some((vertices, text.insertion_point.z)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((vertices, elevation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + let points = vertices + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point[0]), vm.new_pyobj(point[1])]) + .into() + }) + .collect(); + dict.set_item("vertices", vm.ctx.new_list(points).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + Ok(dict.into()) + } + + /// Add a top-left anchored MTEXT entity in the world XY plane. + #[pyfunction] + fn add_mtext( + value: rustpython_vm::builtins::PyStrRef, + point: Vec, + height: ArgIntoFloat, + width: ArgIntoFloat, + rotation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (height, width, rotation) = ( + height.into_float(), + width.into_float(), + rotation.into_float(), + ); + if point.len() != 3 + || point.iter().any(|v| !v.is_finite()) + || ![height, width, rotation].iter().all(|v| v.is_finite()) + || height <= 0.0 + || width <= 0.0 + { + return Err(vm.new_value_error( + "ocs.add_mtext: coordinates must be finite; height and width must be positive" + .to_owned(), + )); + } + let mut text = MText::with_value( + value.to_string(), + Vector3::new(point[0], point[1], point[2]), + ) + .with_height(height) + .with_width(width); + text.rotation = rotation; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::MText(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Delete one entity in the script's shared undo group. + #[pyfunction] + fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> { + let removed = host_ctx::with_host(|host| { + if host.document().get_entity(Handle::new(handle)).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.remove_entity(Handle::new(handle)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !removed { + return Err(vm.new_runtime_error(format!( + "ocs.remove_entity: entity {handle} was not deleted" + ))); + } + Ok(()) + } + + /// Read the XDATA record for `app_name` on entity `handle`, or `None` if + /// the entity doesn't exist or carries no such record. Each value is a + /// `{"kind": ..., "value": ...}` dict — see `write_record` for the + /// supported kinds. + /// + /// The `{app_name, values}`/`{kind, value}` shape mirrors + /// `schoeller/ocs_python_repl`'s `ocs.doc.read_record(...)` (its `_ocs` + /// PyO3 extension) so a script's XDATA handling ports between the two + /// plugins unchanged. + #[pyfunction] + fn read_record(handle: u64, app_name: String, vm: &VirtualMachine) -> PyResult { + let record = + host_ctx::with_host(|host| host.read_record(Handle::new(handle), &app_name).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(record) = record else { + return Ok(vm.ctx.none()); + }; + xdata_record_to_py(vm, &record) + } + + /// Attach an XDATA record to `handle` for `app_name`, replacing any + /// existing record for the same application. `values` is a list of + /// `{"kind": ..., "value": ...}` dicts; supported kinds: `String`, + /// `ControlString`, `LayerName`, `BinaryData` (Python `bytes`), `Handle` + /// (`int`), `Point3D`/`Position3D`/`Displacement3D`/`Direction3D` + /// (`[x, y, z]`), `Real`/`Distance`/`ScaleFactor` (`float`), and + /// `Integer16`/`Integer32` (`int`). Raises if `handle` does not exist. + #[pyfunction] + fn write_record( + handle: u64, + app_name: String, + values: Vec, + vm: &VirtualMachine, + ) -> PyResult<()> { + let mut record = ExtendedDataRecord::new(app_name); + for value in values { + let dict = value.try_into_value::(vm)?; + record.add_value(py_to_xdata_value(&dict, vm)?); + } + let written = host_ctx::with_host(|host| { + if host.document().get_entity(Handle::new(handle)).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.write_record(Handle::new(handle), record) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !written { + return Err( + vm.new_runtime_error(format!("ocs.write_record: entity {handle} does not exist")) + ); + } + Ok(()) + } + + /// Remove the XDATA record for `app_name` from `handle`, if any. Returns + /// `True` if a record was actually removed. + #[pyfunction] + fn remove_record(handle: u64, app_name: String, vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| { + if host.read_record(Handle::new(handle), &app_name).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.remove_record(Handle::new(handle), &app_name) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + fn xdata_record_to_py( + vm: &VirtualMachine, + record: &ExtendedDataRecord, + ) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item( + "app_name", + vm.new_pyobj(record.application_name.clone()), + vm, + )?; + let mut values = Vec::with_capacity(record.values.len()); + for value in &record.values { + values.push(xdata_value_to_py(vm, value)?); + } + dict.set_item("values", vm.ctx.new_list(values).into(), vm)?; + Ok(dict.into()) + } + + fn xdata_value_to_py(vm: &VirtualMachine, value: &XDataValue) -> PyResult { + let dict = vm.ctx.new_dict(); + let (kind, py_value): (&str, PyObjectRef) = match value { + XDataValue::String(s) => ("String", vm.new_pyobj(s.clone())), + XDataValue::ControlString(s) => ("ControlString", vm.new_pyobj(s.clone())), + XDataValue::LayerName(s) => ("LayerName", vm.new_pyobj(s.clone())), + XDataValue::BinaryData(b) => ("BinaryData", vm.ctx.new_bytes(b.clone()).into()), + XDataValue::Handle(h) => ("Handle", vm.new_pyobj(h.value())), + XDataValue::Point3D(v) => ("Point3D", vector3_to_py(vm, v)), + XDataValue::Position3D(v) => ("Position3D", vector3_to_py(vm, v)), + XDataValue::Displacement3D(v) => ("Displacement3D", vector3_to_py(vm, v)), + XDataValue::Direction3D(v) => ("Direction3D", vector3_to_py(vm, v)), + XDataValue::Real(r) => ("Real", vm.new_pyobj(*r)), + XDataValue::Distance(d) => ("Distance", vm.new_pyobj(*d)), + XDataValue::ScaleFactor(s) => ("ScaleFactor", vm.new_pyobj(*s)), + XDataValue::Integer16(i) => ("Integer16", vm.new_pyobj(*i)), + XDataValue::Integer32(i) => ("Integer32", vm.new_pyobj(*i)), + }; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("value", py_value, vm)?; + Ok(dict.into()) + } + + fn vector3_to_py(vm: &VirtualMachine, v: &Vector3) -> PyObjectRef { + vm.ctx + .new_list(vec![ + vm.new_pyobj(v.x), + vm.new_pyobj(v.y), + vm.new_pyobj(v.z), + ]) + .into() + } + + fn py_to_xdata_value( + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, + ) -> PyResult { + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + let value = dict.get_item("value", vm)?; + match kind.as_str() { + "String" => Ok(XDataValue::String(value.try_into_value(vm)?)), + "ControlString" => Ok(XDataValue::ControlString(value.try_into_value(vm)?)), + "LayerName" => Ok(XDataValue::LayerName(value.try_into_value(vm)?)), + "BinaryData" => Ok(XDataValue::BinaryData(value.try_into_value(vm)?)), + "Handle" => Ok(XDataValue::Handle(Handle::new(value.try_into_value(vm)?))), + "Point3D" => Ok(XDataValue::Point3D(py_to_vector3(value, vm)?)), + "Position3D" => Ok(XDataValue::Position3D(py_to_vector3(value, vm)?)), + "Displacement3D" => Ok(XDataValue::Displacement3D(py_to_vector3(value, vm)?)), + "Direction3D" => Ok(XDataValue::Direction3D(py_to_vector3(value, vm)?)), + "Real" => Ok(XDataValue::Real(value.try_into_value(vm)?)), + "Distance" => Ok(XDataValue::Distance(value.try_into_value(vm)?)), + "ScaleFactor" => Ok(XDataValue::ScaleFactor(value.try_into_value(vm)?)), + "Integer16" => Ok(XDataValue::Integer16(value.try_into_value(vm)?)), + "Integer32" => Ok(XDataValue::Integer32(value.try_into_value(vm)?)), + other => Err(vm.new_value_error(format!( + "unsupported XDATA value kind: {other}; supported: String, ControlString, \ + LayerName, BinaryData, Handle, Point3D, Position3D, Displacement3D, \ + Direction3D, Real, Distance, ScaleFactor, Integer16, Integer32" + ))), + } + } + + fn py_to_vector3(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let coords: Vec = value.try_into_value(vm)?; + if coords.len() != 3 || coords.iter().any(|v| !v.is_finite()) { + return Err(vm.new_value_error( + "XDATA Point3D/Position3D/Displacement3D/Direction3D value must be \ + [finite x, y, z]" + .to_owned(), + )); + } + Ok(Vector3::new(coords[0], coords[1], coords[2])) + } + + /// All top-level single- and multiline text in the current document. + #[pyfunction] + fn text_entities(vm: &VirtualMachine) -> PyResult { + let items = host_ctx::with_host(|host| { + host.document() + .entities() + .filter_map(|entity| match entity { + EntityType::Text(text) => { + Some((text.common.handle.value(), "TEXT", text.value.clone())) + } + EntityType::MText(text) => { + Some((text.common.handle.value(), "MTEXT", text.value.clone())) + } + _ => None, + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(items.len()); + for (handle, kind, value) in items { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("value", vm.new_pyobj(value), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// All top-level block references in the active document. + #[pyfunction] + fn block_references(vm: &VirtualMachine) -> PyResult { + let items = host_ctx::with_host(|host| { + let document = host.document(); + document + .entities() + .filter_map(|entity| match entity { + EntityType::Insert(insert) => { + let raw_name = insert.block_name.clone(); + let record = document + .block_records + .iter() + .find(|block| block.name == raw_name); + let is_xref = record.is_some_and(|block| block.flags.is_xref); + let dynamic_definition = document + .dynamic_definition_for_insert(insert.common.handle) + .and_then(|handle| { + document + .block_records + .iter() + .find(|block| block.handle == handle) + }); + let effective_name = dynamic_definition + .map_or_else(|| raw_name.clone(), |block| block.name.clone()); + let is_dynamic = effective_name != raw_name + || document + .dynamic_visibility_for_insert(insert.common.handle) + .is_some(); + let visibility = document + .dynamic_visibility_for_insert(insert.common.handle) + .and_then(|(definition, parameter)| { + let def_record = document + .block_records + .iter() + .find(|block| block.handle == definition)?; + let anon_record = record?; + let current: std::collections::HashSet = anon_record + .entity_handles + .iter() + .enumerate() + .filter(|(_, handle)| { + document + .get_entity(**handle) + .is_some_and(|entity| !entity.common().invisible) + }) + .map(|(index, _)| index) + .collect(); + parameter + .states + .iter() + .find(|state| { + let visible: std::collections::HashSet = def_record + .entity_handles + .iter() + .enumerate() + .filter(|(_, handle)| { + state.visible_blocks.contains(handle) + }) + .map(|(index, _)| index) + .collect(); + visible == current + }) + .map(|state| state.name.clone()) + }); + Some(( + insert.common.handle.value(), + raw_name, + effective_name, + insert.row_count, + insert.column_count, + is_xref, + is_dynamic, + visibility, + )) + } + _ => None, + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(items.len()); + for (handle, name, effective_name, rows, columns, is_xref, is_dynamic, visibility) in items + { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("effective_name", vm.new_pyobj(effective_name), vm)?; + dict.set_item("is_xref", vm.new_pyobj(is_xref), vm)?; + dict.set_item("is_dynamic", vm.new_pyobj(is_dynamic), vm)?; + dict.set_item( + "visibility", + visibility.map_or_else(|| vm.ctx.none(), |state| vm.new_pyobj(state)), + vm, + )?; + dict.set_item( + "count", + vm.new_pyobj(u64::from(rows) * u64::from(columns)), + vm, + )?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Layer names, visibility flags, and top-level entity counts. + #[pyfunction] + fn layers(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let layout_blocks = top_level_blocks(document); + let mut counts = std::collections::HashMap::::new(); + for entity in document.entities() { + if layout_blocks.contains(&entity.common().owner_handle) { + *counts.entry(entity.common().layer.clone()).or_default() += 1; + } + } + document + .layers + .iter() + .map(|layer| { + ( + layer.name.clone(), + layer.flags.off, + layer.flags.frozen, + layer.flags.locked, + counts.get(&layer.name).copied().unwrap_or(0), + ) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (name, off, frozen, locked, count) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("off", vm.new_pyobj(off), vm)?; + dict.set_item("frozen", vm.new_pyobj(frozen), vm)?; + dict.set_item("locked", vm.new_pyobj(locked), vm)?; + dict.set_item("entity_count", vm.new_pyobj(count), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Move a top-level entity to an existing layer without changing its owner. + #[pyfunction] + fn set_entity_layer(handle: u64, layer_name: String, vm: &VirtualMachine) -> PyResult<()> { + let changed = host_ctx::with_host(|host| { + let document = host.document(); + if document.layers.get(&layer_name).is_none() { + return false; + } + let Some(mut entity) = document.get_entity(Handle::new(handle)).cloned() else { + return false; + }; + if !top_level_blocks(document).contains(&entity.common().owner_handle) { + return false; + } + if entity.common().layer == layer_name { + return true; + } + entity.common_mut().layer = layer_name; + host_ctx::ensure_undo_started(host); + host.update_entity(entity) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_value_error(format!( + "ocs.set_entity_layer: top-level entity {handle} or target layer not found" + ))); + } + Ok(()) + } + + /// Export one layer's top-level entities as a new DWG, never overwriting. + #[pyfunction] + fn export_layer_dwg( + layer_name: String, + output_path: String, + vm: &VirtualMachine, + ) -> PyResult { + let path = std::path::Path::new(&output_path); + if path + .extension() + .is_none_or(|extension| !extension.eq_ignore_ascii_case("dwg")) + || path.file_name().is_none() + || path.parent().is_none_or(|parent| !parent.is_dir()) + { + return Err(vm.new_value_error( + "ocs.export_layer_dwg: output must be a .dwg file in an existing directory" + .to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let source = host.document(); + source.layers.get(&layer_name)?; + let layout_blocks = top_level_blocks(source); + let mut snapshot = source.clone(); + let removed: Vec = source + .entities() + .filter(|entity| { + layout_blocks.contains(&entity.common().owner_handle) + && entity.common().layer != layer_name + }) + .map(|entity| entity.common().handle) + .collect(); + for handle in removed { + snapshot.remove_entity(handle); + } + let kept: std::collections::HashSet = snapshot + .entities() + .map(|entity| entity.common().handle) + .collect(); + for record in snapshot.block_records.iter_mut() { + record.entity_handles.retain(|handle| kept.contains(handle)); + } + let count = snapshot + .entities() + .filter(|entity| { + layout_blocks.contains(&entity.common().owner_handle) + && entity.common().layer == layer_name + }) + .count(); + Some((snapshot, count)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error(format!( + "ocs.export_layer_dwg: layer {layer_name:?} does not exist" + )) + })?; + let (snapshot, count) = prepared; + if count == 0 { + return Err(vm.new_value_error(format!( + "ocs.export_layer_dwg: layer {layer_name:?} has no top-level entities" + ))); + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| { + vm.new_runtime_error(format!( + "ocs.export_layer_dwg: cannot create {output_path:?}: {error}" + )) + })?; + if let Err(error) = acadrust::DwgWriter::write_to_writer(&mut file, &snapshot) { + drop(file); + let _ = std::fs::remove_file(path); + return Err( + vm.new_runtime_error(format!("ocs.export_layer_dwg: DWG write failed: {error}")) + ); + } + Ok(count) + } + + /// Export a planned set of layers as new DWGs. If any export fails, + /// remove only files created by this call, leaving pre-existing files + /// untouched. No source drawing changes are made. + #[pyfunction] + fn export_layer_dwgs(exports: Vec>, vm: &VirtualMachine) -> PyResult { + let mut paths = std::collections::HashSet::new(); + if exports + .iter() + .any(|row| row.len() != 2 || !paths.insert(row[1].clone())) + { + return Err(vm.new_value_error( + "ocs.export_layer_dwgs: expected unique [layer, path] pairs".to_owned(), + )); + } + let mut created = Vec::with_capacity(exports.len()); + let mut counts = Vec::with_capacity(exports.len()); + for row in exports { + match export_layer_dwg(row[0].clone(), row[1].clone(), vm) { + Ok(count) => { + created.push(row[1].clone()); + counts.push(count); + } + Err(error) => { + let failed_cleanup: Vec = created + .into_iter() + .filter(|path| std::fs::remove_file(path).is_err()) + .collect(); + if !failed_cleanup.is_empty() { + return Err(vm.new_runtime_error(format!( + "ocs.export_layer_dwgs: export failed ({error:?}); cleanup failed for {failed_cleanup:?}" + ))); + } + return Err(error); + } + } + } + Ok(vm + .ctx + .new_list( + counts + .into_iter() + .map(|count| vm.new_pyobj(count)) + .collect(), + ) + .into()) + } + + /// Name of the active document's current layer (CLAYER). + #[pyfunction] + fn current_layer(vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| host.document().header.current_layer_name.clone()) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Read a host-managed system variable without invoking a CAD command. + #[cfg(feature = "experimental-host-settings")] + #[pyfunction] + fn system_variable(name: String, vm: &VirtualMachine) -> PyResult { + let value = host_ctx::with_host(|host| host.system_variable(&name)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + Ok(match value { + Some(HostSettingValue::Text(text)) => vm.new_pyobj(text), + Some(HostSettingValue::Number(number)) => vm.new_pyobj(number), + None => vm.ctx.none(), + }) + } + + /// Set a host-managed system variable without nested command dispatch. + #[cfg(feature = "experimental-host-settings")] + #[pyfunction] + fn set_system_variable( + name: String, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + let setting = match value.clone().try_into_value::(vm) { + Ok(text) => HostSettingValue::Text(text), + Err(_) => { + HostSettingValue::Number(ArgIntoFloat::try_from_object(vm, value)?.into_float()) + } + }; + let changed = host_ctx::with_host(|host| host.set_system_variable(&name, setting)) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })? + .map_err(|error| vm.new_value_error(error))?; + Ok(match changed { + HostSettingValue::Text(text) => vm.new_pyobj(text), + HostSettingValue::Number(number) => vm.new_pyobj(number), + }) + } + + /// Owning layout of a top-level entity, including sheet order/count. + #[pyfunction] + fn layout_for_entity(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let entity = document.get_entity(Handle::new(handle))?; + let block = document + .block_records + .iter() + .find(|record| record.handle == entity.common().owner_handle)?; + let ObjectType::Layout(layout) = document.objects.get(&block.layout)? else { + return None; + }; + let sheet_count = document + .objects + .values() + .filter(|object| matches!(object, ObjectType::Layout(item) if item.name != "Model")) + .count(); + Some((layout.name.clone(), layout.tab_order, sheet_count)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((name, order, sheet_count)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("order", vm.new_pyobj(order), vm)?; + dict.set_item("sheet_count", vm.new_pyobj(sheet_count), vm)?; + Ok(dict.into()) + } + + /// Tag/value pairs attached to one INSERT, or None for other entities. + #[pyfunction] + fn block_attributes(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Insert(insert)) => Some( + insert + .attributes + .iter() + .map(|attribute| (attribute.tag.clone(), attribute.value.clone())) + .collect::>(), + ), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(attributes) = found else { + return Ok(vm.ctx.none()); + }; + let mut output = Vec::with_capacity(attributes.len()); + for (tag, value) in attributes { + let dict = vm.ctx.new_dict(); + dict.set_item("tag", vm.new_pyobj(tag), vm)?; + dict.set_item("value", vm.new_pyobj(value), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Replace one INSERT attribute's value by tag, preserving other data. + #[pyfunction] + fn set_block_attribute( + handle: u64, + tag: rustpython_vm::builtins::PyStrRef, + value: rustpython_vm::builtins::PyStrRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + let tag = tag.to_string(); + let value = value.to_string(); + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Insert(mut insert)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + let Some(attribute) = insert + .attributes + .iter_mut() + .find(|attribute| attribute.tag.eq_ignore_ascii_case(&tag)) + else { + return false; + }; + attribute.value = value; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Insert(insert)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_block_attribute: INSERT {handle} or tag {tag} was not updated" + ))); + } + Ok(()) + } + + /// Replace the content of one top-level TEXT or MTEXT entity in place. + #[pyfunction] + fn set_text_value( + handle: u64, + value: rustpython_vm::builtins::PyStrRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + let value = value.to_string(); + let changed = host_ctx::with_host(|host| { + let Some(entity) = host.document().get_entity(Handle::new(handle)).cloned() else { + return false; + }; + let updated = match entity { + EntityType::Text(mut text) => { + text.value = value; + EntityType::Text(text) + } + EntityType::MText(mut text) => { + text.value = value; + EntityType::MText(text) + } + _ => return false, + }; + host_ctx::ensure_undo_started(host); + host.update_entity(updated) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_text_value: TEXT/MTEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Set an MTEXT background mode (off, mask, or fill) and border scale. + #[pyfunction] + fn set_mtext_mask( + handle: u64, + mode: rustpython_vm::builtins::PyStrRef, + scale: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let scale = scale.into_float(); + let mode = mode.to_string(); + if !scale.is_finite() || scale < 1.0 || !["off", "mask", "fill"].contains(&mode.as_str()) { + return Err(vm.new_value_error( + "ocs.set_mtext_mask: mode must be off/mask/fill and scale at least 1".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::MText(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + text.background_fill_flags &= !0x03; + text.background_fill_flags |= match mode.as_str() { + "mask" => 0x02, + "fill" => 0x01, + _ => 0, + }; + text.background_scale = scale; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::MText(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_mtext_mask: MTEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Change only the active insertion/alignment point of a TEXT entity, + /// preserving its style, rotation, content and other properties. + #[pyfunction] + fn move_text( + handle: u64, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let (x, y, z) = (x.into_float(), y.into_float(), z.into_float()); + if ![x, y, z].iter().all(|value| value.is_finite()) { + return Err(vm.new_value_error("ocs.move_text: coordinates must be finite".to_owned())); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Text(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + let point = Vector3::new(x, y, z); + if uses_text_alignment_point(&text) { + text.alignment_point = Some(point); + } else { + text.insertion_point = point; + } + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Text(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.move_text: TEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Move and rotate one XY TEXT entity, preserving its other properties. + #[pyfunction] + fn set_text_pose( + handle: u64, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + rotation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let (x, y, z, rotation) = ( + x.into_float(), + y.into_float(), + z.into_float(), + rotation.into_float(), + ); + if ![x, y, z, rotation].iter().all(|v| v.is_finite()) { + return Err(vm.new_value_error( + "ocs.set_text_pose: coordinates and rotation must be finite".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Text(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + if text.normal != Vector3::UNIT_Z { + return false; + } + let point = Vector3::new(x, y, z); + if uses_text_alignment_point(&text) { + text.alignment_point = Some(point); + } else { + text.insertion_point = point; + } + text.rotation = rotation; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Text(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_text_pose: XY TEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + fn uses_text_alignment_point(text: &Text) -> bool { + !(text.horizontal_alignment == TextHorizontalAlignment::Left + && text.vertical_alignment == TextVerticalAlignment::Baseline) + } + + fn active_text_point(text: &Text) -> Vector3 { + if uses_text_alignment_point(text) { + text.alignment_point.unwrap_or(text.insertion_point) + } else { + text.insertion_point + } + } + + /// Area and label point for a circle or a straight, closed lightweight + /// polyline in the world XY plane. Unsupported geometry returns None. + #[pyfunction] + fn get_area(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + match document.get_entity(Handle::new(handle)) { + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + Some((circle.area(), circle.center)) + } + Some(EntityType::LwPolyline(polyline)) + if polyline.is_closed + && polyline.normal == Vector3::UNIT_Z + && polyline.vertices.len() >= 3 + && polyline.vertices.iter().all(|v| v.bulge == 0.0) => + { + let mut double_area = 0.0; + let mut cx = 0.0; + let mut cy = 0.0; + for index in 0..polyline.vertices.len() { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let cross = a.x * b.y - b.x * a.y; + double_area += cross; + cx += (a.x + b.x) * cross; + cy += (a.y + b.y) * cross; + } + if double_area.abs() <= f64::EPSILON { + None + } else { + Some(( + double_area.abs() / 2.0, + Vector3::new( + cx / (3.0 * double_area), + cy / (3.0 * double_area), + polyline.elevation, + ), + )) + } + } + _ => None, + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((area, point)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("area", vm.new_pyobj(area), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + Ok(dict.into()) + } + + /// Length, halfway point, and local tangent for an XY line, circle, arc, + /// or straight-segment lightweight polyline. + #[pyfunction] + fn get_curve_measure(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + match document.get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + Some(( + line.length(), + line.midpoint(), + (line.end.y - line.start.y).atan2(line.end.x - line.start.x), + )) + } + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + Some(( + 2.0 * std::f64::consts::PI * circle.radius, + Vector3::new( + circle.center.x - circle.radius, + circle.center.y, + circle.center.z, + ), + std::f64::consts::FRAC_PI_2, + )) + } + Some(EntityType::Arc(arc)) + if arc.normal == Vector3::UNIT_Z + && arc.radius > 0.0 + && arc.arc_length() > 0.0 => + { + let angle = arc.start_angle + arc.sweep_angle() / 2.0; + Some(( + arc.arc_length(), + arc.midpoint(), + angle + std::f64::consts::FRAC_PI_2, + )) + } + Some(EntityType::LwPolyline(polyline)) + if polyline.normal == Vector3::UNIT_Z + && polyline.vertices.len() >= 2 + && polyline.vertices.iter().all(|v| v.bulge == 0.0) => + { + let segment_count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len() - 1 + }; + let mut segments = Vec::with_capacity(segment_count); + let mut total = 0.0; + for index in 0..segment_count { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let length = ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt(); + segments.push((a, b, length)); + total += length; + } + if total <= 0.0 { + return None; + } + let mut remaining = total / 2.0; + for (a, b, length) in segments { + if length == 0.0 { + continue; + } + if remaining <= length { + let fraction = remaining / length; + let point = Vector3::new( + a.x + (b.x - a.x) * fraction, + a.y + (b.y - a.y) * fraction, + polyline.elevation, + ); + return Some((total, point, (b.y - a.y).atan2(b.x - a.x))); + } + remaining -= length; + } + None + } + _ => None, + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((length, point, rotation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("length", vm.new_pyobj(length), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + dict.set_item("rotation", vm.new_pyobj(rotation), vm)?; + dict.set_item( + "tangent", + vm.ctx + .new_list(vec![ + vm.new_pyobj(rotation.cos()), + vm.new_pyobj(rotation.sin()), + ]) + .into(), + vm, + )?; + Ok(dict.into()) + } + + /// Start and end points of a nonzero XY line, or None. + #[pyfunction] + fn line_endpoints(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + Some((line.start, line.end)) + } + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some((start, end)) = found else { + return Ok(vm.ctx.none()); + }; + let output = vm.ctx.new_list(vec![ + vm.ctx + .new_list(vec![ + vm.new_pyobj(start.x), + vm.new_pyobj(start.y), + vm.new_pyobj(start.z), + ]) + .into(), + vm.ctx + .new_list(vec![ + vm.new_pyobj(end.x), + vm.new_pyobj(end.y), + vm.new_pyobj(end.z), + ]) + .into(), + ]); + Ok(output.into()) + } + + /// Insertion point of one top-level block reference, or None. + #[pyfunction] + fn block_insert_point(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Insert(insert)) => Some(insert.insert_point), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(point) = found else { + return Ok(vm.ctx.none()); + }; + Ok(vm + .ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into()) + } + + /// Axis-aligned XY footprint of a simple unrotated block reference. + /// Rejects nested/unsupported definition entities rather than guessing. + #[pyfunction] + fn block_bounds_xy(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Insert(insert)) = document.get_entity(Handle::new(handle)) else { + return None; + }; + if insert.normal != Vector3::UNIT_Z + || insert.rotation.abs() > 1e-9 + || insert.row_count != 1 + || insert.column_count != 1 + { + return None; + } + let record = document.block_records.get(&insert.block_name)?; + if record.entity_handles.is_empty() { + return None; + } + let base = record.base_point; + let transform = Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z)) + .then(&Transform::from_scaling(Vector3::new( + insert.x_scale(), + insert.y_scale(), + insert.z_scale(), + ))) + .then(&Transform::from_translation(insert.insert_point)); + let mut xmin = f64::INFINITY; + let mut ymin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymax = f64::NEG_INFINITY; + for entity_handle in &record.entity_handles { + let mut entity = document.get_entity(*entity_handle)?.clone(); + if !matches!( + entity, + EntityType::Line(_) + | EntityType::Arc(_) + | EntityType::Circle(_) + | EntityType::LwPolyline(_) + ) { + return None; + } + entity.apply_transform(&transform); + let bounds = entity.as_entity().bounding_box(); + if ![bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y] + .iter() + .all(|value| value.is_finite()) + { + return None; + } + xmin = xmin.min(bounds.min.x); + ymin = ymin.min(bounds.min.y); + xmax = xmax.max(bounds.max.x); + ymax = ymax.max(bounds.max.y); + } + if xmax - xmin <= 1e-9 || ymax - ymin <= 1e-9 { + return None; + } + Some([xmin, ymin, xmax, ymax]) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + match found { + Some(bounds) => Ok(vm + .ctx + .new_list( + bounds + .into_iter() + .map(|value| vm.new_pyobj(value)) + .collect(), + ) + .into()), + None => Ok(vm.ctx.none()), + } + } + + /// Segment metrics for one XY lightweight polyline, including bulge arcs. + #[pyfunction] + fn polyline_segments(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::LwPolyline(polyline)) = document.get_entity(Handle::new(handle)) + else { + return None; + }; + if polyline.normal != Vector3::UNIT_Z || polyline.vertices.len() < 2 { + return None; + } + let count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len() - 1 + }; + let mut segments = Vec::with_capacity(count); + for index in 0..count { + let a = polyline.vertices[index]; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()]; + let chord = ((b.location.x - a.location.x).powi(2) + + (b.location.y - a.location.y).powi(2)) + .sqrt(); + let radius = if a.bulge.abs() > f64::EPSILON && chord > 0.0 { + Some(chord * (1.0 + a.bulge * a.bulge) / (4.0 * a.bulge.abs())) + } else { + None + }; + let center = radius.map(|_| { + let offset = (1.0 - a.bulge * a.bulge) / (4.0 * a.bulge); + ( + (a.location.x + b.location.x) / 2.0 + - (b.location.y - a.location.y) * offset, + (a.location.y + b.location.y) / 2.0 + + (b.location.x - a.location.x) * offset, + ) + }); + let length = radius.map_or(chord, |r| r * (4.0 * a.bulge.atan()).abs()); + segments.push(( + a.location, + b.location, + a.start_width, + a.end_width, + length, + center, + radius, + )); + } + Some(segments) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some(segments) = found else { + return Ok(vm.ctx.none()); + }; + let mut output = Vec::with_capacity(segments.len()); + for (start, end, start_width, end_width, length, center, radius) in segments { + let dict = vm.ctx.new_dict(); + dict.set_item( + "start", + vm.ctx + .new_list(vec![vm.new_pyobj(start.x), vm.new_pyobj(start.y)]) + .into(), + vm, + )?; + dict.set_item( + "end", + vm.ctx + .new_list(vec![vm.new_pyobj(end.x), vm.new_pyobj(end.y)]) + .into(), + vm, + )?; + dict.set_item("start_width", vm.new_pyobj(start_width), vm)?; + dict.set_item("end_width", vm.new_pyobj(end_width), vm)?; + dict.set_item("length", vm.new_pyobj(length), vm)?; + dict.set_item( + "center", + center.map_or_else( + || vm.ctx.none(), + |(x, y)| { + vm.ctx + .new_list(vec![vm.new_pyobj(x), vm.new_pyobj(y)]) + .into() + }, + ), + vm, + )?; + dict.set_item( + "radius", + radius.map_or_else(|| vm.ctx.none(), |r| vm.new_pyobj(r)), + vm, + )?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Equal-distance samples for an XY line, arc, or circle. + #[pyfunction] + fn curve_samples(handle: u64, segments: usize, vm: &VirtualMachine) -> PyResult { + if !(2..=4096).contains(&segments) { + return Err(vm.new_value_error( + "ocs.curve_samples: segments must be between 2 and 4096".to_owned(), + )); + } + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + let points = (0..=segments) + .map(|i| { + let t = i as f64 / segments as f64; + Vector2::new( + line.start.x + (line.end.x - line.start.x) * t, + line.start.y + (line.end.y - line.start.y) * t, + ) + }) + .collect::>(); + Some((points, line.start.z, false)) + } + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + let points = (0..segments) + .map(|i| { + let angle = 2.0 * std::f64::consts::PI * i as f64 / segments as f64; + Vector2::new( + circle.center.x + circle.radius * angle.cos(), + circle.center.y + circle.radius * angle.sin(), + ) + }) + .collect::>(); + Some((points, circle.center.z, true)) + } + Some(EntityType::Arc(arc)) + if arc.normal == Vector3::UNIT_Z + && arc.radius > 0.0 + && arc.arc_length() > 0.0 => + { + let points = (0..=segments) + .map(|i| { + let angle = arc.start_angle + + arc.sweep_angle() * i as f64 / segments as f64; + Vector2::new( + arc.center.x + arc.radius * angle.cos(), + arc.center.y + arc.radius * angle.sin(), + ) + }) + .collect::>(); + Some((points, arc.center.z, false)) + } + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some((points, elevation, closed)) = found else { + return Ok(vm.ctx.none()); + }; + let vertices = points + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point.x), vm.new_pyobj(point.y)]) + .into() + }) + .collect(); + let dict = vm.ctx.new_dict(); + dict.set_item("vertices", vm.ctx.new_list(vertices).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + dict.set_item("closed", vm.new_pyobj(closed), vm)?; + Ok(dict.into()) + } + + /// Modelspace XY outline of a rectangular, unclipped +Z paperspace viewport. + #[pyfunction] + fn viewport_model_outline(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let Some(EntityType::Viewport(viewport)) = + host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if viewport.id == 1 + || !viewport.clip_boundary_handle.is_null() + || viewport.view_direction != Vector3::UNIT_Z + || ![ + viewport.width, + viewport.height, + viewport.view_height, + viewport.twist_angle, + viewport.view_center.x, + viewport.view_center.y, + viewport.view_target.x, + viewport.view_target.y, + viewport.view_target.z, + ] + .iter() + .all(|value| value.is_finite()) + || viewport.width <= 0.0 + || viewport.height <= 0.0 + || viewport.view_height <= 0.0 + { + return None; + } + let scale = viewport.view_height / viewport.height; + let (sin, cos) = (-viewport.twist_angle).sin_cos(); + let corners = [ + (-viewport.width / 2.0, -viewport.height / 2.0), + (viewport.width / 2.0, -viewport.height / 2.0), + (viewport.width / 2.0, viewport.height / 2.0), + (-viewport.width / 2.0, viewport.height / 2.0), + ]; + let points = corners.map(|(x, y)| { + let dx = x * scale + viewport.view_center.x; + let dy = y * scale + viewport.view_center.y; + [ + viewport.view_target.x + dx * cos - dy * sin, + viewport.view_target.y + dx * sin + dy * cos, + ] + }); + Some((points, viewport.view_target.z)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((points, elevation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + let vertices = points + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point[0]), vm.new_pyobj(point[1])]) + .into() + }) + .collect(); + dict.set_item("vertices", vm.ctx.new_list(vertices).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + Ok(dict.into()) + } + + /// Copy supported modelspace XY curves through a rectangular +Z viewport. + #[pyfunction] + fn copy_model_to_paper( + handles: Vec, + viewport_handle: u64, + vm: &VirtualMachine, + ) -> PyResult { + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let copies = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Viewport(viewport)) = + document.get_entity(Handle::new(viewport_handle)) + else { + return None; + }; + if viewport.id == 1 + || !viewport.clip_boundary_handle.is_null() + || viewport.view_direction != Vector3::UNIT_Z + || ![ + viewport.width, + viewport.height, + viewport.view_height, + viewport.twist_angle, + viewport.center.x, + viewport.center.y, + viewport.view_center.x, + viewport.view_center.y, + viewport.view_target.x, + viewport.view_target.y, + viewport.view_target.z, + ] + .iter() + .all(|value| value.is_finite()) + || viewport.width <= 0.0 + || viewport.height <= 0.0 + || viewport.view_height <= 0.0 + { + return None; + } + let paper_block = viewport.common.owner_handle; + let block = document + .block_records + .iter() + .find(|record| record.handle == paper_block)?; + let ObjectType::Layout(layout) = document.objects.get(&block.layout)? else { + return None; + }; + if layout.name == "Model" { + return None; + } + let scale = viewport.height / viewport.view_height; + let transform = Transform::from_translation(Vector3::new( + -viewport.view_target.x, + -viewport.view_target.y, + -viewport.view_target.z, + )) + .then(&Transform::from_rotation( + Vector3::UNIT_Z, + viewport.twist_angle, + )) + .then(&Transform::from_scaling(Vector3::new(scale, scale, 1.0))) + .then(&Transform::from_translation(Vector3::new( + viewport.center.x - viewport.view_center.x * scale, + viewport.center.y - viewport.view_center.y * scale, + 0.0, + ))); + let mut copies = Vec::with_capacity(handles.len()); + for handle in handles { + let mut entity = document.get_entity(Handle::new(handle))?.clone(); + if entity.common().owner_handle != document.header.model_space_block_handle { + return None; + } + let planar = match &entity { + EntityType::Line(item) => { + item.normal == Vector3::UNIT_Z + && (item.start.z - viewport.view_target.z).abs() <= 1e-9 + && (item.end.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::Arc(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::Circle(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::LwPolyline(item) => { + item.normal == Vector3::UNIT_Z + && (item.elevation - viewport.view_target.z).abs() <= 1e-9 + } + _ => false, + }; + if !planar { + return None; + } + entity.as_entity_mut().apply_transform(&transform); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = paper_block; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.copy_model_to_paper: unsupported viewport or source geometry".to_owned(), + ) + })?; + let new_handles = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entities(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if new_handles.iter().any(|handle| handle.is_null()) { + return Err(vm.new_runtime_error( + "ocs.copy_model_to_paper: host did not add every entity".to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + new_handles + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Mirror supported planar curves across an existing XY LINE, optionally + /// removing the originals. The axis itself is never mirrored or removed. + #[pyfunction] + fn mirror_xy_entities( + handles: Vec, + axis_handle: u64, + erase_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let unique: std::collections::HashSet = handles.iter().copied().collect(); + if unique.len() != handles.len() || unique.contains(&axis_handle) { + return Err(vm.new_value_error( + "ocs.mirror_xy_entities: handles must be unique and exclude the axis".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Line(axis)) = document.get_entity(Handle::new(axis_handle)) else { + return None; + }; + if axis.normal != Vector3::UNIT_Z + || (axis.start.z - axis.end.z).abs() > 1e-9 + || axis.length() <= 0.0 + { + return None; + } + let transform = Transform::from_mirror_line(axis.start, axis.end); + let mut copies = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = document.get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => { + item.normal == Vector3::UNIT_Z + && (item.start.z - axis.start.z).abs() <= 1e-9 + && (item.end.z - axis.start.z).abs() <= 1e-9 + } + EntityType::Arc(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - axis.start.z).abs() <= 1e-9 + } + EntityType::Circle(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - axis.start.z).abs() <= 1e-9 + } + EntityType::LwPolyline(item) => { + item.normal == Vector3::UNIT_Z + && (item.elevation - axis.start.z).abs() <= 1e-9 + } + _ => false, + }; + if !planar { + return None; + } + entity.apply_mirror(&transform); + entity.common_mut().handle = Handle::NULL; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.mirror_xy_entities: axis or source must be supported planar geometry" + .to_owned(), + ) + })?; + let (added, removed_all) = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entities(prepared); + let mut removed_all = true; + if erase_source && added.iter().all(|handle| !handle.is_null()) { + for handle in &handles { + removed_all &= host.remove_entity(Handle::new(*handle)); + } + } + (added, removed_all) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if added.iter().any(|handle| handle.is_null()) { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_entities: host did not add every entity".to_owned(), + )); + } + if !removed_all { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_entities: a source could not be removed; inspect the drawing" + .to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + added + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Mirror planar curves across the XY line through `point` in `tangent` + /// direction. The calling script can derive these from a curve midpoint. + #[pyfunction] + fn mirror_xy_about_line( + handles: Vec, + point: Vec, + tangent: Vec, + erase_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + if point.len() != 3 + || tangent.len() != 2 + || point + .iter() + .chain(tangent.iter()) + .any(|value| !value.is_finite()) + || tangent[0].hypot(tangent[1]) <= 1e-9 + || handles.len() + != handles + .iter() + .copied() + .collect::>() + .len() + { + return Err(vm.new_value_error( + "ocs.mirror_xy_about_line: invalid point, tangent, or duplicate handles".to_owned(), + )); + } + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let start = Vector3::new(point[0], point[1], point[2]); + let end = Vector3::new(point[0] + tangent[0], point[1] + tangent[1], point[2]); + let transform = Transform::from_mirror_line(start, end); + let prepared = host_ctx::with_host(|host| { + let mut copies = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = host.document().get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => item.normal == Vector3::UNIT_Z + && (item.start.z - start.z).abs() <= 1e-9 + && (item.end.z - start.z).abs() <= 1e-9, + EntityType::Arc(item) => item.normal == Vector3::UNIT_Z + && (item.center.z - start.z).abs() <= 1e-9, + EntityType::Circle(item) => item.normal == Vector3::UNIT_Z + && (item.center.z - start.z).abs() <= 1e-9, + EntityType::LwPolyline(item) => item.normal == Vector3::UNIT_Z + && (item.elevation - start.z).abs() <= 1e-9, + _ => false, + }; + if !planar { + return None; + } + entity.apply_mirror(&transform); + entity.common_mut().handle = Handle::NULL; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.mirror_xy_about_line: only planar LINE/ARC/CIRCLE/LWPolyline sources are supported".to_owned(), + ))?; + let (added, removed_all) = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entities(prepared); + let mut removed_all = true; + if erase_source && added.iter().all(|handle| !handle.is_null()) { + for handle in &handles { + removed_all &= host.remove_entity(Handle::new(*handle)); + } + } + (added, removed_all) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if added.iter().any(|handle| handle.is_null()) || !removed_all { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_about_line: host could not mirror every entity; inspect the drawing" + .to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + added + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Remove the span between two points strictly inside an XY LINE, keeping + /// its first fragment under the original handle and adding the second. + #[pyfunction] + fn break_xy_line( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_line: break points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Line(line)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if line.normal != Vector3::UNIT_Z || (line.start.z - line.end.z).abs() > 1e-9 { + return None; + } + let dx = line.end.x - line.start.x; + let dy = line.end.y - line.start.y; + let length_sq = dx * dx + dy * dy; + if length_sq <= 1e-18 { + return None; + } + let parameter = |point: &[f64]| -> Option { + let px = point[0] - line.start.x; + let py = point[1] - line.start.y; + let t = (px * dx + py * dy) / length_sq; + let deviation = (px * dy - py * dx).abs() / length_sq.sqrt(); + if t <= 1e-9 || t >= 1.0 - 1e-9 || deviation > 1e-6 { + None + } else { + Some(t) + } + }; + let a = parameter(&first)?; + let b = parameter(&second)?; + let before = a.min(b); + let after = a.max(b); + let at = + |t: f64| Vector3::new(line.start.x + t * dx, line.start.y + t * dy, line.start.z); + let mut initial = line.clone(); + let mut final_part = line.clone(); + initial.end = at(before); + final_part.start = at(after); + final_part.common.handle = Handle::NULL; + Some((EntityType::Line(initial), EntityType::Line(final_part))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_line: target must be an XY LINE and both points strictly on it" + .to_owned(), + ) + })?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entity(prepared.1); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error("ocs.break_xy_line: host could not edit the line".to_owned()) + })?; + Ok(result) + } + + /// Remove the angular span between two interior XY points on an ARC, + /// retaining the first fragment's original handle. + #[pyfunction] + fn break_xy_arc( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err( + vm.new_value_error("ocs.break_xy_arc: points must be finite XY pairs".to_owned()) + ); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Arc(arc)) = host.document().get_entity(Handle::new(handle)) else { + return None; + }; + if arc.normal != Vector3::UNIT_Z + || arc.radius <= 0.0 + || arc.sweep_angle() <= 1e-9 + || arc.sweep_angle() >= std::f64::consts::TAU - 1e-9 + { + return None; + } + let parameter = |point: &[f64]| -> Option { + let dx = point[0] - arc.center.x; + let dy = point[1] - arc.center.y; + if (dx.hypot(dy) - arc.radius).abs() > 1e-6 { + return None; + } + let angle = dy.atan2(dx); + let t = (angle - arc.start_angle).rem_euclid(std::f64::consts::TAU); + if t <= 1e-9 || t >= arc.sweep_angle() - 1e-9 { + None + } else { + Some(t) + } + }; + let a = parameter(&first)?; + let b = parameter(&second)?; + let mut initial = arc.clone(); + let mut final_part = arc.clone(); + initial.end_angle = arc.start_angle + a.min(b); + final_part.start_angle = arc.start_angle + a.max(b); + final_part.common.handle = Handle::NULL; + Some((EntityType::Arc(initial), EntityType::Arc(final_part))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_arc: target must be a nonfull XY ARC with interior points on it" + .to_owned(), + ) + })?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entity(prepared.1); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error("ocs.break_xy_arc: host could not edit the arc".to_owned()) + })?; + Ok(result) + } + + /// Replace an XY CIRCLE with the complementary ARC after removing the + /// counterclockwise span from `first` to `second`. + #[pyfunction] + fn break_xy_circle( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult<()> { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_circle: points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Circle(circle)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if circle.normal != Vector3::UNIT_Z || circle.radius <= 0.0 { + return None; + } + let angle = |point: &[f64]| -> Option { + let dx = point[0] - circle.center.x; + let dy = point[1] - circle.center.y; + if (dx.hypot(dy) - circle.radius).abs() > 1e-6 { + None + } else { + Some(dy.atan2(dx).rem_euclid(std::f64::consts::TAU)) + } + }; + let start = angle(&first)?; + let end = angle(&second)?; + if (end - start).rem_euclid(std::f64::consts::TAU) <= 1e-9 { + return None; + } + let mut arc = Arc::from_center_radius_angles(circle.center, circle.radius, end, start); + arc.common = circle.common.clone(); + arc.thickness = circle.thickness; + arc.normal = circle.normal; + Some(EntityType::Arc(arc)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_circle: target must be an XY CIRCLE with distinct points on it" + .to_owned(), + ) + })?; + let updated = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.update_entity(prepared) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !updated { + return Err(vm.new_runtime_error( + "ocs.break_xy_circle: host could not edit the circle".to_owned(), + )); + } + Ok(()) + } + + /// Remove the path interval between two interior points on a straight, + /// zero-width XY lightweight polyline. A closed polyline becomes one open + /// complementary path, under its original handle. + #[pyfunction] + fn break_xy_polyline( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_polyline: points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::LwPolyline(polyline)) = host.document().get_entity(Handle::new(handle)) else { + return None; + }; + if polyline.normal != Vector3::UNIT_Z + || polyline.vertices.len() < (if polyline.is_closed { 3 } else { 2 }) + || polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0 + || vertex.start_width != 0.0 || vertex.end_width != 0.0) { + return None; + } + let segment_count = polyline.vertices.len() - if polyline.is_closed { 0 } else { 1 }; + let mut lengths = Vec::with_capacity(segment_count); + let mut total = 0.0; + for index in 0..segment_count { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let length = (b.x - a.x).hypot(b.y - a.y); + lengths.push(length); + total += length; + } + if total <= 1e-9 { + return None; + } + let locate = |point: &[f64]| -> Option<(usize, f64)> { + let mut walked = 0.0; + for (index, length) in lengths.iter().copied().enumerate() { + if length <= 1e-9 { + continue; + } + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let dx = b.x - a.x; + let dy = b.y - a.y; + let px = point[0] - a.x; + let py = point[1] - a.y; + let fraction = (px * dx + py * dy) / (length * length); + let deviation = (px * dy - py * dx).abs() / length; + let position = walked + fraction * length; + if fraction > 1e-9 && fraction < 1.0 - 1e-9 + && deviation <= 1e-6 && position > 1e-9 + && position < total - 1e-9 { + return Some((index, position)); + } + walked += length; + } + None + }; + let a = locate(&first)?; + let b = locate(&second)?; + if (first[0] - second[0]).hypot(first[1] - second[1]) <= 1e-9 { + return None; + } + if polyline.is_closed { + let mut complement = polyline.clone(); + let mut vertices = vec![LwVertex::new(Vector2::new(second[0], second[1]))]; + if b.0 != a.0 || b.1 > a.1 { + let mut index = (b.0 + 1) % polyline.vertices.len(); + loop { + vertices.push(polyline.vertices[index]); + if index == a.0 { + break; + } + index = (index + 1) % polyline.vertices.len(); + } + } + vertices.push(LwVertex::new(Vector2::new(first[0], first[1]))); + complement.vertices = vertices; + complement.is_closed = false; + return Some((EntityType::LwPolyline(complement), None)); + } + let ((before_index, before_point), (after_index, after_point)) = + if a.1 <= b.1 { ((a.0, &first), (b.0, &second)) } + else { ((b.0, &second), (a.0, &first)) }; + let mut initial = polyline.clone(); + initial.vertices.truncate(before_index + 1); + initial.vertices.push(LwVertex::new(Vector2::new(before_point[0], before_point[1]))); + let mut final_part = polyline.clone(); + final_part.vertices = vec![LwVertex::new(Vector2::new(after_point[0], after_point[1]))]; + final_part.vertices.extend_from_slice(&polyline.vertices[after_index + 1..]); + final_part.common.handle = Handle::NULL; + Some((EntityType::LwPolyline(initial), Some(EntityType::LwPolyline(final_part)))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.break_xy_polyline: target must be a straight zero-width XY polyline with distinct interior points".to_owned(), + ))?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + if let Some(final_part) = prepared.1 { + let added = host.add_entity(final_part); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + } else if host.update_entity(prepared.0) { + Some(handle) + } else { + None + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error( + "ocs.break_xy_polyline: host could not edit the polyline".to_owned(), + ) + })?; + Ok(result) + } + + /// Rigidly move and rotate planar curves, mapping one source XY point to + /// one destination point. Optionally add transformed copies instead of + /// changing the originals. This intentionally does not scale geometry. + #[pyfunction] + fn align_xy_entities( + handles: Vec, + source: Vec, + destination: Vec, + source_direction: Vec, + target_direction: Vec, + copy_mode: bool, + vm: &VirtualMachine, + ) -> PyResult { + if source.len() != 3 + || destination.len() != 3 + || source_direction.len() != 2 + || target_direction.len() != 2 + || source + .iter() + .chain(destination.iter()) + .chain(source_direction.iter()) + .chain(target_direction.iter()) + .any(|value| !value.is_finite()) + || handles.len() + != handles + .iter() + .copied() + .collect::>() + .len() + { + return Err(vm.new_value_error( + "ocs.align_xy_entities: invalid points, directions, or duplicate handles" + .to_owned(), + )); + } + let source_length = source_direction[0].hypot(source_direction[1]); + let target_length = target_direction[0].hypot(target_direction[1]); + if source_length <= 0.0 || target_length <= 0.0 { + return Err( + vm.new_value_error("ocs.align_xy_entities: directions must be nonzero".to_owned()) + ); + } + if handles.is_empty() { + return Ok(0); + } + let rotation = target_direction[1].atan2(target_direction[0]) + - source_direction[1].atan2(source_direction[0]); + let source = Vector3::new(source[0], source[1], source[2]); + let destination = Vector3::new(destination[0], destination[1], destination[2]); + let transform = Transform::from_translation(Vector3::new(-source.x, -source.y, -source.z)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, rotation)) + .then(&Transform::from_translation(destination)); + let mut prepared = host_ctx::with_host(|host| { + let mut entities = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = host.document().get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => item.normal == Vector3::UNIT_Z + && (item.start.z - item.end.z).abs() <= 1e-9, + EntityType::Arc(item) => item.normal == Vector3::UNIT_Z, + EntityType::Circle(item) => item.normal == Vector3::UNIT_Z, + EntityType::LwPolyline(item) => item.normal == Vector3::UNIT_Z, + _ => false, + }; + if !planar { + return None; + } + entity.apply_transform(&transform); + entities.push(entity); + } + Some(entities) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.align_xy_entities: only planar LINE/ARC/CIRCLE/LWPolyline sources are supported".to_owned(), + ))?; + if copy_mode { + for entity in &mut prepared { + entity.common_mut().handle = Handle::NULL; + } + } + let updated = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + if copy_mode { + host.add_entities(prepared) + .iter() + .all(|handle| !handle.is_null()) + } else { + prepared + .into_iter() + .all(|entity| host.update_entity(entity)) + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !updated { + return Err(vm.new_runtime_error( + "ocs.align_xy_entities: host could not write every entity; inspect the drawing" + .to_owned(), + )); + } + Ok(handles.len()) + } + + /// Replace an XY line, arc, or circle with a lightweight polyline, + /// retaining its handle, layer, and common drawing properties. + #[pyfunction] + fn replace_with_polyline( + handle: u64, + vertices: Vec>, + elevation: ArgIntoFloat, + closed: bool, + vm: &VirtualMachine, + ) -> PyResult<()> { + let elevation = elevation.into_float(); + if vertices.len() < (if closed { 3 } else { 2 }) + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|value| !value.is_finite())) + || !elevation.is_finite() + { + return Err(vm.new_value_error( + "ocs.replace_with_polyline: invalid vertices or elevation".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(entity) = host.document().get_entity(Handle::new(handle)).cloned() else { + return false; + }; + let supported = match &entity { + EntityType::Line(line) => { + line.normal == Vector3::UNIT_Z && (line.start.z - line.end.z).abs() <= 1e-9 + } + EntityType::Arc(arc) => arc.normal == Vector3::UNIT_Z, + EntityType::Circle(circle) => circle.normal == Vector3::UNIT_Z, + _ => false, + }; + if !supported { + return false; + } + let mut polyline = LwPolyline::new(); + polyline.common = entity.common().clone(); + polyline.elevation = elevation; + polyline.is_closed = closed; + for point in vertices { + polyline + .vertices + .push(LwVertex::new(Vector2::new(point[0], point[1]))); + } + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::LwPolyline(polyline)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.replace_with_polyline: entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Create a native OCS table from a rectangular grid of cell strings. + #[pyfunction] + fn add_table( + rows: Vec>, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + row_height: ArgIntoFloat, + column_width: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, row_height, column_width) = ( + x.into_float(), + y.into_float(), + z.into_float(), + row_height.into_float(), + column_width.into_float(), + ); + let columns = rows.first().map_or(0, Vec::len); + if rows.is_empty() + || columns == 0 + || rows.iter().any(|row| row.len() != columns) + || ![x, y, z, row_height, column_width] + .iter() + .all(|v| v.is_finite()) + || row_height <= 0.0 + || column_width <= 0.0 + { + return Err(vm.new_value_error("ocs.add_table: rows must be rectangular and nonempty; dimensions must be positive and finite".to_owned())); + } + let mut builder = TableBuilder::new(rows.len(), columns) + .at(Vector3::new(x, y, z)) + .row_height(row_height) + .column_width(column_width); + for (row_index, row) in rows.iter().enumerate() { + for (column_index, value) in row.iter().enumerate() { + builder = builder.cell_text(row_index, column_index, value); + } + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Table(builder.build())).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D line from `(x1, y1)` to `(x2, y2)` on layer `"0"`, returning + /// its handle. Starts the script's shared undo group (see + /// `host_ctx::ensure_undo_started`) if this is the first write this call. + /// + /// Coordinates take `ArgIntoFloat` rather than a bare `f64` so plain + /// Python ints work too (`ocs.add_line(0, 0, 10, 10)`) — a bare `f64` + /// parameter in RustPython, unlike CPython's C-function argument parsing, + /// does *not* implicitly coerce an `int`, and rejects it with a `TypeError`. + #[pyfunction] + fn add_line( + x1: ArgIntoFloat, + y1: ArgIntoFloat, + x2: ArgIntoFloat, + y2: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let line = acadrust::Line::from_coords( + x1.into_float(), + y1.into_float(), + 0.0, + x2.into_float(), + y2.into_float(), + 0.0, + ); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Line(line)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D circle centered at `(x, y)` with the given `radius`, on layer + /// `"0"`, returning its handle. Same undo-grouping and int/float argument + /// handling as `add_line`. + #[pyfunction] + fn add_circle( + x: ArgIntoFloat, + y: ArgIntoFloat, + radius: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let circle = + acadrust::Circle::from_coords(x.into_float(), y.into_float(), 0.0, radius.into_float()); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Circle(circle)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D arc centered at `(x, y)`, from `start_deg` to `end_deg` + /// (degrees, counterclockwise from the +X axis — matching how OCS's own + /// `ARC`/angle fields are conventionally read), on layer `"0"`. Returns + /// its handle. Same undo-grouping and int/float argument handling as + /// `add_line`. + #[pyfunction] + fn add_arc( + x: ArgIntoFloat, + y: ArgIntoFloat, + radius: ArgIntoFloat, + start_deg: ArgIntoFloat, + end_deg: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let center = acadrust::Vector3::new(x.into_float(), y.into_float(), 0.0); + let arc = acadrust::Arc::from_center_radius_angles( + center, + radius.into_float(), + start_deg.into_float().to_radians(), + end_deg.into_float().to_radians(), + ); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Arc(arc)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add an open 2D lightweight polyline. Each vertex is + /// [x, y, start_width, end_width]; widths belong to the segment starting + /// at that vertex. The polyline uses a single explicit elevation. + #[pyfunction] + fn add_polyline( + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let elevation = elevation.into_float(); + if vertices.len() < 2 || !elevation.is_finite() { + return Err(vm.new_value_error( + "ocs.add_polyline: need at least two vertices and a finite elevation".to_owned(), + )); + } + let mut polyline = LwPolyline::new(); + polyline.elevation = elevation; + for vertex in vertices { + if vertex.len() != 4 + || vertex.iter().any(|value| !value.is_finite()) + || vertex[2] < 0.0 + || vertex[3] < 0.0 + { + return Err(vm.new_value_error("ocs.add_polyline: each vertex must be [finite x, y, nonnegative start_width, end_width]".to_owned())); + } + let mut item = LwVertex::new(Vector2::new(vertex[0], vertex[1])); + item.start_width = vertex[2]; + item.end_width = vertex[3]; + polyline.vertices.push(item); + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::LwPolyline(polyline)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a closed XY lightweight polyline with vertices `[x, y]`. + #[pyfunction] + fn add_closed_polyline( + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let elevation = elevation.into_float(); + if vertices.len() < 3 + || !elevation.is_finite() + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|v| !v.is_finite())) + { + return Err(vm.new_value_error("ocs.add_closed_polyline: need at least three finite [x, y] vertices and finite elevation".to_owned())); + } + let mut polyline = LwPolyline::new(); + polyline.elevation = elevation; + polyline.is_closed = true; + for point in vertices { + polyline + .vertices + .push(LwVertex::new(Vector2::new(point[0], point[1]))); + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::LwPolyline(polyline)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Replace a closed lightweight polyline's shape, preserving handle/properties/XDATA. + #[pyfunction] + fn update_closed_polyline( + handle: u64, + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let elevation = elevation.into_float(); + if vertices.len() < 3 + || !elevation.is_finite() + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|v| !v.is_finite())) + { + return Err(vm.new_value_error("ocs.update_closed_polyline: need at least three finite [x, y] vertices and finite elevation".to_owned())); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::LwPolyline(mut polyline)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + if !polyline.is_closed { + return false; + } + polyline.elevation = elevation; + polyline.vertices = vertices + .into_iter() + .map(|point| LwVertex::new(Vector2::new(point[0], point[1]))) + .collect(); + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::LwPolyline(polyline)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!("ocs.update_closed_polyline: entity {handle} is not a writable closed lightweight polyline"))); + } + Ok(()) + } + + /// Add many 3D points in one host round trip, each `[x, y, z]`. Returns + /// the new handles in the same order as `points`. + /// + /// Ported from `schoeller/ocs_python_repl`'s `ocs.doc.add_many(...)`, + /// which batches thousands of points into a single request instead of + /// one per point. That plugin is out-of-process (real round-trip cost + /// per call); this one is in-process, so the win here is different but + /// still real: `HostApi::add_entities` lets the host coalesce the + /// undo/dirty/geometry-rebuild bookkeeping across the whole batch + /// instead of repeating it once per point (its default implementation + /// merely loops `add_entity`, but hosts are expected to override it for + /// batch efficiency — see its doc comment in `ocs_plugin_api`). + #[pyfunction] + fn add_points(points: Vec>, vm: &VirtualMachine) -> PyResult { + if points.is_empty() { + return Err(vm.new_value_error("ocs.add_points: need at least one point".to_owned())); + } + let mut entities = Vec::with_capacity(points.len()); + for point in &points { + if point.len() != 3 || point.iter().any(|value| !value.is_finite()) { + return Err(vm.new_value_error( + "ocs.add_points: each point must be [finite x, y, z]".to_owned(), + )); + } + entities.push(EntityType::Point(acadrust::Point::from_coords( + point[0], point[1], point[2], + ))); + } + let handles = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entities(entities) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let items = handles + .into_iter() + .map(|h| vm.new_pyobj(h.value())) + .collect(); + Ok(vm.ctx.new_list(items).into()) + } + + /// Add one left/baseline-aligned single-line TEXT entity. + /// Its insertion point is the supplied point, which also makes it + /// eligible for `text_box_vertices` and the associated-textbox port. + #[pyfunction] + fn add_text_left( + value: rustpython_vm::builtins::PyStrRef, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + height: ArgIntoFloat, + rotation_radians: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, height, rotation_radians) = ( + x.into_float(), + y.into_float(), + z.into_float(), + height.into_float(), + rotation_radians.into_float(), + ); + if ![x, y, z, height, rotation_radians] + .iter() + .all(|v| v.is_finite()) + || height <= 0.0 + { + return Err(vm.new_value_error("ocs.add_text_left: coordinates and rotation must be finite; height must be positive".to_owned())); + } + let mut text = Text::with_value(value.to_string(), Vector3::new(x, y, z)) + .with_height(height) + .with_rotation(rotation_radians); + text.horizontal_alignment = TextHorizontalAlignment::Left; + text.vertical_alignment = TextVerticalAlignment::Baseline; + text.alignment_point = None; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Text(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add one centered, middle-aligned single-line TEXT entity. + #[pyfunction] + fn add_text_centered( + value: rustpython_vm::builtins::PyStrRef, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + height: ArgIntoFloat, + rotation_radians: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, height, rotation_radians) = ( + x.into_float(), + y.into_float(), + z.into_float(), + height.into_float(), + rotation_radians.into_float(), + ); + if ![x, y, z, height, rotation_radians] + .iter() + .all(|v| v.is_finite()) + || height <= 0.0 + { + return Err(vm.new_value_error("ocs.add_text_centered: coordinates and rotation must be finite; height must be positive".to_owned())); + } + let point = Vector3::new(x, y, z); + let mut text = Text::with_value(value.to_string(), point) + .with_height(height) + .with_rotation(rotation_radians); + text.alignment_point = Some(point); + text.horizontal_alignment = TextHorizontalAlignment::Center; + text.vertical_alignment = TextVerticalAlignment::Middle; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Text(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Run a full command line (built-in or plugin) to completion, exactly + /// as if typed — including any inline point/keyword/handle tokens an + /// interactive command needs (e.g. `ocs.command("LINE 0,0 10,10")`, or + /// `ocs.command(f"PCONSTRAINT {h1:X} {h2:X}")` with two entity handles + /// for a constraint command's object picks). Raises a Python exception + /// on failure — including on hosts that predate `HostApi::run_command` + /// (Phase 1, `ocs.command()` — see `docs/python-scripting-roadmap.md` + /// in the OpenCADStudio repo for what this can and can't do). + #[cfg(feature = "experimental-command-replay")] + #[pyfunction] + fn command(cmd: rustpython_vm::builtins::PyStrRef, vm: &VirtualMachine) -> PyResult<()> { + let cmd = cmd.to_string(); + let result = host_ctx::with_host(|host| host.run_command(&cmd)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + result.map_err(|e| vm.new_runtime_error(format!("ocs.command({cmd:?}): {e}"))) + } + + /// Set the document's selection to exactly these entity handles + /// (clearing any existing selection first). The missing piece for + /// driving a constraint command via `ocs.command()`: those read a prior + /// selection rather than picks fed as command-line tokens. Errors, + /// without partially applying, if any handle doesn't exist. + #[cfg(any( + feature = "experimental-command-replay", + feature = "experimental-host-model" + ))] + #[pyfunction] + fn select(handles: Vec, vm: &VirtualMachine) -> PyResult<()> { + let handles: Vec = handles.into_iter().map(Handle::new).collect(); + let result = host_ctx::with_host(|host| host.set_selection(&handles)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + result.map_err(|e| vm.new_runtime_error(format!("ocs.select(...): {e}"))) + } + + fn kind_name(kind: ReaderEntityKind) -> &'static str { + match kind { + ReaderEntityKind::Point => "point", + ReaderEntityKind::Line => "line", + ReaderEntityKind::Circle => "circle", + ReaderEntityKind::Arc => "arc", + ReaderEntityKind::Polyline => "polyline", + ReaderEntityKind::Text => "text", + ReaderEntityKind::Other => "other", + } + } + + // ════════════════════════════════════════════════════════════════════ + // Generic entity CRUD (Phase 0 — see this session's + // `project_opencad_python_generic_entity_crud_plan.md` memory). + // + // `dict_to_entity`/`entity_to_dict` below are generated at build time + // (`build.rs` + `build/generate.rs`, driven by `entity_manifest.json` + // plus the type registry `ocs_plugin_api` embeds) from the registry's + // 12 currently-traced entity kinds, wired into `ocs.add` / `ocs.update` + // and the feature-gated document model. Round trips are exercised by + // `entity_crud_tests` below. + // + // A handful of leaf conversions the generated code calls into are + // hand-written here rather than generated, matching how `vector3_to_py` + // /`py_to_vector3` above are already hand-written for XDATA: `Vector3`/ + // `Vector2`/`Handle` have no registry-driven struct shape worth + // generating (they're represented as `{"x":..,"y":..,"z":..}` / + // `{"x":..,"y":..}` dicts, matching `get()`'s existing `point` dict — + // not the `[x, y, z]` list XDATA uses), and `get_opt_*` are tiny, + // reused by every generated setter. + + fn vector2_to_py(vm: &VirtualMachine, v: &Vector2) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item("x", vm.new_pyobj(v.x), vm)?; + dict.set_item("y", vm.new_pyobj(v.y), vm)?; + Ok(dict.into()) + } + + fn py_to_vector2(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = value.try_into_value::(vm)?; + let x = get_opt_f64(&dict, "x", vm)?; + let y = get_opt_f64(&dict, "y", vm)?; + Ok(Vector2::new(x, y)) + } + + fn vector3_to_py_dict(vm: &VirtualMachine, v: &Vector3) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item("x", vm.new_pyobj(v.x), vm)?; + dict.set_item("y", vm.new_pyobj(v.y), vm)?; + dict.set_item("z", vm.new_pyobj(v.z), vm)?; + Ok(dict.into()) + } + + fn py_to_vector3_dict(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = value.try_into_value::(vm)?; + let x = get_opt_f64(&dict, "x", vm)?; + let y = get_opt_f64(&dict, "y", vm)?; + let z = get_opt_f64(&dict, "z", vm)?; + Ok(Vector3::new(x, y, z)) + } + + fn get_opt_f64( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(0.0), + Some(v) if vm.is_none(&v) => Ok(0.0), + Some(v) => v.try_into_value::(vm), + } + } + + fn get_opt_bool( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(false), + Some(v) if vm.is_none(&v) => Ok(false), + Some(v) => v.try_into_value::(vm), + } + } + + fn get_opt_u64( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(0), + Some(v) if vm.is_none(&v) => Ok(0), + Some(v) => v.try_into_value::(vm), + } + } + + fn get_opt_string( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + default: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(default.to_owned()), + Some(v) if vm.is_none(&v) => Ok(default.to_owned()), + Some(v) => v.try_into_value::(vm), + } + } + + fn ensure_known_entity_keys( + dict: &rustpython_vm::builtins::PyDictRef, + kind: &str, + allowed: &[&str], + vm: &VirtualMachine, + ) -> PyResult<()> { + for key in dict.keys_vec() { + let key = key.try_into_value::(vm)?; + if !allowed.contains(&key.as_str()) { + return Err(vm.new_value_error(format!( + "ocs: {kind} property {key:?} is outside the editable schema" + ))); + } + } + Ok(()) + } + + include!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); + + // ════════════════════════════════════════════════════════════════════ + // Phase 1 — wiring the generic conversion functions above into `ocs`. + // + // `get_entity` is deliberately a *new*, separate function rather than a + // change to `get()` above: existing scripts already depend on `get()`'s + // small `{handle, kind, layer, point}` shape (Felix's Phase 1 review — + // don't break that compatibility to hand out the fuller schema). + + /// Full entity schema for `handle` (every field the type registry knows + /// about, not just `get()`'s small `{handle, kind, layer, point}`), or + /// `None` if the entity doesn't exist or isn't one of the kinds this + /// generic API covers yet. + #[pyfunction] + fn get_entity(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + match found { + Some(entity) => entity_to_dict(vm, &entity), + None => Ok(vm.ctx.none()), + } + } + + /// API v7 document-model view: every entity has identity, kind and layer; + /// only manifest-covered kinds also expose editable geometry properties. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_descriptor(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(entity) = found else { + return Ok(vm.ctx.none()); + }; + if let Ok(full) = entity_to_dict(vm, &entity) { + let dict = full + .clone() + .try_into_value::(vm)?; + dict.set_item("_editable", vm.new_pyobj(true), vm)?; + return Ok(full); + } + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + // `entity_type()` is the DXF name (for example `HATCH`), while the + // coverage catalog uses the Rust `EntityType` variant (`Hatch`). + let kind = ocs_plugin_api::entity_coverage::entity_snapshot(&entity) + .ok() + .and_then(|value| { + value + .as_object() + .and_then(|object| object.keys().next().cloned()) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: cannot identify entity variant".to_owned()) + })?; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("layer", vm.new_pyobj(entity.common().layer.clone()), vm)?; + dict.set_item( + "owner_handle", + vm.new_pyobj(entity.common().owner_handle.value()), + vm, + )?; + dict.set_item("_editable", vm.new_pyobj(false), vm)?; + Ok(dict.into()) + } + + #[cfg(feature = "experimental-host-model")] + fn snapshot_value_to_py( + value: serde_json::Value, + vm: &VirtualMachine, + ) -> PyResult { + Ok(match value { + serde_json::Value::Null => vm.ctx.none(), + serde_json::Value::Bool(value) => vm.new_pyobj(value), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + vm.new_pyobj(value) + } else if let Some(value) = value.as_u64() { + vm.new_pyobj(value) + } else { + vm.new_pyobj(value.as_f64().expect("JSON number")) + } + } + serde_json::Value::String(value) => vm.new_pyobj(value), + serde_json::Value::Array(values) => vm + .ctx + .new_list( + values + .into_iter() + .map(|value| snapshot_value_to_py(value, vm)) + .collect::>>()?, + ) + .into(), + serde_json::Value::Object(values) => { + let dict = vm.ctx.new_dict(); + for (key, value) in values { + dict.set_item(&key, snapshot_value_to_py(value, vm)?, vm)?; + } + dict.into() + } + }) + } + + /// Copy every serializable field of an entity, including kinds outside + /// the editable Python schema. The result is detached and read-only with + /// respect to the drawing; mutations to the returned dict do not commit. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_snapshot(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(entity) = found else { + return Ok(vm.ctx.none()); + }; + let value = ocs_plugin_api::entity_coverage::entity_snapshot(&entity).map_err(|error| { + vm.new_runtime_error(format!("ocs: entity snapshot failed: {error}")) + })?; + snapshot_value_to_py(value, vm) + } + + /// Host-owned coverage for a variant. `unmapped` properties exist in the + /// typed snapshot but have no Python document-model getter or setter. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_coverage(kind: String, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::entity_coverage::{EntityCoverageCatalog, EntityScope, ModelAccess}; + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()) + .map_err(|error| vm.new_runtime_error(error.to_string()))?; + let Some(entry) = catalog + .entity_kinds + .into_iter() + .find(|entry| entry.kind == kind) + else { + return Ok(vm.ctx.none()); + }; + let row = vm.ctx.new_dict(); + row.set_item("kind", vm.new_pyobj(entry.kind), vm)?; + let scope = match entry.scope { + EntityScope::Canvas => "canvas", + EntityScope::Internal => "internal", + EntityScope::Opaque => "opaque", + }; + row.set_item("scope", vm.new_pyobj(scope), vm)?; + let mut readable = Vec::new(); + let mut editable = Vec::new(); + let mut unmapped = Vec::new(); + let mut properties = Vec::new(); + for property in entry.properties { + let access = match property.model_access { + ModelAccess::ReadWrite => { + readable.push(property.name.clone()); + editable.push(property.name.clone()); + "read_write" + } + ModelAccess::ReadOnly => { + readable.push(property.name.clone()); + "read_only" + } + ModelAccess::Unmapped => { + unmapped.push(property.name.clone()); + "unmapped" + } + }; + let item = vm.ctx.new_dict(); + item.set_item("name", vm.new_pyobj(property.name), vm)?; + item.set_item("source_path", vm.new_pyobj(property.source_path), vm)?; + item.set_item("type", vm.new_pyobj(property.type_id), vm)?; + item.set_item("optional", vm.new_pyobj(property.optional), vm)?; + item.set_item("sequence", vm.new_pyobj(property.is_sequence), vm)?; + item.set_item( + "snapshot_readable", + vm.new_pyobj(property.snapshot_readable), + vm, + )?; + item.set_item("access", vm.new_pyobj(access), vm)?; + item.set_item("validation", vm.new_pyobj(property.validation), vm)?; + properties.push(item.into()); + } + let list = |values: Vec| { + vm.ctx.new_list( + values + .into_iter() + .map(|value| vm.new_pyobj(value)) + .collect(), + ) + }; + row.set_item("readable", list(readable).into(), vm)?; + row.set_item("editable", list(editable).into(), vm)?; + row.set_item("unmapped", list(unmapped).into(), vm)?; + row.set_item("properties", vm.ctx.new_list(properties).into(), vm)?; + Ok(row.into()) + } + + /// Enumerate every kind in the host-owned coverage catalog. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_kinds(vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::entity_coverage::EntityCoverageCatalog; + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()) + .map_err(|error| vm.new_runtime_error(error.to_string()))?; + Ok(vm + .ctx + .new_list( + catalog + .entity_kinds + .into_iter() + .map(|entry| vm.new_pyobj(entry.kind)) + .collect(), + ) + .into()) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_handles(vm: &VirtualMachine) -> PyResult { + let handles = host_ctx::with_host(|host| { + host.document() + .entities() + .map(|entity| entity.common().handle.value()) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + Ok(vm + .ctx + .new_list( + handles + .into_iter() + .map(|handle| vm.new_pyobj(handle)) + .collect(), + ) + .into()) + } + + /// Add a new entity from a dict with a `"kind"` key (see `entity_to_dict` + /// for the shape `get_entity`/this function share) — a generic + /// alternative to `add_line`/`add_circle`/etc. covering every entity kind + /// the type registry currently traces. Returns the new handle. Errors if + /// `entity` is missing the geometry that actually defines the shape (a + /// line's `start`/`end`, a circle's `center`/`radius`, ...) rather than + /// silently defaulting it to zero. + #[pyfunction] + fn add(entity: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = entity.try_into_value::(vm)?; + let built = dict_to_entity(&dict, vm)?; + #[cfg(feature = "experimental-host-model")] + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&built) + .map_err(|error| vm.new_value_error(error))?; + host_ctx::with_host(|host| -> PyResult { + #[allow(unused_mut)] + let mut built = built; + #[cfg(feature = "experimental-host-model")] + { + ocs_plugin_api::entity_coverage::bind_canvas_entity_references( + host.document(), + &mut built, + ) + .map_err(|error| vm.new_value_error(error))?; + } + host_ctx::ensure_undo_started(host); + Ok(host.add_entity(built).value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + } + + /// Update `handle` by merging `entity`'s keys onto its *current* value — + /// a key `entity` doesn't mention is left exactly as it was, not reset to + /// a type default (Felix's Phase 1 review: reconstructing from a partial + /// dict could silently drop properties this generic API can't represent + /// at all, e.g. color/line-weight/XDATA). Errors if `handle` doesn't + /// exist or isn't a supported kind. + #[pyfunction] + fn update(handle: u64, entity: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + let dict = entity.try_into_value::(vm)?; + let changed = host_ctx::with_host(|host| -> PyResult { + let Some(existing) = host.document().get_entity(Handle::new(handle)).cloned() else { + return Ok(false); + }; + #[allow(unused_mut)] + let mut updated = apply_dict_to_entity(&existing, &dict, vm)?; + #[cfg(feature = "experimental-host-model")] + { + ocs_plugin_api::entity_coverage::bind_canvas_entity_references( + host.document(), + &mut updated, + ) + .map_err(|error| vm.new_value_error(error))?; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, &updated) + .map_err(|error| vm.new_value_error(error))?; + } + host_ctx::ensure_undo_started(host); + Ok(host.update_entity(updated)) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })??; + if !changed { + return Err(vm.new_runtime_error(format!("ocs.update: entity {handle} does not exist"))); + } + Ok(()) + } + + /// API v7: merge a list of property dictionaries and commit one undo step. + /// The host validates the entire batch before changing the drawing. + #[cfg(feature = "experimental-host-model")] + fn apply_layer_only_patch( + existing: &EntityType, + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, + ) -> PyResult { + if dict.keys_vec().len() != 2 || dict.get_item_opt("layer", vm)?.is_none() { + return Err(vm.new_value_error( + "ocs.update_many: this kind supports only a layer patch".to_owned(), + )); + } + let layer = dict.get_item("layer", vm)?.try_into_value::(vm)?; + ocs_plugin_api::entity_coverage::patch_canvas_layer(existing, &layer) + .map_err(|error| vm.new_value_error(format!("ocs.update_many: {error}"))) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn update_many(label: String, updates: Vec, vm: &VirtualMachine) -> PyResult<()> { + let result = host_ctx::with_host(|host| -> PyResult> { + let mut entities = Vec::with_capacity(updates.len()); + for patch in updates { + let dict = patch.try_into_value::(vm)?; + let handle = dict.get_item("handle", vm)?.try_into_value::(vm)?; + let existing = host + .document() + .get_entity(Handle::new(handle)) + .cloned() + .ok_or_else(|| { + vm.new_runtime_error(format!("entity {handle} does not exist")) + })?; + let changed = if entity_to_dict(vm, &existing).is_ok() { + apply_dict_to_entity(&existing, &dict, vm)? + } else { + apply_layer_only_patch(&existing, &dict, vm)? + }; + entities.push(changed); + } + Ok(host.update_entities_transaction(&label, entities)) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })??; + result.map_err(|error| vm.new_runtime_error(format!("ocs.update_many: {error}"))) + } + + #[cfg(test)] + mod entity_crud_tests { + use super::*; + #[cfg(feature = "experimental-host-model")] + use rustpython_vm::AsObject; + + #[cfg(feature = "experimental-host-model")] + #[test] + fn snapshot_conversion_preserves_nested_values() { + with_vm(|vm| { + let source = serde_json::json!({"Hatch": {"common": {"layer": "0"}, + "angles": [1.5, 2.0], "optional": null, "enabled": true}}); + let converted = snapshot_value_to_py(source, vm).unwrap(); + let dict = converted + .try_into_value::(vm) + .unwrap(); + let hatch = dict + .get_item("Hatch", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(); + let common = hatch + .get_item("common", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + common + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "0" + ); + assert!(hatch.get_item("optional", vm).unwrap().is(&vm.ctx.none())); + assert!(hatch + .get_item("enabled", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn unmapped_canvas_kind_accepts_only_layer_patch() { + with_vm(|vm| { + let entity = EntityType::MLine(acadrust::entities::MLine::default()); + let patch = vm.ctx.new_dict(); + patch.set_item("handle", vm.new_pyobj(1_u64), vm).unwrap(); + patch + .set_item("layer", vm.new_pyobj("HATCHES"), vm) + .unwrap(); + let changed = apply_layer_only_patch(&entity, &patch, vm).unwrap(); + assert_eq!(changed.common().layer, "HATCHES"); + patch.set_item("scale", vm.new_pyobj(2.0), vm).unwrap(); + assert!(apply_layer_only_patch(&entity, &patch, vm).is_err()); + }); + } + + fn with_vm(f: impl FnOnce(&VirtualMachine) -> R) -> R { + let interp = rustpython_vm::Interpreter::without_stdlib(Default::default()); + interp.enter(f) + } + + fn set_vector3( + vm: &VirtualMachine, + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + x: f64, + y: f64, + z: f64, + ) { + let v = vm.ctx.new_dict(); + v.set_item("x", vm.new_pyobj(x), vm).unwrap(); + v.set_item("y", vm.new_pyobj(y), vm).unwrap(); + v.set_item("z", vm.new_pyobj(z), vm).unwrap(); + dict.set_item(key, v.into(), vm).unwrap(); + } + + #[cfg(feature = "experimental-host-model")] + fn vector2(vm: &VirtualMachine, x: f64, y: f64) -> PyObjectRef { + let value = vm.ctx.new_dict(); + value.set_item("x", vm.new_pyobj(x), vm).unwrap(); + value.set_item("y", vm.new_pyobj(y), vm).unwrap(); + value.into() + } + + #[cfg(feature = "experimental-host-model")] + fn hatch_boundary(vm: &VirtualMachine, size: f64) -> PyObjectRef { + let edges = [ + (0.0, 0.0, size, 0.0), + (size, 0.0, size, size), + (size, size, 0.0, size), + (0.0, size, 0.0, 0.0), + ] + .into_iter() + .map(|(x1, y1, x2, y2)| { + let line = vm.ctx.new_dict(); + line.set_item("start", vector2(vm, x1, y1), vm).unwrap(); + line.set_item("end", vector2(vm, x2, y2), vm).unwrap(); + let edge = vm.ctx.new_dict(); + edge.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + edge.set_item("value", line.into(), vm).unwrap(); + edge.into() + }) + .collect(); + let path = vm.ctx.new_dict(); + path.set_item("flags", vm.new_pyobj(1_i64), vm).unwrap(); + path.set_item("edges", vm.ctx.new_list(edges).into(), vm) + .unwrap(); + path.set_item("boundary_handles", vm.ctx.new_list(Vec::new()).into(), vm) + .unwrap(); + path.into() + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn phase_seven_kinds_create_and_preserve_unmentioned_properties() { + with_vm(|vm| { + for kind in ["Ray", "XLine", "Solid", "Face3D"] { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj(kind), vm).unwrap(); + input + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + match kind { + "Ray" | "XLine" => { + set_vector3(vm, &input, "base_point", 1.0, 2.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + } + _ => { + for (name, x, y) in [ + ("first_corner", 0.0, 0.0), + ("second_corner", 1.0, 0.0), + ("third_corner", 0.0, 1.0), + ("fourth_corner", 1.0, 1.0), + ] { + set_vector3(vm, &input, name, x, y, 0.0); + } + } + } + if kind == "Solid" { + input.set_item("is_trace", vm.new_pyobj(true), vm).unwrap(); + } + if kind == "Face3D" { + input + .set_item("invisible_edges", vm.new_pyobj(5_i64), vm) + .unwrap(); + } + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let rendered = entity_to_dict(vm, &original) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + kind + ); + let patch = vm.ctx.new_dict(); + let changed_field = if matches!(kind, "Ray" | "XLine") { + "base_point" + } else { + "first_corner" + }; + set_vector3(vm, &patch, changed_field, 7.0, 8.0, 0.0); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &updated) + .unwrap(); + let updated_dict = entity_to_dict(vm, &updated) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + updated_dict + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Geometry" + ); + if kind == "Solid" { + assert!(updated_dict + .get_item("is_trace", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + } + if kind == "Face3D" { + assert_eq!( + updated_dict + .get_item("invisible_edges", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 5 + ); + } + } + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn face3d_flags_reject_integer_overflow() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Face3D"), vm).unwrap(); + for name in [ + "first_corner", + "second_corner", + "third_corner", + "fourth_corner", + ] { + set_vector3(vm, &input, name, 0.0, 0.0, 0.0); + } + input + .set_item("invisible_edges", vm.new_pyobj(256_i64), vm) + .unwrap(); + assert!(dict_to_entity(&input, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn insert_is_update_only_and_preserves_block_identity() { + with_vm(|vm| { + let mut insert = + acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0)); + insert.set_x_scale(2.0); + let original = EntityType::Insert(insert); + let rendered = entity_to_dict(vm, &original) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("block_name", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "DOOR" + ); + assert_eq!( + rendered + .get_item("x_scale", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 2.0 + ); + assert!(dict_to_entity(&rendered, vm).is_err()); + + let patch = vm.ctx.new_dict(); + set_vector3(vm, &patch, "insert_point", 4.0, 5.0, 0.0); + patch.set_item("y_scale", vm.new_pyobj(3.0), vm).unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &updated) + .unwrap(); + let EntityType::Insert(updated) = updated else { + panic!("expected Insert"); + }; + assert_eq!(updated.block_name, "DOOR"); + assert_eq!(updated.insert_point, Vector3::new(4.0, 5.0, 0.0)); + assert_eq!(updated.x_scale(), 2.0); + assert_eq!(updated.y_scale(), 3.0); + + patch + .set_item("block_name", vm.new_pyobj("OTHER"), vm) + .unwrap(); + assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); + patch.del_item("block_name", vm).unwrap(); + patch.set_item("z_scale", vm.new_pyobj(0.0), vm).unwrap(); + assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn tolerance_converts_and_style_change_clears_stale_handle() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("Tolerance"), vm) + .unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + input + .set_item("text", vm.new_pyobj("{\\Fgdt;p}%%v0.1"), vm) + .unwrap(); + input + .set_item("dimension_style_name", vm.new_pyobj("Standard"), vm) + .unwrap(); + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let EntityType::Tolerance(mut with_handle) = original.clone() else { + panic!("expected Tolerance"); + }; + with_handle.dimension_style_handle = Some(Handle::new(42)); + let original = EntityType::Tolerance(with_handle); + let patch = vm.ctx.new_dict(); + patch + .set_item("text", vm.new_pyobj("{\\Fgdt;p}%%v0.2"), vm) + .unwrap(); + patch + .set_item("dimension_style_name", vm.new_pyobj("ISO-25"), vm) + .unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + let EntityType::Tolerance(updated) = updated else { + panic!("expected Tolerance"); + }; + assert_eq!(updated.text, "{\\Fgdt;p}%%v0.2"); + assert_eq!(updated.dimension_style_name, "ISO-25"); + assert_eq!(updated.dimension_style_handle, None); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + let rendered = entity_to_dict(vm, &EntityType::Tolerance(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert!(rendered + .get_item_opt("dimension_style_handle", vm) + .unwrap() + .is_some()); + assert!(rendered + .get_item_opt("dwg_unknown_short", vm) + .unwrap() + .is_none()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn shape_converts_and_style_change_clears_stale_handle() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Shape"), vm).unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + input.set_item("size", vm.new_pyobj(2.0), vm).unwrap(); + input + .set_item("shape_name", vm.new_pyobj("ARROW"), vm) + .unwrap(); + input + .set_item("shape_number", vm.new_pyobj(1_i32), vm) + .unwrap(); + input + .set_item("style_name", vm.new_pyobj("Symbols"), vm) + .unwrap(); + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let EntityType::Shape(mut with_handle) = original.clone() else { + panic!("expected Shape"); + }; + with_handle.style_handle = Some(Handle::new(42)); + let original = EntityType::Shape(with_handle); + let patch = vm.ctx.new_dict(); + patch.set_item("size", vm.new_pyobj(3.0), vm).unwrap(); + patch + .set_item("style_name", vm.new_pyobj("OtherSymbols"), vm) + .unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + let EntityType::Shape(updated) = updated else { + panic!("expected Shape"); + }; + assert_eq!(updated.size, 3.0); + assert_eq!(updated.shape_name, "ARROW"); + assert_eq!(updated.shape_number, 1); + assert_eq!(updated.style_name, "OtherSymbols"); + assert_eq!(updated.style_handle, None); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn attribute_definition_converts_owner_and_preserves_embedded_mtext() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("AttributeDefinition"), vm) + .unwrap(); + input + .set_item("owner_handle", vm.new_pyobj(42_u64), vm) + .unwrap(); + input.set_item("tag", vm.new_pyobj("PART_NO"), vm).unwrap(); + input + .set_item("prompt", vm.new_pyobj("Part number"), vm) + .unwrap(); + input + .set_item("default_value", vm.new_pyobj("PN-001"), vm) + .unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + input.set_item("height", vm.new_pyobj(2.5), vm).unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + let EntityType::AttributeDefinition(mut definition) = entity else { + panic!("expected AttributeDefinition"); + }; + assert_eq!(definition.common.owner_handle, Handle::new(42)); + assert_eq!(definition.tag, "PART_NO"); + assert_eq!(definition.text_style, "Standard"); + + let mut embedded = acadrust::entities::MText::default(); + embedded.value = "first\\Psecond".into(); + definition.embedded_mtext = Some(Box::new(embedded)); + let entity = EntityType::AttributeDefinition(definition); + let patch = vm.ctx.new_dict(); + patch + .set_item("default_value", vm.new_pyobj("PN-002"), vm) + .unwrap(); + set_vector3(vm, &patch, "insertion_point", 4.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::AttributeDefinition(updated) = updated else { + panic!("expected AttributeDefinition"); + }; + assert_eq!(updated.default_value, "PN-002"); + assert_eq!(updated.insertion_point, Vector3::new(4.0, 5.0, 0.0)); + assert_eq!( + updated.embedded_mtext.as_ref().unwrap().value, + "first\\Psecond" + ); + + let rendered = entity_to_dict(vm, &EntityType::AttributeDefinition(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("owner_handle", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 42 + ); + assert!(rendered + .get_item_opt("embedded_mtext", vm) + .unwrap() + .is_none()); + + let owner_patch = vm.ctx.new_dict(); + owner_patch + .set_item("owner_handle", vm.new_pyobj(43_u64), vm) + .unwrap(); + assert!(apply_dict_to_entity(&entity, &owner_patch, vm) + .unwrap_err() + .args() + .as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap() + .contains("read-only after creation")); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn attribute_entity_converts_nested_identity_and_preserves_embedded_mtext() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("AttributeEntity"), vm) + .unwrap(); + input + .set_item("owner_handle", vm.new_pyobj(50_u64), vm) + .unwrap(); + input.set_item("tag", vm.new_pyobj("PART_NO"), vm).unwrap(); + input.set_item("value", vm.new_pyobj("PN-101"), vm).unwrap(); + set_vector3(vm, &input, "insertion_point", 10.0, 2.0, 0.0); + input.set_item("height", vm.new_pyobj(2.5), vm).unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + let EntityType::AttributeEntity(mut attribute) = entity else { + panic!("expected AttributeEntity"); + }; + assert_eq!(attribute.common.owner_handle, Handle::new(50)); + attribute.attdef_handle = Handle::new(42); + let mut embedded = acadrust::entities::MText::default(); + embedded.value = "first\\Psecond".into(); + attribute.embedded_mtext = Some(Box::new(embedded)); + let entity = EntityType::AttributeEntity(attribute); + + let patch = vm.ctx.new_dict(); + patch.set_item("value", vm.new_pyobj("PN-102"), vm).unwrap(); + set_vector3(vm, &patch, "insertion_point", 12.0, 3.0, 0.0); + let updated_entity = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::AttributeEntity(updated) = &updated_entity else { + unreachable!() + }; + assert_eq!(updated.value, "PN-102"); + assert_eq!(updated.attdef_handle, Handle::new(42)); + assert_eq!( + updated.embedded_mtext.as_ref().unwrap().value, + "first\\Psecond" + ); + + let tag_patch = vm.ctx.new_dict(); + tag_patch + .set_item("tag", vm.new_pyobj("SERIAL"), vm) + .unwrap(); + let retagged = apply_dict_to_entity(&updated_entity, &tag_patch, vm).unwrap(); + assert!(matches!(retagged, EntityType::AttributeEntity(value) + if value.tag == "SERIAL" && value.attdef_handle == Handle::new(42))); + let rendered = entity_to_dict(vm, &updated_entity) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("attdef_handle", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 42 + ); + assert!(rendered + .get_item_opt("embedded_mtext", vm) + .unwrap() + .is_none()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn hatch_converts_tagged_boundaries_patterns_and_preserves_gradient() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Hatch"), vm).unwrap(); + input + .set_item( + "paths", + vm.ctx.new_list(vec![hatch_boundary(vm, 10.0)]).into(), + vm, + ) + .unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity).unwrap(); + let EntityType::Hatch(mut hatch) = entity else { + panic!("expected Hatch") + }; + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + let entity = EntityType::Hatch(hatch); + + let pattern_line = vm.ctx.new_dict(); + pattern_line + .set_item("angle", vm.new_pyobj(0.0), vm) + .unwrap(); + pattern_line + .set_item("base_point", vector2(vm, 0.0, 0.0), vm) + .unwrap(); + pattern_line + .set_item("offset", vector2(vm, 0.0, 2.0), vm) + .unwrap(); + pattern_line + .set_item( + "dash_lengths", + vm.ctx + .new_list(vec![vm.new_pyobj(1.0), vm.new_pyobj(-1.0)]) + .into(), + vm, + ) + .unwrap(); + let pattern = vm.ctx.new_dict(); + pattern.set_item("name", vm.new_pyobj("TEST"), vm).unwrap(); + pattern + .set_item("description", vm.new_pyobj("test pattern"), vm) + .unwrap(); + pattern + .set_item( + "lines", + vm.ctx.new_list(vec![pattern_line.into()]).into(), + vm, + ) + .unwrap(); + let patch = vm.ctx.new_dict(); + patch.set_item("is_solid", vm.new_pyobj(false), vm).unwrap(); + patch.set_item("pattern", pattern.into(), vm).unwrap(); + patch + .set_item("pattern_scale", vm.new_pyobj(2.0), vm) + .unwrap(); + patch + .set_item( + "paths", + vm.ctx.new_list(vec![hatch_boundary(vm, 12.0)]).into(), + vm, + ) + .unwrap(); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&entity, &updated) + .unwrap(); + let EntityType::Hatch(updated) = updated else { + unreachable!() + }; + assert!(!updated.is_solid); + assert_eq!(updated.pattern.name, "TEST"); + assert_eq!(updated.pattern.lines.len(), 1); + assert_eq!(updated.paths[0].edges.len(), 4); + assert!(updated.gradient_color.enabled); + let rendered = entity_to_dict(vm, &EntityType::Hatch(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert!(rendered + .get_item_opt("gradient_color", vm) + .unwrap() + .is_none()); + assert_eq!( + rendered + .get_item("paths", vm) + .unwrap() + .try_into_value::>(vm) + .unwrap() + .len(), + 1 + ); + let forbidden = vm.ctx.new_dict(); + forbidden + .set_item("gradient_color", vm.ctx.new_dict().into(), vm) + .unwrap(); + assert!(apply_dict_to_entity(&entity, &forbidden, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn phase_seven_kinds_survive_dwg_round_trip() { + with_vm(|vm| { + let mut doc = acadrust::CadDocument::new(); + let mut handles = Vec::new(); + for kind in ["Ray", "XLine", "Solid", "Face3D"] { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj(kind), vm).unwrap(); + if matches!(kind, "Ray" | "XLine") { + set_vector3(vm, &input, "base_point", 2.0, 3.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + } else { + for (name, x, y) in [ + ("first_corner", 0.0, 0.0), + ("second_corner", 1.0, 0.0), + ("third_corner", 0.0, 1.0), + ("fourth_corner", 1.0, 1.0), + ] { + set_vector3(vm, &input, name, x, y, 0.0); + } + } + let entity = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity).unwrap(); + handles.push((kind, doc.add_entity(entity).unwrap())); + } + let path = std::env::temp_dir() + .join(format!("ocs_phase_seven_{}.dwg", std::process::id())); + acadrust::DwgWriter::write_to_file(&path, &doc).unwrap(); + let reopened = acadrust::DwgReader::from_file(&path) + .unwrap() + .read() + .unwrap(); + let _ = std::fs::remove_file(&path); + for (kind, handle) in handles { + let entity = reopened.get_entity(handle).expect("entity survives reopen"); + let rendered = entity_to_dict(vm, entity) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + kind + ); + } + }); + } + + #[test] + fn text_is_in_editable_schema_and_keeps_unmentioned_fields() { + with_vm(|vm| { + let mut text = acadrust::entities::Text::new(); + text.value = "Before".into(); + text.height = 3.5; + text.insertion_point = Vector3::new(1.0, 2.0, 0.0); + let entity = EntityType::Text(text); + let data = entity_to_dict(vm, &entity).unwrap(); + let data = data + .try_into_value::(vm) + .unwrap(); + assert_eq!( + data.get_item("text", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Before" + ); + let patch = vm.ctx.new_dict(); + patch.set_item("text", vm.new_pyobj("After"), vm).unwrap(); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::Text(updated) = updated else { + panic!("expected text"); + }; + assert_eq!(updated.value, "After"); + assert_eq!(updated.height, 3.5); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + }); + } + + #[test] + fn circle_round_trips_through_dict_to_entity_and_back() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap(); + dict.set_item("layer", vm.new_pyobj("Walls"), vm).unwrap(); + set_vector3(vm, &dict, "center", 1.0, 2.0, 0.0); + dict.set_item("radius", vm.new_pyobj(5.0), vm).unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::Circle(circle) = &entity else { + panic!("expected Circle, got {entity:?}"); + }; + assert_eq!(circle.common.layer, "Walls"); + assert_eq!(circle.center, Vector3::new(1.0, 2.0, 0.0)); + assert_eq!(circle.radius, 5.0); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert_eq!( + round_tripped + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Circle" + ); + assert_eq!( + round_tripped + .get_item("radius", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 5.0 + ); + }); + } + + #[test] + fn add_rejects_missing_required_geometry_instead_of_defaulting_to_zero() { + with_vm(|vm| { + // Felix's Phase 1 review: a Circle with no center/radius must + // error, not silently become a zero-radius circle at the origin. + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("missing radius/center must error"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!( + message.contains("requires"), + "unexpected message: {message}" + ); + + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("missing vertices must error"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!( + message.contains("at least one"), + "unexpected message: {message}" + ); + + // An explicitly empty vertex list is exactly as invalid as an + // absent one — both parse to the same empty Vec. + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + dict.set_item("vertices", vm.ctx.new_list(vec![]).into(), vm) + .unwrap(); + assert!(dict_to_entity(&dict, vm).is_err()); + }); + } + + #[test] + fn polyline_flags_override_exposes_closed_as_a_plain_bool() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + dict.set_item("closed", vm.new_pyobj(true), vm).unwrap(); + let vertex = vm.ctx.new_dict(); + let location = vm.ctx.new_dict(); + location.set_item("x", vm.new_pyobj(0.0), vm).unwrap(); + location.set_item("y", vm.new_pyobj(0.0), vm).unwrap(); + location.set_item("z", vm.new_pyobj(0.0), vm).unwrap(); + vertex.set_item("location", location.into(), vm).unwrap(); + dict.set_item("vertices", vm.ctx.new_list(vec![vertex.into()]).into(), vm) + .unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::Polyline(polyline) = &entity else { + panic!("expected Polyline, got {entity:?}"); + }; + assert!(polyline.flags.is_closed()); + assert_eq!(polyline.vertices.len(), 1); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert!(round_tripped + .get_item("closed", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + }); + } + + #[test] + fn mtext_round_trips_unit_enum_and_excluded_fields() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("MText"), vm).unwrap(); + dict.set_item("text", vm.new_pyobj("hello"), vm).unwrap(); + set_vector3(vm, &dict, "insertion", 0.0, 0.0, 0.0); + dict.set_item("attachment_point", vm.new_pyobj("MiddleCenter"), vm) + .unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::MText(text) = &entity else { + panic!("expected MText, got {entity:?}"); + }; + assert_eq!(text.value, "hello"); + assert_eq!( + text.attachment_point, + acadrust::entities::AttachmentPoint::MiddleCenter + ); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert_eq!( + round_tripped + .get_item("attachment_point", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "MiddleCenter" + ); + assert!(round_tripped + .get_item_opt("background_color", vm) + .unwrap() + .is_none()); + }); + } + + #[test] + fn update_preserves_fields_the_dict_does_not_mention() { + with_vm(|vm| { + let add_dict = vm.ctx.new_dict(); + add_dict.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + add_dict + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_dict, "start", 0.0, 0.0, 0.0); + set_vector3(vm, &add_dict, "end", 10.0, 0.0, 0.0); + let existing = dict_to_entity(&add_dict, vm).expect("dict_to_entity"); + + // Only "end" is mentioned — "start" and "layer" must survive + // untouched, unlike a fresh `dict_to_entity` from this same + // partial dict (which would reset them to their type defaults). + let update_dict = vm.ctx.new_dict(); + set_vector3(vm, &update_dict, "end", 20.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&existing, &update_dict, vm) + .expect("apply_dict_to_entity"); + + let acadrust::EntityType::Line(line) = &updated else { + panic!("expected Line, got {updated:?}"); + }; + assert_eq!(line.common.layer, "Geometry", "layer must be preserved"); + assert_eq!( + line.start, + Vector3::new(0.0, 0.0, 0.0), + "start must be preserved" + ); + assert_eq!( + line.end, + Vector3::new(20.0, 5.0, 0.0), + "end must be updated" + ); + + // A second update, this time touching the layer, must not + // disturb the geometry it doesn't mention either. + let update_dict = vm.ctx.new_dict(); + update_dict + .set_item("layer", vm.new_pyobj("Renamed"), vm) + .unwrap(); + let updated = + apply_dict_to_entity(&updated, &update_dict, vm).expect("apply_dict_to_entity"); + let acadrust::EntityType::Line(line) = &updated else { + panic!("expected Line, got {updated:?}"); + }; + assert_eq!(line.common.layer, "Renamed"); + assert_eq!(line.start, Vector3::new(0.0, 0.0, 0.0)); + assert_eq!(line.end, Vector3::new(20.0, 5.0, 0.0)); + }); + } + + /// The concrete Phase 1 acceptance test Felix asked for: create, read, + /// edit, delete, save, and reopen a Line and an Ellipse, with common + /// properties preserved — exercised against a real + /// `acadrust::CadDocument` and a real DWG round trip (not a fake + /// `HostApi`; `dict_to_entity`/`apply_dict_to_entity`/`entity_to_dict` + /// are exactly what a live `HostApi`-backed `ocs.add`/`ocs.update`/ + /// `ocs.get_entity` call into, so this covers the same logic without + /// needing a from-scratch test double for the whole `HostApi` trait). + #[test] + fn line_and_ellipse_survive_create_read_edit_delete_save_reopen() { + with_vm(|vm| { + let mut doc = acadrust::CadDocument::new(); + // A layer must exist in the document's layer table before an + // entity can reference it — same requirement `set_entity_layer` + // above already enforces for a live document. Without this, + // the DWG writer silently falls back the entity to layer "0" + // instead of erroring, which this test caught the first time + // around (it used "Geometry" without registering it first). + doc.layers.add(acadrust::Layer::new("Geometry")).unwrap(); + + // --- CREATE --- + let add_line = vm.ctx.new_dict(); + add_line.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + add_line + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_line, "start", 0.0, 0.0, 0.0); + set_vector3(vm, &add_line, "end", 10.0, 5.0, 0.0); + let line_handle = doc + .add_entity(dict_to_entity(&add_line, vm).expect("dict_to_entity Line")) + .expect("doc.add_entity Line"); + + let add_ellipse = vm.ctx.new_dict(); + add_ellipse + .set_item("kind", vm.new_pyobj("Ellipse"), vm) + .unwrap(); + add_ellipse + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_ellipse, "center", 1.0, 2.0, 0.0); + set_vector3(vm, &add_ellipse, "major_axis", 5.0, 0.0, 0.0); + add_ellipse + .set_item("minor_axis_ratio", vm.new_pyobj(0.5), vm) + .unwrap(); + let ellipse_handle = doc + .add_entity(dict_to_entity(&add_ellipse, vm).expect("dict_to_entity Ellipse")) + .expect("doc.add_entity Ellipse"); + + // --- READ --- + let line_dict = + entity_to_dict(vm, doc.get_entity(line_handle).expect("line exists")) + .expect("entity_to_dict Line"); + assert_eq!( + line_dict + .clone() + .try_into_value::(vm) + .unwrap() + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Geometry" + ); + + // --- EDIT (update-merge: only "end" changes) --- + let existing = doc.get_entity(line_handle).unwrap().clone(); + let edit_dict = vm.ctx.new_dict(); + set_vector3(vm, &edit_dict, "end", 20.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&existing, &edit_dict, vm) + .expect("apply_dict_to_entity Line"); + *doc.get_entity_mut(line_handle).unwrap() = updated; + + let acadrust::EntityType::Line(line) = doc.get_entity(line_handle).unwrap() else { + panic!("expected Line"); + }; + assert_eq!( + line.common.layer, "Geometry", + "layer survives an unrelated edit" + ); + assert_eq!( + line.start, + Vector3::new(0.0, 0.0, 0.0), + "start survives an unrelated edit" + ); + assert_eq!( + line.end, + Vector3::new(20.0, 5.0, 0.0), + "end reflects the edit" + ); + + // --- DELETE --- + assert!(doc.remove_entity(ellipse_handle).is_some()); + assert!(doc.get_entity(ellipse_handle).is_none()); + + // --- SAVE / REOPEN --- + let path = std::env::temp_dir().join(format!( + "ocs_entity_crud_acceptance_test_{}.dwg", + std::process::id() + )); + acadrust::DwgWriter::write_to_file(&path, &doc).expect("write dwg"); + let reopened = acadrust::DwgReader::from_file(&path) + .expect("open dwg") + .read() + .expect("read dwg"); + let _ = std::fs::remove_file(&path); + + let acadrust::EntityType::Line(reopened_line) = reopened + .get_entity(line_handle) + .expect("line survives save/reopen") + else { + panic!("expected Line"); + }; + assert_eq!( + reopened_line.common.layer, "Geometry", + "layer survives a real DWG round trip" + ); + assert_eq!( + reopened_line.start, + Vector3::new(0.0, 0.0, 0.0), + "start survives a real DWG round trip" + ); + assert_eq!( + reopened_line.end, + Vector3::new(20.0, 5.0, 0.0), + "the edit survives a real DWG round trip" + ); + assert!( + reopened.get_entity(ellipse_handle).is_none(), + "the deleted ellipse must not reappear after reopen" + ); + }); + } + } +} + +/// These run a real RustPython interpreter with the `ocs` module registered +/// (same setup as `lib.rs`'s `run_eval`), but with no `HostGuard` active — so +/// `host_ctx::with_host` always returns `None`. `add_points` and +/// `write_record` both finish all of their own argument/dict parsing +/// *before* calling `host_ctx::with_host`, so getting exactly the sentinel +/// "not running inside a PY_ command" `RuntimeError` (rather than a +/// `TypeError`/`ValueError` from the parsing itself) confirms the new +/// RustPython glue code — `Vec` extraction, `PyDictRef` +/// downcasting, `try_into_value` for every XDATA value kind — actually works +/// against the real interpreter, without needing a live `HostApi`. +#[cfg(test)] +mod tests { + use super::module_def; + + #[cfg(feature = "experimental-host-model")] + #[test] + fn host_coverage_matches_generated_python_keys() { + use ocs_plugin_api::entity_coverage::{EntityCoverageCatalog, ModelAccess}; + use std::collections::{BTreeMap, BTreeSet}; + + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()).unwrap(); + let generated = include_str!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); + let converter = generated + .split("pub(crate) fn entity_to_dict") + .nth(1) + .unwrap() + .split("pub(crate) fn dict_to_entity") + .next() + .unwrap(); + let mut emitted: BTreeMap> = BTreeMap::new(); + let mut current = None; + for line in converter.lines() { + let line = line.trim(); + if let Some(arm) = line.strip_prefix("acadrust::EntityType::") { + if let Some((kind, _)) = arm.split_once("(value) => {") { + current = Some(kind.to_owned()); + emitted.entry(kind.to_owned()).or_default(); + } + } + if let (Some(kind), Some(key)) = (¤t, line.strip_prefix("dict.set_item(\"")) { + let key = key.split('"').next().unwrap(); + emitted.get_mut(kind).unwrap().insert(key.to_owned()); + } + if line == "}" { + current = None; + } + } + for entry in &catalog.entity_kinds { + let expected: BTreeSet<_> = entry + .properties + .iter() + .filter(|property| property.model_access != ModelAccess::Unmapped) + .map(|property| property.name.clone()) + .collect(); + if let Some(actual) = emitted.get(&entry.kind) { + assert_eq!(&expected, actual, "{}", entry.kind); + } else { + assert_eq!( + expected, + BTreeSet::from([ + "handle".to_owned(), + "kind".to_owned(), + "layer".to_owned(), + "owner_handle".to_owned() + ]), + "{}", + entry.kind + ); + } + } + assert_eq!(emitted.len(), 22); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn entity_snapshot_uses_catalog_variant_name() { + use ocs_plugin_api::host::acadrust; + let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default()); + let value = serde_json::to_value(hatch).unwrap(); + let kind = value.as_object().unwrap().keys().next().unwrap(); + assert_eq!(kind, "Hatch"); + assert_ne!(kind, "HATCH"); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn python_can_query_host_coverage_without_document() { + assert_eq!(eval("len(ocs.entity_kinds())"), Ok("48".to_owned())); + assert_eq!( + eval("'paths' in ocs.entity_coverage('Hatch')['editable']"), + Ok("True".to_owned()) + ); + assert_eq!( + eval("ocs.entity_coverage('Line')['scope']"), + Ok("canvas".to_owned()) + ); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn update_many_binding_parses_python_batch() { + let error = eval("ocs.update_many('Move', [{'handle': 1, 'layer': '0'}])").unwrap_err(); + assert!( + error.contains("not running inside a PY_ command"), + "{error}" + ); + } + + // Runs `source` (a hardcoded literal in each test below, never external + // input) as a single expression inside a fresh, stdlib-free RustPython + // interpreter with only the `ocs` module registered — the same sandboxed + // `Mode::Eval` setup `lib.rs`'s `run_eval` uses for real `PY_EVAL` + // commands. Not a generic "eval untrusted string" helper. + fn eval(source: &str) -> Result { + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = 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).map_err(|exc| exc_string(vm, &exc))?; + scope + .globals + .set_item("ocs", ocs, vm) + .map_err(|exc| exc_string(vm, &exc))?; + let code_obj = vm + .compile( + source, + rustpython_vm::compiler::Mode::Eval, + "".to_string(), + ) + .map_err(|err| err.to_string())?; + match vm.run_code_obj(code_obj, scope) { + Ok(result) => result + .str(vm) + .map(|s| s.to_string()) + .map_err(|exc| exc_string(vm, &exc)), + Err(exc) => Err(exc_string(vm, &exc)), + } + }) + } + + fn exc_string( + vm: &rustpython_vm::VirtualMachine, + exc: &rustpython_vm::builtins::PyBaseExceptionRef, + ) -> String { + let obj: rustpython_vm::PyObjectRef = exc.clone().into(); + let name = obj.class().name().to_string(); + let message = obj.str(vm).map(|s| s.to_string()).unwrap_or_default(); + format!("{name}: {message}") + } + + const NO_HOST: &str = "RuntimeError: ocs: not running inside a PY_ command"; + + #[test] + fn add_points_parses_before_reaching_the_host() { + let result = eval("ocs.add_points([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])"); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn add_points_rejects_wrong_point_shape() { + let result = eval("ocs.add_points([[1.0, 2.0]])"); + assert!(matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add_points"))); + } + + #[test] + fn add_points_rejects_empty_list() { + let result = eval("ocs.add_points([])"); + assert!(matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add_points"))); + } + + #[test] + fn write_record_parses_every_xdata_kind_before_reaching_the_host() { + let result = eval( + r#"ocs.write_record(1, "TEST", [ + {"kind": "String", "value": "hi"}, + {"kind": "ControlString", "value": "{"}, + {"kind": "LayerName", "value": "0"}, + {"kind": "BinaryData", "value": b"\x01\x02"}, + {"kind": "Handle", "value": 7}, + {"kind": "Point3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Position3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Displacement3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Direction3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Real", "value": 1.5}, + {"kind": "Distance", "value": 2.5}, + {"kind": "ScaleFactor", "value": 1.0}, + {"kind": "Integer16", "value": 16}, + {"kind": "Integer32", "value": 32}, + ])"#, + ); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn write_record_rejects_unknown_kind() { + let result = eval(r#"ocs.write_record(1, "TEST", [{"kind": "Nope", "value": 1}])"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: unsupported XDATA value kind: Nope") + )); + } + + #[test] + fn write_record_rejects_malformed_point_value() { + let result = + eval(r#"ocs.write_record(1, "TEST", [{"kind": "Point3D", "value": [1.0, 2.0]}])"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: XDATA Point3D") + )); + } + + #[test] + fn read_record_and_remove_record_parse_before_reaching_the_host() { + assert_eq!( + eval(r#"ocs.read_record(1, "TEST")"#), + Err(NO_HOST.to_string()) + ); + assert_eq!( + eval(r#"ocs.remove_record(1, "TEST")"#), + Err(NO_HOST.to_string()) + ); + } + + #[test] + fn add_parses_before_reaching_the_host() { + let result = eval( + r#"ocs.add({"kind": "Line", "start": {"x": 0.0, "y": 0.0, "z": 0.0}, "end": {"x": 1.0, "y": 1.0, "z": 0.0}})"#, + ); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn add_rejects_missing_required_geometry_before_reaching_the_host() { + let result = eval(r#"ocs.add({"kind": "Circle"})"#); + assert!( + matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add: Circle requires")), + "unexpected result: {result:?}" + ); + } + + #[test] + fn add_rejects_unknown_kind() { + let result = eval(r#"ocs.add({"kind": "Nope"})"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: ocs: unsupported entity kind: Nope") + )); + } + + #[test] + fn update_and_get_entity_hit_the_host_sentinel() { + assert_eq!( + eval(r#"ocs.update(1, {"layer": "X"})"#), + Err(NO_HOST.to_string()) + ); + assert_eq!(eval("ocs.get_entity(1)"), Err(NO_HOST.to_string())); + } + + #[test] + fn add_text_left_validates_before_reaching_host() { + assert_eq!( + eval(r#"ocs.add_text_left("Note", 0.0, 0.0, 0.0, 2.0, 0.0)"#), + Err(NO_HOST.to_string()) + ); + let bad = eval(r#"ocs.add_text_left("Note", 0.0, 0.0, 0.0, 0.0, 0.0)"#); + assert!(matches!(bad, Err(ref msg) if msg.starts_with("ValueError: ocs.add_text_left:"))); + } +} diff --git a/plugins/opencad-python/src/selection_cache.rs b/plugins/opencad-python/src/selection_cache.rs new file mode 100644 index 000000000..8a6313462 --- /dev/null +++ b/plugins/opencad-python/src/selection_cache.rs @@ -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> = Mutex::new(Vec::new()); + +pub(crate) fn set(handles: Vec) { + if let Ok(mut cache) = CACHE.lock() { + *cache = handles; + } +} + +pub(crate) fn get() -> Vec { + CACHE.lock().map(|cache| cache.clone()).unwrap_or_default() +} diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py new file mode 100644 index 000000000..bbb746616 --- /dev/null +++ b/plugins/opencad-python/tests/test_document_model.py @@ -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() diff --git a/plugins/opencad-python/tools/stage-bundled.sh b/plugins/opencad-python/tools/stage-bundled.sh new file mode 100755 index 000000000..65da83240 --- /dev/null +++ b/plugins/opencad-python/tools/stage-bundled.sh @@ -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 [--debug] [--target ]" >&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" diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 493696def..2edf4be6b 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -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, /// 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 { 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 { + 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 { - 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 { + 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) { + 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 { } 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 { 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(); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index a1965b8be..1ac31a2ab 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -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:?}" ); } -} \ No newline at end of file +} diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 341fc09a6..753038da7 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -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)