diff --git a/.cargo/config.toml b/.cargo/config.toml index 1138309ea..b3e368cb6 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,4 @@ -# getrandom 0.3 (pulled by ahash via acadrust) needs this cfg to select its +# getrandom 0.3 (pulled by ahash via opencadcodec) needs this cfg to select its # browser backend on wasm32-unknown-unknown, alongside its `wasm_js` feature # (enabled in Cargo.toml for the wasm target). Without it the web build fails # with "wasm32-unknown-unknown targets are not supported by default". @@ -30,6 +30,8 @@ rustflags = [ "-Clink-arg=/STACK:16777216", ] +# Also sizes rustc's own threads: rustc 1.99 overflows the default 8 MB +# compiling this crate in release on Windows/macOS. [env] -RUST_MIN_STACK = "8388608" +RUST_MIN_STACK = "67108864" diff --git a/.github/workflows/mcp-conformance.yml b/.github/workflows/mcp-conformance.yml new file mode 100644 index 000000000..5b15e6ecf --- /dev/null +++ b/.github/workflows/mcp-conformance.yml @@ -0,0 +1,89 @@ +name: MCP conformance + +# Strict third-party proof for the MCP bridge (src/mcp.rs): run the official +# MCP Inspector CLI against a fresh release build and assert the stable +# surface. Headless-safe methods only — anything touching the drawing needs +# a live desktop GUI and stays in unit tests plus the pinned schema digest. +# +# Two deliberate limits, both documented: +# - The server is addressed through a --config file (array-form command). +# Passing the command positionally breaks on Windows shells and can launch +# the GUI instead of the --mcp bridge. +# - Assertions cover fields the Inspector preserves (names, titles, +# templates, handshake purity). Freshness hints (ttlMs/cacheScope) are +# asserted at unit level (legacy_list_results_carry_sep2549_ttl) and by +# direct stdio probes: this CLI normalizes result envelopes on output. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: mcp-conformance-${{ github.ref }} + cancel-in-progress: true + +jobs: + inspector: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + # Same libraries the Linux release build needs to start. + - name: System libraries + run: | + sudo apt-get update + sudo apt-get install -y \ + libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ + libxkbcommon-dev libwayland-dev libfontconfig1-dev libfreetype6-dev + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Release build + run: cargo build --release --locked + + - name: Inspector config + run: | + printf '{"mcpServers":{"ocs":{"command":"%s/target/release/OpenCADStudio","args":["--mcp"]}}}' \ + "${{ github.workspace }}" > "${{ runner.temp }}/mcp-inspector.json" + + - name: initialize (pure serverInfo, experimental build) + run: | + npx -y @modelcontextprotocol/inspector --cli \ + --config "${{ runner.temp }}/mcp-inspector.json" --server ocs \ + --method initialize --format json 2>/dev/null | jq -e ' + .result.serverInfo.name == "OpenCADStudio" + and ((.result.serverInfo | keys_unsorted | sort) == ["name","title","version"]) + and (.result.capabilities.experimental["opencadstudio.build"].build_rev | type == "string")' + + - name: tools/list (four tools with titles) + run: | + npx -y @modelcontextprotocol/inspector --cli \ + --config "${{ runner.temp }}/mcp-inspector.json" --server ocs \ + --method tools/list --format json 2>/dev/null | jq -e ' + ([.result.tools[].name] | sort) == ["ocs_capture","ocs_execute","ocs_read","ocs_sessions"] + and ([.result.tools[].title] | length == 4)' + + - name: resources/list (reachable headless) + run: | + npx -y @modelcontextprotocol/inspector --cli \ + --config "${{ runner.temp }}/mcp-inspector.json" --server ocs \ + --method resources/list --format json 2>/dev/null | jq -e ' + (.result.resources | type == "array")' + + - name: resources/templates/list (tile and snapshot templates) + run: | + npx -y @modelcontextprotocol/inspector --cli \ + --config "${{ runner.temp }}/mcp-inspector.json" --server ocs \ + --method resources/templates/list --format json 2>/dev/null | jq -e ' + ([.result.resourceTemplates[].uriTemplate] | sort) == [ + "cad://session/{session_id}/snapshot/{hash}.png", + "cad://session/{session_id}/tile/{level}/{x}/{y}.png" + ]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9769a3d7c..79e07fb89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,9 +24,17 @@ on: options: - all - snap + # Rebuild an already-published release from the commit its tag points at. + tag: + description: Existing release tag to rebuild (optional) + default: '' + type: string env: RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }} + # Mirrors .cargo/config.toml so rebuilding a commit that predates the bump + # still gives rustc 1.99 enough stack. + RUST_MIN_STACK: "67108864" jobs: build-appimage: @@ -38,7 +46,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ inputs.commit || github.sha }} + ref: ${{ inputs.commit || inputs.tag || github.sha }} # Full history so build.rs can measure the distance from the # release tag; a shallow clone would label the build `+g`. fetch-depth: 0 @@ -129,7 +137,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ inputs.commit || github.sha }} + ref: ${{ inputs.commit || inputs.tag || github.sha }} # Full history so build.rs can measure the distance from the # release tag; a shallow clone would label the build `+g`. fetch-depth: 0 @@ -204,7 +212,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ inputs.commit || github.sha }} + ref: ${{ inputs.commit || inputs.tag || github.sha }} # Full history so build.rs can measure the distance from the # release tag; a shallow clone would label the build `+g`. fetch-depth: 0 @@ -238,6 +246,7 @@ jobs: - name: Build env: OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} + shell: bash run: | cargo build --locked --release cargo build --locked --release -p dwg-thumbnailer-win @@ -385,7 +394,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ inputs.commit || github.sha }} + ref: ${{ inputs.commit || inputs.tag || github.sha }} # Full history so build.rs can measure the distance from the # release tag; a shallow clone would label the build `+g`. fetch-depth: 0 @@ -552,7 +561,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ inputs.commit || github.sha }} + ref: ${{ inputs.commit || inputs.tag || github.sha }} - name: Verify native release assets and notes env: GH_TOKEN: ${{ github.token }} diff --git a/Cargo.lock b/Cargo.lock index 2c94eff96..78908a489 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,16 +4,14 @@ version = 4 [[package]] name = "OpenCADStudio" -version = "2026.38.0" +version = "2026.40.1" dependencies = [ - "acadrust", + "allsorts", "ashpd", "base64", "bincode", "block2 0.5.1", "bytemuck", - "cadkernel", - "cadkernel-constraints", "calamine", "clap", "console_error_panic_hook", @@ -36,6 +34,7 @@ dependencies = [ "image", "inventory", "js-sys", + "kurbo 0.13.1", "libloading", "log", "lyon_tessellation", @@ -46,17 +45,24 @@ dependencies = [ "objc2-foundation 0.2.2", "ocs_plugin_api", "open", + "opencadcodec", + "opencadgraph", + "opencadkernel", + "opencadkernel-constraints", "pollster", "printpdf", "rayon", "resvg", "rfd", + "roxmltree", "rust-embed", "rustc-hash 2.1.3", "semver", "serde", "serde_json", "sha2 0.10.9", + "skrifa 0.42.1", + "tiny-skia", "ttf-parser", "unicode-bidi", "ureq", @@ -66,6 +72,7 @@ dependencies = [ "web-sys", "windows-sys 0.61.2", "winresource", + "zip", ] [[package]] @@ -84,29 +91,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" -[[package]] -name = "acadrust" -version = "0.5.5" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=dd1d7bf#dd1d7bfe03f4e733d351c0396e2cf82ae9df957d" -dependencies = [ - "ahash 0.8.12", - "anyhow", - "bitflags 2.13.1", - "byteorder", - "encoding_rs", - "flate2", - "indexmap", - "itoa", - "memmap2", - "nom 7.1.3", - "once_cell", - "rayon", - "ryu", - "serde", - "thiserror 1.0.69", - "web-time", -] - [[package]] name = "adler2" version = "2.0.1" @@ -144,7 +128,6 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", - "serde", "version_check", "zerocopy", ] @@ -920,25 +903,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cadkernel" -version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6eae7c9#6eae7c97fb29a21a818164af7c009f93e3ffe5b8" -dependencies = [ - "acadrust", - "cavalier_contours", - "rustc-hash 2.1.3", - "spade", -] - -[[package]] -name = "cadkernel-constraints" -version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6eae7c9#6eae7c97fb29a21a818164af7c009f93e3ffe5b8" -dependencies = [ - "nalgebra", -] - [[package]] name = "calamine" version = "0.36.1" @@ -4187,13 +4151,13 @@ dependencies = [ name = "ocs_plugin_api" version = "0.2.1" dependencies = [ - "acadrust", "bincode", "cargo-lock", "getrandom 0.2.17", "interprocess", "libloading", "memmap2", + "opencadcodec", "rkyv", "serde", "serde-reflection", @@ -4206,11 +4170,11 @@ dependencies = [ name = "ocs_web_worker" version = "0.1.0" dependencies = [ - "acadrust", "bincode", "console_error_panic_hook", "getrandom 0.3.4", "js-sys", + "opencadcodec", "serde", "sha2 0.10.9", "wasm-bindgen", @@ -4238,6 +4202,57 @@ dependencies = [ "libc", ] +[[package]] +name = "opencadcodec" +version = "0.6.0" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=937745d#937745d9ec1a62d252074ff41b140b92303d0dc0" +dependencies = [ + "anyhow", + "bitflags 2.13.1", + "byteorder", + "encoding_rs", + "flate2", + "foldhash 0.2.0", + "indexmap", + "itoa", + "memmap2", + "nom 7.1.3", + "once_cell", + "rayon", + "ryu", + "serde", + "thiserror 2.0.18", + "web-time", +] + +[[package]] +name = "opencadgraph" +version = "0.1.0" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=81bf45f#81bf45f57cbab5ff3784f293365279bf2d93f758" +dependencies = [ + "opencadkernel", + "serde_json", +] + +[[package]] +name = "opencadkernel" +version = "0.1.0" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=86497ae#86497ae923b7d9297707aaedb712a8678815a238" +dependencies = [ + "cavalier_contours", + "opencadcodec", + "rustc-hash 2.1.3", + "spade", +] + +[[package]] +name = "opencadkernel-constraints" +version = "0.1.0" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=86497ae#86497ae923b7d9297707aaedb712a8678815a238" +dependencies = [ + "nalgebra", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 8d5dbfc21..0d21f6f45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "OpenCADStudio" -version = "2026.38.0" +version = "2026.40.1" edition = "2021" build = "build.rs" default-run = "OpenCADStudio" +autotests = false [workspace] members = [ @@ -33,13 +34,19 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd1d7bf", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6eae7c9", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6eae7c9" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "937745d", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "86497ae", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "86497ae" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "81bf45f" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } hayro = "0.7.1" +kurbo = "0.13" +# DWF/DWFx packages, their XPS pages and DWF/DGN underlay rasters. +zip = { version = "8", default-features = false, features = ["deflate"] } +roxmltree = "0.20" +tiny-skia = "0.11" # Rasterises assets/logo.svg into the window icon at startup, so the taskbar # icon comes from the same file every other build target does. Already in the # tree behind iced's svg feature; named here only to use it directly. @@ -52,6 +59,9 @@ rustc-hash = "2" sha2 = "0.10" fontdb = "0.23" ttf-parser = "0.25" +# TrueType interpreter for hint-reliant ("tricky") fonts such as DFKai-SB and +# MingLiU, whose raw outlines are missing strokes. Already in the tree (hayro). +skrifa = "0.42.1" cosmic-text = "0.15" unicode-bidi = "0.3" lyon_tessellation = "1.0.20" @@ -64,7 +74,7 @@ naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] libloading = "0.8" -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps", "Win32_Globalization", "Win32_Security_Authentication_Identity"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } @@ -93,6 +103,11 @@ i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" printpdf = { version = "0.9.1", default-features = false, features = ["images"] } +# TrueType subsetting for searchable PDF text (same version printpdf itself +# uses, so no new binary enters the lockfile). Same feature set as printpdf's +# own allsorts use (pure-Rust flate backend) — the defaults would drag in the +# libz-sys C dependency. Native-only like printpdf. +allsorts = { version = "0.16", default-features = false, features = ["flate2_rust"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] @@ -143,9 +158,26 @@ web-sys = { version = "=0.3.85", features = [ ] } iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] } +[profile.dev] +debug = 1 + +[profile.dev.package."*"] +opt-level = 2 + +[profile.test] +debug = 1 + [profile.release] strip = true [[bench]] name = "performance_benchmarks" harness = false + +[[bench]] +name = "command_benchmarks" +harness = false + +[[test]] +name = "integration" +path = "tests/integration.rs" diff --git a/README.md b/README.md index 4bd949a2a..5759a2bd1 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,19 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ./OpenCADStudio-*-linux-x86_64.AppImage ``` +Or install it as a Flatpak from [FlatPark](https://flatpark.org/apps/io.github.HakanSeven12.OpenCadStudio), which installs the official AppImage and follows new releases: + +```bash +flatpak remote-add --if-not-exists flatpark https://dl.flatpark.org/flatpark.flatpakrepo +flatpak install flatpark io.github.HakanSeven12.OpenCadStudio +``` + +The Flatpak opens and saves drawings through the file chooser. External references and underlays that point at other files need home access: + +```bash +flatpak override --user --filesystem=home io.github.HakanSeven12.OpenCadStudio +``` + ### macOS The published macOS package supports Apple Silicon: diff --git a/assets/fonts/ltypeshp.lff b/assets/fonts/ltypeshp.lff index 9a64e3799..fd2834e73 100644 --- a/assets/fonts/ltypeshp.lff +++ b/assets/fonts/ltypeshp.lff @@ -43,3 +43,6 @@ [009F] PLANT6 0,0;2.58,-0.5;3.81,1.37;4.42,0 + +[003C] RIGHT_ARROW +0.5,0.5;0,0;0.5,-0.5 diff --git a/assets/icons/layers/layvpfrz.svg b/assets/icons/layers/layvpfrz.svg new file mode 100644 index 000000000..67da9cae9 --- /dev/null +++ b/assets/icons/layers/layvpfrz.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/layers/layvpthw.svg b/assets/icons/layers/layvpthw.svg new file mode 100644 index 000000000..525158289 --- /dev/null +++ b/assets/icons/layers/layvpthw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/model/shell.svg b/assets/icons/model/shell.svg new file mode 100644 index 000000000..f3c9079b8 --- /dev/null +++ b/assets/icons/model/shell.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/osnap/geometric_center.svg b/assets/icons/osnap/geometric_center.svg new file mode 100644 index 000000000..8f82c5570 --- /dev/null +++ b/assets/icons/osnap/geometric_center.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ui/info.svg b/assets/icons/ui/info.svg new file mode 100644 index 000000000..212f70fce --- /dev/null +++ b/assets/icons/ui/info.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ui/more.svg b/assets/icons/ui/more.svg new file mode 100644 index 000000000..50d01007a --- /dev/null +++ b/assets/icons/ui/more.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ui/node_graph.svg b/assets/icons/ui/node_graph.svg new file mode 100644 index 000000000..70175913e --- /dev/null +++ b/assets/icons/ui/node_graph.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ui/refresh.svg b/assets/icons/ui/refresh.svg new file mode 100644 index 000000000..a7da370a7 --- /dev/null +++ b/assets/icons/ui/refresh.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ui/swap.svg b/assets/icons/ui/swap.svg new file mode 100644 index 000000000..28b9a6395 --- /dev/null +++ b/assets/icons/ui/swap.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/xclip_remove.svg b/assets/icons/xclip_remove.svg new file mode 100644 index 000000000..2c808ee42 --- /dev/null +++ b/assets/icons/xclip_remove.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 1e5790c6c..cf8ba9bc6 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -65,6 +65,7 @@ PC, *PASTECLIP ; ── Blocks / references ─────────────────────────────────────────────────── ATT, *ATTDEF +-ATT, *-ATTDEF BE, *BEDIT IAT, *IMAGEATTACH XA, *XATTACH diff --git a/assets/patterns/OpenCADStudio.pat b/assets/patterns/OpenCADStudio.pat index 65b4d62ce..c722a0965 100644 --- a/assets/patterns/OpenCADStudio.pat +++ b/assets/patterns/OpenCADStudio.pat @@ -626,6 +626,25 @@ 0, 0,0, 0,.79375 90, 0,0, 0,3.175 +*GRAVEL,GRAVEL +-26.5651, 3.175,3.175, 22.7185,11.3592, 7.0995,-49.6966 +26.5651, 9.525,0, -22.7185,11.3592, 7.0995,-49.6966 +90, 15.875,3.175, 0,25.4, 6.35,-19.05 +-26.5651, 9.525,12.7, 22.7185,11.3592, 7.0995,-49.6966 +26.5651, 3.175,9.525, -22.7185,11.3592, 7.0995,-49.6966 +90, 3.175,3.175, 0,25.4, 6.35,-19.05 +45, 19.05,9.525, 17.9605,17.9605, 4.4901,-31.4309 +90, 22.225,12.7, 0,25.4, 6.35,-19.05 +-45, 19.05,22.225, 17.9605,17.9605, 4.4901,-31.4309 +63.4349, 15.875,15.875, 22.7185,11.3592, 7.0995,-49.6966 +-63.4349, 15.875,15.875, -22.7185,11.3592, 7.0995,-49.6966 +0, 3.175,15.875, 0,25.4, 6.35,-19.05 +45, 9.525,15.875, 17.9605,17.9605, 4.4901,-31.4309 +-45, 9.525,22.225, 17.9605,17.9605, 4.4901,-31.4309 +0, 3.175,22.225, 0,25.4, 6.35,-19.05 +45, 0,19.05, 17.9605,17.9605, 4.4901,-31.4309 +-45, 0,19.05, 17.9605,17.9605, 4.4901,-31.4309 + *HATCH-DOTS,HATCH-DOTS ;By John Hyslop, Tile2Hatch tool CVH 2020 ;Developed in mm as metric QCAD3 pattern diff --git a/assets/sponsors/cad-editor-mobile-dwg-viewer.png b/assets/sponsors/cad-editor-mobile-dwg-viewer.png deleted file mode 100644 index 265032df7..000000000 Binary files a/assets/sponsors/cad-editor-mobile-dwg-viewer.png and /dev/null differ diff --git a/benches/README.md b/benches/README.md index 92483d0e4..f4d7e2d0c 100644 --- a/benches/README.md +++ b/benches/README.md @@ -5,12 +5,12 @@ A standardized, statistical performance benchmarking suite measuring throughput, ## Quick Start Run the full benchmark suite with native `--release` optimizations: -```powershell +```bash cargo bench ``` Run a fast validation smoke test (~2 seconds): -```powershell +```bash cargo bench --bench performance_benchmarks -- --quick ``` @@ -66,10 +66,23 @@ cargo bench --bench performance_benchmarks -- --quick --- +## Command Benchmarks + +First benchmarked commands: **EXPLODE** (metrics `explode_geometry`, +`explode_scene_apply`) and **FLATTEN** (metrics `flatten_plan`, +`flatten_scene_apply`). + +```bash +cargo bench --bench command_benchmarks -- --filter explode +cargo bench --bench command_benchmarks -- --quick +cargo bench --bench command_benchmarks -- --filter explode --output target/explode_metrics.json +cargo bench --bench command_benchmarks -- --filter explode --baseline target/explode_before.json +``` + ## Filtering Benchmarks Filter execution by subsystem or name pattern: -```powershell +```bash # Filter by UI cargo bench --bench performance_benchmarks -- ui @@ -85,12 +98,12 @@ cargo bench --bench performance_benchmarks -- hit_test ## Baseline Tracking & Regression Detection ### 1. Save Current Metrics as Baseline -```powershell +```bash cargo bench --bench performance_benchmarks -- --output target/baseline_metrics.json ``` ### 2. Compare PR / Branch Against Baseline -```powershell +```bash cargo bench --bench performance_benchmarks -- --baseline target/baseline_metrics.json ``` diff --git a/benches/command_benchmarks.rs b/benches/command_benchmarks.rs new file mode 100644 index 000000000..3cd183a34 --- /dev/null +++ b/benches/command_benchmarks.rs @@ -0,0 +1,428 @@ +//! OpenCADStudio COMMANDS benchmark suite (`harness = false`). +//! Currently: EXPLODE (geometry pipeline + scene apply). + +#[path = "support/harness.rs"] +mod harness; + +use std::hint::black_box; +use std::time::Instant; + +use codec::entities::{ + Circle, Dimension, DimensionLinear, Insert, Line, LwPolyline, LwVertex, MLine, Point, + Polyline2D, Polyline3D, Ray, Solid, Text, Vertex2D, Vertex3DPolyline, +}; +use codec::tables::BlockRecord; +use codec::types::{Vector2, Vector3}; +use codec::{CadDocument, EntityType, Handle}; + +use harness::BenchmarkRunner; +use OpenCADStudio::modules::draw::modify::explode::{ + apply_explode_replacements, explode_batch, +}; +use OpenCADStudio::modules::draw::modify::flatten; +use OpenCADStudio::scene::Scene; + +const BLOCK_NAME: &str = "BENCH_BLOCK"; +const VERTS_PER_POLYLINE: usize = 50; +const MEMBERS_PER_BLOCK: usize = 20; + +/// (lwpolylines, inserts, dimensions, mlines) — full = 2,500 compounds. +fn counts(quick: bool) -> (usize, usize, usize, usize) { + if quick { + (1_000, 100, 100, 50) + } else { + (2_000, 200, 200, 100) + } +} + +fn fixture_entities(quick: bool) -> usize { + let (a, b, c, d) = counts(quick); + a + b + c + d +} + +/// Shared 20-member block (10 lines + 10 three-vertex polylines) registered +/// in `block_records` with `entity_handles` set — what explode_from_document +/// needs to materialize inserts. +fn add_bench_block(doc: &mut CadDocument) { + let mut member_handles = Vec::with_capacity(MEMBERS_PER_BLOCK); + for k in 0..MEMBERS_PER_BLOCK { + if k % 2 == 0 { + let mut line = Line::new(); + let x = (k / 2) as f64 * 3.0; + line.start = Vector3::new(x, 0.0, 0.0); + line.end = Vector3::new(x, 4.0, 0.0); + member_handles.push(doc.add_entity(EntityType::Line(line)).unwrap()); + } else { + let mut pl = LwPolyline::new(); + pl.vertices = vec![ + LwVertex::new(Vector2::new(0.0, (k as f64) * 0.5)), + LwVertex::new(Vector2::new(2.0, (k as f64) * 0.5)), + LwVertex::new(Vector2::new(2.0, (k as f64) * 0.5 + 2.0)), + ]; + member_handles.push(doc.add_entity(EntityType::LwPolyline(pl)).unwrap()); + } + } + let mut block = BlockRecord::new(BLOCK_NAME); + block.handle = doc.allocate_handle(); + block.entity_handles = member_handles; + doc.block_records.add(block).unwrap(); +} + +/// Populate the document with the compound fixture; returns the handles of +/// the fixture entities (block members are NOT included). +fn push_fixture_entities(doc: &mut CadDocument, quick: bool) -> Vec { + let (n_pl, n_ins, n_dim, n_ml) = counts(quick); + let mut handles = Vec::with_capacity(fixture_entities(quick)); + + for i in 0..n_pl { + let base = i as f64 * 0.01; + let mut pl = LwPolyline::new(); + pl.vertices = (0..VERTS_PER_POLYLINE) + .map(|k| { + let mut v = LwVertex::new(Vector2::new( + base + k as f64 * 2.0, + (k % 5) as f64 * 0.75, + )); + if k % 2 == 1 { + v.bulge = 0.5; + } + v + }) + .collect(); + handles.push(doc.add_entity(EntityType::LwPolyline(pl)).unwrap()); + } + + for i in 0..n_ins { + let pos = Vector3::new((i % 50) as f64 * 5.0, (i / 50) as f64 * 5.0, 0.0); + let ins = Insert::new(BLOCK_NAME, pos); + handles.push(doc.add_entity(EntityType::Insert(ins)).unwrap()); + } + + for i in 0..n_dim { + let x = (i % 100) as f64 * 12.0; + let y = (i / 100) as f64 * 12.0; + let mut d = DimensionLinear::new( + Vector3::new(x, y, 0.0), + Vector3::new(x + 10.0, y, 0.0), + ); + d.definition_point = Vector3::new(x + 5.0, y + 3.0, 0.0); + d.base.text_middle_point = Vector3::new(x + 5.0, y + 4.0, 0.0); + handles.push(doc.add_entity(EntityType::Dimension(Dimension::Linear(d))).unwrap()); + } + + for i in 0..n_ml { + let x = (i % 100) as f64 * 8.0; + let y = (i / 100) as f64 * 8.0; + let pts = [ + Vector3::new(x, y, 0.0), + Vector3::new(x + 4.0, y, 0.0), + Vector3::new(x + 4.0, y + 4.0, 0.0), + Vector3::new(x + 8.0, y + 4.0, 0.0), + Vector3::new(x + 8.0, y + 8.0, 0.0), + Vector3::new(x + 12.0, y + 8.0, 0.0), + ]; + handles.push(doc.add_entity(EntityType::MLine(MLine::from_points(&pts))).unwrap()); + } + + handles +} + +fn median(times: &[f64]) -> f64 { + let mut sorted = times.to_vec(); + sorted.sort_by(|a, b| a.total_cmp(b)); + let n = sorted.len(); + if n % 2 == 0 { + (sorted[n / 2 - 1] + sorted[n / 2]) * 0.5 + } else { + sorted[n / 2] + } +} + +/// EXPLODE geometry: time `explode_batch` over the whole fixture. +/// Document build + `items` slice construction are untimed; `black_box` on +/// the result keeps the compiler from eliding the work. +fn bench_explode_geometry(runner: &mut BenchmarkRunner) { + let name = "explode_geometry"; + if !runner.should_run(name) { + return; + } + let quick = runner.quick_mode; + let sample_count = if quick { 3 } else { 10 }; + let entity_count = fixture_entities(quick); + let mut times = Vec::with_capacity(sample_count); + + for _ in 0..sample_count { + let mut doc = CadDocument::new(); + add_bench_block(&mut doc); + let handles = push_fixture_entities(&mut doc, quick); + let items: Vec<(Handle, &EntityType)> = handles + .iter() + .filter_map(|&h| doc.get_entity(h).map(|e| (h, e))) + .collect(); + assert_eq!(items.len(), entity_count); + + let t0 = Instant::now(); + let pieces = black_box(explode_batch(&items, &doc)); + times.push(t0.elapsed().as_micros() as f64); + assert_eq!(pieces.len(), entity_count); + assert!( + pieces.iter().any(|p| !p.is_empty()), + "fixture must produce non-empty pieces — block-record/explosion path broken" + ); + } + + let med = median(×); + let throughput = entity_count as f64 / (med / 1_000_000.0); + runner.record( + name, + "EXPLODE geometry pipeline: explode_batch over the compound fixture", + "us", + times, + Some((throughput, "entities/s")), + None, + ); +} + +/// EXPLODE apply: fresh Scene per sample (untimed), replacements computed +/// untimed, `apply_explode_replacements` timed. +fn bench_explode_scene_apply(runner: &mut BenchmarkRunner) { + let name = "explode_scene_apply"; + if !runner.should_run(name) { + return; + } + let quick = runner.quick_mode; + let sample_count = if quick { 3 } else { 10 }; + let entity_count = fixture_entities(quick); + let mut times = Vec::with_capacity(sample_count); + let mut total_pieces = 0usize; + + for _ in 0..sample_count { + let mut scene = Scene::new(); + add_bench_block(&mut scene.document); + let handles = push_fixture_entities(&mut scene.document, quick); + + let (replacements, pieces_count) = { + let items: Vec<(Handle, &EntityType)> = handles + .iter() + .filter_map(|&h| scene.document.get_entity(h).map(|e| (h, e))) + .collect(); + let per_entity = explode_batch(&items, &scene.document); + let reps: Vec<(Handle, Vec)> = handles + .into_iter() + .zip(per_entity) + .filter(|(_, p)| !p.is_empty()) + .collect(); + let count = reps.iter().map(|(_, p)| p.len()).sum(); + (reps, count) + }; // `items` borrow of scene.document ends here + + let t0 = Instant::now(); + black_box(apply_explode_replacements(&mut scene, replacements)); + times.push(t0.elapsed().as_micros() as f64); + assert!( + pieces_count > entity_count, + "fixture pieces ({pieces_count}) must far exceed input count ({entity_count}) — a broken explosion path returns empty lists" + ); + if total_pieces == 0 { + total_pieces = pieces_count; // deterministic — capture once + } + } + + let med = median(×); + let throughput = total_pieces as f64 / (med / 1_000_000.0); + runner.record( + name, + "EXPLODE apply: apply_explode_replacements (erase sources + add pieces)", + "us", + times, + Some((throughput, "pieces/s")), + None, + ); +} + +/// (lines, circles, polyline3d, polyline2d, texts, points, solids, rays, flat +/// lines) — full = 2,500 entities, 2,300 of which move. +fn flatten_counts(quick: bool) -> [usize; 9] { + if quick { + [120, 60, 80, 60, 60, 40, 40, 20, 20] // 500 total, 460 move + } else { + [600, 300, 400, 300, 300, 200, 200, 100, 100] // 2,500 total, 2,300 move + } +} + +fn flatten_fixture_entities(quick: bool) -> usize { + flatten_counts(quick).iter().sum() +} + +fn flatten_moved_expected(quick: bool) -> usize { + flatten_fixture_entities(quick) - if quick { 40 } else { 200 } +} + +/// Mixed-Z fixture: everything non-flat except the trailing ray + flat-line +/// groups, which FLATTEN must leave untouched (None). +fn push_flatten_fixture(doc: &mut CadDocument, quick: bool) -> Vec { + let [n_line, n_circle, n_pl3d, n_pl2d, n_text, n_point, n_solid, n_ray, n_flat] = + flatten_counts(quick); + let mut handles = Vec::with_capacity(flatten_fixture_entities(quick)); + + for i in 0..n_line { + let mut line = Line::new(); + let x = i as f64; + line.start = Vector3::new(x, 0.0, 1.0 + (i % 5) as f64); + line.end = Vector3::new(x + 1.0, 1.0, 2.0); + line.thickness = 1.0; + handles.push(doc.add_entity(EntityType::Line(line)).unwrap()); + } + for i in 0..n_circle { + let mut c = Circle::from_center_radius(Vector3::new(i as f64, 0.0, 1.0 + (i % 3) as f64), 2.0); + if i % 2 == 1 { + c.normal = Vector3::new(0.0, 0.6, 0.8); // tilted → ellipse + } + handles.push(doc.add_entity(EntityType::Circle(c)).unwrap()); + } + for i in 0..n_pl3d { + let mut pl = Polyline3D::new(); + pl.elevation = (i % 4) as f64; + for k in 0..6 { + pl.vertices.push(Vertex3DPolyline::new(Vector3::new( + i as f64 + k as f64, + k as f64, + 1.0 + (k % 3) as f64, + ))); + } + handles.push(doc.add_entity(EntityType::Polyline3D(pl)).unwrap()); + } + for i in 0..n_pl2d { + let mut pl = Polyline2D::new(); + pl.elevation = 3.0 + (i % 5) as f64; + for k in 0..6 { + pl.vertices.push(Vertex2D::new(Vector3::new(k as f64, i as f64, 0.0))); + } + handles.push(doc.add_entity(EntityType::Polyline2D(pl)).unwrap()); + } + for i in 0..n_text { + let mut t = Text::new(); + t.insertion_point = Vector3::new(i as f64, 0.0, 1.5); + handles.push(doc.add_entity(EntityType::Text(t)).unwrap()); + } + for i in 0..n_point { + let mut p = Point::new(); + p.location = Vector3::new(i as f64, 1.0, 0.75); + handles.push(doc.add_entity(EntityType::Point(p)).unwrap()); + } + for i in 0..n_solid { + let x = i as f64; + let s = Solid::new( + Vector3::new(x, 0.0, 1.0), + Vector3::new(x + 1.0, 0.0, 2.0), + Vector3::new(x + 1.0, 1.0, 3.0), + Vector3::new(x, 1.0, 4.0), + ); + handles.push(doc.add_entity(EntityType::Solid(s)).unwrap()); + } + for i in 0..n_ray { + let r = Ray::new(Vector3::new(i as f64, 0.0, 5.0), Vector3::new(0.0, 0.0, 1.0)); + handles.push(doc.add_entity(EntityType::Ray(r)).unwrap()); + } + for i in 0..n_flat { + let mut line = Line::new(); + let x = i as f64; + line.start = Vector3::new(x, 0.0, 0.0); + line.end = Vector3::new(x + 1.0, 1.0, 0.0); + handles.push(doc.add_entity(EntityType::Line(line)).unwrap()); + } + handles +} + +/// FLATTEN plan: collect candidates + compute replacements (fresh doc/sample). +fn bench_flatten_plan(runner: &mut BenchmarkRunner) { + let name = "flatten_plan"; + if !runner.should_run(name) { + return; + } + let quick = runner.quick_mode; + let sample_count = if quick { 3 } else { 10 }; + let entity_count = flatten_fixture_entities(quick); + let moved_expected = flatten_moved_expected(quick); + let mut times = Vec::with_capacity(sample_count); + + for _ in 0..sample_count { + let mut scene = Scene::new(); + push_flatten_fixture(&mut scene.document, quick); + + let t0 = Instant::now(); + let candidates = black_box(flatten::collect_flatten_handles(&scene)); + let updates = black_box(flatten::plan_flatten(&scene, &candidates)); + times.push(t0.elapsed().as_micros() as f64); + + assert_eq!(candidates.len(), entity_count); + assert_eq!(updates.len(), moved_expected); + } + + let med = median(×); + let throughput = entity_count as f64 / (med / 1_000_000.0); + runner.record( + name, + "FLATTEN plan: collect_flatten_handles + plan_flatten over the mixed-Z fixture", + "us", + times, + Some((throughput, "entities/s")), + None, + ); +} + +/// FLATTEN apply: fresh Scene per sample, plan computed untimed, the publish +/// loop timed. +fn bench_flatten_scene_apply(runner: &mut BenchmarkRunner) { + let name = "flatten_scene_apply"; + if !runner.should_run(name) { + return; + } + let quick = runner.quick_mode; + let sample_count = if quick { 3 } else { 10 }; + let moved_expected = flatten_moved_expected(quick); + let mut times = Vec::with_capacity(sample_count); + + for _ in 0..sample_count { + let mut scene = Scene::new(); + let handles = push_flatten_fixture(&mut scene.document, quick); + let updates = flatten::plan_flatten(&scene, &handles); + assert_eq!(updates.len(), moved_expected); + + let t0 = Instant::now(); + let moved = black_box(flatten::apply_flatten_updates(&mut scene, updates)); + times.push(t0.elapsed().as_micros() as f64); + assert_eq!(moved, moved_expected); + } + + let med = median(×); + let throughput = moved_expected as f64 / (med / 1_000_000.0); + runner.record( + name, + "FLATTEN apply: apply_flatten_updates (per-entity update_entity publish)", + "us", + times, + Some((throughput, "entities/s")), + None, + ); +} + +fn main() { + println!("\nInitializing OpenCADStudio Command Benchmarks..."); + let mut runner = BenchmarkRunner::new( + format!("{:^120}", "OPENCADSTUDIO COMMAND BENCHMARK REPORT"), + "cad_command_metrics.json", + ); + if runner.quick_mode { + println!("Mode: QUICK (halved fixture, 3 samples per metric)"); + } else { + println!("Mode: FULL (2,500 compounds, 10 samples per metric)"); + } + + bench_explode_geometry(&mut runner); + bench_explode_scene_apply(&mut runner); + bench_flatten_plan(&mut runner); + bench_flatten_scene_apply(&mut runner); + + runner.finish(); +} diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index 81b96069f..dede3f7c8 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -33,20 +33,15 @@ #![allow(clippy::field_reassign_with_default, clippy::manual_is_multiple_of)] -use std::collections::HashMap; -use std::env; use std::f64::consts::{PI, TAU}; use std::hint::black_box; use std::io::Cursor; -use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; -use serde::{Deserialize, Serialize}; - -use acadrust::entities::{Arc as AcadArc, Circle, Ellipse, Line, LwPolyline, LwVertex}; -use acadrust::types::{Vector2, Vector3}; -use acadrust::{CadDocument, DxfReader, DxfWriter, EntityType}; +use codec::entities::{Arc as AcadArc, Circle, Ellipse, Line, LwPolyline, LwVertex}; +use codec::types::{Vector2, Vector3}; +use codec::{CadDocument, DxfReader, DxfWriter, EntityType}; use glam::{DVec3, Mat4, Vec3}; use iced::{Color, Point, Rectangle}; @@ -55,7 +50,9 @@ use OpenCADStudio::scene::parametric_constraints::{ }; use OpenCADStudio::scene::pick::hit_test::{box_hit, click_hit}; use OpenCADStudio::scene::pick::interaction_index::InteractionIndex; -use OpenCADStudio::scene::pick::selection_state::SelectionState; +use OpenCADStudio::scene::pick::selection_state::{ + SelectionGesture, SelectionState, SelectionView, +}; use OpenCADStudio::scene::pipeline::wire_arena::partition_wires; use OpenCADStudio::scene::view::camera::Camera; use OpenCADStudio::scene::{ChangeKind, Scene}; @@ -63,303 +60,16 @@ use OpenCADStudio::snap::Snapper; use OpenCADStudio::ui::icons::{self, CHECK}; use OpenCADStudio::ui::overlay::{ grid_segments, selection_overlay, should_reuse, CrosshairOptions, GridCanvasState, - GridKey, GridParams, GridStyle, GripMarker, OstTrackPoint, SelectionVisualOptions, + GridKey, GridParams, GridStyle, GripMarker, NavCursor, OstTrackPoint, SelectionVisualOptions, UcsIconParams, }; use OpenCADStudio::ui::properties::LinetypeItem; use OpenCADStudio::ui::ribbon::{LayerInfo, Ribbon}; use OpenCADStudio::ui::style::plotstyle::build_layer_usage; +#[path = "support/harness.rs"] +mod harness; -// ── Metric Structures ─────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BenchmarkMetric { - pub name: String, - pub description: String, - pub unit: String, - pub samples: usize, - pub min: f64, - pub max: f64, - pub mean: f64, - pub median: f64, - pub p95: f64, - pub p99: f64, - pub std_dev: f64, - pub throughput: Option, - pub throughput_unit: Option, - pub target_threshold: Option, - pub passed_target: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BenchmarkSuiteReport { - pub timestamp: String, - pub os: String, - pub arch: String, - pub profile: String, - pub is_quick_mode: bool, - pub metrics: Vec, -} - -pub struct BenchmarkRunner { - pub quick_mode: bool, - pub filter: Option, - pub baseline_path: Option, - pub output_path: PathBuf, - pub results: Vec, -} - -impl Default for BenchmarkRunner { - fn default() -> Self { - Self::new() - } -} - -impl BenchmarkRunner { - pub fn new() -> Self { - let args: Vec = env::args().collect(); - let quick_mode = - args.iter().any(|a| a == "--quick" || a == "-q") || env::var("CAD_BENCH_QUICK").is_ok(); - - let filter = args - .windows(2) - .find(|w| w[0] == "--filter") - .map(|w| w[1].clone()) - .or_else(|| env::var("CAD_BENCH_FILTER").ok()); - - let baseline_path = args - .windows(2) - .find(|w| w[0] == "--baseline") - .map(|w| PathBuf::from(&w[1])) - .or_else(|| env::var("CAD_BENCH_BASELINE").ok().map(PathBuf::from)); - - let output_path = args - .windows(2) - .find(|w| w[0] == "--output") - .map(|w| PathBuf::from(&w[1])) - .or_else(|| env::var("CAD_BENCH_OUTPUT").ok().map(PathBuf::from)) - .unwrap_or_else(|| { - let target_dir = Path::new("target"); - if !target_dir.exists() { - let _ = std::fs::create_dir_all(target_dir); - } - target_dir.join("cad_performance_metrics.json") - }); - - Self { - quick_mode, - filter, - baseline_path, - output_path, - results: Vec::new(), - } - } - - pub fn should_run(&self, name: &str) -> bool { - if let Some(ref filter) = self.filter { - name.to_lowercase().contains(&filter.to_lowercase()) - } else { - true - } - } - - /// Record a measured benchmark with multiple timing samples. - pub fn record( - &mut self, - name: &str, - description: &str, - unit: &str, - mut samples: Vec, - throughput: Option<(f64, &str)>, - target_threshold: Option, - ) { - if samples.is_empty() { - return; - } - samples.sort_by(|a, b| a.total_cmp(b)); - - let n = samples.len(); - let min = samples[0]; - let max = samples[n - 1]; - let sum: f64 = samples.iter().sum(); - let mean = sum / (n as f64); - let median = if n % 2 == 0 { - (samples[n / 2 - 1] + samples[n / 2]) * 0.5 - } else { - samples[n / 2] - }; - - let p95_idx = ((n as f64) * 0.95).floor() as usize; - let p95 = samples[p95_idx.min(n - 1)]; - - let p99_idx = ((n as f64) * 0.99).floor() as usize; - let p99 = samples[p99_idx.min(n - 1)]; - - let variance: f64 = samples.iter().map(|&x| (x - mean).powi(2)).sum::() / (n as f64); - let std_dev = variance.sqrt(); - - let passed_target = match target_threshold { - Some(thresh) => median <= thresh, - None => true, - }; - - let (thru_val, thru_unit) = match throughput { - Some((val, u)) => (Some(val), Some(u.to_string())), - None => (None, None), - }; - - self.results.push(BenchmarkMetric { - name: name.to_string(), - description: description.to_string(), - unit: unit.to_string(), - samples: n, - min, - max, - mean, - median, - p95, - p99, - std_dev, - throughput: thru_val, - throughput_unit: thru_unit, - target_threshold, - passed_target, - }); - } - - /// Print summary table to stdout and persist JSON report. - pub fn finish(&self) { - let baseline_map: HashMap = - if let Some(ref path) = self.baseline_path { - if let Ok(content) = std::fs::read_to_string(path) { - if let Ok(report) = serde_json::from_str::(&content) { - println!( - "\nLoaded baseline from '{}' ({} metrics)", - path.display(), - report.metrics.len() - ); - report - .metrics - .into_iter() - .map(|m| (m.name.clone(), m)) - .collect() - } else { - eprintln!( - "Warning: Failed to parse baseline JSON at '{}'", - path.display() - ); - HashMap::new() - } - } else { - eprintln!( - "Warning: Could not read baseline file at '{}'", - path.display() - ); - HashMap::new() - } - } else { - HashMap::new() - }; - - println!("\n========================================================================================================================"); - println!(" OPENCADSTUDIO PERFORMANCE BENCHMARK REPORT "); - println!("========================================================================================================================"); - println!( - "{:<38} | {:>8} | {:>8} | {:>8} | {:>8} | {:>16} | {:>10} | {:^6}", - "Benchmark Name", "Median", "Mean", "P95", "Min", "Throughput", "Baseline", "Status" - ); - println!("------------------------------------------------------------------------------------------------------------------------"); - - for m in &self.results { - let throughput_str = match (&m.throughput, &m.throughput_unit) { - (Some(val), Some(unit)) => { - if *val >= 1_000_000.0 { - format!("{:.2}M {}", val / 1_000_000.0, unit) - } else if *val >= 1_000.0 { - format!("{:.1}k {}", val / 1_000.0, unit) - } else { - format!("{:.1} {}", val, unit) - } - } - _ => "-".to_string(), - }; - - let baseline_col = if let Some(base) = baseline_map.get(&m.name) { - if base.median > 0.0 { - let delta_pct = ((m.median - base.median) / base.median) * 100.0; - if delta_pct < -1.0 { - format!("{:+.1}% (fast)", delta_pct) - } else if delta_pct > 1.0 { - format!("{:+.1}% (slow)", delta_pct) - } else { - "~0.0%".to_string() - } - } else { - "-".to_string() - } - } else { - "-".to_string() - }; - - let status = if m.passed_target { "PASS" } else { "FAIL" }; - - println!( - "{:<38} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>16} | {:>10} | {:^6}", - m.name, - m.median, - m.unit, - m.mean, - m.unit, - m.p95, - m.unit, - m.min, - m.unit, - throughput_str, - baseline_col, - status, - ); - } - println!("========================================================================================================================\n"); - - let report = BenchmarkSuiteReport { - timestamp: chrono_now(), - os: env::consts::OS.to_string(), - arch: env::consts::ARCH.to_string(), - profile: if cfg!(debug_assertions) { - "debug".to_string() - } else { - "release".to_string() - }, - is_quick_mode: self.quick_mode, - metrics: self.results.clone(), - }; - - if let Ok(json) = serde_json::to_string_pretty(&report) { - if let Some(parent) = self.output_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&self.output_path, json) { - eprintln!( - "Error saving metrics to '{}': {}", - self.output_path.display(), - e - ); - } else { - println!( - "Saved complete performance metrics JSON to: {}\n", - self.output_path.display() - ); - } - } - } -} - -fn chrono_now() -> String { - let d = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - format!("unix_{}", d.as_secs()) -} +use harness::BenchmarkRunner; // ── 1. Scene Entity Ingestion & Batch Creation ─────────────────────────────── @@ -685,6 +395,7 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { let n_queries = if runner.quick_mode { 200 } else { 1_000 }; let mut click_samples = Vec::with_capacity(5); + let draw_depth = rustc_hash::FxHashMap::default(); for _ in 0..5 { let t0 = Instant::now(); for q in 0..n_queries { @@ -698,6 +409,7 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { bounds, false, 8.0, + &draw_depth, ); black_box(hit); } @@ -1003,12 +715,17 @@ fn bench_selection_cloning(runner: &mut BenchmarkRunner) { } let mut state = SelectionState { - vp_size: (1920.0, 1080.0), - poly_points: vec![Point::new(10.0, 10.0); 64], + view: SelectionView { + vp_size: (1920.0, 1080.0), + }, + gesture: SelectionGesture { + box_anchor: Some(Point::new(0.0, 0.0)), + box_current: Some(Point::new(100.0, 100.0)), + poly_points: vec![Point::new(10.0, 10.0); 64], + ..Default::default() + }, ..Default::default() }; - state.box_anchor = Some(Point::new(0.0, 0.0)); - state.box_current = Some(Point::new(100.0, 100.0)); let n = if runner.quick_mode { 1_000 } else { 5_000 }; let runs = 5; @@ -1083,6 +800,7 @@ fn bench_ui_ribbon_view_construction(runner: &mut BenchmarkRunner) { visible: true, frozen: false, locked: false, + vp_frozen: None, }, LayerInfo { name: "DRAWING".to_string(), @@ -1090,6 +808,7 @@ fn bench_ui_ribbon_view_construction(runner: &mut BenchmarkRunner) { visible: true, frozen: false, locked: false, + vp_frozen: None, }, LayerInfo { name: "DIMENSIONS".to_string(), @@ -1097,6 +816,7 @@ fn bench_ui_ribbon_view_construction(runner: &mut BenchmarkRunner) { visible: true, frozen: false, locked: false, + vp_frozen: None, }, LayerInfo { name: "ANNOTATIONS".to_string(), @@ -1104,6 +824,7 @@ fn bench_ui_ribbon_view_construction(runner: &mut BenchmarkRunner) { visible: true, frozen: false, locked: false, + vp_frozen: None, }, ], "0", @@ -1375,9 +1096,9 @@ fn bench_ui_plotstyle_layer_usage(runner: &mut BenchmarkRunner) { let mut doc = CadDocument::new(); for i in 0..200 { - let mut layer = acadrust::tables::Layer::new(&format!("LAYER_{i:03}")); + let mut layer = codec::tables::Layer::new(&format!("LAYER_{i:03}")); layer.handle = doc.allocate_handle(); - layer.color = acadrust::types::Color::Index((i % 8 + 1) as u8); + layer.color = codec::types::Color::Index((i % 8 + 1) as u8); let _ = doc.layers.add(layer); } @@ -1531,8 +1252,8 @@ fn bench_ui_grip_budget(runner: &mut BenchmarkRunner) { axis: None, }); } - let all_handles: Vec = (0..all_grips.len() as u64) - .map(|k| acadrust::Handle::new(k + 1)) + let all_handles: Vec = (0..all_grips.len() as u64) + .map(|k| codec::Handle::new(k + 1)) .collect(); let n_fixture_grips = all_grips.len(); @@ -1661,7 +1382,7 @@ fn bench_ui_constraint_glyphs(runner: &mut BenchmarkRunner) { ); scene.note_parametric_constraint_applied(ParametricScope::ModelSpace, id, 3); } - scene.selection.borrow_mut().vp_size = (1920.0, 1080.0); + scene.selection.borrow_mut().view.vp_size = (1920.0, 1080.0); let vp = (1920.0_f32, 1080.0_f32); // Prime the cache so every timed call below is a hit (same key, same Arc). @@ -1802,7 +1523,7 @@ fn build_selection_overlay_element( vec![], None, None, - false, + NavCursor::None, false, false, [0.1, 0.1, 0.1, 1.0], @@ -1816,6 +1537,8 @@ fn build_selection_overlay_element( snap_angle_deg: 0.0, point_mode: false, pick_pending: false, + hide_arms: false, + snap_color: None, }, SelectionVisualOptions::default(), empty_glyphs, @@ -2213,10 +1936,10 @@ fn bench_view_render_viewport_construction(runner: &mut BenchmarkRunner) { // Populate scene with non-graphical document objects for i in 0..obj_count { - let handle = acadrust::Handle::new(0x2000 + i as u64); + let handle = codec::Handle::new(0x2000 + i as u64); scene.document.objects.insert( handle, - acadrust::objects::ObjectType::Dictionary(acadrust::objects::Dictionary::default()), + codec::objects::ObjectType::Dictionary(codec::objects::Dictionary::default()), ); } // Add lines to model space @@ -2244,7 +1967,7 @@ fn bench_view_render_viewport_construction(runner: &mut BenchmarkRunner) { let t0 = Instant::now(); let primitive = scene.build_viewports( bounds, - acadrust::entities::ViewportRenderMode::Wireframe2D, + codec::entities::ViewportRenderMode::Wireframe2D, None, false, false, @@ -2268,7 +1991,7 @@ fn bench_view_render_viewport_construction(runner: &mut BenchmarkRunner) { // 2. Cached / After: Memoized document render environment + O(1) fast paths let _ = scene.build_viewports( bounds, - acadrust::entities::ViewportRenderMode::Wireframe2D, + codec::entities::ViewportRenderMode::Wireframe2D, None, false, false, @@ -2280,7 +2003,7 @@ fn bench_view_render_viewport_construction(runner: &mut BenchmarkRunner) { let t0 = Instant::now(); let primitive = scene.build_viewports( bounds, - acadrust::entities::ViewportRenderMode::Wireframe2D, + codec::entities::ViewportRenderMode::Wireframe2D, None, false, false, @@ -2306,7 +2029,10 @@ fn bench_view_render_viewport_construction(runner: &mut BenchmarkRunner) { fn main() { println!("\nInitializing OpenCADStudio Performance Benchmarks..."); - let mut runner = BenchmarkRunner::new(); + let mut runner = BenchmarkRunner::new( + " OPENCADSTUDIO PERFORMANCE BENCHMARK REPORT ", + "cad_performance_metrics.json", + ); if runner.quick_mode { println!("Mode: QUICK (reduced iteration/entity counts for fast validation)"); diff --git a/benches/support/harness.rs b/benches/support/harness.rs new file mode 100644 index 000000000..4862ab866 --- /dev/null +++ b/benches/support/harness.rs @@ -0,0 +1,291 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; +use std::path::{Path, PathBuf}; + +// ── Metric Structures ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkMetric { + pub name: String, + pub description: String, + pub unit: String, + pub samples: usize, + pub min: f64, + pub max: f64, + pub mean: f64, + pub median: f64, + pub p95: f64, + pub p99: f64, + pub std_dev: f64, + pub throughput: Option, + pub throughput_unit: Option, + pub target_threshold: Option, + pub passed_target: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkSuiteReport { + pub timestamp: String, + pub os: String, + pub arch: String, + pub profile: String, + pub is_quick_mode: bool, + pub metrics: Vec, +} + +pub struct BenchmarkRunner { + pub title: String, + pub quick_mode: bool, + pub filter: Option, + pub baseline_path: Option, + pub output_path: PathBuf, + pub results: Vec, +} + +impl BenchmarkRunner { + pub fn new(title: impl Into, default_output: &str) -> Self { + let args: Vec = env::args().collect(); + let quick_mode = + args.iter().any(|a| a == "--quick" || a == "-q") || env::var("CAD_BENCH_QUICK").is_ok(); + + let filter = args + .windows(2) + .find(|w| w[0] == "--filter") + .map(|w| w[1].clone()) + .or_else(|| env::var("CAD_BENCH_FILTER").ok()); + + let baseline_path = args + .windows(2) + .find(|w| w[0] == "--baseline") + .map(|w| PathBuf::from(&w[1])) + .or_else(|| env::var("CAD_BENCH_BASELINE").ok().map(PathBuf::from)); + + let output_path = args + .windows(2) + .find(|w| w[0] == "--output") + .map(|w| PathBuf::from(&w[1])) + .or_else(|| env::var("CAD_BENCH_OUTPUT").ok().map(PathBuf::from)) + .unwrap_or_else(|| { + let target_dir = Path::new("target"); + if !target_dir.exists() { + let _ = std::fs::create_dir_all(target_dir); + } + target_dir.join(default_output) + }); + + Self { + title: title.into(), + quick_mode, + filter, + baseline_path, + output_path, + results: Vec::new(), + } + } + + pub fn should_run(&self, name: &str) -> bool { + if let Some(ref filter) = self.filter { + name.to_lowercase().contains(&filter.to_lowercase()) + } else { + true + } + } + + /// Record a measured benchmark with multiple timing samples. + pub fn record( + &mut self, + name: &str, + description: &str, + unit: &str, + mut samples: Vec, + throughput: Option<(f64, &str)>, + target_threshold: Option, + ) { + if samples.is_empty() { + return; + } + samples.sort_by(|a, b| a.total_cmp(b)); + + let n = samples.len(); + let min = samples[0]; + let max = samples[n - 1]; + let sum: f64 = samples.iter().sum(); + let mean = sum / (n as f64); + let median = if n % 2 == 0 { + (samples[n / 2 - 1] + samples[n / 2]) * 0.5 + } else { + samples[n / 2] + }; + + let p95_idx = ((n as f64) * 0.95).floor() as usize; + let p95 = samples[p95_idx.min(n - 1)]; + + let p99_idx = ((n as f64) * 0.99).floor() as usize; + let p99 = samples[p99_idx.min(n - 1)]; + + let variance: f64 = samples.iter().map(|&x| (x - mean).powi(2)).sum::() / (n as f64); + let std_dev = variance.sqrt(); + + let passed_target = match target_threshold { + Some(thresh) => median <= thresh, + None => true, + }; + + let (thru_val, thru_unit) = match throughput { + Some((val, u)) => (Some(val), Some(u.to_string())), + None => (None, None), + }; + + self.results.push(BenchmarkMetric { + name: name.to_string(), + description: description.to_string(), + unit: unit.to_string(), + samples: n, + min, + max, + mean, + median, + p95, + p99, + std_dev, + throughput: thru_val, + throughput_unit: thru_unit, + target_threshold, + passed_target, + }); + } + + /// Print summary table to stdout and persist JSON report. + pub fn finish(&self) { + let baseline_map: HashMap = + if let Some(ref path) = self.baseline_path { + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(report) = serde_json::from_str::(&content) { + println!( + "\nLoaded baseline from '{}' ({} metrics)", + path.display(), + report.metrics.len() + ); + report + .metrics + .into_iter() + .map(|m| (m.name.clone(), m)) + .collect() + } else { + eprintln!( + "Warning: Failed to parse baseline JSON at '{}'", + path.display() + ); + HashMap::new() + } + } else { + eprintln!( + "Warning: Could not read baseline file at '{}'", + path.display() + ); + HashMap::new() + } + } else { + HashMap::new() + }; + + println!("\n{}", "=".repeat(120)); + println!("{}", self.title); + println!("{}", "=".repeat(120)); + println!( + "{:<38} | {:>8} | {:>8} | {:>8} | {:>8} | {:>16} | {:>10} | {:^6}", + "Benchmark Name", "Median", "Mean", "P95", "Min", "Throughput", "Baseline", "Status" + ); + println!("------------------------------------------------------------------------------------------------------------------------"); + + for m in &self.results { + let throughput_str = match (&m.throughput, &m.throughput_unit) { + (Some(val), Some(unit)) => { + if *val >= 1_000_000.0 { + format!("{:.2}M {}", val / 1_000_000.0, unit) + } else if *val >= 1_000.0 { + format!("{:.1}k {}", val / 1_000.0, unit) + } else { + format!("{:.1} {}", val, unit) + } + } + _ => "-".to_string(), + }; + + let baseline_col = if let Some(base) = baseline_map.get(&m.name) { + if base.median > 0.0 { + let delta_pct = ((m.median - base.median) / base.median) * 100.0; + if delta_pct < -1.0 { + format!("{:+.1}% (fast)", delta_pct) + } else if delta_pct > 1.0 { + format!("{:+.1}% (slow)", delta_pct) + } else { + "~0.0%".to_string() + } + } else { + "-".to_string() + } + } else { + "-".to_string() + }; + + let status = if m.passed_target { "PASS" } else { "FAIL" }; + + println!( + "{:<38} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>16} | {:>10} | {:^6}", + m.name, + m.median, + m.unit, + m.mean, + m.unit, + m.p95, + m.unit, + m.min, + m.unit, + throughput_str, + baseline_col, + status, + ); + } + println!("========================================================================================================================\n"); + + let report = BenchmarkSuiteReport { + timestamp: chrono_now(), + os: env::consts::OS.to_string(), + arch: env::consts::ARCH.to_string(), + profile: if cfg!(debug_assertions) { + "debug".to_string() + } else { + "release".to_string() + }, + is_quick_mode: self.quick_mode, + metrics: self.results.clone(), + }; + + if let Ok(json) = serde_json::to_string_pretty(&report) { + if let Some(parent) = self.output_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&self.output_path, json) { + eprintln!( + "Error saving metrics to '{}': {}", + self.output_path.display(), + e + ); + } else { + println!( + "Saved complete performance metrics JSON to: {}\n", + self.output_path.display() + ); + } + } + } +} + +fn chrono_now() -> String { + let d = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format!("unix_{}", d.as_secs()) +} diff --git a/build.rs b/build.rs index 0a3d19be1..6c69b391a 100644 --- a/build.rs +++ b/build.rs @@ -6,10 +6,13 @@ use std::path::Path; fn main() { let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); let parts: Vec<&str> = version.split('.').collect(); - let app_version = if parts.len() == 3 && parts[0].len() == 4 - && parts[0].starts_with("20") && parts[2] == "0" - { - format!("{}.{:02}", parts[0], parts[1].parse::().expect("week number")) + let app_version = if parts.len() == 3 && parts[0].len() == 4 && parts[0].starts_with("20") { + let week = parts[1].parse::().expect("week number"); + if parts[2] == "0" { + format!("{}.{week:02}", parts[0]) + } else { + format!("{}.{week:02}.{}", parts[0], parts[2]) + } } else { version.clone() }; diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index a964cd793..c7e71939b 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -11,7 +11,7 @@ Internal-facing architecture documentation for `crates/ocs_plugin_api`. For plug | Tier | Feature | Dependencies | Purpose | |------|---------|--------------|---------| | **Core** | default | none | `PluginManifest`, `ApiVersion`, ribbon vocabulary, type registry, version info. Engine crates and external tooling can depend on this cheaply. | -| **Runtime host surface** | `host` | `acadrust`, `interprocess`, `memmap2`, `rkyv`, etc. | `HostApi`, `BuiltinPlugin`, out-of-process spawn, IPC, shared-memory snapshots, runner. | +| **Runtime host surface** | `host` | `opencadcodec`, `interprocess`, `memmap2`, `rkyv`, etc. | `HostApi`, `BuiltinPlugin`, out-of-process spawn, IPC, shared-memory snapshots, runner. | The host and each plugin run in separate OS processes. The host re-executes itself in `--ocs-plugin-runner` mode, the runner dynamically loads the plugin `cdylib`, and the two sides talk over a local socket. Process isolation keeps plugin crashes from affecting the host or other plugins. @@ -38,7 +38,7 @@ The host and each plugin run in separate OS processes. The host re-executes itse | [`src/shm.rs`](src/shm.rs) | Shared-memory document snapshot layout, `DocumentSnapshotStore`, `SharedDocumentReader`, V3/V4 view data. | | [`src/host_v4.rs`](src/host_v4.rs) | Host-side per-tab V4 snapshot manager. | | [`src/type_registry.rs`](src/type_registry.rs) / [`src/type_registry_types.rs`](src/type_registry_types.rs) | Build-time `serde-reflection` registry, embedded JSON, schema types. | -| [`src/version_info.rs`](src/version_info.rs) | Embedded version metadata and acadrust source-compatibility helper. | +| [`src/version_info.rs`](src/version_info.rs) | Embedded version metadata and opencadcodec source-compatibility helper. | --- @@ -48,7 +48,7 @@ The host and each plugin run in separate OS processes. The host re-executes itse - `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads. - `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `5` to disable V6). - A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end. -- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)). +- V4 introduces the **opencadcodec gate**: plugins targeting API v4 or later must resolve the same `opencadcodec` source as the host (see [`src/version_info.rs`](src/version_info.rs)). - V5 introduces `BuiltinPlugin::on_load` and tab-keyed document paths. - V6 appends typed system-variable access to `HostApi`; the host currently supports `CLAYER` and `SNAPANG` (plus a read-only `CTAB`). @@ -222,7 +222,7 @@ Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 p The crate embeds two JSON blobs at build time: - **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for every editor record root and its dependent types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). -- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, `rustc` version, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). +- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `opencadcodec` version and source, `rustc` version, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). Both are accessible without enabling the `host` feature. diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 659a36381..9225013d5 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -12,10 +12,10 @@ license = "GPL-3.0-only" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", optional = true } -# Pulled in only by the `host` feature, which adds the `acadrust`-typed +# Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd1d7bf", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "937745d", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -31,15 +31,15 @@ getrandom = { version = "0.2", optional = true } # Enables the runtime host surface (`HostApi` trait) and the out-of-process # plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers # leave it off. -host = ["dep:acadrust", "dep:serde_json", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] +host = ["dep:codec", "dep:serde_json", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] [build-dependencies] serde-reflection = "0.6" serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" -# acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd1d7bf", features = ["serde"] } +# opencadcodec is scanned at build time to generate the embedded type registry. +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "937745d", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index eee1f09d8..c17292a0a 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -359,8 +359,8 @@ struct SnapshotWorker { impl SnapshotWorker { fn add_point(&self, x: f64, y: f64, z: f64) -> anyhow::Result { - use acadrust::entities::Point; - use acadrust::EntityType; + use opencadcodec::entities::Point; + use opencadcodec::EntityType; let mut p = Point::from_coords(x, y, z); p.common.layer = "0".to_string(); diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 87387e1c5..dda5ba997 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -66,79 +66,79 @@ fn generate_type_registry(out_dir: &Path) { // entity/object variants and their enums are added to the same registry. type TraceFn = fn(&mut Tracer, &Samples); let types: Vec<(&str, TraceFn)> = vec![ - ("EntityType", trace::), - ("ObjectType", trace::), + ("EntityType", trace::), + ("ObjectType", trace::), ( "HeaderVariables", - trace::, + trace::, ), - ("SummaryInfo", trace::), - ("LineType", trace::), - ("TextStyle", trace::), - ("BlockRecord", trace::), - ("DimStyle", trace::), - ("AppId", trace::), - ("View", trace::), - ("VPort", trace::), - ("Ucs", trace::), - ("VxTableRecord", trace::), - ("DxfClass", trace::), + ("SummaryInfo", trace::), + ("LineType", trace::), + ("TextStyle", trace::), + ("BlockRecord", trace::), + ("DimStyle", trace::), + ("AppId", trace::), + ("View", trace::), + ("VPort", trace::), + ("Ucs", trace::), + ("VxTableRecord", trace::), + ("DxfClass", trace::), ( "BlockVisibilityParameter", - trace::, + trace::, ), - ("FieldDef", trace::), + ("FieldDef", trace::), ( "DgnLsDefinition", - trace::, + trace::, ), - ("DgnLsComponent", trace::), + ("DgnLsComponent", trace::), ( "NotificationCollection", - trace::, + trace::, ), - ("Preview", trace::), - ("Point", trace::), - ("Line", trace::), - ("Circle", trace::), - ("Arc", trace::), - ("Ellipse", trace::), - ("Polyline", trace::), - ("Polyline2D", trace::), - ("Polyline3D", trace::), - ("LwPolyline", trace::), - ("MText", trace::), - ("Spline", trace::), - ("EntityCommon", trace::), - ("Handle", trace::), - ("Vector2", trace::), - ("Vector3", trace::), - ("Color", trace::), - ("Layer", trace::), - ("XDataValue", trace::), - ("XRecord", trace::), - ("XRecordEntry", trace::), - ("XRecordValue", trace::), + ("Preview", trace::), + ("Point", trace::), + ("Line", trace::), + ("Circle", trace::), + ("Arc", trace::), + ("Ellipse", trace::), + ("Polyline", trace::), + ("Polyline2D", trace::), + ("Polyline3D", trace::), + ("LwPolyline", trace::), + ("MText", trace::), + ("Spline", trace::), + ("EntityCommon", trace::), + ("Handle", trace::), + ("Vector2", trace::), + ("Vector3", trace::), + ("Color", trace::), + ("Layer", trace::), + ("XDataValue", trace::), + ("XRecord", trace::), + ("XRecordEntry", trace::), + ("XRecordValue", trace::), ( "XRecordValueType", - trace::, + trace::, ), - ("XRecordSection", trace::), + ("XRecordSection", trace::), ( "DictionaryCloningFlags", - trace::, + trace::, ), ( "KnownXRecordKind", - trace::, + trace::, ), ( "ProxyObjectReference", - trace::, + trace::, ), ( "ProxyReferenceKind", - trace::, + trace::, ), ]; @@ -156,338 +156,342 @@ fn generate_type_registry(out_dir: &Path) { let enums: Vec<(&str, TraceSimpleFn)> = vec![ ( "AssocAnnotationKind", - trace_simple::, + trace_simple::, ), ( "AssocConstraintNodeData", - trace_simple::, + trace_simple::, + ), + ( + "AssocCurveValue", + trace_simple::, ), ( "AssocEvalValue", - trace_simple::, + trace_simple::, ), ( "AssocSubcurveKind", - trace_simple::, + trace_simple::, ), ( "AssocSurfaceActionKind", - trace_simple::, + trace_simple::, ), ( "AssocViewObjectActionParamKind", - trace_simple::, + trace_simple::, ), ( "AcisVersion", - trace_simple::, + trace_simple::, ), ( "AssociativeData", - trace_simple::, + trace_simple::, ), ( "AttachmentPointType", - trace_simple::, + trace_simple::, ), ( "BlockContentConnectionType", - trace_simple::, + trace_simple::, ), ( "BlockEvalValue", - trace_simple::, + trace_simple::, ), - ("BorderType", trace_simple::), + ("BorderType", trace_simple::), ( "BoundaryEdge", - trace_simple::, + trace_simple::, ), ( "BreakFlowDirection", - trace_simple::, + trace_simple::, ), ( "CellAlignment", - trace_simple::, + trace_simple::, ), ( "CellStyleType", - trace_simple::, + trace_simple::, ), - ("CellType", trace_simple::), + ("CellType", trace_simple::), ( "CellValueType", - trace_simple::, + trace_simple::, ), ( "ClassObjectData", - trace_simple::, + trace_simple::, ), - ("ClipMode", trace_simple::), - ("ClipType", trace_simple::), + ("ClipMode", trace_simple::), + ("ClipType", trace_simple::), ( "CompoundEntry", - trace_simple::, + trace_simple::, ), ( "CompoundPropertyValue", - trace_simple::, + trace_simple::, ), ( "CompoundStreamContent", - trace_simple::, + trace_simple::, ), ( "DataObjectData", - trace_simple::, + trace_simple::, ), ( "DgnLineStyleData", - trace_simple::, + trace_simple::, ), ( "DgnLsComponentData", - trace_simple::, + trace_simple::, ), ( "DgnLsComponentType", - trace_simple::, + trace_simple::, ), ( "DgnLsPhaseMode", - trace_simple::, + trace_simple::, ), - ("DimSubtype", trace_simple::), - ("Dimension", trace_simple::), + ("DimSubtype", trace_simple::), + ("Dimension", trace_simple::), ( "DimensionType", - trace_simple::, + trace_simple::, ), ( "DynamicBlockData", - trace_simple::, + trace_simple::, ), ( "EmbeddedEntity", - trace_simple::, + trace_simple::, ), ( "ExtendedEntityData", - trace_simple::, + trace_simple::, ), ( "FlowDirectionType", - trace_simple::, + trace_simple::, ), ( "HatchPatternType", - trace_simple::, + trace_simple::, ), ( "HatchStyleType", - trace_simple::, + trace_simple::, ), ( "HelixConstraint", - trace_simple::, + trace_simple::, ), ( "HooklineDirection", - trace_simple::, + trace_simple::, ), ( "HorizontalAlignment", - trace_simple::, + trace_simple::, ), ( "LeaderContentType", - trace_simple::, + trace_simple::, ), ( "LeaderCreationType", - trace_simple::, + trace_simple::, ), ( "LeaderDrawOrderType", - trace_simple::, + trace_simple::, ), ( "LeaderPathType", - trace_simple::, + trace_simple::, ), ( "LineTypeComplexContent", - trace_simple::, + trace_simple::, ), ( "LegacyEntityData", - trace_simple::, + trace_simple::, ), ( "MLineJustification", - trace_simple::, + trace_simple::, ), - ("MTextFlag", trace_simple::), + ("MTextFlag", trace_simple::), ( "MaterialProceduralValue", - trace_simple::, + trace_simple::, ), ( "MultiLeaderDrawOrderType", - trace_simple::, + trace_simple::, ), ( "MultiLeaderPathType", - trace_simple::, + trace_simple::, ), ( "ObjectContextKind", - trace_simple::, + trace_simple::, ), ( "NotificationType", - trace_simple::, + trace_simple::, ), ( "OleFrameEnvelope", - trace_simple::, + trace_simple::, ), ( "OleObjectType", - trace_simple::, + trace_simple::, ), ( "PlotPaperUnits", - trace_simple::, + trace_simple::, ), ( "PlotRotation", - trace_simple::, + trace_simple::, ), - ("PlotType", trace_simple::), + ("PlotType", trace_simple::), ( "PreviewFormat", - trace_simple::, + trace_simple::, ), ( "PolyfaceSmoothType", - trace_simple::, + trace_simple::, ), ( "ProxyPayloadEncoding", - trace_simple::, + trace_simple::, ), ( "ResolutionUnit", - trace_simple::, + trace_simple::, ), - ("ScaledType", trace_simple::), + ("ScaledType", trace_simple::), ( "SemanticPropertyValue", - trace_simple::, + trace_simple::, ), ( "ShadePlotMode", - trace_simple::, + trace_simple::, ), ( "ShadePlotResolutionLevel", - trace_simple::, + trace_simple::, ), ( "SolidHistoryOperation", - trace_simple::, + trace_simple::, ), ( "SurfaceData", - trace_simple::, + trace_simple::, ), ( "SurfaceKind", - trace_simple::, + trace_simple::, ), ( "SurfaceSmoothType", - trace_simple::, + trace_simple::, ), ( "TableBorderType", - trace_simple::, + trace_simple::, ), ( "TableCellContentType", - trace_simple::, + trace_simple::, ), ( "TableFlowDirection", - trace_simple::, + trace_simple::, ), ( "TextAlignmentType", - trace_simple::, + trace_simple::, ), ( "TextAngleType", - trace_simple::, + trace_simple::, ), ( "TextAttachmentDirectionType", - trace_simple::, + trace_simple::, ), ( "TextAttachmentPointType", - trace_simple::, + trace_simple::, ), ( "TextAttachmentType", - trace_simple::, + trace_simple::, ), ( "TextHorizontalAlignment", - trace_simple::, + trace_simple::, ), ( "TextVerticalAlignment", - trace_simple::, + trace_simple::, ), ( "UnderlayType", - trace_simple::, + trace_simple::, ), ( "ValueUnitType", - trace_simple::, + trace_simple::, ), ( "VbaDirectoryValue", - trace_simple::, + trace_simple::, ), ( "VerticalAlignment", - trace_simple::, + trace_simple::, ), ( "ViewportRenderMode", - trace_simple::, + trace_simple::, ), ( "VisualStylePropertyValue", - trace_simple::, + trace_simple::, ), ( "ViewRepSketchGeometry", - trace_simple::, + trace_simple::, ), ( "WipeoutClipMode", - trace_simple::, + trace_simple::, ), ( "WipeoutClipType", - trace_simple::, + trace_simple::, ), - ("WireType", trace_simple::), + ("WireType", trace_simple::), ]; for (name, trace) in enums { trace(&mut tracer); @@ -500,7 +504,7 @@ fn generate_type_registry(out_dir: &Path) { let mut registry = map_to_custom_schema(&traced); let mut section_style_tracer = Tracer::new(TracerConfig::default()); section_style_tracer - .trace_simple_type::() + .trace_simple_type::() .expect("section view style tracing failed"); let section_style_registry = section_style_tracer .registry() @@ -892,122 +896,122 @@ fn add_enum_samples(tracer: &mut Tracer, samples: &mut Samples) { // serde-reflection needs at least one sample value per enum variant in // order to reconstruct the full schema. Provide samples for the enums that // need concrete serialized values. - let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByLayer); - let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByBlock); - let _ = tracer.trace_value(samples, &acadrust::LineWeight::Default); - let _ = tracer.trace_value(samples, &acadrust::LineWeight::Value(0)); + let _ = tracer.trace_value(samples, &codec::LineWeight::ByLayer); + let _ = tracer.trace_value(samples, &codec::LineWeight::ByBlock); + let _ = tracer.trace_value(samples, &codec::LineWeight::Default); + let _ = tracer.trace_value(samples, &codec::LineWeight::Value(0)); - let _ = tracer.trace_value(samples, &acadrust::Transparency::BY_LAYER); - let _ = tracer.trace_value(samples, &acadrust::Transparency::BY_BLOCK); - let _ = tracer.trace_value(samples, &acadrust::Transparency::OPAQUE); + let _ = tracer.trace_value(samples, &codec::Transparency::BY_LAYER); + let _ = tracer.trace_value(samples, &codec::Transparency::BY_BLOCK); + let _ = tracer.trace_value(samples, &codec::Transparency::OPAQUE); - let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::None); + let _ = tracer.trace_value(samples, &codec::entities::SmoothSurfaceType::None); let _ = tracer.trace_value( samples, - &acadrust::entities::SmoothSurfaceType::QuadraticBSpline, + &codec::entities::SmoothSurfaceType::QuadraticBSpline, ); let _ = tracer.trace_value( samples, - &acadrust::entities::SmoothSurfaceType::CubicBSpline, + &codec::entities::SmoothSurfaceType::CubicBSpline, ); - let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::Bezier); + let _ = tracer.trace_value(samples, &codec::entities::SmoothSurfaceType::Bezier); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::TopLeft); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::TopCenter); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::TopRight); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::MiddleLeft); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::MiddleCenter); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::MiddleRight); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::BottomLeft); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::BottomCenter); - let _ = tracer.trace_value(samples, &acadrust::entities::AttachmentPoint::BottomRight); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::TopLeft); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::TopCenter); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::TopRight); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::MiddleLeft); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::MiddleCenter); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::MiddleRight); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::BottomLeft); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::BottomCenter); + let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::BottomRight); - let _ = tracer.trace_value(samples, &acadrust::entities::DrawingDirection::LeftToRight); - let _ = tracer.trace_value(samples, &acadrust::entities::DrawingDirection::TopToBottom); - let _ = tracer.trace_value(samples, &acadrust::entities::DrawingDirection::ByStyle); + let _ = tracer.trace_value(samples, &codec::entities::DrawingDirection::LeftToRight); + let _ = tracer.trace_value(samples, &codec::entities::DrawingDirection::TopToBottom); + let _ = tracer.trace_value(samples, &codec::entities::DrawingDirection::ByStyle); - let _ = tracer.trace_value(samples, &acadrust::entities::LineSpacingStyle::AtLeast); - let _ = tracer.trace_value(samples, &acadrust::entities::LineSpacingStyle::Exactly); + let _ = tracer.trace_value(samples, &codec::entities::LineSpacingStyle::AtLeast); + let _ = tracer.trace_value(samples, &codec::entities::LineSpacingStyle::Exactly); - let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::String(String::new())); + let _ = tracer.trace_value(samples, &codec::xdata::XDataValue::String(String::new())); let _ = tracer.trace_value( samples, - &acadrust::xdata::XDataValue::ControlString(String::new()), + &codec::xdata::XDataValue::ControlString(String::new()), ); let _ = tracer.trace_value( samples, - &acadrust::xdata::XDataValue::LayerName(String::new()), + &codec::xdata::XDataValue::LayerName(String::new()), ); let _ = tracer.trace_value( samples, - &acadrust::xdata::XDataValue::BinaryData(Vec::new()), + &codec::xdata::XDataValue::BinaryData(Vec::new()), ); let _ = tracer.trace_value( samples, - &acadrust::xdata::XDataValue::Handle(acadrust::Handle::default()), + &codec::xdata::XDataValue::Handle(codec::Handle::default()), ); let _ = tracer.trace_value( samples, - &acadrust::xdata::XDataValue::Point3D(acadrust::Vector3::default()), + &codec::xdata::XDataValue::Point3D(codec::Vector3::default()), ); let _ = tracer.trace_value( samples, - &acadrust::xdata::XDataValue::Position3D(acadrust::Vector3::default()), + &codec::xdata::XDataValue::Position3D(codec::Vector3::default()), ); let _ = tracer.trace_value( samples, - &acadrust::xdata::XDataValue::Displacement3D(acadrust::Vector3::default()), + &codec::xdata::XDataValue::Displacement3D(codec::Vector3::default()), ); let _ = tracer.trace_value( samples, - &acadrust::xdata::XDataValue::Direction3D(acadrust::Vector3::default()), + &codec::xdata::XDataValue::Direction3D(codec::Vector3::default()), ); - let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Real(0.0)); - let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Distance(0.0)); - let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::ScaleFactor(0.0)); - let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Integer16(0)); - let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Integer32(0)); + let _ = tracer.trace_value(samples, &codec::xdata::XDataValue::Real(0.0)); + let _ = tracer.trace_value(samples, &codec::xdata::XDataValue::Distance(0.0)); + let _ = tracer.trace_value(samples, &codec::xdata::XDataValue::ScaleFactor(0.0)); + let _ = tracer.trace_value(samples, &codec::xdata::XDataValue::Integer16(0)); + let _ = tracer.trace_value(samples, &codec::xdata::XDataValue::Integer32(0)); // XRecord value variants let _ = tracer.trace_value( samples, - &acadrust::objects::XRecordValue::String(String::new()), + &codec::objects::XRecordValue::String(String::new()), ); - let _ = tracer.trace_value(samples, &acadrust::objects::XRecordValue::Double(0.0)); - let _ = tracer.trace_value(samples, &acadrust::objects::XRecordValue::Int16(0)); - let _ = tracer.trace_value(samples, &acadrust::objects::XRecordValue::Int32(0)); - let _ = tracer.trace_value(samples, &acadrust::objects::XRecordValue::Int64(0)); - let _ = tracer.trace_value(samples, &acadrust::objects::XRecordValue::Byte(0)); - let _ = tracer.trace_value(samples, &acadrust::objects::XRecordValue::Bool(false)); + let _ = tracer.trace_value(samples, &codec::objects::XRecordValue::Double(0.0)); + let _ = tracer.trace_value(samples, &codec::objects::XRecordValue::Int16(0)); + let _ = tracer.trace_value(samples, &codec::objects::XRecordValue::Int32(0)); + let _ = tracer.trace_value(samples, &codec::objects::XRecordValue::Int64(0)); + let _ = tracer.trace_value(samples, &codec::objects::XRecordValue::Byte(0)); + let _ = tracer.trace_value(samples, &codec::objects::XRecordValue::Bool(false)); let _ = tracer.trace_value( samples, - &acadrust::objects::XRecordValue::Handle(acadrust::Handle::default()), + &codec::objects::XRecordValue::Handle(codec::Handle::default()), ); let _ = tracer.trace_value( samples, - &acadrust::objects::XRecordValue::Point3D(0.0, 0.0, 0.0), + &codec::objects::XRecordValue::Point3D(0.0, 0.0, 0.0), ); - let _ = tracer.trace_value(samples, &acadrust::objects::XRecordValue::Chunk(Vec::new())); + let _ = tracer.trace_value(samples, &codec::objects::XRecordValue::Chunk(Vec::new())); // Proxy reference kinds pulled in by XRecord.object_references - let _ = tracer.trace_value(samples, &acadrust::objects::ProxyReferenceKind::Undefined); + let _ = tracer.trace_value(samples, &codec::objects::ProxyReferenceKind::Undefined); let _ = tracer.trace_value( samples, - &acadrust::objects::ProxyReferenceKind::SoftOwnership, + &codec::objects::ProxyReferenceKind::SoftOwnership, ); let _ = tracer.trace_value( samples, - &acadrust::objects::ProxyReferenceKind::HardOwnership, + &codec::objects::ProxyReferenceKind::HardOwnership, ); - let _ = tracer.trace_value(samples, &acadrust::objects::ProxyReferenceKind::SoftPointer); - let _ = tracer.trace_value(samples, &acadrust::objects::ProxyReferenceKind::HardPointer); + let _ = tracer.trace_value(samples, &codec::objects::ProxyReferenceKind::SoftPointer); + let _ = tracer.trace_value(samples, &codec::objects::ProxyReferenceKind::HardPointer); // KnownXRecordKind variants let _ = tracer.trace_value( samples, - &acadrust::objects::KnownXRecordKind::LayerViewportAlphaOverride, + &codec::objects::KnownXRecordKind::LayerViewportAlphaOverride, ); - let _ = tracer.trace_value(samples, &acadrust::objects::KnownXRecordKind::Unknown); + let _ = tracer.trace_value(samples, &codec::objects::KnownXRecordKind::Unknown); } fn map_to_custom_schema(traced: &Registry) -> TypeRegistry { @@ -1257,11 +1261,11 @@ fn generate_version_info(out_dir: &Path) { .iter() .find(|p| p.name.as_str() == "OpenCADStudio") .expect("OpenCADStudio package in Cargo.lock"); - let acadrust = lockfile + let opencadcodec = lockfile .packages .iter() - .find(|p| p.name.as_str() == "acadrust") - .expect("acadrust package in Cargo.lock"); + .find(|p| p.name.as_str() == "opencadcodec") + .expect("opencadcodec package in Cargo.lock"); let rustc = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()); let rustc_version = Command::new(rustc) @@ -1280,11 +1284,11 @@ fn generate_version_info(out_dir: &Path) { let info = serde_json::json!({ "ocs_version": ocs.version.to_string(), "ocs_plugin_api_version": env!("CARGO_PKG_VERSION"), - "acadrust_version": acadrust.version.to_string(), - "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), + "acadrust_version": opencadcodec.version.to_string(), + "acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, "api_version": 7, - "api_version_min_supported": 2, + "api_version_min_supported": 3, "build_timestamp": build_timestamp, }); fs::write(out_dir.join("version_info.json"), info.to_string()).unwrap(); diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index a58dba1da..b04b14ace 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -23,7 +23,7 @@ pub fn entity_snapshot( } #[cfg(feature = "host")] -fn finite_vector(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { +fn finite_vector(name: &str, value: &crate::host::codec::types::Vector3) -> Result<(), String> { if value.x.is_finite() && value.y.is_finite() && value.z.is_finite() { Ok(()) } else { @@ -32,7 +32,7 @@ fn finite_vector(name: &str, value: &crate::host::acadrust::types::Vector3) -> R } #[cfg(feature = "host")] -fn unit_direction(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { +fn unit_direction(name: &str, value: &crate::host::codec::types::Vector3) -> Result<(), String> { finite_vector(name, value)?; let length = value.x.hypot(value.y).hypot(value.z); if (length - 1.0).abs() <= 1e-6 { @@ -43,7 +43,7 @@ fn unit_direction(name: &str, value: &crate::host::acadrust::types::Vector3) -> } #[cfg(feature = "host")] -fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { +fn solid_normal(name: &str, value: &crate::host::codec::types::Vector3) -> Result<(), String> { finite_vector(name, value)?; if value.x.hypot(value.y).hypot(value.z) > 0.0 { Ok(()) @@ -62,9 +62,9 @@ fn insert_scale(name: &str, value: f64) -> Result<(), String> { } #[cfg(feature = "host")] -fn validate_hatch_paths(paths: &[crate::host::acadrust::entities::BoundaryPath]) -> Result<(), String> { - use crate::host::acadrust::entities::BoundaryEdge; - use crate::host::acadrust::types::Vector2; +fn validate_hatch_paths(paths: &[crate::host::codec::entities::BoundaryPath]) -> Result<(), String> { + use crate::host::codec::entities::BoundaryEdge; + use crate::host::codec::types::Vector2; let close = |a: Vector2, b: Vector2| (a.x - b.x).hypot(a.y - b.y) <= 1e-8; let ellipse_point = |center: Vector2, major: Vector2, ratio: f64, angle: f64| { Vector2::new( @@ -200,7 +200,7 @@ fn validate_hatch_paths(paths: &[crate::host::acadrust::entities::BoundaryPath]) } #[cfg(feature = "host")] -fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Result<(), String> { +fn validate_hatch_pattern(value: &crate::host::codec::entities::Hatch) -> Result<(), String> { if value.pattern.name.trim().is_empty() { return Err("Hatch.pattern.name is empty".into()); } @@ -235,12 +235,12 @@ fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Res /// checks to fields the edit touched so legacy DWG leaders stay editable. #[cfg(feature = "host")] fn validate_leader( - old: Option<&crate::host::acadrust::entities::Leader>, - new: &crate::host::acadrust::entities::Leader, + old: Option<&crate::host::codec::entities::Leader>, + new: &crate::host::codec::entities::Leader, ) -> Result<(), String> { - use crate::host::acadrust::entities::LeaderCreationType; - let vec_changed = |a: &crate::host::acadrust::types::Vector3, - b: &crate::host::acadrust::types::Vector3| { + use crate::host::codec::entities::LeaderCreationType; + let vec_changed = |a: &crate::host::codec::types::Vector3, + b: &crate::host::codec::types::Vector3| { old.is_none() || a.x.to_bits() != b.x.to_bits() || a.y.to_bits() != b.y.to_bits() @@ -323,8 +323,8 @@ fn validate_leader( /// finiteness is checked here. #[cfg(feature = "host")] fn validate_mline( - old: Option<&crate::host::acadrust::entities::MLine>, - new: &crate::host::acadrust::entities::MLine, + old: Option<&crate::host::codec::entities::MLine>, + new: &crate::host::codec::entities::MLine, ) -> Result<(), String> { if old.is_none_or(|old| old.flags != new.flags) && new.flags.bits() & !0x0f != 0 { return Err("MLine.flags has unknown bits".into()); @@ -369,7 +369,7 @@ fn validate_mline( return Err("MLine.vertices must not all coincide".into()); } } - if new.flags.contains(crate::host::acadrust::entities::MLineFlags::CLOSED) + if new.flags.contains(crate::host::codec::entities::MLineFlags::CLOSED) && new.vertices.len() < 3 { return Err("closed MLine requires at least 3 vertices".into()); @@ -382,10 +382,10 @@ fn validate_mline( /// validated as inputs; the measurement the geometry implies must be finite. #[cfg(feature = "host")] fn validate_dimension( - old: Option<&crate::host::acadrust::entities::Dimension>, - new: &crate::host::acadrust::entities::Dimension, + old: Option<&crate::host::codec::entities::Dimension>, + new: &crate::host::codec::entities::Dimension, ) -> Result<(), String> { - use crate::host::acadrust::entities::Dimension as D; + use crate::host::codec::entities::Dimension as D; if old == Some(new) { return Ok(()); } @@ -398,7 +398,7 @@ fn validate_dimension( if base.style_name.trim().is_empty() { return Err("Dimension.style_name is empty".into()); } - let (points, scalars, distinct): (Vec<(&str, &crate::host::acadrust::types::Vector3)>, Vec<(&str, f64)>, Vec<(&str, &str)>) = match new { + let (points, scalars, distinct): (Vec<(&str, &crate::host::codec::types::Vector3)>, Vec<(&str, f64)>, Vec<(&str, &str)>) = match new { D::Aligned(d) => ( vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point)], vec![("ext_line_rotation", d.ext_line_rotation)], @@ -468,10 +468,10 @@ fn validate_dimension( /// Field checks shared by MultiLeader creation and mutation. #[cfg(feature = "host")] fn validate_multileader( - old: Option<&crate::host::acadrust::entities::MultiLeader>, - new: &crate::host::acadrust::entities::MultiLeader, + old: Option<&crate::host::codec::entities::MultiLeader>, + new: &crate::host::codec::entities::MultiLeader, ) -> Result<(), String> { - use crate::host::acadrust::entities::LeaderContentType; + use crate::host::codec::entities::LeaderContentType; if old == Some(new) { return Ok(()); } @@ -551,8 +551,8 @@ fn validate_multileader( /// Field checks shared by Table creation and mutation. #[cfg(feature = "host")] fn validate_table( - old: Option<&crate::host::acadrust::entities::Table>, - new: &crate::host::acadrust::entities::Table, + old: Option<&crate::host::codec::entities::Table>, + new: &crate::host::codec::entities::Table, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -625,8 +625,8 @@ fn validate_table( /// PolygonMesh: an M x N grid; the vertex list must match exactly. #[cfg(feature = "host")] fn validate_polygon_mesh( - old: Option<&crate::host::acadrust::entities::PolygonMeshEntity>, - new: &crate::host::acadrust::entities::PolygonMeshEntity, + old: Option<&crate::host::codec::entities::PolygonMeshEntity>, + new: &crate::host::codec::entities::PolygonMeshEntity, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -660,8 +660,8 @@ fn validate_polygon_mesh( /// that edge; 0 marks an unused corner). #[cfg(feature = "host")] fn validate_polyface_mesh( - old: Option<&crate::host::acadrust::entities::PolyfaceMesh>, - new: &crate::host::acadrust::entities::PolyfaceMesh, + old: Option<&crate::host::codec::entities::PolyfaceMesh>, + new: &crate::host::codec::entities::PolyfaceMesh, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -703,8 +703,8 @@ fn validate_polyface_mesh( /// Mesh (subdivision): faces and edges index the vertex list (0-based). #[cfg(feature = "host")] fn validate_mesh( - old: Option<&crate::host::acadrust::entities::Mesh>, - new: &crate::host::acadrust::entities::Mesh, + old: Option<&crate::host::codec::entities::Mesh>, + new: &crate::host::codec::entities::Mesh, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -747,8 +747,8 @@ fn validate_mesh( /// the parameters are validated. #[cfg(feature = "host")] fn validate_helix( - old: Option<&crate::host::acadrust::entities::Helix>, - new: &crate::host::acadrust::entities::Helix, + old: Option<&crate::host::codec::entities::Helix>, + new: &crate::host::codec::entities::Helix, ) -> Result<(), String> { if old.is_some_and(|old| { old.axis_base_point == new.axis_base_point @@ -785,8 +785,8 @@ fn validate_helix( /// by the host; a script supplies the file and the placement. #[cfg(feature = "host")] fn validate_raster_image( - old: Option<&crate::host::acadrust::entities::RasterImage>, - new: &crate::host::acadrust::entities::RasterImage, + old: Option<&crate::host::codec::entities::RasterImage>, + new: &crate::host::codec::entities::RasterImage, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -833,10 +833,10 @@ fn validate_raster_image( /// normalized image coordinates) defines the masked area. #[cfg(feature = "host")] fn validate_wipeout( - old: Option<&crate::host::acadrust::entities::Wipeout>, - new: &crate::host::acadrust::entities::Wipeout, + old: Option<&crate::host::codec::entities::Wipeout>, + new: &crate::host::codec::entities::Wipeout, ) -> Result<(), String> { - use crate::host::acadrust::entities::WipeoutClipType; + use crate::host::codec::entities::WipeoutClipType; if old == Some(new) { return Ok(()); } @@ -878,8 +878,8 @@ fn validate_wipeout( /// `UnderlayDefinition`, which a script names by handle at creation. #[cfg(feature = "host")] fn validate_underlay( - old: Option<&crate::host::acadrust::entities::Underlay>, - new: &crate::host::acadrust::entities::Underlay, + old: Option<&crate::host::codec::entities::Underlay>, + new: &crate::host::codec::entities::Underlay, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -923,8 +923,8 @@ fn validate_underlay( /// other references; this validates the numbers. #[cfg(feature = "host")] fn validate_viewport( - old: Option<&crate::host::acadrust::entities::Viewport>, - new: &crate::host::acadrust::entities::Viewport, + old: Option<&crate::host::codec::entities::Viewport>, + new: &crate::host::codec::entities::Viewport, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -952,8 +952,8 @@ fn validate_viewport( /// ViewBorder: the frame of a drawing view, tied to a viewport and a scale. #[cfg(feature = "host")] fn validate_view_border( - old: Option<&crate::host::acadrust::entities::ViewBorder>, - new: &crate::host::acadrust::entities::ViewBorder, + old: Option<&crate::host::codec::entities::ViewBorder>, + new: &crate::host::codec::entities::ViewBorder, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -978,8 +978,8 @@ fn validate_view_border( /// Light: type, placement, intensity, attenuation and shadow settings. #[cfg(feature = "host")] fn validate_light( - old: Option<&crate::host::acadrust::entities::Light>, - new: &crate::host::acadrust::entities::Light, + old: Option<&crate::host::codec::entities::Light>, + new: &crate::host::codec::entities::Light, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -1227,8 +1227,8 @@ fn validate_basic_entity( /// storage is never rewritten. #[cfg(feature = "host")] fn validate_ole_frame( - old: Option<&crate::host::acadrust::entities::Ole2Frame>, - new: &crate::host::acadrust::entities::Ole2Frame, + old: Option<&crate::host::codec::entities::Ole2Frame>, + new: &crate::host::codec::entities::Ole2Frame, ) -> Result<(), String> { if old == Some(new) { return Ok(()); @@ -1249,8 +1249,8 @@ fn validate_ole_frame( /// in the ACIS payload that the geometry kernel owns. #[cfg(feature = "host")] fn validate_surface( - old: Option<&crate::host::acadrust::entities::Surface>, - new: &crate::host::acadrust::entities::Surface, + old: Option<&crate::host::codec::entities::Surface>, + new: &crate::host::codec::entities::Surface, ) -> Result<(), String> { if old.is_some_and(|old| old.u_isolines == new.u_isolines && old.v_isolines == new.v_isolines) { return Ok(()); @@ -1268,8 +1268,8 @@ fn validate_surface( /// derived by the host, so only the inputs are validated. #[cfg(feature = "host")] fn validate_section_symbol( - old: Option<&crate::host::acadrust::entities::SectionSymbol>, - new: &crate::host::acadrust::entities::SectionSymbol, + old: Option<&crate::host::codec::entities::SectionSymbol>, + new: &crate::host::codec::entities::SectionSymbol, ) -> Result<(), String> { if old.is_some_and(|old| old.points == new.points && old.symbol_scale.to_bits() == new.symbol_scale.to_bits()) { return Ok(()); @@ -1553,8 +1553,8 @@ pub fn validate_entity_mutation( Err(format!("{name} must contain finite coordinates")) } }; - let same3 = |a: &crate::host::acadrust::types::Vector3, - b: &crate::host::acadrust::types::Vector3| { + let same3 = |a: &crate::host::codec::types::Vector3, + b: &crate::host::codec::types::Vector3| { a.x.to_bits() == b.x.to_bits() && a.y.to_bits() == b.y.to_bits() && a.z.to_bits() == b.z.to_bits() @@ -1567,8 +1567,8 @@ pub fn validate_entity_mutation( } }; let changed3 = |name: &str, - old: &crate::host::acadrust::types::Vector3, - new: &crate::host::acadrust::types::Vector3| { + old: &crate::host::codec::types::Vector3, + new: &crate::host::codec::types::Vector3| { if !same3(old, new) { finite_vector(name, new) } else { @@ -1958,7 +1958,7 @@ pub fn validate_entity_mutation( if old.is_mpolygon != new.is_mpolygon || old.mpolygon_hatch_color != new.mpolygon_hatch_color || old.mpolygon_x_direction != new.mpolygon_x_direction - || old.mpolygon_boundary_handle_count != new.mpolygon_boundary_handle_count + || old.mpolygon_invalid_loops != new.mpolygon_invalid_loops { return Err("Hatch MPOLYGON fields are unmapped and read-only".into()); } @@ -2060,7 +2060,7 @@ pub fn validate_canvas_entity_references( // An edit that keeps the block name is left alone so a legacy insert of a // missing block stays editable; creation and renames must name a real, // ordinary block that does not create a cycle. - let insert_is_unchanged_edit = |value: &crate::host::acadrust::entities::Insert| { + let insert_is_unchanged_edit = |value: &crate::host::codec::entities::Insert| { matches!(document.get_entity(value.common.handle), Some(EntityType::Insert(existing)) if existing.block_name == value.block_name) }; @@ -2105,7 +2105,7 @@ pub fn validate_canvas_entity_references( } } if let EntityType::SectionSymbol(value) = entity { - use crate::host::acadrust::objects::{ClassObjectData, ObjectType}; + use crate::host::codec::objects::{ClassObjectData, ObjectType}; for (name, handle, wanted) in [ ("style", value.style_handle, "SectionViewStyle"), ("view representation", value.view_rep_handle, "ViewRep"), @@ -2132,7 +2132,7 @@ pub fn validate_canvas_entity_references( } if !matches!( document.objects.get(&value.scale_handle), - Some(crate::host::acadrust::objects::ObjectType::Scale(_)) + Some(crate::host::codec::objects::ObjectType::Scale(_)) ) { return Err(format!("ViewBorder scale {:?} does not exist", value.scale_handle)); } @@ -2155,7 +2155,7 @@ pub fn validate_canvas_entity_references( } if let EntityType::Underlay(value) = entity { match document.objects.get(&value.definition_handle) { - Some(crate::host::acadrust::objects::ObjectType::UnderlayDefinition(definition)) => { + Some(crate::host::codec::objects::ObjectType::UnderlayDefinition(definition)) => { if definition.underlay_type != value.underlay_type { return Err(format!( "Underlay type {:?} does not match its definition ({:?})", @@ -2175,7 +2175,7 @@ pub fn validate_canvas_entity_references( if let Some(handle) = value.definition_handle.filter(|handle| !handle.is_null()) { if !matches!( document.objects.get(&handle), - Some(crate::host::acadrust::objects::ObjectType::ImageDefinition(_)) + Some(crate::host::codec::objects::ObjectType::ImageDefinition(_)) ) { return Err(format!("RasterImage image definition {handle:?} does not exist")); } @@ -2185,7 +2185,7 @@ pub fn validate_canvas_entity_references( if let Some(handle) = value.table_style_handle.filter(|handle| !handle.is_null()) { if !matches!( document.objects.get(&handle), - Some(crate::host::acadrust::objects::ObjectType::TableStyle(_)) + Some(crate::host::codec::objects::ObjectType::TableStyle(_)) ) { return Err(format!("Table style handle {handle:?} does not exist")); } @@ -2208,8 +2208,8 @@ pub fn validate_canvas_entity_references( } } if let EntityType::MultiLeader(value) = entity { - use crate::host::acadrust::objects::ObjectType; - let handle_exists = |handle: Option, what: &str, ok: &dyn Fn(crate::host::acadrust::Handle) -> bool| { + use crate::host::codec::objects::ObjectType; + let handle_exists = |handle: Option, what: &str, ok: &dyn Fn(crate::host::codec::Handle) -> bool| { match handle.filter(|handle| !handle.is_null()) { Some(handle) if !ok(handle) => Err(format!("MultiLeader {what} handle {handle:?} does not exist")), _ => Ok(()), @@ -2252,7 +2252,7 @@ pub fn validate_canvas_entity_references( } } if let EntityType::Leader(value) = entity { - use crate::host::acadrust::entities::LeaderCreationType; + use crate::host::codec::entities::LeaderCreationType; if !document .dim_styles .iter() @@ -2376,9 +2376,9 @@ pub fn validate_canvas_entity_references( #[cfg(feature = "host")] fn resolve_mline_style<'a>( document: &'a crate::host::CadDocument, - mline: &crate::host::acadrust::entities::MLine, -) -> Option<&'a crate::host::acadrust::objects::MLineStyle> { - use crate::host::acadrust::objects::ObjectType; + mline: &crate::host::codec::entities::MLine, +) -> Option<&'a crate::host::codec::objects::MLineStyle> { + use crate::host::codec::objects::ObjectType; mline .style_handle .filter(|handle| !handle.is_null()) @@ -2980,18 +2980,18 @@ mod tests { #[cfg(feature = "host")] #[test] fn tolerance_geometry_and_style_references_are_validated() { - use crate::host::acadrust::{self, types::Vector3}; - let document = acadrust::CadDocument::new(); - let mut tolerance = acadrust::entities::Tolerance::with_text( + use crate::host::codec::{self, types::Vector3}; + let document = codec::CadDocument::new(); + let mut tolerance = codec::entities::Tolerance::with_text( Vector3::new(1.0, 2.0, 0.0), "{\\Fgdt;p}%%v0.1", ); - let entity = acadrust::EntityType::Tolerance(tolerance.clone()); + let entity = codec::EntityType::Tolerance(tolerance.clone()); validate_new_canvas_entity(&entity).unwrap(); validate_canvas_entity_references(&document, &entity).unwrap(); tolerance.direction = Vector3::new(2.0, 0.0, 0.0); assert!( - validate_new_canvas_entity(&acadrust::EntityType::Tolerance(tolerance.clone())) + validate_new_canvas_entity(&codec::EntityType::Tolerance(tolerance.clone())) .unwrap_err() .contains("unit vector") ); @@ -2999,7 +2999,7 @@ mod tests { tolerance.dimension_style_name = "Missing".into(); assert!(validate_canvas_entity_references( &document, - &acadrust::EntityType::Tolerance(tolerance) + &codec::EntityType::Tolerance(tolerance) ) .unwrap_err() .contains("does not exist")); @@ -3008,18 +3008,18 @@ mod tests { #[cfg(feature = "host")] #[test] fn leader_geometry_and_annotation_references_are_validated() { - use crate::host::acadrust::{self, entities::LeaderCreationType, types::Vector3}; - let mut document = acadrust::CadDocument::new(); - let mut text = acadrust::entities::Text::new(); + use crate::host::codec::{self, entities::LeaderCreationType, types::Vector3}; + let mut document = codec::CadDocument::new(); + let mut text = codec::entities::Text::new(); text.common.handle = document.allocate_handle(); let text_handle = text.common.handle; - document.add_entity(acadrust::EntityType::Text(text)).unwrap(); - let mut leader = acadrust::entities::Leader::two_point( + document.add_entity(codec::EntityType::Text(text)).unwrap(); + let mut leader = codec::entities::Leader::two_point( Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 5.0, 0.0), ); leader.annotation_handle = text_handle; - let entity = acadrust::EntityType::Leader(leader.clone()); + let entity = codec::EntityType::Leader(leader.clone()); validate_new_canvas_entity(&entity).unwrap(); validate_canvas_entity_references(&document, &entity).unwrap(); @@ -3027,33 +3027,33 @@ mod tests { wrong_kind.creation_type = LeaderCreationType::WithTolerance; assert!(validate_canvas_entity_references( &document, - &acadrust::EntityType::Leader(wrong_kind) + &codec::EntityType::Leader(wrong_kind) ) .unwrap_err() .contains("does not match")); let mut missing = leader.clone(); - missing.annotation_handle = acadrust::Handle::new(0xdead); + missing.annotation_handle = codec::Handle::new(0xdead); assert!(validate_canvas_entity_references( &document, - &acadrust::EntityType::Leader(missing) + &codec::EntityType::Leader(missing) ) .unwrap_err() .contains("does not exist")); let mut none_with_handle = leader.clone(); none_with_handle.creation_type = LeaderCreationType::NoAnnotation; - assert!(validate_new_canvas_entity(&acadrust::EntityType::Leader(none_with_handle)) + assert!(validate_new_canvas_entity(&codec::EntityType::Leader(none_with_handle)) .unwrap_err() .contains("no annotation")); let mut one_point = leader.clone(); one_point.vertices.truncate(1); - assert!(validate_new_canvas_entity(&acadrust::EntityType::Leader(one_point)) + assert!(validate_new_canvas_entity(&codec::EntityType::Leader(one_point)) .unwrap_err() .contains("at least 2")); let mut bad_style = leader.clone(); bad_style.dimension_style = "Missing".into(); assert!(validate_canvas_entity_references( &document, - &acadrust::EntityType::Leader(bad_style) + &codec::EntityType::Leader(bad_style) ) .unwrap_err() .contains("dimension style")); @@ -3061,8 +3061,8 @@ mod tests { let mut edited = leader.clone(); edited.vertices[1] = Vector3::new(f64::NAN, 0.0, 0.0); assert!(validate_entity_mutation( - &acadrust::EntityType::Leader(leader.clone()), - &acadrust::EntityType::Leader(edited) + &codec::EntityType::Leader(leader.clone()), + &codec::EntityType::Leader(edited) ) .unwrap_err() .contains("finite")); @@ -3071,48 +3071,48 @@ mod tests { #[cfg(feature = "host")] #[test] fn mline_geometry_and_style_references_are_validated_and_bound() { - use crate::host::acadrust::{self, entities::MLineVertex, types::Vector3}; - let document = acadrust::CadDocument::new(); - let mut mline = acadrust::entities::MLine::new(); + use crate::host::codec::{self, entities::MLineVertex, types::Vector3}; + let document = codec::CadDocument::new(); + let mut mline = codec::entities::MLine::new(); for x in [0.0, 5.0] { mline.vertices.push(MLineVertex::new(Vector3::new(x, 0.0, 0.0))); } - let mut entity = acadrust::EntityType::MLine(mline.clone()); + let mut entity = codec::EntityType::MLine(mline.clone()); validate_new_canvas_entity(&entity).unwrap(); bind_canvas_entity_references(&document, &mut entity).unwrap(); - assert!(matches!(&entity, acadrust::EntityType::MLine(value) if value.style_handle.is_some())); + assert!(matches!(&entity, codec::EntityType::MLine(value) if value.style_handle.is_some())); let mut missing = mline.clone(); missing.style_name = "Missing".into(); - assert!(bind_canvas_entity_references(&document, &mut acadrust::EntityType::MLine(missing)) + assert!(bind_canvas_entity_references(&document, &mut codec::EntityType::MLine(missing)) .unwrap_err().contains("does not exist")); let mut wrong_segments = mline.clone(); wrong_segments.vertices[0].init_segments(3); - assert!(bind_canvas_entity_references(&document, &mut acadrust::EntityType::MLine(wrong_segments)) + assert!(bind_canvas_entity_references(&document, &mut codec::EntityType::MLine(wrong_segments)) .unwrap_err().contains("segments but style")); let mut one = mline.clone(); one.vertices.truncate(1); - assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(one)).unwrap_err().contains("at least 2")); + assert!(validate_new_canvas_entity(&codec::EntityType::MLine(one)).unwrap_err().contains("at least 2")); let mut closed = mline.clone(); - closed.flags |= acadrust::entities::MLineFlags::CLOSED; - assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(closed)).unwrap_err().contains("at least 3")); + closed.flags |= codec::entities::MLineFlags::CLOSED; + assert!(validate_new_canvas_entity(&codec::EntityType::MLine(closed)).unwrap_err().contains("at least 3")); let mut scale = mline.clone(); scale.scale_factor = 0.0; - assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(scale)).unwrap_err().contains("nonzero")); + assert!(validate_new_canvas_entity(&codec::EntityType::MLine(scale)).unwrap_err().contains("nonzero")); } #[cfg(feature = "host")] #[test] fn shape_geometry_and_shape_file_style_are_validated_and_bound() { - use crate::host::acadrust::{self, tables::TableEntry, types::Vector3}; - let mut document = acadrust::CadDocument::new(); - let mut style = acadrust::tables::TextStyle::new("Symbols"); + use crate::host::codec::{self, tables::TableEntry, types::Vector3}; + let mut document = codec::CadDocument::new(); + let mut style = codec::tables::TextStyle::new("Symbols"); style.set_handle(document.allocate_handle()); style.is_shape_file = true; style.font_file = "symbols.shx".into(); let style_handle = style.handle; document.text_styles.add(style).unwrap(); - let mut shape = acadrust::entities::Shape::with_style( + let mut shape = codec::entities::Shape::with_style( Vector3::new(1.0, 2.0, 0.0), "ARROW", "Symbols", @@ -3120,17 +3120,17 @@ mod tests { 0.5, ); shape.shape_number = 1; - let mut entity = acadrust::EntityType::Shape(shape); + let mut entity = codec::EntityType::Shape(shape); validate_new_canvas_entity(&entity).unwrap(); bind_canvas_entity_references(&document, &mut entity).unwrap(); - assert!(matches!(entity, acadrust::EntityType::Shape(ref value) + assert!(matches!(entity, codec::EntityType::Shape(ref value) if value.style_handle == Some(style_handle))); - let acadrust::EntityType::Shape(mut bad) = entity else { + let codec::EntityType::Shape(mut bad) = entity else { unreachable!() }; bad.size = 0.0; assert!( - validate_new_canvas_entity(&acadrust::EntityType::Shape(bad)) + validate_new_canvas_entity(&codec::EntityType::Shape(bad)) .unwrap_err() .contains("greater than zero") ); @@ -3204,8 +3204,8 @@ mod tests { #[cfg(feature = "host")] #[test] fn unsupported_canvas_kind_has_typed_snapshot_fields() { - use crate::host::acadrust; - let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default()); + use crate::host::codec; + let hatch = codec::EntityType::Hatch(codec::entities::Hatch::default()); let snapshot = entity_snapshot(&hatch).unwrap(); let fields = snapshot .get("Hatch") @@ -3218,21 +3218,21 @@ mod tests { #[cfg(feature = "host")] #[test] fn changed_geometry_is_validated_without_rejecting_untouched_legacy_values() { - use crate::host::acadrust; - let mut before = acadrust::entities::Circle::default(); + use crate::host::codec; + let mut before = codec::entities::Circle::default(); before.radius = f64::NAN; let mut layer_only = before.clone(); layer_only.common.layer = "OTHER".into(); assert!(validate_entity_mutation( - &acadrust::EntityType::Circle(before.clone()), - &acadrust::EntityType::Circle(layer_only), + &codec::EntityType::Circle(before.clone()), + &codec::EntityType::Circle(layer_only), ) .is_ok()); let mut bad_radius = before.clone(); bad_radius.radius = -1.0; assert!(validate_entity_mutation( - &acadrust::EntityType::Circle(before), - &acadrust::EntityType::Circle(bad_radius), + &codec::EntityType::Circle(before), + &codec::EntityType::Circle(bad_radius), ) .unwrap_err() .contains("Circle.radius")); @@ -3241,29 +3241,29 @@ mod tests { #[cfg(feature = "host")] #[test] fn new_phase_seven_shapes_reject_invalid_geometry_and_keep_legacy_fields() { - use crate::host::acadrust::{self, types::Vector3}; - let mut ray = acadrust::entities::Ray::default(); + use crate::host::codec::{self, types::Vector3}; + let mut ray = codec::entities::Ray::default(); ray.direction = Vector3::new(2.0, 0.0, 0.0); assert!( - validate_new_canvas_entity(&acadrust::EntityType::Ray(ray.clone())) + validate_new_canvas_entity(&codec::EntityType::Ray(ray.clone())) .unwrap_err() .contains("Ray.direction") ); - let before = acadrust::EntityType::Ray(ray.clone()); + let before = codec::EntityType::Ray(ray.clone()); ray.common.layer = "CONSTRUCTION".into(); - assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray.clone())).is_ok()); + assert!(validate_entity_mutation(&before, &codec::EntityType::Ray(ray.clone())).is_ok()); ray.direction = Vector3::new(0.0, 1.0, 0.0); - assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray)).is_ok()); + assert!(validate_entity_mutation(&before, &codec::EntityType::Ray(ray)).is_ok()); - let mut xline = acadrust::entities::XLine::default(); + let mut xline = codec::entities::XLine::default(); xline.base_point.x = f64::NAN; assert!( - validate_new_canvas_entity(&acadrust::EntityType::XLine(xline)) + validate_new_canvas_entity(&codec::EntityType::XLine(xline)) .unwrap_err() .contains("XLine.base_point") ); - let solid = acadrust::entities::Solid::new( + let solid = codec::entities::Solid::new( Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, @@ -3272,23 +3272,23 @@ mod tests { let mut changed = solid.clone(); changed.thickness = f64::INFINITY; assert!(validate_entity_mutation( - &acadrust::EntityType::Solid(solid), - &acadrust::EntityType::Solid(changed) + &codec::EntityType::Solid(solid), + &codec::EntityType::Solid(changed) ) .unwrap_err() .contains("Solid.thickness")); - let face = acadrust::entities::Face3D::new( + let face = codec::entities::Face3D::new( Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO, ); let mut changed = face.clone(); - changed.invisible_edges = acadrust::entities::InvisibleEdgeFlags::from_bits(0x10); + changed.invisible_edges = codec::entities::InvisibleEdgeFlags::from_bits(0x10); assert!(validate_entity_mutation( - &acadrust::EntityType::Face3D(face), - &acadrust::EntityType::Face3D(changed) + &codec::EntityType::Face3D(face), + &codec::EntityType::Face3D(changed) ) .unwrap_err() .contains("Face3D.invisible_edges")); @@ -3297,37 +3297,37 @@ mod tests { #[cfg(feature = "host")] #[test] fn insert_transaction_checks_transforms_and_protects_references() { - use crate::host::acadrust::{self, types::Vector3}; - let before = acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0)); + use crate::host::codec::{self, types::Vector3}; + let before = codec::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0)); let mut moved = before.clone(); moved.insert_point = Vector3::new(5.0, 6.0, 0.0); moved.set_x_scale(2.0); assert!(validate_entity_mutation( - &acadrust::EntityType::Insert(before.clone()), - &acadrust::EntityType::Insert(moved) + &codec::EntityType::Insert(before.clone()), + &codec::EntityType::Insert(moved) ) .is_ok()); let mut bad = before.clone(); bad.set_y_scale(f64::NAN); assert!(validate_entity_mutation( - &acadrust::EntityType::Insert(before.clone()), - &acadrust::EntityType::Insert(bad) + &codec::EntityType::Insert(before.clone()), + &codec::EntityType::Insert(bad) ) .unwrap_err() .contains("Insert.y_scale")); let mut bad = before.clone(); bad.column_count = 0; assert!(validate_entity_mutation( - &acadrust::EntityType::Insert(before.clone()), - &acadrust::EntityType::Insert(bad) + &codec::EntityType::Insert(before.clone()), + &codec::EntityType::Insert(bad) ) .unwrap_err() .contains("array counts")); let mut bad = before.clone(); bad.block_name = "OTHER".into(); assert!(validate_entity_mutation( - &acadrust::EntityType::Insert(before), - &acadrust::EntityType::Insert(bad) + &codec::EntityType::Insert(before), + &codec::EntityType::Insert(bad) ) .unwrap_err() .contains("block identity")); @@ -3336,8 +3336,8 @@ mod tests { #[cfg(feature = "host")] #[test] fn canvas_layer_patch_preserves_unmapped_hatch_fields() { - use crate::host::acadrust; - let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default()); + use crate::host::codec; + let hatch = codec::EntityType::Hatch(codec::entities::Hatch::default()); let patched = patch_canvas_layer(&hatch, "HATCHES").unwrap(); assert_eq!(patched.common().layer, "HATCHES"); assert_eq!(hatch.common().layer, "0"); @@ -3350,17 +3350,17 @@ mod tests { #[cfg(feature = "host")] #[test] fn attribute_definition_requires_a_block_owner_and_text_style() { - use crate::host::acadrust::{ + use crate::host::codec::{ self, entities::{Block, BlockEnd}, types::{Handle, Vector3}, }; - let mut document = acadrust::CadDocument::new(); + let mut document = codec::CadDocument::new(); let next = document.next_handle(); let record_handle = Handle::new(next); let block_handle = Handle::new(next + 1); let end_handle = Handle::new(next + 2); - let mut record = acadrust::tables::BlockRecord::new("TAGBLOCK"); + let mut record = codec::tables::BlockRecord::new("TAGBLOCK"); record.handle = record_handle; record.block_entity_handle = block_handle; record.block_end_handle = end_handle; @@ -3369,30 +3369,30 @@ mod tests { block.common.handle = block_handle; block.common.owner_handle = record_handle; document - .add_entity(acadrust::EntityType::Block(block)) + .add_entity(codec::EntityType::Block(block)) .unwrap(); let mut end = BlockEnd::new(); end.common.handle = end_handle; end.common.owner_handle = record_handle; document - .add_entity(acadrust::EntityType::BlockEnd(end)) + .add_entity(codec::EntityType::BlockEnd(end)) .unwrap(); - let mut definition = acadrust::entities::AttributeDefinition::new( + let mut definition = codec::entities::AttributeDefinition::new( "PART_NO".into(), "Part number".into(), "PN-001".into(), ); definition.common.owner_handle = record_handle; definition.insertion_point = Vector3::new(1.0, 2.0, 0.0); - let entity = acadrust::EntityType::AttributeDefinition(definition.clone()); + let entity = codec::EntityType::AttributeDefinition(definition.clone()); validate_new_canvas_entity(&entity).unwrap(); validate_canvas_entity_references(&document, &entity).unwrap(); definition.common.owner_handle = Handle::NULL; assert!(validate_canvas_entity_references( &document, - &acadrust::EntityType::AttributeDefinition(definition.clone()) + &codec::EntityType::AttributeDefinition(definition.clone()) ) .unwrap_err() .contains("block-record owner")); @@ -3400,22 +3400,26 @@ mod tests { definition.text_style = "Missing".into(); assert!(validate_canvas_entity_references( &document, - &acadrust::EntityType::AttributeDefinition(definition.clone()) + &codec::EntityType::AttributeDefinition(definition.clone()) ) .unwrap_err() .contains("does not exist")); definition.text_style = "Standard".into(); definition.tag = "BAD TAG".into(); assert!( - validate_new_canvas_entity(&acadrust::EntityType::AttributeDefinition(definition)) + validate_new_canvas_entity(&codec::EntityType::AttributeDefinition(definition)) .unwrap_err() .contains("no whitespace") ); } + #[cfg(feature = "host")] #[test] fn hatch_geometry_and_boundaries_are_validated() { - use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, LineEdge}; - let mut hatch = acadrust::entities::Hatch::new(); + use crate::host::codec::{ + self, + entities::hatch::{BoundaryEdge, BoundaryPath, LineEdge}, + }; + let mut hatch = codec::entities::Hatch::new(); let mut path = BoundaryPath::new(); for (start, end) in [ ((0.0, 0.0), (10.0, 0.0)), @@ -3424,32 +3428,32 @@ mod tests { ((0.0, 10.0), (0.0, 0.0)), ] { path.edges.push(BoundaryEdge::Line(LineEdge { - start: acadrust::types::Vector2::new(start.0, start.1), - end: acadrust::types::Vector2::new(end.0, end.1), + start: codec::types::Vector2::new(start.0, start.1), + end: codec::types::Vector2::new(end.0, end.1), })); } hatch.paths.push(path); - let entity = acadrust::EntityType::Hatch(hatch.clone()); + let entity = codec::EntityType::Hatch(hatch.clone()); validate_new_canvas_entity(&entity).unwrap(); let mut invalid = hatch.clone(); invalid.paths.clear(); - assert!(validate_new_canvas_entity(&acadrust::EntityType::Hatch(invalid)) + assert!(validate_new_canvas_entity(&codec::EntityType::Hatch(invalid)) .unwrap_err() .contains("at least one boundary path")); let mut invalid = hatch.clone(); invalid.is_solid = false; invalid.pattern_scale = 0.0; - assert!(validate_new_canvas_entity(&acadrust::EntityType::Hatch(invalid)) + assert!(validate_new_canvas_entity(&codec::EntityType::Hatch(invalid)) .unwrap_err() .contains("greater than zero")); - let mut document = acadrust::CadDocument::new(); - let boundary = document.add_entity(acadrust::EntityType::Line( - acadrust::entities::Line::from_points( - acadrust::types::Vector3::ZERO, - acadrust::types::Vector3::UNIT_X, + let mut document = codec::CadDocument::new(); + let boundary = document.add_entity(codec::EntityType::Line( + codec::entities::Line::from_points( + codec::types::Vector3::ZERO, + codec::types::Vector3::UNIT_X, ), )).unwrap(); let mut associative = hatch.clone(); @@ -3457,12 +3461,12 @@ mod tests { associative.paths[0].boundary_handles.push(boundary); validate_canvas_entity_references( &document, - &acadrust::EntityType::Hatch(associative.clone()), + &codec::EntityType::Hatch(associative.clone()), ).unwrap(); - associative.paths[0].boundary_handles[0] = acadrust::Handle::new(u64::MAX); + associative.paths[0].boundary_handles[0] = codec::Handle::new(u64::MAX); assert!(validate_canvas_entity_references( &document, - &acadrust::EntityType::Hatch(associative), + &codec::EntityType::Hatch(associative), ).unwrap_err().contains("does not exist")); } } diff --git a/crates/ocs_plugin_api/src/entity_coverage_types.rs b/crates/ocs_plugin_api/src/entity_coverage_types.rs index 9c056c2e2..5d101c694 100644 --- a/crates/ocs_plugin_api/src/entity_coverage_types.rs +++ b/crates/ocs_plugin_api/src/entity_coverage_types.rs @@ -22,7 +22,7 @@ pub enum ModelAccess { pub struct PropertyCoverage { /// Name shown in the document model, or source name when unmapped. pub name: String, - /// Field in the acadrust snapshot; `kind` is synthesized from the variant. + /// Field in the opencadcodec snapshot; `kind` is synthesized from the variant. pub source_path: String, pub type_id: String, pub optional: bool, diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 22e1d80dc..5880a0335 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -1,6 +1,6 @@ //! Runtime host surface (`host` feature). //! -//! [`HostApi`] is the `acadrust`-typed adapter a plugin uses at *dispatch* time +//! [`HostApi`] is the `opencadcodec`-typed adapter a plugin uses at *dispatch* time //! — document access, entity creation, XDATA, undo, and the command line. It is //! the stable counterpart to the dependency-free manifest/ribbon contract: a //! plugin's `dispatch` receives `&mut dyn HostApi` rather than the host's @@ -19,16 +19,16 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::manifest::PluginManifest; use crate::ribbon::CadModule; -// Re-export the acadrust crate and the types that appear in the HostApi trait -// so out-of-tree plugins can use them without adding their own acadrust +// Re-export the opencadcodec crate and the types that appear in the HostApi trait +// so out-of-tree plugins can use them without adding their own opencadcodec // dependency (which would risk an ABI-mismatching version). -pub use acadrust; -pub use acadrust::objects::{ +pub use codec; +pub use codec::objects::{ DictionaryCloningFlags, KnownXRecordKind, ProxyObjectReference, ProxyReferenceKind, XRecord, XRecordEntry, XRecordSection, XRecordValue, XRecordValueType, }; -pub use acadrust::xdata::{ExtendedDataRecord, XDataValue}; -pub use acadrust::{CadDocument, EntityType, Handle}; +pub use codec::xdata::{ExtendedDataRecord, XDataValue}; +pub use codec::{CadDocument, EntityType, Handle}; use crate::ipc::protocol::{PluginRequest, PluginResponse}; @@ -833,11 +833,12 @@ pub trait HostApi { fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>; fn plugin_state_any_mut(&mut self, plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)>; /// Get the state for `plugin_id`, inserting `init()`'s result if absent. + /// Returns `None` when this host cannot store state (e.g. out-of-process). fn ensure_plugin_state_any( &mut self, plugin_id: &'static str, init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn Any + Send + Sync); + ) -> Option<&mut (dyn Any + Send + Sync)>; // ── DocumentReader (added in API v3; appended at the end to keep vtable // indices stable for API v2 plugins) ───────────────────────────────────── @@ -1016,14 +1017,14 @@ pub trait HostApi { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct LayerConfig { pub name: String, - pub color: Option, + pub color: Option, pub linetype: Option, - pub lineweight: Option, + pub lineweight: Option, pub off: Option, pub frozen: Option, pub locked: Option, pub plottable: Option, - pub transparency: Option, + pub transparency: Option, pub description: Option, } @@ -1159,17 +1160,20 @@ pub fn plugin_state_mut<'a, T: Any + Send + Sync>( } /// Typed get-or-insert of per-tab plugin state stored under `plugin_id`. +/// +/// Returns `None` when this host cannot hold state (out-of-process plugins) +/// or when existing state under `plugin_id` has a different type. pub fn ensure_plugin_state<'a, T: Any + Send + Sync>( host: &'a mut dyn HostApi, plugin_id: &'static str, init: impl FnOnce() -> T, -) -> &'a mut T { +) -> Option<&'a mut T> { let mut init = Some(init); - let any = host.ensure_plugin_state_any(plugin_id, &mut || { - Box::new((init.take().expect("init called once"))()) + let any = host.ensure_plugin_state_any(plugin_id, &mut || match init.take() { + Some(make) => Box::new(make()), + None => Box::new(()), }); - any.downcast_mut::() - .expect("plugin state type mismatch for plugin_id") + any?.downcast_mut::() } #[cfg(feature = "host")] diff --git a/crates/ocs_plugin_api/src/host_v4.rs b/crates/ocs_plugin_api/src/host_v4.rs index 3674ea8ff..40bca01b7 100644 --- a/crates/ocs_plugin_api/src/host_v4.rs +++ b/crates/ocs_plugin_api/src/host_v4.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; -use acadrust::CadDocument; +use codec::CadDocument; use crate::shm::{DocumentSnapshotStore, DocumentViewDataV4, DocumentViewInfo}; diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index b347d44fb..1d10b1130 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -6,8 +6,8 @@ use std::collections::HashMap; use std::path::Path; use std::rc::Rc; -use acadrust::xdata::ExtendedDataRecord; -use acadrust::{CadDocument, EntityType, Handle}; +use codec::xdata::ExtendedDataRecord; +use codec::{CadDocument, EntityType, Handle}; use interprocess::local_socket::traits::Stream as StreamTrait; use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; @@ -364,10 +364,11 @@ impl HostApi for PluginHostApi { &mut self, _plugin_id: &'static str, _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn Any + Send + Sync) { + ) -> Option<&mut (dyn Any + Send + Sync)> { // Same limitation as `plugin_state_any`. This would need a serializable - // state contract to work across processes. - panic!("ensure_plugin_state is not supported for out-of-process plugins; keep state in the plugin crate") + // state contract to work across processes. Degrade to `None` so plugins + // keep running with state unavailable instead of crashing the runner. + None } fn document_reader(&self) -> Box { @@ -589,8 +590,8 @@ mod tests { use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; - use acadrust::entities::Point; - use acadrust::{EntityType, Handle}; + use codec::entities::Point; + use codec::{EntityType, Handle}; use interprocess::local_socket::{ traits::{Listener, Stream as StreamTrait}, GenericNamespaced, ListenerOptions, Stream, ToNsName, @@ -633,6 +634,22 @@ mod tests { (api, client_stream) } + #[test] + fn ensure_plugin_state_does_not_panic_for_out_of_process_hosts() { + let (mut api, _peer) = make_client(); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = api.ensure_plugin_state_any("opencad.demo", &mut || Box::new(7u32)); + })); + assert!( + outcome.is_ok(), + "out-of-process ensure_plugin_state must degrade, not panic: {:?}", + outcome.err() + ); + assert!(api + .ensure_plugin_state_any("opencad.demo", &mut || Box::new(7u32)) + .is_none()); + } + #[test] fn push_info_emits_request() { let (mut api, mut peer) = make_client(); diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index 44e7e02e9..971b47e9b 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -1,6 +1,6 @@ //! Inter-process communication layer for out-of-process plugins. //! -//! Built only with the `host` feature because it needs `acadrust`-typed +//! Built only with the `host` feature because it needs `opencadcodec`-typed //! messages and the plugin runner binary. //! //! Submodules: diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 70d7513df..a7f282be7 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -21,8 +21,8 @@ use crate::host::{CommandSource, CommandStep, HostSettingValue}; use crate::manifest::ApiVersion; use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; -pub use acadrust::xdata::{ExtendedDataRecord, XDataValue}; -pub use acadrust::{CadDocument, EntityType, Handle}; +pub use codec::xdata::{ExtendedDataRecord, XDataValue}; +pub use codec::{CadDocument, EntityType, Handle}; pub use crate::host::PreviewWire; /// Events the host forwards to an active plugin `InteractiveCommand`. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 33d1e8df6..1941184d2 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -3,17 +3,17 @@ use crate::host::HostApi; use crate::ipc::protocol::{PluginRequest, PluginResponse}; -fn addressable_document_snapshot(host: &dyn HostApi) -> acadrust::CadDocument { +fn addressable_document_snapshot(host: &dyn HostApi) -> codec::CadDocument { let mut snapshot = host.document().clone(); let attributes = host .document() .entities() .filter_map(|entity| match entity { - acadrust::EntityType::Insert(insert) => Some(insert.attributes.clone()), + codec::EntityType::Insert(insert) => Some(insert.attributes.clone()), _ => None, }) .flatten() - .map(acadrust::EntityType::AttributeEntity) + .map(codec::EntityType::AttributeEntity) .collect::>(); for attribute in attributes { // Attribute entities remain canonical children of INSERT in the host diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index d22983e66..41834fc05 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -9,8 +9,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::time::{Duration, Instant}; -use acadrust::xdata::ExtendedDataRecord; -use acadrust::{CadDocument, EntityType, Handle}; +use codec::xdata::ExtendedDataRecord; +use codec::{CadDocument, EntityType, Handle}; use interprocess::local_socket::traits::Stream as StreamTrait; use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; use interprocess::TryClone; @@ -607,8 +607,10 @@ impl HostApi for V4PluginHostApi { &mut self, _plugin_id: &'static str, _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn Any + Send + Sync) { - panic!("ensure_plugin_state is not supported for out-of-process plugins; keep state in the plugin crate") + ) -> Option<&mut (dyn Any + Send + Sync)> { + // Same limitation as `plugin_state_any`; degrade to `None` so the + // plugin keeps running instead of dying on its own helper call. + None } fn document_reader(&self) -> Box { @@ -937,7 +939,7 @@ mod tests { use crate::ipc::transport::recv; use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4}; use crate::test_lock::ENV_LOCK; - use acadrust::entities::Point; + use codec::entities::Point; fn unique_socket_name() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index bbf969f41..7f5822603 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -114,7 +114,7 @@ mod tests { CommandRequest::Point { point: [1.0, 2.0, 3.0] }, CommandRequest::Text { text: "2".into() }, CommandRequest::Token { text: "R".into() }, - CommandRequest::Entity { handle: acadrust::Handle::new(9), point: [0.0; 3] }, + CommandRequest::Entity { handle: codec::Handle::new(9), point: [0.0; 3] }, CommandRequest::Selection, CommandRequest::Enter, CommandRequest::Cancel, @@ -149,7 +149,7 @@ mod tests { TableOperation::LayerCreate { config: LayerConfig { name: "Walls".into(), - color: Some(acadrust::types::Color::Index(5)), + color: Some(codec::types::Color::Index(5)), frozen: Some(true), description: Some("d".into()), ..Default::default() @@ -170,7 +170,7 @@ mod tests { TableOperation::StyleSetCurrent { kind: crate::host::TableStyleKind::Dim, name: "A".into() }, TableOperation::BlockCreate { name: "B".into(), - entities: vec![acadrust::Handle::new(4), acadrust::Handle::new(5)], + entities: vec![codec::Handle::new(4), codec::Handle::new(5)], base_point: [1.0, 2.0, 3.0], erase_originals: true, description: Some("d".into()), @@ -180,7 +180,7 @@ mod tests { TableOperation::BlockDelete { name: "C".into() }, TableOperation::BlockEntityAdd { block: "C".into(), - entity: acadrust::EntityType::Line(acadrust::entities::Line::new()), + entity: codec::EntityType::Line(codec::entities::Line::new()), }, TableOperation::LinetypeCreate { name: "L".into(), description: "d".into(), pattern: vec![1.0, -1.0] }, TableOperation::LinetypeModify { name: "L".into(), description: None, pattern: Some(vec![2.0, -2.0]) }, @@ -196,7 +196,7 @@ mod tests { assert!(matches!(bincode::deserialize::(&bytes).unwrap(), PluginRequest::TableOperation { operation: decoded } if decoded == operation)); } - for result in [Ok(acadrust::Handle::new(9)), Err("refused".to_owned())] { + for result in [Ok(codec::Handle::new(9)), Err("refused".to_owned())] { let bytes = bincode::serialize(&PluginResponse::TableResult(result.clone())).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), PluginResponse::TableResult(decoded) if decoded == result)); @@ -211,24 +211,24 @@ mod tests { primitive: SolidPrimitive::Pyramid { center: [1.0, 2.0, 3.0], radius: 4.0, height: 5.0, sides: 6 }, layer: Some("SOLIDS".into()), }, - SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] }, - SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true }, + SolidOperation::Transform { handle: codec::Handle::new(7), matrix: [1.0; 16] }, + SolidOperation::RegionFromProfile { source: codec::Handle::new(3), layer: None, delete_source: true }, SolidOperation::EmbedPicture { path: "p.png".into(), origin: [0.0; 3], width: 5.0, layer: None }, SolidOperation::Boolean { - first: acadrust::Handle::new(1), - second: acadrust::Handle::new(2), + first: codec::Handle::new(1), + second: codec::Handle::new(2), operation: crate::host::SolidBoolean::Subtract, layer: None, keep_operands: false, }, - SolidOperation::SurfaceFromProfile { source: acadrust::Handle::new(4), layer: Some("S".into()), delete_source: false }, - SolidOperation::Extrude { source: acadrust::Handle::new(5), direction: [0.0, 0.0, 2.0], layer: None, delete_source: true }, + SolidOperation::SurfaceFromProfile { source: codec::Handle::new(4), layer: Some("S".into()), delete_source: false }, + SolidOperation::Extrude { source: codec::Handle::new(5), direction: [0.0, 0.0, 2.0], layer: None, delete_source: true }, ] { let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), PluginRequest::SolidOperation { operation: decoded } if decoded == operation)); } - for result in [Ok(acadrust::Handle::new(9)), Err("refused".to_owned())] { + for result in [Ok(codec::Handle::new(9)), Err("refused".to_owned())] { let bytes = bincode::serialize(&PluginResponse::SolidResult(result.clone())).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), PluginResponse::SolidResult(decoded) if decoded == result)); @@ -237,14 +237,14 @@ mod tests { #[test] fn selection_request_and_response_roundtrip() { - let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] }; + let request = PluginRequest::SetSelection { handles: vec![codec::Handle::new(9)] }; let bytes = bincode::serialize(&request).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginRequest::SetSelection { handles } if handles == vec![acadrust::Handle::new(9)])); - let response = PluginResponse::Selection(vec![acadrust::Handle::new(9)]); + PluginRequest::SetSelection { handles } if handles == vec![codec::Handle::new(9)])); + let response = PluginResponse::Selection(vec![codec::Handle::new(9)]); let bytes = bincode::serialize(&response).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginResponse::Selection(handles) if handles == vec![acadrust::Handle::new(9)])); + PluginResponse::Selection(handles) if handles == vec![codec::Handle::new(9)])); } #[test] diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index c55cc0342..c36f23d49 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -2,7 +2,7 @@ //! //! The stable, semver-versioned contract an add-on package targets instead of //! the `OpenCADStudio` binary internals. It is intentionally **dependency -//! free** (no `iced`, no `acadrust`) so engine crates and external tooling can +//! free** (no `iced`, no `opencadcodec`) so engine crates and external tooling can //! depend on it cheaply. //! //! Two pieces live here: @@ -13,12 +13,12 @@ //! ([`RibbonGroup`], [`ToolDef`], …) a plugin uses to describe its tab. //! //! The runtime host surface a plugin uses at *dispatch* time (document access, -//! command line, undo) is `acadrust`-typed and therefore lives in the `host` -//! feature; it re-exports `acadrust` so plugins do not need to depend on it +//! command line, undo) is `opencadcodec`-typed and therefore lives in the `host` +//! feature; it re-exports `opencadcodec` so plugins do not need to depend on it //! directly and risk an ABI mismatch from a different version. //! //! For binary compatibility, the host and every plugin must resolve the same -//! `acadrust` source. The host does this via a `[patch.crates-io]` entry in +//! `opencadcodec` source. The host does this via a `[patch.crates-io]` entry in //! `Cargo.toml`; out-of-tree plugins should copy that exact patch. //! //! For internal architecture (process model, wire protocols, versioning policy, @@ -40,7 +40,7 @@ pub mod entity_coverage; pub mod entity_coverage_types; pub mod version_info; -/// Runtime host surface — only built with the `host` feature (pulls `acadrust`). +/// Runtime host surface — only built with the `host` feature (pulls `opencadcodec`). #[cfg(feature = "host")] pub mod host; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 8bf2ee892..849e63a94 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -26,12 +26,14 @@ pub const API_VERSION: u32 = 7; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the -/// current `HostApi` trait. -pub const API_VERSION_MIN_SUPPORTED: u32 = 2; +/// current `HostApi` trait. V2 is no longer loaded: its ribbon wire types +/// predate the current ones, so a V2 plugin passed the gate and then failed +/// silently while registering its ribbon (#1085, #987). +pub const API_VERSION_MIN_SUPPORTED: u32 = 3; /// Environment variable that caps the API major accepted by the host at -/// runtime. Set to `5` to disable V6 plugins, `4` to disable V5, or `2` for -/// V2-only mode. +/// runtime. Set to `5` to disable V6 plugins, `4` to disable V5, or `3` for +/// V3-only mode. pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -112,18 +114,18 @@ mod tests { assert_eq!(effective_max_api_version(), 3); assert!(!host_accepts_plugin_version(4)); assert!(host_accepts_plugin_version(3)); - assert!(host_accepts_plugin_version(2)); + assert!(!host_accepts_plugin_version(2)); std::env::remove_var(MAX_API_VERSION_ENV); } #[test] - fn effective_max_v2_only_mode() { + fn effective_max_below_minimum_is_v3_only_mode() { let _guard = ENV_LOCK.lock().unwrap(); std::env::set_var(MAX_API_VERSION_ENV, "2"); - assert_eq!(effective_max_api_version(), 2); + assert_eq!(effective_max_api_version(), 3); assert!(!host_accepts_plugin_version(4)); - assert!(!host_accepts_plugin_version(3)); - assert!(host_accepts_plugin_version(2)); + assert!(host_accepts_plugin_version(3)); + assert!(!host_accepts_plugin_version(2)); std::env::remove_var(MAX_API_VERSION_ENV); } diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 161ff97a1..760f1353c 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -8,7 +8,7 @@ //! 2. The runner presents a pre-shared token via [`crate::ipc::protocol::PLUGIN_TOKEN_ENV`]; //! the host rejects the connection on mismatch. //! 3. The host requests the manifest, checks `api_version` and (for v4+) the -//! acadrust source gate, then keeps the process alive. +//! opencadcodec source gate, then keeps the process alive. //! 4. Host → plugin calls (`dispatch`, `execute_code`, interactive events) are //! sent over the socket with a configurable per-call timeout. //! 5. Stdout/stderr of the child are drained into `PluginIoLine` records for @@ -128,8 +128,8 @@ impl HostApi for NullHost { &mut self, _plugin_id: &'static str, _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn std::any::Any + Send + Sync) { - panic!("NullHost: ensure_plugin_state_any not available") + ) -> Option<&mut (dyn std::any::Any + Send + Sync)> { + None } } @@ -1158,6 +1158,22 @@ mod tests { let runner = distinct_runner_path(&host); assert_eq!(runner, PathBuf::from("/app/OpenCADStudio-plugin-runner")); } + + #[test] + fn null_host_ensure_plugin_state_does_not_panic() { + let mut null = NullHost; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = null.ensure_plugin_state_any("opencad.demo", &mut || Box::new(7u32)); + })); + assert!( + outcome.is_ok(), + "NullHost ensure_plugin_state must degrade, not panic: {:?}", + outcome.err() + ); + assert!(null + .ensure_plugin_state_any("opencad.demo", &mut || Box::new(7u32)) + .is_none()); + } } #[cfg(all(test, feature = "host"))] @@ -1171,8 +1187,8 @@ mod timeout_tests { use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedPluginManifest; use crate::test_lock::ENV_LOCK; - use acadrust::xdata::ExtendedDataRecord; - use acadrust::{CadDocument, EntityType, Handle}; + use codec::xdata::ExtendedDataRecord; + use codec::{CadDocument, EntityType, Handle}; use interprocess::local_socket::{ traits::{Listener, Stream as StreamTrait}, GenericNamespaced, ListenerOptions, Stream, ToNsName, @@ -1261,8 +1277,8 @@ mod timeout_tests { &mut self, _plugin_id: &'static str, _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn std::any::Any + Send + Sync) { - panic!("not used") + ) -> Option<&mut (dyn std::any::Any + Send + Sync)> { + None } } diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index 003f6ff02..90ac264d7 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -286,10 +286,10 @@ mod tests { 0 } fn for_each_entity(&self, _f: &mut dyn FnMut(crate::host::ReaderEntity<'_>)) {} - fn layer_name(&self, _handle: acadrust::Handle) -> Option<&str> { + fn layer_name(&self, _handle: codec::Handle) -> Option<&str> { None } - fn app_id_name(&self, _handle: acadrust::Handle) -> Option<&str> { + fn app_id_name(&self, _handle: codec::Handle) -> Option<&str> { None } } @@ -299,34 +299,34 @@ mod tests { fn tab_index(&self) -> usize { 0 } - fn document(&self) -> &acadrust::CadDocument { + fn document(&self) -> &codec::CadDocument { panic!("not used") } - fn document_mut(&mut self) -> &mut acadrust::CadDocument { + fn document_mut(&mut self) -> &mut codec::CadDocument { panic!("not used") } fn document_reader(&self) -> Box { Box::new(EmptyReader) } - fn add_entity(&mut self, _entity: acadrust::EntityType) -> acadrust::Handle { + fn add_entity(&mut self, _entity: codec::EntityType) -> codec::Handle { panic!("not used") } fn bump_geometry(&mut self) {} fn read_record( &self, - _handle: acadrust::Handle, + _handle: codec::Handle, _app_name: &str, - ) -> Option<&acadrust::xdata::ExtendedDataRecord> { + ) -> Option<&codec::xdata::ExtendedDataRecord> { None } fn write_record( &mut self, - _handle: acadrust::Handle, - _record: acadrust::xdata::ExtendedDataRecord, + _handle: codec::Handle, + _record: codec::xdata::ExtendedDataRecord, ) -> bool { false } - fn remove_record(&mut self, _handle: acadrust::Handle, _app_name: &str) -> bool { + fn remove_record(&mut self, _handle: codec::Handle, _app_name: &str) -> bool { false } fn push_undo(&mut self, _label: &str) {} @@ -351,8 +351,8 @@ mod tests { &mut self, _plugin_id: &'static str, _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn std::any::Any + Send + Sync) { - panic!("not used") + ) -> Option<&mut (dyn std::any::Any + Send + Sync)> { + None } } diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index dfcd7b4a1..f7e46d02e 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -338,7 +338,7 @@ mod tests { use crate::ipc::protocol::{HostResponse, PluginRequest, PluginResponse}; use crate::ipc::transport::recv; use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4}; - use acadrust::{CadDocument, Handle}; + use codec::{CadDocument, Handle}; use interprocess::local_socket::{ traits::{Listener, Stream as StreamTrait}, GenericNamespaced, ListenerOptions, ToNsName, @@ -386,7 +386,7 @@ mod tests { struct DummyHost { push_info_messages: StdMutex>, - transactions: StdMutex)>>, + transactions: StdMutex)>>, } impl DummyHost { fn new() -> Self { @@ -398,7 +398,7 @@ mod tests { fn take_push_info(&self) -> Vec { std::mem::take(&mut *self.push_info_messages.lock().unwrap()) } - fn take_transactions(&self) -> Vec<(String, Vec)> { + fn take_transactions(&self) -> Vec<(String, Vec)> { std::mem::take(&mut *self.transactions.lock().unwrap()) } } @@ -415,7 +415,7 @@ mod tests { fn document_reader(&self) -> Box { Box::new(EmptyReader) } - fn add_entity(&mut self, _entity: acadrust::EntityType) -> Handle { + fn add_entity(&mut self, _entity: codec::EntityType) -> Handle { panic!("not used") } fn bump_geometry(&mut self) {} @@ -423,13 +423,13 @@ mod tests { &self, _handle: Handle, _app_name: &str, - ) -> Option<&acadrust::xdata::ExtendedDataRecord> { + ) -> Option<&codec::xdata::ExtendedDataRecord> { None } fn write_record( &mut self, _handle: Handle, - _record: acadrust::xdata::ExtendedDataRecord, + _record: codec::xdata::ExtendedDataRecord, ) -> bool { false } @@ -440,7 +440,7 @@ mod tests { fn update_entities_transaction( &mut self, label: &str, - entities: Vec, + entities: Vec, ) -> Result<(), String> { self.transactions.lock().unwrap().push((label.to_owned(), entities)); Ok(()) @@ -468,8 +468,8 @@ mod tests { &mut self, _plugin_id: &'static str, _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn std::any::Any + Send + Sync) { - panic!("not used") + ) -> Option<&mut (dyn std::any::Any + Send + Sync)> { + None } } @@ -624,10 +624,10 @@ mod tests { let handler: Arc, PluginNotification) + Send + Sync> = Arc::new(|_, _| {}); let conn = V4Connection::new(host_stream, handler).unwrap(); - let mut point = acadrust::entities::Point::new(); + let mut point = codec::entities::Point::new(); point.common.handle = Handle::new(42); point.location.x = 7.0; - let expected = acadrust::EntityType::Point(point); + let expected = codec::EntityType::Point(point); let sent = expected.clone(); let runner = thread::spawn(move || { diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index 5344737da..4ded3f7f0 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -29,7 +29,7 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, AtomicUsize, Ordering}; -use acadrust::{CadDocument, EntityType, Handle}; +use codec::{CadDocument, EntityType, Handle}; use memmap2::{Mmap, MmapMut, MmapOptions}; use rkyv::{check_archived_root, to_bytes, Archive, Deserialize, Serialize}; @@ -444,8 +444,8 @@ pub struct LayerView { pub name: String, } -impl From<&acadrust::tables::Layer> for LayerView { - fn from(layer: &acadrust::tables::Layer) -> Self { +impl From<&codec::tables::Layer> for LayerView { + fn from(layer: &codec::tables::Layer) -> Self { Self { handle: layer.handle.value(), name: layer.name.clone(), @@ -460,8 +460,8 @@ pub struct AppIdView { pub name: String, } -impl From<&acadrust::tables::AppId> for AppIdView { - fn from(app_id: &acadrust::tables::AppId) -> Self { +impl From<&codec::tables::AppId> for AppIdView { + fn from(app_id: &codec::tables::AppId) -> Self { Self { handle: app_id.handle.value(), name: app_id.name.clone(), @@ -539,8 +539,8 @@ impl ReaderEntityKind { // ── V4 full-entity document view ──────────────────────────────────────────── /// Serializable V4 document view. The outer structure is rkyv; each entity's -/// `data` is a bincode-encoded `acadrust::EntityType` so that the plugin can -/// reconstruct the full typed entity without relying on acadrust's own rkyv +/// `data` is a bincode-encoded `codec::EntityType` so that the plugin can +/// reconstruct the full typed entity without relying on opencadcodec's own rkyv /// support. #[derive(Archive, Serialize, Deserialize, Debug, Clone)] #[archive(check_bytes)] @@ -605,9 +605,9 @@ impl From<&EntityType> for EntityViewV4 { mod tests { use super::*; use crate::host::{DocumentReader, ReaderEntityKind}; - use acadrust::entities::Point; - use acadrust::tables::Layer; - use acadrust::{CadDocument, EntityType}; + use codec::entities::Point; + use codec::tables::Layer; + use codec::{CadDocument, EntityType}; fn unique_path(name: &str) -> PathBuf { std::env::temp_dir().join(format!( diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs index 2b5dfc794..a3e9d4288 100644 --- a/crates/ocs_plugin_api/src/type_registry.rs +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -1,7 +1,7 @@ //! Embedded type registry generated at build time. //! //! The JSON embedded here is produced by tracing every editor record root and -//! its dependent `acadrust` types with `serde-reflection`, then mapping the +//! its dependent `opencadcodec` types with `serde-reflection`, then mapping the //! result into a stable, language-binding-friendly schema defined in //! [`crate::type_registry_types`]. The registry is embedded via //! `include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"))` so the @@ -9,7 +9,7 @@ //! //! The registry covers the complete entity, object, symbol-table, header, and //! document-record roots used by the editor. Language bindings and tooling can -//! inspect every variant without pulling the full `acadrust` dependency tree. +//! inspect every variant without pulling the full `opencadcodec` dependency tree. pub use crate::type_registry_types::*; diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 8d25e0ede..da14db317 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -2,13 +2,13 @@ //! //! At compile time `build.rs` writes a small JSON blob to //! `OUT_DIR/version_info.json` containing host version, `ocs_plugin_api` -//! version, `acadrust` version and source, API version bounds, and a build +//! version, `opencadcodec` version and source, API version bounds, and a build //! timestamp. The blob is embedded via `include_str!` and is available without //! enabling the `host` feature. //! -//! The acadrust source string is used to detect binary-incompatible plugin +//! The opencadcodec source string is used to detect binary-incompatible plugin //! builds. For API v4 and later the host compares the plugin's resolved -//! `acadrust` source with its own via [`acadrust_sources_compatible`]. Two +//! `opencadcodec` source with its own via [`acadrust_sources_compatible`]. Two //! sources are considered compatible only when they resolve to the same 40 //! character git commit hash. //! @@ -60,7 +60,7 @@ fn parsed_version_info() -> &'static VersionInfo { }) } -/// Returns the host's full `acadrust` Cargo source. +/// Returns the host's full `opencadcodec` Cargo source. pub fn host_acadrust_source() -> &'static str { &parsed_version_info().acadrust_source } @@ -148,11 +148,14 @@ mod tests { Some(env!("CARGO_PKG_VERSION")) ); assert_eq!(value["api_version"].as_i64(), Some(crate::API_VERSION as i64)); - assert_eq!(value["api_version_min_supported"].as_i64(), Some(2)); + assert_eq!( + value["api_version_min_supported"].as_i64(), + Some(crate::API_VERSION_MIN_SUPPORTED as i64) + ); // acadrust_version should have a patch component (e.g. "0.4.0"). - let acadrust = value["acadrust_version"].as_str().expect("acadrust_version string"); - assert_eq!(acadrust.split('.').count(), 3); + let opencadcodec = value["acadrust_version"].as_str().expect("acadrust_version string"); + assert_eq!(opencadcodec.split('.').count(), 3); } #[test] @@ -208,7 +211,7 @@ mod tests { #[test] fn extracts_full_hash_from_cargo_source() { - let src = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let src = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; assert_eq!( acadrust_source_hash(src), Some("94df2c3f87fa051b16ffc3923f80e9247c85c5fd") @@ -228,22 +231,22 @@ mod tests { #[test] fn source_comparison_matches_full_hashes() { - let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; - let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let a = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; assert!(acadrust_sources_compatible(a, b)); } #[test] fn source_comparison_detects_mismatch() { - let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; - let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb"; + let a = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb"; assert!(!acadrust_sources_compatible(a, b)); } #[test] fn source_comparison_case_insensitive() { - let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; - let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94DF2C3F87FA051B16FFC3923F80E9247C85C5FD"; + let a = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=94df2c3#94DF2C3F87FA051B16FFC3923F80E9247C85C5FD"; assert!(acadrust_sources_compatible(a, b)); } } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 225783fd6..6e3acdefe 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd1d7bf", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "937745d", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/ocs_web_worker/src/lib.rs b/crates/ocs_web_worker/src/lib.rs index a3593b95d..ed8c657a6 100644 --- a/crates/ocs_web_worker/src/lib.rs +++ b/crates/ocs_web_worker/src/lib.rs @@ -1,8 +1,8 @@ use std::io::Cursor; use std::sync::Arc; -use acadrust::io::dwg::DwgReader; -use acadrust::{DwgReadOptions, DxfReader, DxfReaderConfiguration}; +use codec::io::dwg::DwgReader; +use codec::{DwgReadOptions, DxfReader, DxfReaderConfiguration}; use js_sys::{Function, Uint8Array}; use sha2::{Digest, Sha256}; use wasm_bindgen::prelude::*; @@ -121,14 +121,14 @@ pub fn parse_document( } if recovery_mode { outcome.document.notifications.notify( - acadrust::notification::NotificationType::Error, + codec::notification::NotificationType::Error, format!("Initial read failed; recovery mode continued: {initial_error}"), ); - acadrust::push_read_diagnostic( + codec::push_read_diagnostic( &mut outcome.stats.diagnostics, - acadrust::ReadDiagnostic::new( + codec::ReadDiagnostic::new( "strict-read-failed", - acadrust::ReadStage::RecordStream, + codec::ReadStage::RecordStream, initial_error, ), ); @@ -147,30 +147,30 @@ pub fn sha256_document(bytes: Uint8Array) -> String { sha256_document_bytes(&bytes.to_vec()) } -fn report_fingerprint_needed(stats: &acadrust::ReadStats) -> bool { +fn report_fingerprint_needed(stats: &codec::ReadStats) -> bool { stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed } -fn recoverable_reader_error(error: &acadrust::DxfError) -> bool { +fn recoverable_reader_error(error: &codec::DxfError) -> bool { matches!( error, - acadrust::DxfError::Compression(_) - | acadrust::DxfError::Parse(_) - | acadrust::DxfError::InvalidDxfCode(_) - | acadrust::DxfError::InvalidHandle(_) - | acadrust::DxfError::ObjectNotFound(_) - | acadrust::DxfError::InvalidEntityType(_) - | acadrust::DxfError::ChecksumMismatch { .. } - | acadrust::DxfError::InvalidHeader(_) - | acadrust::DxfError::InvalidFormat(_) - | acadrust::DxfError::InvalidSentinel(_) - | acadrust::DxfError::Decompression(_) - | acadrust::DxfError::Encoding(_) + codec::DxfError::Compression(_) + | codec::DxfError::Parse(_) + | codec::DxfError::InvalidDxfCode(_) + | codec::DxfError::InvalidHandle(_) + | codec::DxfError::ObjectNotFound(_) + | codec::DxfError::InvalidEntityType(_) + | codec::DxfError::ChecksumMismatch { .. } + | codec::DxfError::InvalidHeader(_) + | codec::DxfError::InvalidFormat(_) + | codec::DxfError::InvalidSentinel(_) + | codec::DxfError::Decompression(_) + | codec::DxfError::Encoding(_) ) } fn encode_result( - result: Result)>, + result: Result)>, source_sha256: Option, recoverable_parse_error: bool, bytes: &[u8], diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml deleted file mode 100644 index 0236b7294..000000000 --- a/crates/plugin-template-api2/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "plugin-template-api2" -version = "0.1.0" -edition = "2021" -description = "API v2 compatibility fixture for the out-of-process plugin path." -license = "GPL-3.0-only" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8a28c21", features = ["serde"] } diff --git a/crates/plugin-template-api2/src/lib.rs b/crates/plugin-template-api2/src/lib.rs deleted file mode 100644 index ee26e847c..000000000 --- a/crates/plugin-template-api2/src/lib.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! API v2 compatibility fixture. -//! -//! Mimics an old plugin compiled against `ocs_plugin_api` v2: it reports API -//! major 2 from `ocs_plugin_api_version()` and implements only the v2 surface -//! (`HostApi` methods up to `start_interactive`). The current host must still -//! be able to load and dispatch it. - -use ocs_plugin_api::host::{BuiltinPlugin, HostApi}; -use ocs_plugin_api::manifest::{ApiVersion, PluginManifest}; -use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; - -static MANIFEST: PluginManifest = PluginManifest { - id: "opencad.my_plugin", - name: "My Plugin", - version: env!("CARGO_PKG_VERSION"), - description: "API v2 fixture plugin.", - api_version: ApiVersion { major: 2 }, - ribbon_order: 60, - xdata_apps: &[], - command_prefixes: &["MP_"], -}; - -struct MyModule; - -impl CadModule for MyModule { - fn id(&self) -> &'static str { - "my_plugin" - } - fn title(&self) -> &'static str { - "My Plugin" - } - 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: "MP_HELLO", - label: "Hello", - icon: IconKind::Glyph("*"), - event: ModuleEvent::Command("MP_HELLO".to_string()), - })], - }] - }) - } -} - -struct MyPlugin; - -impl BuiltinPlugin for MyPlugin { - fn manifest(&self) -> &'static PluginManifest { - &MANIFEST - } - fn ribbon(&self) -> Box { - Box::new(MyModule) - } - fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { - match cmd { - "MP_HELLO" => { - host.push_info("Hello from API v2 plugin"); - true - } - _ => false, - } - } -} - -// Custom C-ABI export that reports API v2, emulating an older build of -// `ocs_plugin_api::export_plugin!`. -#[no_mangle] -pub extern "C" fn ocs_plugin_api_version() -> u32 { - 2 -} - -#[no_mangle] -pub extern "C" fn ocs_plugin_register() -> *mut Box { - let plugin: Box = Box::new(MyPlugin); - Box::into_raw(Box::new(plugin)) -} diff --git a/docs/automation/API-SPEC.md b/docs/automation/API-SPEC.md index d175f8bdb..c124f6b31 100644 --- a/docs/automation/API-SPEC.md +++ b/docs/automation/API-SPEC.md @@ -72,15 +72,22 @@ MCP advertise the whole surface; feature detection is `capabilities` (§4). `OpenCADStudio --http 8090` runs a private, window-less session behind a resource-oriented REST surface at `http://127.0.0.1:/api/v1` (loopback -bind only, permissive CORS, JSON bodies, one request per connection). A +bind only, JSON bodies, one request per connection, no CORS headers). A machine-readable OpenAPI 3 description of the whole surface is served at `GET /api/v1/openapi`. +Every request carries `Authorization: Bearer `: the value of +`OCS_API_TOKEN` when the launcher set it, otherwise a random token printed on +stderr at startup. A request without it is answered **401** +`{"code":"unauthorized"}`. Loopback is shared by every process and user on +the machine, so the port alone is no credential. + | Status | Meaning | |---|---| | `200` | Success (read or update) | | `201` | A resource was created (document, entities, block, group, selection set, layout) | | `400` | Validation failure — the body keeps the symbolic `code` and message | +| `401` | Missing or wrong bearer token (`code:"unauthorized"`) | | `404` | Unknown route or unknown handle (`code:"entity_absent"`) | | `409` | State conflict (`stale_state`, `document_not_active`) or GUI required (`gui_required`) | | `503` | Dispatcher busy | @@ -126,7 +133,7 @@ Differences worth knowing: - Unknown POST paths are treated as op attempts and forwarded (any op of the surface works — including `operation` to poll a pending pick and `cancel` to retract it); truly unrouted paths answer `404 {"code":"unknown_route"}`. -- `OPTIONS *` answers **204** (CORS preflight). +- The same bearer token as §3.1 is required on every request. ### 3.3 stdio JSONL (`--serve`) @@ -138,6 +145,9 @@ envelope; mutations use the envelope of §1). ### 3.4 TCP (`--serve --port N`) The same JSONL protocol as §3.3 over `127.0.0.1:`, one client at a time. +Every request line carries `"token":""` (the §3.1 token); a line +without it is answered `{"ok":false,"code":"unauthorized"}` and the +connection is closed. ### 3.5 AI-tool session (`--mcp`) @@ -506,7 +516,7 @@ client's own log. ```sh # Create (missing layers auto-created; the batch is atomic). -curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{ "entities":[ {"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"}, {"type":"Text","value":"PAGE-01","position":[10,50],"height":3.0} @@ -514,16 +524,16 @@ curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: applicat # → 201 {"result":{"handles":["63","64"],"created":2,"layers_created":["FRAME"]}} # Filter by property. -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D" # Mark, wrap into a reusable block, plot every sheet, save as a template. -curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \ -H "Content-Type: application/json" -d '[{"code":1000,"value":"PAGE-01"}]' -curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \ -H "Content-Type: application/json" -d '{"name":"MARK","base":[0,0,0],"handles":["63","64"]}' -curl -s -X POST http://127.0.0.1:8090/api/v1/plot \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \ -H "Content-Type: application/json" -d '{"path":"C:/out/pages.pdf","layout":"all","per_page":true}' -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}' ``` @@ -534,14 +544,14 @@ OpenCADStudio.exe "C:/drawings/plan.dwg" --http 8090 # the editor stays intera # Ask the person to pick block references; the connection parks until they # press Enter (or Esc). No polling loop needed. -curl -s -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \ -H "Content-Type: application/json" \ -d '{"request_id":"sample-1","type":"INSERT","detail":"full","prompt":"Pick the title blocks"}' # → {"ok":true,"status":"completed","result":{"cancelled":false,"count":2,…}} # Read the same entities back, then continue driving the live drawing. -curl -s -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}' -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}' +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \ -H "Content-Type: application/json" \ -d '{"handles":["6F"],"action":"move","vector":[5,0]}' ``` diff --git a/docs/automation/DEVELOPING.md b/docs/automation/DEVELOPING.md new file mode 100644 index 000000000..dae5f5da8 --- /dev/null +++ b/docs/automation/DEVELOPING.md @@ -0,0 +1,98 @@ +# Developing the MCP bridge + +Operator-facing setup lives in [README.md](README.md). This note is for +contributors iterating on the bridge itself: the stdio JSON-RPC layer in +`src/mcp.rs` and the operation validators in `src/mcp_ops.rs`. + +## Fast iteration: no version bumps needed + +- Run the bridge straight from a local build: `target/debug/OpenCADStudio --mcp` + for speed, `target/release/OpenCADStudio --mcp` when close to shipping. + Both report the same handshake; only the `build_profile` field differs. +- Tool-surface changes need no version-number change. `serverInfo` carries a + content `tool_schema` digest plus the `build_rev` revision hash, so staleness + is observable without a release. +- If you change any tool schema, the `tool_schema_digest_is_pinned` test + fails: review the diff, then update `TOOL_SCHEMA_DIGEST` deliberately + (never blindly). + +## Sessions are per client connection + +Clients bind the tool surface once when they connect, so after every +rebuild, restart the client session (or reconnect its MCP servers) to pick +up the new schema. The bridge helps: it watches its own executable and, once +it detects a rebuild, finishes the in-flight request and exits, so the next +call spawns a fresh bridge. No in-flight call ever fails because of this; +at worst the following call pays one respawn. + +## Session discovery + +`ocs_sessions` reads GUI descriptor files and verifies each one is alive +before probing it: process-liveness checks come first (cached snapshots +where the OS has no process table to read), dead descriptors are deleted, +and every remaining TCP probe uses a bounded timeout. Any step that cannot +run fails open toward treating the session as alive, so a broken check can +slow discovery but never wedge it. + +Concurrent launches are serialized with `automation/starting.lock` +(`{"pid":..,"started":unix_secs}`, 60s TTL): the first `launch_if_none: true` +caller claims it and spawns the GUI, later callers with a fresh live claim +wait for the same descriptor instead of opening another window. Stale claims +and claims from dead pids are reclaimed; an unusable directory fails open so +a broken lock can never wedge launching. Covered by +`startup_lock_serializes_concurrent_launches` (claim, wait, stale-reclaim, +dead-pid-reclaim, release). + +## Cancellation + +`run()` reads stdin on a feeder thread into a channel (`CancelPump`); the +main loop stays the only stdout writer. Wait loops harvest the channel in +short quanta instead of sleeping blind. A `notifications/cancelled` naming +the in-flight request dismisses the GUI prompt with a second `{"op":"cancel"}` +exchange, drops the late result, and sends nothing back. Cancels arriving +between polls resolve through a pending-operation map; re-polls for dismissed +operations answer `cancelled` without touching the GUI. Interactive picks +(`user_select`, `getpoint`) have a ten-minute single-call ceiling ending in +an explicit timeout error; everything else keeps the one-minute clamp. + +## Error routing + +Tool-call failures are classified at the source (`CallError`): tool-domain +failures (bad arguments, failed operations) carry model-actionable guidance +and stay `isError` results; bridge and GUI infrastructure failures (no live +session, dead transport) become `-32603`; cancelled requests get no response. +Resource-not-found follows the negotiated era (`-32602` modern, `-32002` +legacy). Error results omit `structuredContent`: strict clients validate it +against `outputSchema`, which only describes success. + +## Safety model + +Never assume the client prompts before destructive calls. `ocs_execute` is +marked `destructiveHint: true`, but confirmation behavior is client-defined +and unobservable from here — server-side validation is the only safety net. +Validate every argument before touching the drawing, keep destructive and +read-only paths in separate ops, and prefer reversible effects. + +## Rebuilding while the bridge runs + +If the linker refuses to replace the binary while a bridge process runs +from it, move the running binary aside under a new name and rebuild. The +live bridge keeps serving from the moved file; restart the client +afterwards so it respawns from the fresh binary, and delete the moved file +once nothing runs from it anymore. + +## Conformance gates + +These tests hold the spec line; keep them green and extend them with every +protocol change: `tool_schema_digest_is_pinned`, +`initialize_is_spec_pure_with_experimental_build`, +`malformed_requests_get_jsonrpc_errors`, +`legacy_list_results_carry_sep2549_ttl`, +`resource_not_found_code_follows_era`, +`non_object_input_is_invalid_request`, +`unknown_tool_is_a_protocol_error_with_names`, +`cancel_matching_and_wait_deadlines`. +For end-to-end proof, run the official MCP Inspector CLI against a local +build (headless-safe methods only: `initialize`, `tools/list`, +`resources/list`, `resources/read` — anything touching the drawing needs a +live desktop session and stays in unit tests). diff --git a/docs/automation/README.md b/docs/automation/README.md index 8c700d7f1..8333fc9eb 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -20,6 +20,15 @@ The server provides four tools: - `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor. - `ocs_capture` returns a bounded PNG of the drawing viewport or complete window. +### Stale sessions clean themselves up + +Dead editor sessions used to leave descriptor files behind that slowed down +session discovery. Discovery now skips processes that are gone (and removes +their leftover files), and a bridge started from an older build exits on its +own once it detects a rebuild so the client starts a fresh one. No action is +needed on your side; if a client reports version errors after an upgrade, +reconnecting its MCP servers picks up the new build. + Run `python docs/automation/mcp_acceptance.py target/debug/OpenCADStudio.exe` for a repeatable end-to-end acceptance. It creates visible geometry through MCP, audits and verified-saves DWG 2000/2013/2018 plus DXF 2000, and records a @@ -301,10 +310,58 @@ maps onto these three steps rather than a native op. `query` entities of type `TEXT`/`MTEXT` return the raw stored string in `value` plus a formatting-free rendering in `text` (MTEXT inline codes such as `\A1;` or `\P` are resolved; `%%d`-style TEXT codes become their glyphs). With `detail:"full"`, degenerate-width text bounds are widened with a documented estimate (height × 0.8 × character count) so `bounds`-based region filters stay usable. +### Text operations — search, audit and replace + +Three dedicated text operations go beyond `query` for bulk text work: + +**`text_search`** (read) scans all `Text`, `MText`, and `Insert` attribute values for a pattern. It supports case-insensitive, whole-word, and accent-insensitive matching, scoped to a layer, entity type list, or handle set. + +```json +{"op":"text_search","find":"CUVE","match_case":false,"whole_word":true,"ignore_accents":true} +``` + +**`text_audit`** (read) is a non-mutating spell-check and dry-run replacement preview. It accepts three independent analysis modes, all optional, in one call: + +| Parameter | Effect | +|---|---| +| `pairs` | `[{"find":"X","replace":"Y"}, …]` — simulates a batch replacement and reports what would change, without touching the document | +| `check_terms` | `["CIRTUITS","NON TRAITE"]` — flags entities that contain suspect strings | +| `dictionary` | `["EAU","POMPE","VANNE"]` — any word ≥ 3 letters not found in this list is reported as unrecognised | +| `system_spellcheck` | `true` to use the OS native spell-checker alongside or instead of the agent dictionary | +| `language` | BCP-47 tag for the system speller, e.g. `"en-US"` or `"fr-FR"` (defaults to the OS default) | +| `suggest` | `true` (default) to include spelling suggestions for unrecognised words | + +The agent dictionary and system spell-checker work as a layered pipeline: a word that matches the agent dictionary is accepted immediately; only words that fail (or when no dictionary is supplied) are forwarded to the OS spell-checker when enabled. + +System spell-check backends: + +| Platform | Backend | Notes | +|---|---|---| +| Windows 8.1+ | Win32 `ISpellChecker` COM API | Per-language; checks available via Settings → Language. ALL-CAPS words are checked in lowercase since Windows skips all-caps by default. | +| macOS | `NSSpellChecker` | Uses the system language or the `language` parameter | +| Linux | Hunspell `.dic` files or `hunspell` CLI | Reads `/usr/share/hunspell/` dictionaries; falls back to pipe mode | + +Example combining an agent dictionary with system spell-check: + +```json +{ + "op": "text_audit", + "dictionary": ["VANNE", "CHAUDIÈRE", "POMPE"], + "system_spellcheck": true, + "language": "fr-FR", + "suggest": true +} +``` + +The response includes a `system_speller` object with `enabled`, `available`, `backend`, and `language` fields, plus an `unrecognized_words` array with each word's `handle`, `position`, and optional `suggestions`. + +**`text_replace`** (execute) performs the actual batch replacement with single-transaction undo. Add `"dry_run": true` to preview the result without mutating the document or pushing an undo entry. + ### Smoke test The stdio path is covered black-box by a Python script that draws two entities, exports them with `wblock`, plots them with `plot`, and checks the capability advertisement: + ```sh python3 docs/automation/serve_smoke.py target/debug/OpenCADStudio ``` @@ -321,13 +378,22 @@ editor, events and plotting. resource-oriented REST surface on `http://127.0.0.1:/api/v1`. Any HTTP client works — curl, Python, C# `HttpClient`, JS `fetch`; there is no SDK and nothing AI-specific. The server keeps one drawing session alive -across requests, binds loopback only, sends permissive CORS headers, and -serves its machine-readable description at `GET /api/v1/openapi` -(OpenAPI 3, embedded from `src/rest_openapi.json`). +across requests, binds loopback only, and serves its machine-readable +description at `GET /api/v1/openapi` (OpenAPI 3, embedded from +`src/rest_openapi.json`). + +Every request carries `Authorization: Bearer `; anything else is +answered `401 {"code":"unauthorized"}`. Loopback is shared by every process +and user on the machine, so the port alone proves nothing. Set the token +yourself with `OCS_API_TOKEN` before launching, or read the random one the +server prints on stderr at startup. No CORS headers are sent, so a web page +cannot drive or read the session. The same token guards `--serve --port` +(as a `"token"` field in each JSON line) and the GUI-hosted channel below. ```sh +export OCS_API_TOKEN=$(openssl rand -hex 32) OpenCADStudio --http 8090 # start the REST server (headless) -curl http://127.0.0.1:8090/api/v1/ready +curl -H "Authorization: Bearer $OCS_API_TOKEN" http://127.0.0.1:8090/api/v1/ready ``` ### GUI-hosted REST channel (`--http` + file) @@ -455,7 +521,7 @@ HTTP. ```sh # 1. Create entities (the missing layer "FRAME" is created automatically; # the whole batch commits as one undoable step or not at all). -curl -s -X POST http://127.0.0.1:8090/api/v1/entities \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities \ -H "Content-Type: application/json" \ -d '{"entities":[ {"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"}, @@ -466,71 +532,71 @@ curl -s -X POST http://127.0.0.1:8090/api/v1/entities \ # → 201 {"ok":true,"status":"completed","result":{"handles":["63","64","65","66"],…}} # 2. Verify: query it back. -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full" # → {"ok":true,"entities":[{"handle":"63","start":[0,0,0],"end":[100,0,0],…},…] # 3. Move the circle 10 units right. -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \ -H "Content-Type: application/json" \ -d '{"handles":["66"],"action":"move","vector":[10,0]}' # 4. Mark the text with extended data (RegApp "SPM" registered implicitly). -curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \ -H "Content-Type: application/json" \ -d '[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":3}]' -curl -s "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM" # 5. Turn the two frame lines into a block definition + Insert. -curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \ -H "Content-Type: application/json" \ -d '{"name":"FRAME-MARK","base":[0,0,0],"handles":["63","67"]}' # → 201 {"result":{"block":"FRAME-MARK","insert":"6B"}} # 6. Save and prove persistence. -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwg"}' # 7. Filter entities by any property (RFC 6901 pointers, SQL-ish operators). -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D" # 8. Set and read back drawing sysvars. -curl -s -X POST http://127.0.0.1:8090/api/v1/sysvars \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/sysvars \ -H "Content-Type: application/json" -d '{"set":{"ltscale":2.5}}' -curl -s "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext" # 9. Provision a sheet: create the layout, write its page setup, then # plot every layout to its own PDF (one file per entry in result.files). -curl -s -X POST http://127.0.0.1:8090/api/v1/layouts \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/layouts \ -H "Content-Type: application/json" -d '{"name":"PLAN"}' -curl -s -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \ -H "Content-Type: application/json" \ -d '{"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}' -curl -s -X POST http://127.0.0.1:8090/api/v1/plot \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \ -H "Content-Type: application/json" \ -d '{"path":"C:/out/plan.pdf","layout":"all","per_page":true}' # 10. Save the drawing as a template (a .dwt is DWG bytes — same writer, # no lock held on the file) and start a fresh drawing from it. -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}' -curl -s -X POST http://127.0.0.1:8090/api/v1/documents \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents \ -H "Content-Type: application/json" -d '{"template":"C:/out/session.dwt"}' # 11. Second document, then copy entities across documents. -curl -s -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \ -H "Content-Type: application/json" \ -d '{"handles":["63","67"],"document_id":2}' # 12. Group the copies, save them as a named selection set, recall it. -curl -s -X POST http://127.0.0.1:8090/api/v1/groups \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/groups \ -H "Content-Type: application/json" -d '{"name":"FRAME","handles":["63","67"]}' -curl -s -X POST http://127.0.0.1:8090/api/v1/selection-sets \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/selection-sets \ -H "Content-Type: application/json" -d '{"name":"frame-set","handles":["63","67"]}' -curl -s "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true" # 13. Close a document, discarding unsaved changes. -curl -s -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true" ``` The same lifecycle — create → verify → transform → xdata → block → diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index dd1585f49..ac6a0d74e 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -52,26 +52,33 @@ def legacy(server: Path) -> None: names = set(definitions) assert names == TOOLS, names execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] - assert len(execute_request["oneOf"]) == 38 + variants = execute_request.get("anyOf") or execute_request.get("oneOf") + assert len(variants) == 39 op_enum = execute_request["properties"]["op"]["enum"] for shipped in ("entities_create", "entities_delete", "entities_transform", "block_define", "block_delete", "file_identity", "xdata_set", "view_focus", "wblock", "plot", "entities_copy_to", "group_create", "selection_set_save", "selection_set_load", "close", "sysvar", "layout_create", - "page_setup_set"): + "page_setup_set", "text_replace"): assert shipped in op_enum, shipped assert "xdata_get" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + assert "text_search" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + assert "text_audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] assert execute_request["properties"]["steps"]["maxItems"] == 64 - assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" + variant_map = { + v["properties"]["op"].get("const") or v["properties"]["op"]["enum"][0]: v + for v in variants + } + assert variant_map["run"]["properties"]["cmd"]["type"] == "string" assert "set_properties" in execute_request["properties"]["op"]["enum"] assert "save_verified" in execute_request["properties"]["op"]["enum"] assert "record_schema" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] assert "audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] read_parameters = definitions["ocs_read"]["inputSchema"]["properties"]["parameters"]["properties"] assert {"collection", "where", "paths", "target_format", "target_version"} <= set(read_parameters) - assert execute_request["properties"]["target_version"]["enum"][0] == "R14" - assert execute_request["properties"]["kind"]["enum"] == [ + assert variant_map["save_verified"]["properties"]["target_version"]["enum"][0] == "R14" + assert variant_map["input"]["properties"]["kind"]["enum"] == [ "text", "token", "point", "entity", "structure", "selection", "enter" ] for name in {"ocs_sessions", "ocs_read", "ocs_execute"}: diff --git a/docs/automation/native.md b/docs/automation/native.md index d86cb6b99..ddfbf978f 100644 --- a/docs/automation/native.md +++ b/docs/automation/native.md @@ -33,7 +33,7 @@ Two request styles share the channel, told apart by the `protocol` field: | Style | Shape | Operations | |---|---|---| -| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `audit`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` | +| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`, `text_replace`; reads `state`, `capabilities`, `audit`, `text_audit`, `text_search`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` | | legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `audit`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` | The two styles differ in how a waiting command is treated. A protocol-1 `run` diff --git a/docs/automation/rest_smoke.py b/docs/automation/rest_smoke.py index aa0cda0a2..37bdcbd14 100644 --- a/docs/automation/rest_smoke.py +++ b/docs/automation/rest_smoke.py @@ -14,6 +14,8 @@ sets, and a close-with-discard. Uses only the Python standard library. """ import json +import os +import secrets import subprocess import sys import tempfile @@ -24,10 +26,13 @@ import urllib.request from pathlib import Path +TOKEN = secrets.token_hex(32) + + def call(port: int, method: str, path: str, body=None): """One HTTP call; returns (status, parsed-json-or-None).""" data = None - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"} if body is not None: data = json.dumps(body).encode() request = urllib.request.Request( @@ -67,6 +72,7 @@ def main() -> None: out_dir = Path(tempfile.mkdtemp(prefix="ocs_rest_smoke_")) process = subprocess.Popen( [str(exe), "--http", str(port)], + env={**os.environ, "OCS_API_TOKEN": TOKEN}, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index d726b6c45..597e6f5f4 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -1,14 +1,14 @@ -# cadcodec DXF reader/writer gaps found by the OCS Python host audit +# opencadcodec DXF reader/writer gaps found by the OCS Python host audit -Reports on `HakanSeven12/cadcodec` (the `acadrust` crate). Every finding was made against -**cadcodec revision `5b682ed`** by saving a document with `DxfWriter` or `DwgWriter`, +Reports on `HakanSeven12/opencadcodec` (the `opencadcodec` crate). Every finding was made against +**opencadcodec revision `5b682ed`** by saving a document with `DxfWriter` or `DwgWriter`, reloading it, and comparing entity fields. -**Status (23 September 2026):** the fixes landed in cadcodec as -[#48](https://github.com/HakanSeven12/cadcodec/pull/48) (issues 1-6) and -[#51](https://github.com/HakanSeven12/cadcodec/pull/51) (the first three style findings), and -the block description with commit `dd1d7bf` (cadcodec issue #49). OCS pins `dd1d7bf`, and the -canaries below were flipped against it. What is still open: +**Status (23 September 2026):** the fixes landed in opencadcodec as +[#48](https://github.com/HakanSeven12/opencadcodec/pull/48) (issues 1-6) and +[#51](https://github.com/HakanSeven12/opencadcodec/pull/51) (the first three style findings), and +the block description with commit `dd1d7bf` (opencadcodec issue #49). OCS pins `1c3be0c`, which +includes it; the canaries below were flipped against `dd1d7bf`. What is still open: - **ATTDEF `lock_position`** (issue 1): the reader is complete, but the DXF writer emits no group 280 at all, neither the version byte nor the lock flag the reader expects after it. @@ -17,7 +17,7 @@ canaries below were flipped against it. What is still open: - **`TextStyle::true_type_font`** (style findings): still neither written nor read. Each report names an executable canary in OCS (`src/app/plugin_host.rs`). The -canaries assert today's wrong result, so they fail loudly once cadcodec is +canaries assert today's wrong result, so they fail loudly once opencadcodec is fixed; flip the marked expectation when that happens. To reproduce, from the OCS repository: @@ -115,7 +115,7 @@ raw value: `50 => { if let Some(v) = pair.as_double() { underlay.rotation = v; } **What happens:** a rotation of `0.5` rad is written as 28.6479 degrees and read back as 28.6479 rad. Because the writer converts again on every save, the error compounds: a second save-and-reopen yields 1641.4. This silently corrupts -existing drawings each time they pass through cadcodec. +existing drawings each time they pass through opencadcodec. **Evidence:** `audit_python_underlay_lifecycle_over_real_ipc`; the canary `expect_edited_dxf` / `expect_reedited_dxf` records 28.6 then 1641.4. @@ -210,14 +210,14 @@ These are format constraints or OCS-side issues, not reader bugs: ### Text and dimension style findings (2026-09-21) Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a canary. The first -three are fixed in [HakanSeven12/cadcodec#51](https://github.com/HakanSeven12/cadcodec/pull/51) +three are fixed in [HakanSeven12/opencadcodec#51](https://github.com/HakanSeven12/opencadcodec/pull/51) (independent of #48); the last two are not filed, because fixing them needs the XDATA layout AutoCAD expects and that cannot be checked here. - **`STYLE` generation flags:** the DXF writer hard-codes group 71 to 0, so a text style's `flags.backward` and `flags.upside_down` are lost on every DXF save (DWG keeps them). - **`STYLE` oblique angle units:** group 50 is written and read as the raw radian value, while DXF - defines it in degrees; it round-trips inside cadcodec but AutoCAD would read 15 degrees as 0.26. + defines it in degrees; it round-trips inside opencadcodec but AutoCAD would read 15 degrees as 0.26. DWG stores radians correctly. - **`DIMSTYLE` text style name:** the DXF reader keeps only the text-style handle (group 340) and never resolves `dimtxsty` from it, so the name reopens as `Standard` (the handle is right; DWG resolves both). @@ -225,5 +225,5 @@ AutoCAD expects and that cannot be checked here. - **`BLOCK_RECORD` description:** the DXF codec did not carry `BlockRecord::description` (found by `audit_python_blocks_over_real_ipc`). It needs no XDATA: it is BLOCK group 4, which the reader parsed onto the discarded BLOCK marker and the writer never emitted. Fixed in - cadcodec `dd1d7bf` (cadcodec issue #49). + opencadcodec `dd1d7bf` (opencadcodec issue #49). diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index 312e36960..6832f78aa 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -1,4 +1,4 @@ -# Scripting ACIS bodies through cadkernel: findings and plan +# Scripting ACIS bodies through opencadkernel: findings and plan Investigation of the blocker on **Solid3D, Body, Region and Surface** (`docs/plugin-host-model-coverage-ledger.md`). The blocker said a script can @@ -7,7 +7,7 @@ forbids rewriting an opaque payload without a proven lossless path. This note records what OCS's own kernel path can already do, measured with two spike tests (`spike_kernel_body_round_trip`, `spike_kernel_timings` in `src/app/plugin_host.rs`, both `#[ignore]` because they are slow in a debug -build). Measured on cadcodec `5b682ed` / cadkernel `6f046af`, debug build. +build). Measured on opencadcodec `5b682ed` / opencadkernel `6f046af`, debug build. **Conclusion:** the blocker was overstated. OCS already owns a kernel-backed create/transform/boolean pipeline whose output is verified lossless before it is @@ -63,7 +63,7 @@ Two things to understand about "lossless": ## Design -The obstacle is the channel. The generic entity path moves an acadrust +The obstacle is the channel. The generic entity path moves an opencadcodec `EntityType` from the plugin to the host; the plugin has no kernel and must never see or rewrite ACIS bytes. Three ways to give a script the operations: @@ -141,11 +141,11 @@ Phase 1 alone would move Solid3D from blocked to complete-in-OCS. ## Boolean timings (release build) Measured 2026-09-20 in a scratch crate built with `--release` (opt-level 3) -against the same cadkernel revision OCS pins (`6f046af`, features `acis` and +against the same opencadkernel revision OCS pins (`6f046af`, features `acis` and `offset`), calling exactly what OCS's `solid_model::boolean_result` calls: `brep::operation_tolerance` then `brep::combine`. A debug build of OCS is about 22 times slower, so booleans must never be exercised in a debug test run. The -scratch program is not in the repository; to repeat it, depend on cadkernel at +scratch program is not in the repository; to repeat it, depend on opencadkernel at that revision and time `brep::combine` on `brep::make::{cuboid, cylinder, sphere, torus}` operands. diff --git a/docs/native-vs-web.md b/docs/native-vs-web.md index 6d314ebdf..b91d026fc 100644 --- a/docs/native-vs-web.md +++ b/docs/native-vs-web.md @@ -28,7 +28,7 @@ crates reach `vtkio → xz2 → lzma-sys` — a C library that cannot cross-comp to `wasm32` — so the web build dropped a `solid3d` feature and the Model tab, solid tessellation and ACIS import all did nothing there. -The kernel is now cadkernel, which is pure Rust and has no C dependency at +The kernel is now opencadkernel, which is pure Rust and has no C dependency at all, so none of that applies and the feature is gone. The web build makes primitives, runs booleans, draws ACIS solids read from a file and writes them back out as exact geometry, exactly as the desktop build does. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 439c41d37..2e5994ee0 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -34,7 +34,7 @@ engine crate, and user-installable packages from a curated index. the repos they install from). Process isolation limits the blast radius of a buggy or malicious plugin, but it is not a security sandbox. - Cross-toolchain binary compatibility — see [Compatibility](#compatibility--abi). -- Sandboxed scripting (Python/Lua); replacing the `acadrust` entity model. +- Sandboxed scripting (Python/Lua); replacing the `opencadcodec` entity model. --- @@ -56,7 +56,7 @@ engine crate, and user-installable packages from a curated index. │ pure Rust API ┌───────────────────────────────▼────────────────────────────────────┐ │ Layer C — Domain engine crate (optional) │ -│ hydraulics / COGO / … — `std` only, no iced/acadrust │ +│ hydraulics / COGO / … — `std` only, no iced/opencadcodec │ └────────────────────────────────────────────────────────────────────┘ ``` @@ -69,7 +69,7 @@ engine crate, and user-installable packages from a curated index. **Hard rules** 1. The host (`src/plugin/`) imports no plugin code — it only knows the contract. -2. Engine crates import neither `iced`, `acadrust`, nor `OpenCADStudio`. +2. Engine crates import neither `iced`, `opencadcodec`, nor `OpenCADStudio`. 3. A plugin never edits host source; it runs entirely from its own crate. --- @@ -83,7 +83,7 @@ plugin compiles against. Two tiers: ribbon vocabulary — `CadModule`, `ToolDef`, `RibbonGroup`, `RibbonItem`, `IconKind`, `ModuleEvent`, `StyleKey`. Engine crates and tooling depend on this cheaply. -- **`host` feature** (pulls `acadrust`): the runtime surface — the `HostApi` +- **`host` feature** (pulls `opencadcodec`): the runtime surface — the `HostApi` trait, the `BuiltinPlugin` entry-point trait, the `export_plugin!` macro, and the out-of-process plugin runtime (`PluginProcess`, `runner`). @@ -94,7 +94,7 @@ A plugin enables the `host` feature. For API v4 and later the host records two pieces of build-time metadata that affect whether a prebuilt plugin can safely be loaded: -- `acadrust_source` — the exact git source of the `acadrust` crate the host was +- `acadrust_source` — the exact git source of the `opencadcodec` crate the host was built against. - `rustc_version` — the output of `rustc --version` for the compiler that built the host. @@ -180,9 +180,9 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] } -# Match the host's acadrust so the loaded library is binary-compatible. +# Match the host's opencadcodec so the loaded library is binary-compatible. [patch.crates-io] -acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" } +opencadcodec = { git = "https://github.com/HakanSeven12/opencadcodec", branch = "main" } ``` Your release build must also use the same `rustc` as the host. Record it in @@ -243,7 +243,7 @@ command_prefixes = ["EX_"] xdata_apps = [] # Both are filled at release-build time; see the CI step below. rustc_version = "rustc 1.98.0 (hash date)" -acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=#" +acadrust_source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=#" ``` The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template); @@ -322,7 +322,7 @@ and `acadrust_source` placeholders from the CI run's actual toolchain and resolved dependency, rather than hand-editing them per release — see `docs/plugin-template/.github/workflows/release.yml` for the exact `sed` substitutions, including reading `acadrust_source` out of `Cargo.lock`'s -resolved `acadrust` package entry (`source = "git+...#"`) since that's +resolved `opencadcodec` package entry (`source = "git+...#"`) since that's the same value the host embeds at its own build time. The template's CI pins `rust-toolchain@stable`, which is a floating target — a real release pipeline should pin the exact toolchain version the host was built with (check diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index e56953166..73e24d2fb 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -6,10 +6,10 @@ Build a general Python document model over the OCS host API so a script can cont **State as of 21 September 2026.** The feature is host API v7. An earlier draft pull request, [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391), covered the first slice of it and was closed in favour of this more complete change. -- **Entities:** explicit writes for all 43 canvas kinds (41 creatable; the legacy `Polyline` and `Body` are update-only). **31 kinds are `Complete`** (every C/R/E/D/U/I/W/V/P gate passes). The remaining kinds are integration-tested and held short of `Complete` by a named blocker in the ledger: seven by cadcodec DXF bugs (fixes submitted as [cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48), not yet merged, so OCS still pins the unfixed revision), Polyline3D by the DWG format, Polyline2D by a kind change on DXF save, RasterImage by an image-reactor structure that cannot be verified, and Body by having no creation path. +- **Entities:** explicit writes for all 43 canvas kinds (41 creatable; the legacy `Polyline` and `Body` are update-only). **31 kinds are `Complete`** (every C/R/E/D/U/I/W/V/P gate passes). The remaining kinds are integration-tested and held short of `Complete` by a named blocker in the ledger: seven by opencadcodec DXF bugs (fixes submitted as [opencadcodec#48](https://github.com/HakanSeven12/opencadcodec/pull/48), not yet merged, so OCS still pins the unfixed revision), Polyline3D by the DWG format, Polyline2D by a kind change on DXF save, RasterImage by an image-reactor structure that cannot be verified, and Body by having no creation path. - **Drawing tables:** layers, text and dimension styles, blocks and their contents, linetypes and layouts, through the additive `TableOperation` request (see the log entries dated 2026-09-21). - **Commands:** `HostApi::run_command` drives the real OCS commands step by step; `doc.command`, `doc.start_command` and `doc.modify` build on it. -- **Known open items:** the unfiled cadcodec findings (`cadcodec-reader-gaps.md`), complex (text and shape) linetypes, plot devices and named page setups, and PEDIT's remaining options. +- **Known open items:** the unfiled opencadcodec findings (`cadcodec-reader-gaps.md`), complex (text and shape) linetypes, plot devices and named page setups, and PEDIT's remaining options. Repositories: @@ -28,15 +28,15 @@ Host and adapter changes live in the same repository and are committed together. The earlier Phase 8 was **integration and compatibility**, not a new entity kind. `Insert` was added afterward as a separate coverage increment. Before marking any kind complete, establish an executable integration harness that exercises the actual plugin runner and IPC, not only direct Rust conversion helpers: -1. Stage the in-tree v7 plugin with `bash plugins/opencad-python/tools/stage-bundled.sh [--debug]`. Verify the staged `plugin.toml` says API v7 and its acadrust source and Rust compiler match OCS. Keep the repository-relative `ocs_plugin_api` path; never check in a machine-local path. +1. Stage the in-tree v7 plugin with `bash plugins/opencad-python/tools/stage-bundled.sh [--debug]`. Verify the staged `plugin.toml` says API v7 and its opencadcodec source and Rust compiler match OCS. Keep the repository-relative `ocs_plugin_api` path; never check in a machine-local path. 2. Through real IPC, create, inspect, edit, and delete a representative entity. Verify an invalid multi-entity transaction changes nothing, a valid transaction produces one undo entry, and undo **and redo** restore the expected geometry and references. 3. In the running GUI, exercise a real point pick, entity pick, cancellation/Escape, and plugin command cancellation. Verify drawing/selection/command notifications, including overflow recovery. 4. Repeat an input request and event poll across two tabs; verify token and notification isolation and cleanup when a tab closes. 5. Save and reopen representative edits as **DWG and DXF**, verifying the actual geometry and preserved unmapped fields. Use a real viewport/canvas check where the entity has visible geometry; a converter round trip alone does not satisfy this. -6. Record platform, OCS/acadrust revisions, fixture files, commands, results, and any engine limitation. Keep repeatable tests in the relevant repositories. +6. Record platform, OCS/opencadcodec revisions, fixture files, commands, results, and any engine limitation. Keep repeatable tests in the relevant repositories. Phase 8 is complete only when these checks pass through the bundled plugin and -real runner boundary. If a test is blocked by OCS or acadrust behavior, record +real runner boundary. If a test is blocked by OCS or opencadcodec behavior, record the specific failure and leave Phase 8 open. ## Per-kind completion gate @@ -52,9 +52,9 @@ Use one checklist row per kind in a coverage ledger. Status values: `unstarted`, 7. **Packaged build:** Run the in-tree adapter tests with `--locked`, stage the plugin with `stage-bundled.sh`, and load it from the macOS application resources through the separate runner process. The staged manifest must - match the host's Rust compiler and acadrust source. + match the host's Rust compiler and opencadcodec source. -If OCS/acadrust cannot create or serialize a kind reliably, mark it **blocked**, add a regression test or fixture demonstrating the failure, and move to the next independent kind. An explicit unsupported status is honest progress, not completion. +If OCS/opencadcodec cannot create or serialize a kind reliably, mark it **blocked**, add a regression test or fixture demonstrating the failure, and move to the next independent kind. An explicit unsupported status is honest progress, not completion. ## Existing mapped kinds: audit before claiming completion @@ -97,7 +97,7 @@ The last five may require new engine capabilities rather than only Python conver ## Work pattern for each coding increment -1. Inspect the acadrust entity struct, constructors, private fields, serializer, drawing path, and linked tables/objects. Record the intended editable/read-only/unmapped properties first. +1. Inspect the opencadcodec entity struct, constructors, private fields, serializer, drawing path, and linked tables/objects. Record the intended editable/read-only/unmapped properties first. 2. Put generic validation and transaction behavior in the host. Keep the Python adapter thin; add a manifest override only when the generator cannot safely express a field. 3. Add focused host validation, adapter conversion, document-model, real transaction/undo/redo, IPC, GUI, and DWG/DXF fixture checks. Record test evidence in the ledger, including which checks were manual. 4. Run `cargo test -p ocs_plugin_api --features host --lib` and the relevant @@ -113,7 +113,7 @@ The last five may require new engine capabilities rather than only Python conver - Added a V4 local-socket nested-request test for `UpdateEntitiesTransaction` using a simulated runner peer and a recording host. This proves the request and response cross the V4 IPC framing and reach `HostApi`; it does **not** prove the actual Python plugin runs through IPC. - Extended the OCS app-host Point batch test to assert redo after undo. This proves the real app history path for those edits; creation/deletion history remains untested by this case. - Staged the actual debug Python cdylib with `stage-bundled.sh`. Its generated - manifest declares API v7 and the exact OCS acadrust and Rust compiler + manifest declares API v7 and the exact OCS opencadcodec and Rust compiler fingerprints. The plugin now builds from `plugins/opencad-python` in this repository and ships from the macOS application resources. - The opt-in `staged_python_plugin_line_lifecycle_over_real_ipc` app test passed with the staged v7 Python plugin and built OCS executable. It creates, edits, and deletes a Line through Python over the actual runner IPC; confirms three undo entries and undo/redo; and writes/reopens the edited Line as DWG and DXF. It does not exercise GUI picking, multiple tabs, or cancellation. Re-run with `OCS_TEST_PYTHON_PLUGIN= OCS_PLUGIN_RUNNER_EXE= cargo test app::plugin_host::tests::staged_python_plugin_line_lifecycle_over_real_ipc --lib -- --test-threads=1 --nocapture` from the OCS checkout. @@ -127,14 +127,14 @@ The last five may require new engine capabilities rather than only Python conver - `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the actual runner, checks the real SHX linework before and after DWG/DXF reopen, and proves that invalid size/style edits remain atomic. The DXF renderer fallback now searches named `is_shape_file` styles as well as legacy unnamed styles. Shape is **Complete**; the queue now continues with `AttributeDefinition`. - Added creation-time `owner_handle` to the generated document-model adapter. It is present in snapshots and accepted by `doc.create_entity`, but rejected after creation; host transactions independently reject owner changes. Generic host validation now requires a non-null canvas owner to resolve to a block record, except `AttributeEntity`, whose owner may be an `Insert`. - Mapped stable AttributeDefinition fields and validated tag, placement, normal, height, width, angles, text style, line count and block ownership. `embedded_mtext` stays snapshot-readable but unmapped so partial Python edits preserve its linked R2018+ layout payload. -- `staged_python_attribute_definition_lifecycle_over_real_ipc` passes the real create/read/edit/delete/undo/selection/render/validation/packaged-build path using a structurally valid block and linked Insert. Full DWG state and core DXF geometry survive reopen; deletion stays absent after both formats. OCS also stopped double-converting ATTDEF/ATTRIB DXF rotation already converted by acadrust. -- AttributeDefinition remains **Integration-tested / DXF blocked**, not Complete: acadrust `568a12c`'s ATTDEF DXF reader did not restore optional AcDbText fields including width factor. Revalidate this recorded failure against the currently merged `7ea4247` revision before keeping or clearing the W-gate blocker. +- `staged_python_attribute_definition_lifecycle_over_real_ipc` passes the real create/read/edit/delete/undo/selection/render/validation/packaged-build path using a structurally valid block and linked Insert. Full DWG state and core DXF geometry survive reopen; deletion stays absent after both formats. OCS also stopped double-converting ATTDEF/ATTRIB DXF rotation already converted by opencadcodec. +- AttributeDefinition remains **Integration-tested / DXF blocked**, not Complete: opencadcodec `568a12c`'s ATTDEF DXF reader did not restore optional AcDbText fields including width factor. Revalidate this recorded failure against the currently merged `7ea4247` revision before keeping or clearing the W-gate blocker. - Closed `AttributeEntity` without flattening canonical drawing storage. V4 views and IPC snapshots expose each nested child by handle, while host create/update/delete and transactions rewrite the owning Insert so rendering, serialization and undo remain consistent. - `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the actual v7 adapter. It creates a linked attribute from its block's ATTDEF, reads and edits value/placement/rotation, selects the parent Insert, renders text, rejects invalid tag/height/owner edits atomically, reopens edited and deleted state in DWG and DXF, and proves three-step undo/redo. AttributeEntity is **Complete**; the queue now continues with `Hatch`. - Completed `Hatch` with full solid/pattern definitions and typed nested boundary paths. The adapter generator now handles single-payload tagged enums as `{kind, value}` dictionaries, which covers every Hatch edge variant and is reusable by later nested entity models. Unknown adapter properties are rejected instead of being silently ignored. - `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with closed line-loop fixtures: solid creation, conversion to a stored-line pattern, boundary replacement, selection and live render-cache checks, atomic invalid scale/association/open-loop/unmapped-payload rejection, edited/deleted DWG and DXF reopen, post-reopen edits, and three-step undo/redo. Hatch is **Complete**; the queue now continues with `Leader`. -- Mapped `Leader` (host validation and reference binding in `entity_coverage.rs`, policy/build.rs/manifest entries, adapter count 23). `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P through the real runner. W is blocked by acadrust: the DXF writer omits the annotation handle (340), DXF reads lose `annotation_offset` (213), and DWG R2010+ does not store text size or hookline flag. Each gap is pinned by a canary assertion so it fails loudly once fixed. The queue now continues with `MLine`. -- Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify acadrust's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`. +- Mapped `Leader` (host validation and reference binding in `entity_coverage.rs`, policy/build.rs/manifest entries, adapter count 23). `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P through the real runner. W is blocked by opencadcodec: the DXF writer omits the annotation handle (340), DXF reads lose `annotation_offset` (213), and DWG R2010+ does not store text size or hookline flag. Each gap is pinned by a canary assertion so it fails loudly once fixed. The queue now continues with `MLine`. +- Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify opencadcodec's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`. - Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `_to_dict/_from_dict/_apply`) and the host catalog a `synthetic` policy section. The host derives `actual_measurement` and the base definition point on every scripted create/edit. Python ints are now accepted wherever a coordinate or angle is expected. `staged_python_dimension_lifecycle_over_real_ipc` records each subtype separately and all nine pass C/R/E/D/U/I/W/V/P. The queue now continues with `MultiLeader`. - Completed `MultiLeader`. The adapter generator now converts `Color`, `LineWeight`, mixed unit/payload enums, fixed float arrays and bitflags-2 newtypes, and a `keep_types` override lets a hand-written setter reuse generated converters (used for the merge-style `context` patch). `Leader.override_color` became writable through the same `Color` form; neither file format stores it. `staged_python_multileader_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `Table`. - Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`. @@ -146,26 +146,26 @@ The last five may require new engine capabilities rather than only Python conver - Mapped `Viewport` with a paper-space owner rule and frozen-layer reference validation. Every gate except a paper-space canvas oracle passes through the shared driver; the kind stays integration-tested. The queue now continues with `ViewBorder`. - Mapped `ViewBorder` with viewport and scale reference rules. All gates pass in DWG; DXF loading does not restore the typed entity (pinned by a canary). The queue now continues with `SectionSymbol`. - Recorded `SectionSymbol` (queue item 15) as **blocked**: it needs a section view style and view representation the host cannot create, and its raw point-count fields have no documented derivation. Mapped `Light` (item 16); every gate passes except DXF `cast_shadows`, which the reader drops (pinned by a canary). The queue now continues with `Region`. -- Recorded `Region`, `Body`, `Solid3D`, `Surface` (queue items 17-20) and `Ole2Frame` (item 21) as **blocked**. The ACIS kinds need a host-side kernel-backed create/transform API that regenerates the payload, wires and silhouettes; the OLE frame needs a valid embedded-storage fixture. No opaque payload is rewritten. With this the ordered queue is complete: 12 kinds are fully complete, Leader, Helix, Underlay, ViewBorder and Light are mapped but blocked at DWG/DXF persistence by acadrust gaps, RasterImage and Viewport lack an oracle or linkage, and six kinds are blocked. Next work is the engine fixes behind the canary assertions, the mapped-kind audit of the 17 pre-existing kinds, and merging `origin/main`. +- Recorded `Region`, `Body`, `Solid3D`, `Surface` (queue items 17-20) and `Ole2Frame` (item 21) as **blocked**. The ACIS kinds need a host-side kernel-backed create/transform API that regenerates the payload, wires and silhouettes; the OLE frame needs a valid embedded-storage fixture. No opaque payload is rewritten. With this the ordered queue is complete: 12 kinds are fully complete, Leader, Helix, Underlay, ViewBorder and Light are mapped but blocked at DWG/DXF persistence by opencadcodec gaps, RasterImage and Viewport lack an oracle or linkage, and six kinds are blocked. Next work is the engine fixes behind the canary assertions, the mapped-kind audit of the 17 pre-existing kinds, and merging `origin/main`. - Audited the 17 pre-existing mapped kinds through the shared real-runner driver (`audit_python_*`). The audit found and fixed genuine validation gaps: Ellipse had none, Text and MText accepted a zero height, Arc accepted non-finite angles, Point and Line accepted infinite thickness, and most basic kinds had no creation-time validation. A shared `validate_basic_entity` now covers Point, Line, Circle, Arc, Ellipse, Text, MText, LwPolyline, Polyline, Polyline2D, Polyline3D and Spline, refusing only problems an edit introduces so already-invalid legacy values stay editable. Twelve of the 17 kinds now pass every gate (Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid, Face3D). Four expose persistence findings pinned by canaries (Polyline reopens as Polyline3D; Polyline2D reopens as LwPolyline in DXF; Polyline3D loses elevation in DWG; Spline loses weights in DXF), and Insert is update-only with all applicable gates passing. The driver gained per-format DWG expectations and a host-made block fixture for Insert. -- Audited `AttributeDefinition` against acadrust `5b682ed` with a full-field comparison through both formats. Everything passes in DWG. DXF keeps only tag, prompt, value, insertion point, height and rotation because the reader handles eight group codes; alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. The kind stays integration-tested and the gap is pinned by canary expectations; the fix belongs in cadcodec's `read_attdef`. The shared driver's block fixture now also serves `MAKEBLOCK` scripts. +- Audited `AttributeDefinition` against opencadcodec `5b682ed` with a full-field comparison through both formats. Everything passes in DWG. DXF keeps only tag, prompt, value, insertion point, height and rotation because the reader handles eight group codes; alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. The kind stays integration-tested and the gap is pinned by canary expectations; the fix belongs in opencadcodec's `read_attdef`. The shared driver's block fixture now also serves `MAKEBLOCK` scripts. - Decided the two open audit questions. **Insert is creatable** by naming an existing block (existence, ordinary-block and no-cycle checks; attribute records stay with the AttributeEntity flow), which completes the kind. **Legacy Polyline is update-only** with a hint pointing scripts at Polyline2D and Polyline3D, because both formats convert it on save. The generator gained an `update_only_message` for such kinds, integer coordinates are accepted in Insert scales, and the driver gained `MAKEPOLYLINE` and `MAKEBLOCK` fixtures. -- Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. +- Wrote up the opencadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. - Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface. -- Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked. -- Built the cadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved. +- Investigated the opencadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through opencadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked. +- Built the opencadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved. - Completed `Region`. `SolidOperation::RegionFromProfile` reuses the REGION command's kernel calls and `doc.solids.region` exposes it; the lifecycle test creates regions from real circle and polyline entities and covers deletion of the source, refusals, a move, both formats and undo. Along the way `create_entity` now accepts `(x, y, z)` tuples for point properties (decided from the schema's property types) and every generated float setter accepts Python ints, so `create_entity('Circle', center=(0, 0, 0), radius=5)` works. Left: booleans (release timing first), holes and open-curve merging for regions, and `Surface`. - Completed `Surface` for planes, extruded open profiles, isolines and rigid moves. `SurfaceFromProfile` and `Extrude` join the kernel operations (`doc.solids.surface` and `doc.solids.extrude`); `Extrude` also gives Solid3D extrusions from closed profiles, checked against analytic volumes. The test found and fixed a real bug: Surface's `kind` field silently overwrote the entity-type `kind` key in Python dicts, so it is exposed as `surface_kind`, and a new uniqueness test guards the whole catalog. Transform now accepts surfaces (a plane stays a plane; other kinds turn generic). Left: booleans (release timing first), regions with holes, and lofted, revolved, swept and NURBS surfaces. SectionSymbol is the only unmapped kind. - Timed booleans in a release build (details in [cadkernel-body-path.md](cadkernel-body-path.md)). A curved box-and-cylinder boolean takes about 1 to 2.2 s, overlapping spheres 0.2 s and planar operands 1 to 2 ms, against 23 s in a debug build. The kernel refuses many curved cases quickly with a reason (`Coincident`, `CutRefused`, `NoClosedForm`), including a second cut in an already drilled box. Recommendation: add a synchronous `SolidOperation::Boolean` that surfaces the refusal and leaves the operands untouched, test it with planar operands plus one ignored curved case, and note the 30 s per-script budget. No code changed in this step. -- Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the cadkernel note are all built. SectionSymbol is the only unmapped kind. -- Mapped `SectionSymbol`, the last unmapped kind, so all 43 canvas kinds now have explicit writes. The blocker recorded earlier was overstated: cadcodec documents `points` as canonical, says the counts equal the point count in every verified export, and provides `sync_display_fields()`, which OCS's own edits already call; OCS also renders a symbol with no style or view link. The host derives the counts and projections when `points` changes and validates that references are existing `SectionViewStyle` and `ViewRep` class objects. Every gate passes except DXF, where cadcodec reopens a top-level `SECTIONLINE` as an unknown entity (issue 5 in the cadcodec write-up, pinned by a canary). Still unverifiable here: the meaning of `raw_flags_90` and AutoCAD's acceptance of a symbol with no parent view. +- Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the opencadkernel note are all built. SectionSymbol is the only unmapped kind. +- Mapped `SectionSymbol`, the last unmapped kind, so all 43 canvas kinds now have explicit writes. The blocker recorded earlier was overstated: opencadcodec documents `points` as canonical, says the counts equal the point count in every verified export, and provides `sync_display_fields()`, which OCS's own edits already call; OCS also renders a symbol with no style or view link. The host derives the counts and projections when `points` changes and validates that references are existing `SectionViewStyle` and `ViewRep` class objects. Every gate passes except DXF, where opencadcodec reopens a top-level `SECTIONLINE` as an unknown entity (issue 5 in the opencadcodec write-up, pinned by a canary). Still unverifiable here: the meaning of `raw_flags_90` and AutoCAD's acceptance of a symbol with no parent view. - Completed `Ole2Frame` by adding creation. `SolidOperation::EmbedPicture` and `doc.embed_picture` build the frame from a picture file with OCS's own embedding code, so a script never touches the opaque storage; PNG, JPEG and BMP are stored byte-for-byte and other formats are re-encoded as PNG. The lifecycle test now creates through Python and the new creation-rules test covers formats, aspect, origin, layer and seven refusals. 30 kinds are complete. - Completed `Viewport` by closing its missing canvas oracle and its derived state. The shared driver now switches the scene to the layout that owns a viewport and requires a drawn frame. The host assigns a unique id (at least 2) and derives `custom_scale = height / view_height` on every scripted create or edit, as MVIEW does, so `custom_scale` became read-only. A control showed my earlier suspicion was wrong: the default id 0 still draws a frame in a layout with no sheet viewport, so the id matters for uniqueness, not visibility. 31 kinds are complete. -- Improved `RasterImage` linkage. A scripted image now reuses one definition per file and registers it in `ACAD_IMAGE_DICT`, which owns it; this persists through DWG and DXF. The `IMAGEDEF_REACTOR` is deliberately not written: its owner convention is ambiguous between cadcodec's struct docs and its DXF writer and cannot be checked against AutoCAD here, so the kind stays integration-tested. An exploratory spike also showed that OCS's own IMAGE command creates bare images that lose their file path on save, an OCS bug outside this plugin work that is flagged separately. +- Improved `RasterImage` linkage. A scripted image now reuses one definition per file and registers it in `ACAD_IMAGE_DICT`, which owns it; this persists through DWG and DXF. The `IMAGEDEF_REACTOR` is deliberately not written: its owner convention is ambiguous between opencadcodec's struct docs and its DXF writer and cannot be checked against AutoCAD here, so the kind stays integration-tested. An exploratory spike also showed that OCS's own IMAGE command creates bare images that lose their file path on save, an OCS bug outside this plugin work that is flagged separately. ### 2026-09-21: beyond entities, the layer table (`doc.layers`) -Entity CRUD is at its ceiling without the cadcodec fixes (PR HakanSeven12/cadcodec#48), so work moved to +Entity CRUD is at its ceiling without the opencadcodec fixes (PR HakanSeven12/opencadcodec#48), so work moved to drawing objects a script needs. New additive v7 request `TableOperation` (`LayerCreate`, `LayerModify`, `LayerRename`, `LayerDelete { erase_objects }`, `LayerSetCurrent`) with `HostApi::table_operation`, executed by `HostSession::table_operation`. Every refusal (invalid or duplicate name, bad color, @@ -186,7 +186,7 @@ reference-following rename) by widening four `style_ops` methods to `pub(super)` renamed text style's name follow into dimension styles. Style operations require the active tab. Evidence: `audit_python_text_and_dim_styles_over_real_ipc` (26 refusals, live/DWG/DXF persistence, copy semantics, rename following references, current/delete guards, five-step undo), IPC round-trip and Python unit -tests. Four cadcodec findings (STYLE flags and oblique units, DIMSTYLE name, `true_type_font`) are pinned by +tests. Four opencadcodec findings (STYLE flags and oblique units, DIMSTYLE name, `true_type_font`) are pinned by canaries and listed in `cadcodec-reader-gaps.md`. Next: linetypes, blocks, layouts; then headless commands. ### 2026-09-21: blocks (`doc.blocks`) @@ -199,7 +199,7 @@ tables). Creation reuses `Scene::define_block_from_owned_entities`. Python: `blo lookup and iteration, records with member handles and kinds. Evidence: `audit_python_blocks_over_real_ipc` (18 refusals; shifted geometry and base point; inserts following a rename; original kept vs erased; live/DWG/DXF persistence; guarded delete; three-step undo restoring the definitions), IPC round-trip and Python -unit tests. One more cadcodec finding (DXF drops the block description) is pinned by a canary. Not covered: +unit tests. One more opencadcodec finding (DXF drops the block description) is pinned by a canary. Not covered: editing a definition's contents in place, and attribute definitions inside a scripted block. Next: linetypes and layouts, then headless commands. @@ -303,9 +303,9 @@ to a helper thread would be unsound here (the editor state is not `Send` and som a stack-growing dependency such as `stacker`, adds a crate to H7's tree to guard a case that cannot occur. The 64 MiB thread in the PEDIT audit stays, because a test thread gets only 2 MiB. -### 2026-09-21: cadcodec PR #51 (text and dimension style DXF fixes) +### 2026-09-21: opencadcodec PR #51 (text and dimension style DXF fixes) -Second small upstream PR, [HakanSeven12/cadcodec#51](https://github.com/HakanSeven12/cadcodec/pull/51): STYLE generation +Second small upstream PR, [HakanSeven12/opencadcodec#51](https://github.com/HakanSeven12/opencadcodec/pull/51): STYLE generation flags (group 71 was hard-coded to 0), the STYLE oblique angle (group 50 is degrees; it was written and read as raw radians) and the DIMSTYLE text-style name (resolved from the group 340 handle). Three commits, three regression tests, independent of #48 (merged locally with it; the combined suite passes). Not included: `true_type_font` and the block description, which need diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 4f0555b45..1b4d57fad 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -2,9 +2,9 @@ Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 23 September 2026 (the object tables, block contents and command runner added since are described in [the design note](plugin-host-model.md) and the plan's progress log; they are not entity kinds): **43 of 43 canvas kinds have explicit geometry writes** (41 creatable, plus update-only legacy `Polyline` and `Body`), **36 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. -**Rows that were blocked at W by cadcodec.** The DXF fixes they waited on landed as [HakanSeven12/cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48) and [#51](https://github.com/HakanSeven12/cadcodec/pull/51), and OCS pins cadcodec `dd1d7bf` since 23 September 2026. The canaries flipped in the real-IPC audits and Helix, Underlay, ViewBorder, SectionSymbol and Light moved to `Complete`. Two rows keep a narrower blocker: AttributeDefinition (the DXF writer emits no group 280, so `lock_position` is lost) and Leader (a true-colour `override_color`, which DXF group 77 cannot hold and the DWG writer does not store). +**Rows that were blocked at W by opencadcodec.** The DXF fixes they waited on landed as [HakanSeven12/opencadcodec#48](https://github.com/HakanSeven12/opencadcodec/pull/48) and [#51](https://github.com/HakanSeven12/opencadcodec/pull/51), and OCS pins opencadcodec `dd1d7bf` since 23 September 2026. The canaries flipped in the real-IPC audits and Helix, Underlay, ViewBorder, SectionSymbol and Light moved to `Complete`. Two rows keep a narrower blocker: AttributeDefinition (the DXF writer emits no group 280, so `lock_position` is lost) and Leader (a true-colour `override_color`, which DXF group 77 cannot hold and the DWG writer does not store). -Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. +Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and opencadcodec source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. | Kind | Baseline | Gate evidence / blocker | |---|---|---| @@ -17,7 +17,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Polyline2D | **Integration-tested / kind changes on DXF save** | Exercised: `vertices`, `closed`, `thickness`. Passes every gate in DWG. DXF reopens a 2-D polyline as an `LwPolyline` (vertices, closure and width survive, but the kind changes and `thickness` is not part of that record's digest here); pinned by a canary expectation. `audit_python_polyline2d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Polyline3D | **Integration-tested / blocked at W by the DWG format (elevation)** | Exercised: `vertices`, `elevation`. Passes every gate in DXF; DWG does not store a 3-D polyline's `elevation`, so it reopens as 0 (pinned by a canary). Vertices persist. `audit_python_polyline3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | LwPolyline | **Complete** | Exercised: `vertices` (with a bulge), `is_closed`, `constant_width`. Rejects fewer than 2 vertices, non-finite locations, bulges and widths, a negative constant width and a zero normal. `audit_python_lwpolyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | -| Spline | **Complete** | Exercised: `control_points`, `weights`. Validation covers degree, control-point count, knot count and order, weights and finite points. The host sets `flags.rational` whenever weights are present, because the DXF writer emits group 41 only for a rational spline; the audit first showed weights vanishing in DXF and traced it to that missing flag (an OCS bug, not a cadcodec one), now fixed. `audit_python_spline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen, re-edit, atomic rollback and three-step undo/redo. | +| Spline | **Complete** | Exercised: `control_points`, `weights`. Validation covers degree, control-point count, knot count and order, weights and finite points. The host sets `flags.rational` whenever weights are present, because the DXF writer emits group 41 only for a rational spline; the audit first showed weights vanishing in DXF and traced it to that missing flag (an OCS bug, not a opencadcodec one), now fixed. `audit_python_spline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen, re-edit, atomic rollback and three-step undo/redo. | | Text | **Complete** | Exercised: `text`, `rotation`, `height`. Rejects a non-positive height (previously accepted), a non-positive width factor, non-finite placement and angles, and a zero normal. Style and alignment fields are mapped but not individually exercised. `audit_python_text_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | MText | **Complete** | Exercised: `text`, `rectangle_width`, `rotation`. Rejects a non-positive height (previously accepted), a negative width, non-finite placement, a non-positive line-spacing factor and a zero normal. Attachment, columns and background fields are mapped but not individually exercised. `audit_python_mtext_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Ray | **Complete** | Exercised: `base_point`, `direction`. Unit-direction and finite-point validation already existed; the audit added the full DWG and DXF reopen and re-edit gate. `audit_python_ray_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | @@ -27,28 +27,28 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Insert | **Complete** | Decision (2026-09-20): Insert is creatable. A script names an existing block with `block_name` and a placement; the host does not generate attribute records, because attributes stay with the `AttributeEntity` flow that already creates them from the block's ATTDEFs. Writable: `block_name` (fixed after creation; the mutation check refuses a different block), `insert_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `column_count`, `row_count`, `column_spacing`, `row_spacing`. Creation and rename rules: the block must exist, must not be model or paper space, and must not make a block contain itself directly or through nested inserts; scales must be finite and nonzero, array counts positive, and the name non-empty. An edit that keeps the block name is exempt from the block checks so a legacy insert of a missing block stays editable. `audit_python_insert_lifecycle_over_real_ipc` (shared driver, host-made block) passes C/R/E/D/U/I/W/V/P: creation by block name, a six-property transaction edit, canvas wires, rejection of zero scale, non-finite rotation and point, zero array count, zero normal and an unknown block, an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. `audit_python_insert_creation_rules_over_real_ipc` covers unknown block, model-space block, bad scale, zero count, empty name, direct self-insertion and indirect nesting cycles. Not covered: MINSERT (`dwg_minsert`) and attached-attribute creation, which stay excluded and preserved. | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | -| AttributeDefinition | **Integration-tested / blocked at W by cadcodec (DXF ATTDEF lock)** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. Two staged tests cover it. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes with a real block record, Block/BlockEnd markers and a linked Insert. The 2026-09-20 audit `audit_python_attribute_definition_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, host-made block) compares **every writable field** through both formats: creation with non-default tag, prompt, value, placement, alignment point, height, rotation, width factor, oblique angle, alignments, all attribute flags, field length, generation flags and lock; a nine-property transaction edit; canvas wires; eight rejection cases (whitespace tag, non-positive height and width factor, missing style, owner change, zero normal, non-finite rotation, unknown alignment) plus an atomic rollback; DWG reopen with the **complete** edited state and re-edit; deletion; and three-step undo/redo. **W blocker, revalidated on cadcodec `dd1d7bf`:** the reader now handles every ATTDEF group and all fields round-trip through DXF except `lock_position`, because the DXF writer emits no group 280 (neither the version byte nor the lock flag after it). Pinned by canary expectations for the edit and the re-edit. Not `Complete` until `write_attdef` emits group 280. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | +| AttributeDefinition | **Integration-tested / blocked at W by opencadcodec (DXF ATTDEF lock)** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. Two staged tests cover it. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes with a real block record, Block/BlockEnd markers and a linked Insert. The 2026-09-20 audit `audit_python_attribute_definition_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, host-made block) compares **every writable field** through both formats: creation with non-default tag, prompt, value, placement, alignment point, height, rotation, width factor, oblique angle, alignments, all attribute flags, field length, generation flags and lock; a nine-property transaction edit; canvas wires; eight rejection cases (whitespace tag, non-positive height and width factor, missing style, owner change, zero normal, non-finite rotation, unknown alignment) plus an atomic rollback; DWG reopen with the **complete** edited state and re-edit; deletion; and three-step undo/redo. **W blocker, revalidated on opencadcodec `dd1d7bf`:** the reader now handles every ATTDEF group and all fields round-trip through DXF except `lock_position`, because the DXF writer emits no group 280 (neither the version byte nor the lock flag after it). Pinned by canary expectations for the edit and the re-edit. Not `Complete` until `write_attdef` emits group 280. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | -| Leader | **Integration-tested / W open items (DWG-derived text fields, true-colour override)** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. `override_color` is writable through the new generator `Color` form (`{'kind': 'Rgb', 'value': {'r','g','b'}}`, `Index`, `ByLayer`, `ByBlock`, `None`). Unmapped but preserved on edit: the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W:** (1) the DXF annotation link (group 340) and (2) `annotation_offset` (group 213) round-trip since cadcodec `dd1d7bf`. Still open: (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14), which needs a decision to document or make read-only; (4) a true-colour `override_color` reopens as `ByLayer` in both formats — DXF group 77 holds an ACI index only, and the DWG writer does not store it (canary assertion in the test). | +| Leader | **Integration-tested / W open items (DWG-derived text fields, true-colour override)** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. `override_color` is writable through the new generator `Color` form (`{'kind': 'Rgb', 'value': {'r','g','b'}}`, `Index`, `ByLayer`, `ByBlock`, `None`). Unmapped but preserved on edit: the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against opencadcodec `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W:** (1) the DXF annotation link (group 340) and (2) `annotation_offset` (group 213) round-trip since opencadcodec `dd1d7bf`. Still open: (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (opencadcodec writes them only for R13–R2007 / R13–R14), which needs a decision to document or make read-only; (4) a true-colour `override_color` reopens as `ByLayer` in both formats — DXF group 77 holds an ACI index only, and the DWG writer does not store it (canary assertion in the test). | | MLine | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. | -| Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. | +| Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the opencadcodec convenience constructors. | | MultiLeader | **Complete** | Mapped through the generic generator, which gained support for `Color`, `LineWeight`, mixed unit/payload enums, fixed `[f64; N]` arrays, bitflags-2 newtypes and per-type module paths for this kind. Writable: `content_type`, `path_type`, `line_color`, `line_weight`, `enable_landing`, `enable_dogleg`, `dogleg_length`, `arrowhead_size`, `text_color`, `text_frame`, the four text attachments, attachment direction/point, `text_alignment`, `text_angle_type`, `text_direction_negative`, `scale_factor`, `enable_annotation_scale`, `extend_leader_to_text`, block content fields, the reference handles (`style_handle`, `text_style_handle`, `arrowhead_handle`, `line_type_handle`, `block_content_handle`) and `context`, which merges as a patch so a script can change `text_string` without resetting leader roots. Read-only: `dwg_version`, `property_override_flags`, `text_align_in_ipe` and the entity-level `text_height` (neither writer stores it; set `context.text_height`). Excluded but preserved: `arrowhead_overrides`, `block_attributes`. Host validation checks finite/positive scale, height, dogleg and arrow sizes, finite context geometry and transform, non-empty leader lines, content-type/context consistency, and that every referenced handle exists as the right object kind. `staged_python_multileader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: MText creation with a three-point leader root, a partial-context edit that replaces the path (four points), text, text location, dogleg and colour while preserving scale/content/common fields, canvas wires, eleven rejection cases plus a duplicate-handle atomic rollback, DWG and DXF reopen (text, path, dogleg, `context.text_height`, colour) with post-reopen re-edit, and three-step undo/redo. Not covered: block-content MultiLeaders (no block fixture yet), tolerance content, and style-defined defaults. | | Table | **Complete** | Mapped through the generic generator (now also handling tuples and `usize`, with the generic `LegacyBorderOverrides` fields excluded and preserved). Writable: `insertion_point`, `horizontal_direction`, `normal`, `table_style_handle`, `rows`, `columns`, `merged_ranges`, `break_spacing`, `break_flow_direction`, `break_options`. Read-only: `block_name`, `block_record_handle`, `data_version`, `value_flags`, the override flags, `name`, `description`, `field_handles`, `base_style`, `break_data`, `break_ranges`, `legacy_style_override`. Creation yields the engine default 3x3 grid; scripts read `rows`/`columns`, modify or extend them and assign them back (rows and columns replace wholesale, so copy an existing row dict when adding one). Host validation requires a non-empty grid, positive finite row heights and column widths, every row to have exactly one cell per column, finite cell and content geometry, merged ranges inside the grid and non-overlapping, and existing table-style, cell text-style and cell block handles. `normalize_scripted_table` mirrors `merged_ranges` onto the origin cell's `merge_width/height` on every scripted change to the ranges. `staged_python_table_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: default-grid creation, a row append, column-width edit, a text cell, a three-column merge and break spacing in one transaction with untouched rows/columns/style/common fields preserved, canvas wires, ten rejection cases plus an atomic rollback, DWG and DXF reopen (row count, width, cell text, insertion) with re-edit, and three-step undo/redo. Format notes: DWG restores `merged_ranges` but not per-cell merge dimensions; DXF restores the cell dimensions but its reader does not rebuild `merged_ranges` (recorded by a canary assertion); both keep the merge. Not covered: the anonymous table block is not regenerated, cell styles and formats beyond text, linked data and field content. | | PolygonMesh | **Complete** | Writable: `flags`, `m_vertex_count`, `n_vertex_count`, `m_smooth_density`, `n_smooth_density`, `smooth_type`, `elevation`, `normal`, `vertices` (each `{location, flags}`; a vertex's own common data is not scripted and defaults). Host validation requires M and N of at least 2, exactly M×N vertices, non-negative densities, finite elevation and locations, and a nonzero normal, so inconsistent counts are refused before any mutation. `staged_python_polygon_mesh_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: a 3×3 grid, a vertex move plus density and cubic smoothing in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | PolyfaceMesh | **Complete** | Writable: `elevation`, `flags`, `normal`, `start_width`, `end_width`, `smooth_surface`, `thickness`, `vertices` (location, flags, bulge, widths, tangent, id) and `faces` (four indices, flags, optional colour). `seqend_handle` is read-only and preserved. Host validation requires at least 3 finite vertices and 1 face, every face index within the vertex count (a negative index hides that edge, 0 marks an unused corner), at least 3 indices per face, and finite widths, thickness and elevation. `staged_python_polyface_mesh_lifecycle_over_real_ipc` passes the full gate: two triangles, a vertex move plus a new vertex and a hidden-edge face in one transaction, canvas wires, six rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Mesh | **Complete** | Writable: `blend_crease`, `subdivision_level`, `vertices`, `faces`, `edges` (start, end, optional crease), `override_option`; `version` is read-only. Host validation requires at least 3 finite vertices, faces of at least 3 in-range indices, edges with distinct in-range endpoints and a finite non-negative crease, and a non-negative subdivision level, so out-of-range topology is rejected before mutation. `staged_python_mesh_lifecycle_over_real_ipc` passes the full gate: a quad with a creased edge, an added vertex and triangle plus subdivision level 1 in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | -| Helix | **Complete** | W passes since cadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W (formerly blocked):** the DXF reader parsed boolean group 290 as an `i16` and never applied it, so a clockwise helix reopened counter-clockwise; fixed in cadcodec `dd1d7bf`, and the handedness now round-trips through both formats. | -| RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Linkage (updated 2026-09-20):** a scripted image now reuses one `ImageDefinition` per file and registers each definition in `ACAD_IMAGE_DICT` under the file's name (a `_2`, `_3` suffix keeps keys unique), creating that dictionary under the root when the drawing has none, and the dictionary owns the definition, as for an image AutoCAD attached. `audit_python_raster_image_definition_linkage_over_real_ipc` checks two images of one file sharing a definition, a third file getting its own, the entries resolving with the right owner, and all of it surviving DWG and DXF reopen and an undo without a dangling entry. **Why not `Complete`:** the host still writes no `IMAGEDEF_REACTOR`: cadcodec's struct documents the reactor's owner as the definition while its DXF writer follows a different convention, and AutoCAD's behavior cannot be checked here, so guessing risks a structure AutoCAD repairs or rejects. **Related finding:** OCS's own IMAGE command creates a bare `RasterImage` with no definition, and a save through either writer drops its file path (`spike_raster_image_definition_persistence`), while the definition-linked form keeps it. Editing an existing definition (file path, resolution) is out of scope. | +| Helix | **Complete** | W passes since opencadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W (formerly blocked):** the DXF reader parsed boolean group 290 as an `i16` and never applied it, so a clockwise helix reopened counter-clockwise; fixed in opencadcodec `dd1d7bf`, and the handedness now round-trips through both formats. | +| RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Linkage (updated 2026-09-20):** a scripted image now reuses one `ImageDefinition` per file and registers each definition in `ACAD_IMAGE_DICT` under the file's name (a `_2`, `_3` suffix keeps keys unique), creating that dictionary under the root when the drawing has none, and the dictionary owns the definition, as for an image AutoCAD attached. `audit_python_raster_image_definition_linkage_over_real_ipc` checks two images of one file sharing a definition, a third file getting its own, the entries resolving with the right owner, and all of it surviving DWG and DXF reopen and an undo without a dangling entry. **Why not `Complete`:** the host still writes no `IMAGEDEF_REACTOR`: opencadcodec's struct documents the reactor's owner as the definition while its DXF writer follows a different convention, and AutoCAD's behavior cannot be checked here, so guessing risks a structure AutoCAD repairs or rejects. **Related finding:** OCS's own IMAGE command creates a bare `RasterImage` with no definition, and a save through either writer drops its file path (`spike_raster_image_definition_persistence`), while the definition-linked form keeps it. Editing an existing definition (file path, resolution) is out of scope. | | Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | -| Underlay | **Complete** | W passes since cadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W (formerly blocked):** DXF stores the rotation in degrees and the reader returned it unconverted, compounding on every save-reopen; fixed in cadcodec `dd1d7bf`, and the rotation now round-trips through both formats. Not covered: creating definitions from a file path, DWF/DGN fixtures. | +| Underlay | **Complete** | W passes since opencadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W (formerly blocked):** DXF stores the rotation in degrees and the reader returned it unconverted, compounding on every save-reopen; fixed in opencadcodec `dd1d7bf`, and the rotation now round-trips through both formats. Not covered: creating definitions from a file path, DWF/DGN fixtures. | | Viewport | **Complete (OCS round trip)** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, `custom_scale`, snap and grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block and every frozen-layer handle an existing layer. **Derived by the host (new 2026-09-20), as the MVIEW command does:** a scripted viewport gets an `id` unique in its layout and at least 2 (max existing plus one), and `custom_scale` is `height / view_height`, recomputed whenever either changes, so `custom_scale` is now read-only (setting the scale directly would have let it drift from the geometry). Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. **Canvas oracle (new):** the shared driver now activates the layout that owns the viewport (`Scene::set_current_layout`) and requires the viewport to draw a frame both after creation and after the edit. `staged_python_viewport_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. `audit_python_viewport_ids_and_scale_over_real_ipc` checks unique ids (2, 3, 4), the derived scale on create and after a zoom and a resize, an id kept across edits, a rejected direct `custom_scale` write, and that every viewport draws a frame. A control showed that the engine default id 0 is **not** invisible in a layout with no sheet viewport (it draws a frame), which corrects an earlier suspicion; the derived id matters for uniqueness. Not covered: clip boundary entities (`clip_boundary_handle` is read-only and preserved), non-rectangular viewports, and AutoCAD acceptance of the written viewports, which cannot be tested here. | -| ViewBorder | **Complete** | W passes since cadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W (formerly blocked):** a DXF `DRAWINGVIEW` in the entity list reopened as an opaque entity kind; fixed in cadcodec `dd1d7bf`, and DXF now restores the typed ViewBorder. Not covered: the linked view representation and drawing-view definition records. | -| SectionSymbol | **Complete** | W passes since cadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. The earlier blocker was overstated, and reading cadcodec and OCS showed why. cadcodec documents `points` as the canonical geometry, says the point-count fields equal the point count in every verified ODA export, and ships `sync_display_fields()` to refresh the `end_a`/`end_b`/`tick_a`/`tick_b`/`label` projections; OCS's own grip and transform code already calls it. OCS also renders a symbol with no style and no view link. So the kind is mapped: writable `points` (each `{point, bulge, label, label_offset, raw_flag_280}`), `symbol_scale`, `style_handle` and `view_rep_handle`; read-only `view_symbol_version`, `raw_view_symbol_70`, `version`, `raw_flags_90`, the two derived counts and the five projections. The host sets `raw_point_count_90` and `raw_point_record_count` to the point count and calls `sync_display_fields()` whenever `points` is created or changed, and leaves a symbol whose points did not change exactly as read. Host validation requires at least two finite points with finite bulges and label offsets, a positive scale, and that each non-null reference is an existing `SectionViewStyle` (style) or `ViewRep` (view representation) class object; the wrong object kind is refused. Creating one without references gives a standalone symbol. `audit_python_section_symbol_lifecycle_over_real_ipc` (shared driver) passes C/R/E/D/U/I/V/P and DWG W: a two-point symbol, a three-point edit with a new label, bulge and scale in one transaction (counts and projections follow), canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, three-step undo/redo. `audit_python_section_symbol_references_over_real_ipc` covers valid references, a standalone symbol and four wrong-object refusals with host-made class objects. **W (formerly blocked; issue 5 in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md)):** the DXF reader dispatched `SECTIONLINE` only inside blocks, so a symbol in the entity list reopened as `Unknown`; fixed in cadcodec `dd1d7bf`. Open items that cannot be checked here: `raw_flags_90` has unknown semantics (0 for a new symbol, preserved on read), and whether AutoCAD accepts a symbol with no parent view. Scripts cannot create the style or view-representation objects. | -| Light | **Complete** | W passes since cadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W (formerly blocked):** the DXF reader did not restore `cast_shadows`; fixed in cadcodec `dd1d7bf`, and every mapped field now round-trips through both formats. Not covered: photometric web data edits and sun/skylight objects. | -| Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | +| ViewBorder | **Complete** | W passes since opencadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W (formerly blocked):** a DXF `DRAWINGVIEW` in the entity list reopened as an opaque entity kind; fixed in opencadcodec `dd1d7bf`, and DXF now restores the typed ViewBorder. Not covered: the linked view representation and drawing-view definition records. | +| SectionSymbol | **Complete** | W passes since opencadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. The earlier blocker was overstated, and reading opencadcodec and OCS showed why. opencadcodec documents `points` as the canonical geometry, says the point-count fields equal the point count in every verified ODA export, and ships `sync_display_fields()` to refresh the `end_a`/`end_b`/`tick_a`/`tick_b`/`label` projections; OCS's own grip and transform code already calls it. OCS also renders a symbol with no style and no view link. So the kind is mapped: writable `points` (each `{point, bulge, label, label_offset, raw_flag_280}`), `symbol_scale`, `style_handle` and `view_rep_handle`; read-only `view_symbol_version`, `raw_view_symbol_70`, `version`, `raw_flags_90`, the two derived counts and the five projections. The host sets `raw_point_count_90` and `raw_point_record_count` to the point count and calls `sync_display_fields()` whenever `points` is created or changed, and leaves a symbol whose points did not change exactly as read. Host validation requires at least two finite points with finite bulges and label offsets, a positive scale, and that each non-null reference is an existing `SectionViewStyle` (style) or `ViewRep` (view representation) class object; the wrong object kind is refused. Creating one without references gives a standalone symbol. `audit_python_section_symbol_lifecycle_over_real_ipc` (shared driver) passes C/R/E/D/U/I/V/P and DWG W: a two-point symbol, a three-point edit with a new label, bulge and scale in one transaction (counts and projections follow), canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, three-step undo/redo. `audit_python_section_symbol_references_over_real_ipc` covers valid references, a standalone symbol and four wrong-object refusals with host-made class objects. **W (formerly blocked; issue 5 in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md)):** the DXF reader dispatched `SECTIONLINE` only inside blocks, so a symbol in the entity list reopened as `Unknown`; fixed in opencadcodec `dd1d7bf`. Open items that cannot be checked here: `raw_flags_90` has unknown semantics (0 for a new symbol, preserved on read), and whether AutoCAD accepts a symbol with no parent view. Scripts cannot create the style or view-representation objects. | +| Light | **Complete** | W passes since opencadcodec `dd1d7bf`: the DXF canary in the real-IPC audit flipped. Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W (formerly blocked):** the DXF reader did not restore `cast_shadows`; fixed in opencadcodec `dd1d7bf`, and every mapped field now round-trips through both formats. Not covered: photometric web data edits and sun/skylight objects. | +| Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `opencadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | -| Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. **Booleans** (`SolidOperation::Boolean`, `doc.solids.union`, `subtract` and `intersect`) are covered by `audit_python_boolean_lifecycle_over_real_ipc`: two overlapping boxes give exact volumes (union 360, subtract and intersect 120) and extents, the operands are consumed unless `keep_operands` (undo restores both exactly), a result takes the first operand's layer or an explicit one, a second planar operation chains on the result (a through-hole gives 104), eight refusals change nothing and record no undo step (same operand twice, a non-solid, a missing entity, an unliftable payload, an empty layer, an unknown operation, a disjoint intersection), and DWG and DXF reopen losslessly with a reopened result moving again. The kernel refuses many curved cases (measured in release: about 1 to 2 s when it succeeds, and `Coincident`, `CutRefused` or `NoClosedForm` when it does not, including a second cut in a drilled box); its reason reaches the script and nothing changes, shown by an `#[ignore]` curved test that takes about 27 s in a debug build. Booleans block the host thread and count against the script's 30 s budget; only Solid3D operands are accepted. | +| Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with opencadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. **Booleans** (`SolidOperation::Boolean`, `doc.solids.union`, `subtract` and `intersect`) are covered by `audit_python_boolean_lifecycle_over_real_ipc`: two overlapping boxes give exact volumes (union 360, subtract and intersect 120) and extents, the operands are consumed unless `keep_operands` (undo restores both exactly), a result takes the first operand's layer or an explicit one, a second planar operation chains on the result (a through-hole gives 104), eight refusals change nothing and record no undo step (same operand twice, a non-solid, a missing entity, an unliftable payload, an empty layer, an unknown operation, a disjoint intersection), and DWG and DXF reopen losslessly with a reopened result moving again. The kernel refuses many curved cases (measured in release: about 1 to 2 s when it succeeds, and `Coincident`, `CutRefused` or `NoClosedForm` when it does not, including a second cut in a drilled box); its reason reaches the script and nothing changes, shown by an `#[ignore]` curved test that takes about 27 s in a debug build. Booleans block the host thread and count against the script's 30 s budget; only Solid3D operands are accepted. | | Surface | **Complete (plane surface, extruded open profile, isolines, rigid move)** | New `SolidOperation::SurfaceFromProfile` and `SolidOperation::Extrude`, exposed as `doc.solids.surface(profile)` and `doc.solids.extrude(profile, direction)`. A closed planar profile becomes a `Plane` surface (built as the region body, so it keeps the plane kind); a profile extruded along a nonzero direction becomes a Solid3D when it is closed and a generic Surface when it is open, using OCS's own `extrusion_body`. Every payload is verified lossless by `solid_to_sat`, and rigid moves (now accepted for surfaces too) keep a plane a plane and turn any other kind generic, as OCS does after an edit. Python sees `surface_kind`, `modeler_format_version`, `point_of_reference` and `history_handle` read-only and `u_isolines` and `v_isolines` writable (0 to 200); the payload, wires, silhouettes and the kind-specific `surface_data` are excluded and preserved. The kind field is exposed as `surface_kind` because a bare `kind` key names the entity type in every Python dict; the new `exposed_property_names_are_unique_per_kind` test guards against that class of collision. `audit_python_surface_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: two plane surfaces (one on its own layer), a surface from an open polyline extrusion, three extruded solids checked against their analytic volumes (a box exactly, curved ones within 2 percent), `delete_source` erasing only the intended profile, canvas geometry, nine refusals that change nothing and record no undo step, an isoline transaction with four rejections (500, negative, the read-only kind, an excluded key), moves with exact extents, DWG and DXF reopen with lifting payloads, matching extents, kept isolines and the plane kind, a reopened surface moving again, and exact undo. Not covered: lofted, revolved, swept and NURBS surfaces (a script cannot create them; an existing one is preserved and turns generic on a move), parametric history, and AutoCAD acceptance. | | Ole2Frame | **Complete** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. **Creation (new 2026-09-20):** `SolidOperation::EmbedPicture` and `doc.embed_picture(path, origin=(0, 0, 0), width=1, layer=None)` build a frame from a picture file with OCS's own `EmbeddedImage::from_file` and `build_embedded_ole`: PNG, JPEG and BMP are stored as read, any other format (a TIFF in the test) is re-encoded as PNG, `origin` is the bottom-left corner, `width` the frame width, and the height follows the picture's aspect. The generic `create_entity('Ole2Frame')` is refused with a message pointing at `doc.embed_picture`. Host validation requires finite corners spanning a nonzero size and `lock_aspect` of 0 or 1 for edits, and for creation a readable picture, a finite origin, a positive width and a non-empty layer. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver) passes C/R/E/D/U/I/W/V/P through the real runner: creation from a PNG, a corner and aspect-lock transaction, canvas geometry, five rejection cases (including an attempt to write `storage`), an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and three-step undo/redo. `audit_python_ole2frame_creation_rules_over_real_ipc` checks that PNG and JPEG are stored byte-for-byte, a TIFF becomes a PNG, the frame keeps the picture's 2:1 aspect with the requested origin and layer, and seven refusals (missing file, a non-image, empty layer, zero width, non-finite origin, a short origin list, and generic creation) change nothing and record no undo step. Not covered: OLE objects that are not pictures, and AutoCAD acceptance of the written frame, which cannot be tested here. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 417a576ea..09e0f1189 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -125,7 +125,7 @@ as `{"type": "overflow", "dropped": n}`. Tokens are bound to the tab that asked. ## Known limits - Twelve of the 43 canvas kinds are not yet `Complete`; each has a named blocker in the - ledger. Seven are cadcodec DXF bugs whose fixes are submitted upstream but not yet adopted. + ledger. Seven are opencadcodec DXF bugs whose fixes are submitted upstream but not yet adopted. - The DXF codec also drops some table properties (text style generation flags, block descriptions, a dimension style's text-style name) and mis-scales one angle; these are pinned by canaries and listed in `cadcodec-reader-gaps.md`. diff --git a/docs/plugin-host-review-guide.md b/docs/plugin-host-review-guide.md index d242aa808..4a663ffe4 100644 --- a/docs/plugin-host-review-guide.md +++ b/docs/plugin-host-review-guide.md @@ -126,8 +126,8 @@ several kinds and features are interleaved, so read by area rather than commit b ## What is deliberately not done - **Twelve of the 43 kinds are not `Complete`** (see the ledger). Seven are blocked by DXF bugs in - cadcodec, whose fixes are submitted as [cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48) and - [#51](https://github.com/HakanSeven12/cadcodec/pull/51). **This change still pins the unfixed cadcodec revision**, + opencadcodec, whose fixes are submitted as [opencadcodec#48](https://github.com/HakanSeven12/opencadcodec/pull/48) and + [#51](https://github.com/HakanSeven12/opencadcodec/pull/51). **This change still pins the unfixed opencadcodec revision**, so its canary tests assert the old behaviour; once those merge, the pin moves, the canaries flip and those kinds can be marked complete. The other five are held by the DWG format, a kind change on DXF save, an image-reactor structure that cannot be verified, an update-only decision, and a missing creation path. diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index 44d62f7eb..bae5e7b0b 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -12,4 +12,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8a28c21" } +acadrust = { git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8a28c21" } diff --git a/docs/plugin-template/.github/workflows/release.yml b/docs/plugin-template/.github/workflows/release.yml index 3b3b1e45e..dc1a8f82a 100644 --- a/docs/plugin-template/.github/workflows/release.yml +++ b/docs/plugin-template/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: run: | mkdir -p dist cp target/release/*.${{ matrix.ext }} "dist/${{ matrix.asset }}" - acadrust_source=$(grep -A3 '^name = "acadrust"' Cargo.lock | grep '^source' | head -1 | sed 's/^source = "//;s/"$//') + acadrust_source=$(grep -A3 '^name = "opencadcodec"' Cargo.lock | grep '^source' | head -1 | sed 's/^source = "//;s/"$//') sed \ -e "s|__RUSTC_VERSION__|$(rustc --version)|" \ -e "s|__ACADRUST_SOURCE__|$acadrust_source|" \ diff --git a/docs/screenshots/offset-drag/no-selection-distance-first.png b/docs/screenshots/offset-drag/no-selection-distance-first.png new file mode 100644 index 000000000..32835108c Binary files /dev/null and b/docs/screenshots/offset-drag/no-selection-distance-first.png differ diff --git a/docs/screenshots/offset-drag/preselected-live-preview.png b/docs/screenshots/offset-drag/preselected-live-preview.png new file mode 100644 index 000000000..d7eda8df4 Binary files /dev/null and b/docs/screenshots/offset-drag/preselected-live-preview.png differ diff --git a/docs/screenshots/offset-drag/typed-minus-10-lands-outside.png b/docs/screenshots/offset-drag/typed-minus-10-lands-outside.png new file mode 100644 index 000000000..53bb21750 Binary files /dev/null and b/docs/screenshots/offset-drag/typed-minus-10-lands-outside.png differ diff --git a/docs/screenshots/ribbon-highlight/after-dimbreak.png b/docs/screenshots/ribbon-highlight/after-dimbreak.png new file mode 100644 index 000000000..a6538aa96 Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-dimbreak.png differ diff --git a/docs/screenshots/ribbon-highlight/after-offset.png b/docs/screenshots/ribbon-highlight/after-offset.png new file mode 100644 index 000000000..d7c79c1f7 Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-offset.png differ diff --git a/docs/screenshots/ribbon-highlight/after-xline.png b/docs/screenshots/ribbon-highlight/after-xline.png new file mode 100644 index 000000000..e0edc2e7a Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-xline.png differ diff --git a/docs/screenshots/ribbon-highlight/before-dimbreak.png b/docs/screenshots/ribbon-highlight/before-dimbreak.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-dimbreak.png differ diff --git a/docs/screenshots/ribbon-highlight/before-offset.png b/docs/screenshots/ribbon-highlight/before-offset.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-offset.png differ diff --git a/docs/screenshots/ribbon-highlight/before-xline.png b/docs/screenshots/ribbon-highlight/before-xline.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-xline.png differ diff --git a/docs/tessellation.md b/docs/tessellation.md index 8a6815eba..7a5a62ab7 100644 --- a/docs/tessellation.md +++ b/docs/tessellation.md @@ -1,10 +1,10 @@ # Entity tessellation paths -How each acadrust `EntityType` becomes drawable geometry in OpenCADStudio. +How each opencadcodec `EntityType` becomes drawable geometry in OpenCADStudio. There are **three ways** an entity gets there, and they must not be conflated: -1. **Kernel B-rep mesh** — the ACIS document is lifted into a cadkernel `Body` +1. **Kernel B-rep mesh** — the ACIS document is lifted into a opencadkernel `Body` and each face triangulated in its own surface's parameter space. The only path that produces filled *mesh* triangles from analytic surfaces. 2. **Curve-sampled** — the entity's geometry is read through diff --git a/docs/trackpad.md b/docs/trackpad.md index f697a3eff..b10ebe3b7 100644 --- a/docs/trackpad.md +++ b/docs/trackpad.md @@ -17,6 +17,20 @@ together with the middle button orbits, exactly as before. | Middle-button drag | Pan. Shift together with the middle button orbits the model view. | | `PAN` / `P` | Pan with the left button until Escape. | +`ZOOMFACTOR` scales what one notch does. The system variable takes 3 to 100 — +the range it has everywhere — and `SETVAR ZOOMFACTOR` accepts nothing outside +it. Options → User Preferences reaches 500 for anyone who wants a faster wheel: +the slider covers the variable's own range, the field beside it takes any value +up to that ceiling, and both show what a notch does as a percentage. A value +above 100 therefore lives only in this application's own configuration, and a +bare `SETVAR ZOOMFACTOR` still reports it. + +A notch multiplies the camera distance by `0.9 ^ (ZOOMFACTOR / 60)`, so the +default 60 brings the view a tenth closer, a notch in and a notch out cancel +exactly, and no setting can drive the distance to zero or through it. A floating +viewport in MSPACE zooms its own view the same way, from a base of 0.85 — the +15% a notch has always moved a viewport. + Panning covers the same ground whichever way it starts: the active model tile, a paper sheet, or the model view inside a floating viewport in MSPACE. A pan dragged out of a two-finger gesture and one dragged with the middle button move @@ -46,8 +60,10 @@ from the split described below; it is not a separate setting. means. A wheel reports notches and zooms; a precise-scrolling device reports pixels and pans. There is no modifier and no setting in that decision. `pan_active_view` and `zoom_view_at_cursor` are the bodies shared by the mouse - and the gestures, and they apply a pinch as `1 - step / 10 = 1 / (1 + m)` so a - pinch of `m` leaves the view exactly `1 + m` times closer. + and the gestures. A pinch of `m` leaves the view exactly `1 + m` times closer: + `pinch_zoom_steps` asks the camera for the step that applies that ratio + (`Camera::zoom_steps_for_ratio`) instead of inverting the zoom law here, which + is what let the two drift apart when the law stopped being a subtraction. - `src/app/mod.rs` carries one `Message::TrackpadPinch(f32)` per gesture event. Pixel deltas pan only where they can only mean a trackpad. The web build @@ -64,7 +80,9 @@ cargo test --locked --lib scroll_intent ``` The tests cover the decision between a notch and a pixel, and the 1:1 mapping of -a pinch onto the camera. The gestures themselves are checked by hand: macOS +a pinch onto the camera — the mapping by zooming a real `Camera` and measuring +the distance it ends at, so it is checked against the camera's own zoom law and +not against a second copy of it. The gestures themselves are checked by hand: macOS offers no way to synthesize a magnify event, so a pinch cannot be driven by a test. Two-finger pan and pinch are exercised in model space, in a tiled view, on a paper sheet, and inside a floating viewport in MSPACE. diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 69fc56032..5d25a9dc7 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -818,7 +818,7 @@ common = .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. .slice-retained-update-failed = SLICE: a retained result could not be updated. - .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). + .slice-created-results = SLICE: created __ocs_arg_count__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: تم تمرير منحنى عبر __ocs_fmt_0__ نقطة. .splinefit-need-at-least-3-points = SPLINEFIT: يلزم 3 نقاط على الأقل. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: حدد خطًا متعددًا لتمرير منحنى عبره. @@ -1150,6 +1150,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = تم فتح الرسم، لكن تعذر تخزين النسخة الحديثة: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1436,7 +1440,8 @@ common = .prompt-lines = أسطر المطالبة .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = أسطر المطالبة المؤقتة أعلى نافذة الأوامر (CLIPROMPTLINES). .history-fade-time = زمن تلاشي السجل - .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = مدة بقاء أسطر السجل ظاهرة؛ القيمة 0 تتخطاها (COMMANDLINEFADETIME). + .how-long-overlay-history-lines-stay-visible-0-never-fades-commandlinefadetime = مدة بقاء أسطر السجل ظاهرة؛ القيمة 0 تعني ألا تختفي أبدًا (COMMANDLINEFADETIME). + .never = أبدًا .clicking-overlapping-objects-opens-a-picker = النقر على الكائنات المتداخلة يفتح أداة اختيار .block-edit = تحرير الكتلة .double-click-to-edit-block-in-place-refedit = النقر المزدوج يحرر الكتلة في مكانها (REFEDIT) @@ -1511,6 +1516,8 @@ common = .value-1-layout-selected = تم تحديد تخطيط واحد .layouts-selected = تخطيطات محددة .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = حدد التخطيطات للإخراج. تُطبع كل صفحة في وضع التخطيط. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } @@ -1524,10 +1531,10 @@ common = .are-you-sure-you-want-to-reset-you-will-lose-all-your-current-shortcuts = هل تريد بالتأكيد إعادة الضبط؟ ستفقد جميع اختصاراتك الحالية! .yes-reset = { common.yes }, { common.reset } .reset-to-default = { common.reset } · { common.default-title-case } - .apply-exit = { common.apply } && { draw.sketch-exit } + .apply-exit = { common.apply } & { draw.sketch-exit } .shortcut-already-used-for-command = الاختصار مستخدم بالفعل للأمر: __ocs_fmt_0__ → __ocs_fmt_1__ .unsaved-changes-will-be-discarded = ستُتجاهل التغييرات غير المحفوظة. - .discard-close = { common.discard } && { action.close } + .discard-close = { common.discard } & { action.close } .keep-editing = { common.continue } · { layers.edit } .mcp-control-is-off = التحكم عبر MCP متوقف .mcp-is-handling-a-request = يعالج MCP طلبًا @@ -1917,8 +1924,8 @@ common = .choose-at-least-one-output = Choose at least one output. .data-extraction-settings = Data Extraction Settings .add-drawings = Add Drawings - .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): - .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .select-objects-or-settings-selected-enter-to-apply = __ocs_arg_cmd__ Select objects or [Settings] (__ocs_arg_count__ selected, Enter to apply): + .select-objects-or-settings = __ocs_arg_cmd__ Select objects or [Settings]: .add-parameter = + Add parameter .object-snap-3d = 3D Object Snap .object-snap-on-f4-3d = 3D Object Snap On (F4) @@ -2039,9 +2046,9 @@ common = .grid-y-spacing = Grid Y spacing: .grid-behavior = Grid behavior .grid-spacing = Grid spacing - .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : - .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). - .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:__ocs_arg_name__ Scale:__ocs_arg_scale__ Angle:__ocs_arg_angle__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created __ocs_arg_count__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; __ocs_arg_count__ original object(s) retained. .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. @@ -2165,7 +2172,7 @@ common = .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. - .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-created-result-object-s = SUBTRACT: created __ocs_arg_count__ result object(s). .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. @@ -2250,7 +2257,7 @@ common = .trims = Trims .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. .u-isolines-2 = U isolines - .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-created-result-object-s = UNION: created __ocs_arg_count__ result object(s). .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. @@ -2326,6 +2333,27 @@ common = .wblock-err-no-path = يرجى تحديد اسم الملف والمسار. .wblock-err-current-drawing = لا يمكن الكتابة إلى ملف الرسم الحالي. .wblock-help-info = يقوم WBLOCK بكتابة كائنات أو كتلة أو الرسم بالكامل إلى ملف رسم جديد. + .blank-drawing = رسم فارغ + .default-template-new-drawings = القالب الافتراضي للرسومات الجديدة + .fillet-trim-mode-trim = FILLET Enter Trim mode option [Trim/No trim] : + .fillet-trim-mode-no-trim = FILLET Enter Trim mode option [Trim/No trim] : + .linetype-files = ملفات أنواع الخطوط + .load-linetypes = تحميل أنواع الخطوط + .requires-two-positive-values = يتطلب قيمتين موجبتين (x,y) أو قيمة واحدة لكليهما. + .snap-marker-color = لون علامة الالتقاط + .command-line-text-color = لون نص سطر الأوامر + .operand = المعامل + .linetypes-loaded = تم تحميل __ocs_fmt_0__ من أنواع الخطوط. + .geometric-center-snap = المركز الهندسي + .no-trim-option = بدون تشذيب + .chspace-model-tab = CHSPACE: الأمر غير مسموح به في علامة تبويب النموذج. + .chspace-no-viewport = CHSPACE: لا توجد نافذة عرض مسقطية لتغيير الفضاء من خلالها. + .chspace-to-paper = CHSPACE: تم نقل __ocs_fmt_0__ كائن إلى فضاء الورق. + .chspace-to-model = CHSPACE: تم نقل __ocs_fmt_0__ كائن إلى فضاء النموذج. + .select-template-title = تحديد قالب + .threedrotate-specify-base-point-count-objects = 3DROTATE حدد نقطة الأساس [__ocs_arg_count__ كائنات]: + .threedrotate-specify-rotation-axis = 3DROTATE حدد محور الدوران [X/Y/Z] أو نقطة ثانية عليه : + .threedrotate-specify-rotation-angle-a = 3DROTATE حدد زاوية الدوران <__ocs_arg_a__>: errors = .save-warning = تحذير الحفظ .unable-to-save-drawing = تعذر حفظ الرسم @@ -3029,6 +3057,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ انقر نقطة العبور أو [متعدد]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ متعدد انقر الجانب التالي [المسافة __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ متعدد انقر نقطة العبور التالية: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ انقر نقطة العبور أو اكتب المسافة، والقيمة السالبة للجهة الأخرى [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ متعدد انقر نقطة العبور أو اكتب المسافة، والقيمة السالبة للجهة الأخرى [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = إزاحة (DIMEXO) .overall-scale-dimscale = المقياس الإجمالي (DIMSCALE) .pedit-enter-option = PEDIT أدخل الخيار: @@ -3265,6 +3295,7 @@ layers = .usage-view-list-view-save-name-view-restore-na = الاستخدام: VIEW LIST | VIEW SAVE | VIEW RESTORE | VIEW DELETE .usage-view-restore-name = الاستخدام: VIEW RESTORE .usage-view-save-name = الاستخدام: VIEW SAVE + .usage-view-window = Usage: VIEW WINDOW .usage-prefix-font-style-font-file = الاستخدام: __ocs_fmt_0__ FONT